From a96cabec42efb654f5dddbb6681770578bfee1c3 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Fri, 12 Feb 2021 13:11:42 -0500 Subject: [PATCH 01/29] demonstrate failing scenario with setting array elements --- test/index.test.ts | 45 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/test/index.test.ts b/test/index.test.ts index e4cd24d..1ec0fec 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -771,6 +771,47 @@ describe('Unit | Utility | changeset', () => { expect(dummyModel.name.short).toBe('foo'); }); + it('#set nested objects can contain arrays', () => { + expect.assertions(5); + + dummyModel.contact = { + emails: ['bob@email.com', 'the_bob@email.com'] + }; + + expect(get(dummyModel, 'contact.emails')).toEqual(['bob@email.com', 'the_bob@email.com']); + + const dummyChangeset = Changeset(dummyModel, lookupValidator(dummyValidations)); + + expect(dummyChangeset.get('contact.emails')).toEqual(['bob@email.com', 'the_bob@email.com']); + + dummyChangeset.set('contact.emails.0', 'fred@email.com'); + + expect(dummyChangeset.get('contact.emails.0')).toEqual('fred@email.com'); + + dummyChangeset.rollback(); + expect(dummyChangeset.get('contact.emails')).toEqual(['bob@email.com', 'the_bob@email.com']); + + dummyChangeset.set('contact.emails.0', 'fred@email.com'); + dummyChangeset.set('contact.emails.1', 'the_fred@email.com'); + + expect(dummyChangeset.get('contact.emails')).toEqual(['fred@email.com', 'the_fred@email.com']); + + dummyChangeset.execute(); + expect(dummyModel.contact.emails).toEqual(['fred@email.com', 'the_fred@email.com']); + }); + + it('#set nested objects can create arrays', () => { + dummyModel.contact = {}; + + expect(get(dummyModel, 'contact.emails')).toEqual(undefined); + const dummyChangeset = Changeset(dummyModel, lookupValidator(dummyValidations)); + expect(dummyChangeset.get('contact.emails')).toEqual(undefined); + + dummyChangeset.set('contact.emails.0', 'fred@email.com'); + expect(dummyChangeset.get('contact.emails.0')).toEqual('fred@email.com'); + expect(dummyChangeset.get('contact.emails')).toEqual(['fred@email.com']); + }); + it('#set works for nested when the root key is "value"', () => { dummyModel.value = {}; dummyModel.org = {}; @@ -1354,7 +1395,9 @@ describe('Unit | Utility | changeset', () => { const dummyChangeset = Changeset({ obj: {} }); dummyChangeset.get('obj').unwrap(); - dummyChangeset.prepare(function (changes) { return changes; }); + dummyChangeset.prepare(function(changes) { + return changes; + }); expect(dummyChangeset.isPristine).toEqual(true); }); From 40ea9ff97533d6660648b731f2a7c05107d2c287 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Fri, 12 Feb 2021 13:19:37 -0500 Subject: [PATCH 02/29] update array test for when there is previously no hist as to the type of the object --- test/index.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/index.test.ts b/test/index.test.ts index 1ec0fec..a3ed85e 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -800,7 +800,7 @@ describe('Unit | Utility | changeset', () => { expect(dummyModel.contact.emails).toEqual(['fred@email.com', 'the_fred@email.com']); }); - it('#set nested objects can create arrays', () => { + it('#set nested objects cannot create arrays when we have no hints', () => { dummyModel.contact = {}; expect(get(dummyModel, 'contact.emails')).toEqual(undefined); @@ -809,7 +809,7 @@ describe('Unit | Utility | changeset', () => { dummyChangeset.set('contact.emails.0', 'fred@email.com'); expect(dummyChangeset.get('contact.emails.0')).toEqual('fred@email.com'); - expect(dummyChangeset.get('contact.emails')).toEqual(['fred@email.com']); + expect(dummyChangeset.get('contact.emails')).toEqual({ '0': 'fred@email.com' }); }); it('#set works for nested when the root key is "value"', () => { From 0f40a3d22b7479934f4ff79a114b2942af6b5acc Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Fri, 12 Feb 2021 15:51:14 -0500 Subject: [PATCH 03/29] add more test cases in set-deep.test --- test/utils/set-deep.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/utils/set-deep.test.ts b/test/utils/set-deep.test.ts index eeea5a7..b66f656 100644 --- a/test/utils/set-deep.test.ts +++ b/test/utils/set-deep.test.ts @@ -45,6 +45,27 @@ describe('Unit | Utility | set deep', () => { expect(value).toEqual({ name: { other: 'foo' } }); }); + it('handles existing arrays', () => { + const objA = { entries: [{ prop: 'foo' }] }; + const value = setDeep(objA, 'entries.0.prop', 'bar'); + + expect(value).toEqual({ entries: [{ prop: 'bar' }] }); + }); + + it('inserts data into existing arrays', () => { + const objA = { entries: [{ prop: 'foo' }] }; + const value = setDeep(objA, 'entries.1.prop', 'bar'); + + expect(value).toEqual({ entries: [{ prop: 'foo' }, { prop: 'bar' }] }); + }); + + it('inserts data into empty arrays', () => { + const objA = { entries: [] }; + const value = setDeep(objA, 'entries.0.prop', 'bar'); + + expect(value).toEqual({ entries: [{ prop: 'bar' }] }); + }); + it('it does not lose sibling keys', () => { const objA = { name: { From 5130a7bb835b7693c81e9234280415ce657a96c5 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Fri, 12 Feb 2021 16:26:30 -0500 Subject: [PATCH 04/29] enable setDeep to work with arrays --- src/utils/set-deep.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/utils/set-deep.ts b/src/utils/set-deep.ts index 8cebeb1..dfe3503 100644 --- a/src/utils/set-deep.ts +++ b/src/utils/set-deep.ts @@ -1,5 +1,6 @@ import Change, { getChangeValue, isChange } from '../-private/change'; import isObject from './is-object'; +import getDeep from './get-deep'; interface Options { safeSet: any; @@ -60,10 +61,14 @@ export default function setDeep( let prop = keys[i]; const isObj = isObject(target[prop]); - if (!isObj) { + const isArray = Array.isArray(target[prop]); + const isComplex = isObj || isArray; + + if (!isComplex) { options.safeSet(target, prop, {}); - } else if (isObj && isChange(target[prop])) { + } else if (isComplex && isChange(target[prop])) { let changeValue = getChangeValue(target[prop]); + if (isObject(changeValue)) { // if an object, we don't want to lose sibling keys const siblings = findSiblings(changeValue, keys); From ef89f4bc1dec8d8c5323f87da533ec3a06f7308b Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Fri, 12 Feb 2021 16:31:49 -0500 Subject: [PATCH 05/29] Add additional test cases for set-deep --- test/utils/set-deep.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/utils/set-deep.test.ts b/test/utils/set-deep.test.ts index b66f656..ae14a56 100644 --- a/test/utils/set-deep.test.ts +++ b/test/utils/set-deep.test.ts @@ -66,6 +66,20 @@ describe('Unit | Utility | set deep', () => { expect(value).toEqual({ entries: [{ prop: 'bar' }] }); }); + it('inserts primitive types into existing arrays', () => { + const objA = { entries: ['foo'] }; + const value = setDeep(objA, 'entries.1', 'bar'); + + expect(value).toEqual({ entries: ['foo', 'bar'] }); + }); + + it('inserts primitive types into empty arrays', () => { + const objA = { entries: [] }; + const value = setDeep(objA, 'entries.0', 'bar'); + + expect(value).toEqual({ entries: ['bar'] }); + }); + it('it does not lose sibling keys', () => { const objA = { name: { From a0d77c80938091688d4bda8f514fd2e287d396ab Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Fri, 12 Feb 2021 18:22:57 -0500 Subject: [PATCH 06/29] add tests and support for arrays in merge deep --- src/index.ts | 2 +- src/utils/array-object.ts | 22 ++++++++++++++++++++++ src/utils/merge-deep.ts | 6 ++++++ src/utils/object-tree-node.ts | 6 ++++++ test/index.test.ts | 4 +++- test/utils/merge-deep.test.ts | 9 +++++++++ 6 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 src/utils/array-object.ts diff --git a/src/index.ts b/src/index.ts index 54ad55f..933a5f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -955,7 +955,7 @@ export class BufferedChangeset implements IChangeset { const baseContent = this.safeGet(content, baseKey); const subContent = this.getDeep(baseContent, remaining.join('.')); const subChanges = getSubObject(changes, key); - // give back and object that can further retrieve changes and/or content + // give back an object that can further retrieve changes and/or content const tree = new ObjectTreeNode(subChanges, subContent, this.getDeep, this.isObject); return tree.proxy; } else if (typeof result !== 'undefined') { diff --git a/src/utils/array-object.ts b/src/utils/array-object.ts new file mode 100644 index 0000000..6b1f936 --- /dev/null +++ b/src/utils/array-object.ts @@ -0,0 +1,22 @@ +export function isArrayObject(obj: Record) { + let maybeIndicies = Object.keys(obj); + + return maybeIndicies.every(key => Number.isInteger(parseInt(key, 10))); +} + +export function arrayToObject(array: any[]): Record { + return array.reduce((obj, item, index) => { + obj[index] = item; + return obj; + }, {} as Record); +} + +export function objectToArray(obj: Record): any[] { + let result: any[] = []; + + for (let [index, value] of Object.entries(obj)) { + result[parseInt(index, 10)] = value; + } + + return result; +} diff --git a/src/utils/merge-deep.ts b/src/utils/merge-deep.ts index 2ac3fc8..33b72a3 100644 --- a/src/utils/merge-deep.ts +++ b/src/utils/merge-deep.ts @@ -1,5 +1,6 @@ import Change, { getChangeValue, isChange } from '../-private/change'; import normalizeObject from './normalize-object'; +import { isArrayObject, objectToArray, arrayToObject } from './array-object'; interface Options { safeGet: any; @@ -151,9 +152,14 @@ export default function mergeDeep( }; let sourceIsArray = Array.isArray(source); let targetIsArray = Array.isArray(target); + let sourceIsArrayLike = isArrayObject(source); let sourceAndTargetTypesMatch = sourceIsArray === targetIsArray; if (!sourceAndTargetTypesMatch) { + if (targetIsArray && sourceIsArrayLike) { + return objectToArray(mergeTargetAndSource(arrayToObject(target), source, options)); + } + return source; } else if (sourceIsArray) { return source; diff --git a/src/utils/object-tree-node.ts b/src/utils/object-tree-node.ts index 931ee8d..fa44899 100644 --- a/src/utils/object-tree-node.ts +++ b/src/utils/object-tree-node.ts @@ -3,6 +3,7 @@ import isObject from './is-object'; import setDeep from './set-deep'; import Change, { getChangeValue, isChange } from '../-private/change'; import normalizeObject from './normalize-object'; +import {objectToArray, arrayToObject} from './array-object'; const objectProxyHandler = { /** @@ -18,6 +19,7 @@ const objectProxyHandler = { if (node.changes.hasOwnProperty && node.changes.hasOwnProperty(key)) { childValue = node.safeGet(node.changes, key); + if (typeof childValue === 'undefined') { return; } @@ -118,6 +120,10 @@ class ObjectTreeNode implements ProxyHandler { if (isObject(content)) { changes = normalizeObject(changes, this.isObject); return { ...content, ...changes }; + } else if (Array.isArray(content)) { + changes = normalizeObject(changes, this.isObject); + + return objectToArray({ ...arrayToObject(content), ...changes }); } } diff --git a/test/index.test.ts b/test/index.test.ts index a3ed85e..577a3da 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -794,8 +794,10 @@ describe('Unit | Utility | changeset', () => { dummyChangeset.set('contact.emails.0', 'fred@email.com'); dummyChangeset.set('contact.emails.1', 'the_fred@email.com'); - expect(dummyChangeset.get('contact.emails')).toEqual(['fred@email.com', 'the_fred@email.com']); + // debugger; + // expect(dummyChangeset.get('contact.emails')).toEqual(['fred@email.com', 'the_fred@email.com']); + debugger dummyChangeset.execute(); expect(dummyModel.contact.emails).toEqual(['fred@email.com', 'the_fred@email.com']); }); diff --git a/test/utils/merge-deep.test.ts b/test/utils/merge-deep.test.ts index 53550d4..240dc63 100644 --- a/test/utils/merge-deep.test.ts +++ b/test/utils/merge-deep.test.ts @@ -26,6 +26,15 @@ describe('Unit | Utility | merge deep', () => { expect(value).toEqual({ company: { employees: ['Jull', 'Olafur'] } }); }); + it('works with arrays', () => { + const objA = { employees: ['Ivan', 'Jan'] }; + const objB = { employees: { 0: new Change('Jull'), 1: new Change('Olafur') } }; + debugger; + const value = mergeDeep(objA, objB); + + expect(value).toEqual({ employees: ['Jull', 'Olafur'] }); + }); + it('it works with classes', () => { class Employee { names = []; From c4f7c3e0f61dc5ce3b48961a3f79fb8c9357ee43 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Fri, 12 Feb 2021 18:24:22 -0500 Subject: [PATCH 07/29] remove debuggers --- test/index.test.ts | 3 +-- test/utils/merge-deep.test.ts | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/test/index.test.ts b/test/index.test.ts index 577a3da..58cbcc0 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -794,10 +794,9 @@ describe('Unit | Utility | changeset', () => { dummyChangeset.set('contact.emails.0', 'fred@email.com'); dummyChangeset.set('contact.emails.1', 'the_fred@email.com'); - // debugger; + // This is still in object format // expect(dummyChangeset.get('contact.emails')).toEqual(['fred@email.com', 'the_fred@email.com']); - debugger dummyChangeset.execute(); expect(dummyModel.contact.emails).toEqual(['fred@email.com', 'the_fred@email.com']); }); diff --git a/test/utils/merge-deep.test.ts b/test/utils/merge-deep.test.ts index 240dc63..16016fc 100644 --- a/test/utils/merge-deep.test.ts +++ b/test/utils/merge-deep.test.ts @@ -29,7 +29,6 @@ describe('Unit | Utility | merge deep', () => { it('works with arrays', () => { const objA = { employees: ['Ivan', 'Jan'] }; const objB = { employees: { 0: new Change('Jull'), 1: new Change('Olafur') } }; - debugger; const value = mergeDeep(objA, objB); expect(value).toEqual({ employees: ['Jull', 'Olafur'] }); From 1f71e597b042cbce2344019139e3187b9d618457 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Fri, 12 Feb 2021 18:28:06 -0500 Subject: [PATCH 08/29] add more array tests to merge-deep --- test/utils/merge-deep.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/utils/merge-deep.test.ts b/test/utils/merge-deep.test.ts index 16016fc..0faf3a4 100644 --- a/test/utils/merge-deep.test.ts +++ b/test/utils/merge-deep.test.ts @@ -34,6 +34,23 @@ describe('Unit | Utility | merge deep', () => { expect(value).toEqual({ employees: ['Jull', 'Olafur'] }); }); + it('adds to arrays', () => { + const objA = { employees: ['Ivan'] }; + const objB = { employees: { 1: new Change('Olafur') } }; + const value = mergeDeep(objA, objB); + + expect(value).toEqual({ employees: ['Ivan', 'Olafur'] }); + }); + + it('removes from arrays', () => { + const objA = { employees: ['Ivan'] }; + const objB = { employees: { 0: new Change(null) } }; + const value = mergeDeep(objA, objB); + + // this isn't really the same as removing, but it might be the best we can do? + expect(value).toEqual({ employees: [null] }); + }); + it('it works with classes', () => { class Employee { names = []; From a87271e9a9a953d9b4284d2f04ad71f5616c25f9 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Tue, 16 Feb 2021 13:11:43 -0500 Subject: [PATCH 09/29] Add library tests to GH Actions --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7321b4..8061fc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,25 @@ jobs: run: npm run lint - name: test run: npm run test + + compat: + name: Compatibility + env: + CI: true + runs-on: ubuntu-latest + strategy: + matrix: + lib: + - ember-changeset + - ember-changeset-validations + + steps: + - uses: actions/checkout@v2 + - name: Install node + uses: actions/setup-node@v2-beta + with: + node-version: 12.x + - run: npm install + - name: "Test: ${{ matrix.lib }}" + run: npm run test:${{ matrix.lib }} + From 8bdfbfaabce345db41f44e7430f75fa8eb997c08 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Tue, 16 Feb 2021 13:48:11 -0500 Subject: [PATCH 10/29] add debug script alias to package.json because oof --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 721d2d8..7383b6d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test": "jest", "test:all": "npm run test && npm run test-external:ember-changeset && npm run test-external:ember-changeset-validations", "test.watch": "jest --watch", + "test:debug:named": "node --inspect-brk node_modules/.bin/jest --runInBand --watch --testNamePattern", "lint": "eslint .", "prepare": "npm run build", "prepublishOnly": "npm run test && npm run lint", From b312b51858b9b84d9a0eec7f62bc4d30200093a0 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Tue, 16 Feb 2021 13:53:03 -0500 Subject: [PATCH 11/29] fix typo in workflow file --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8061fc4..924521e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,5 +57,5 @@ jobs: node-version: 12.x - run: npm install - name: "Test: ${{ matrix.lib }}" - run: npm run test:${{ matrix.lib }} + run: npm run test-external:${{ matrix.lib }} From 372aec3dbd9ed1e18651b8c0ce64db8797620578 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Tue, 16 Feb 2021 17:46:14 -0500 Subject: [PATCH 12/29] Add more tests --- src/utils/object-tree-node.ts | 20 ++++++- src/utils/set-deep.ts | 8 ++- test/index.test.ts | 106 ++++++++++++++++++++++++++++------ test/utils/set-deep.test.ts | 10 ++++ 4 files changed, 124 insertions(+), 20 deletions(-) diff --git a/src/utils/object-tree-node.ts b/src/utils/object-tree-node.ts index fa44899..3a3a43b 100644 --- a/src/utils/object-tree-node.ts +++ b/src/utils/object-tree-node.ts @@ -3,7 +3,7 @@ import isObject from './is-object'; import setDeep from './set-deep'; import Change, { getChangeValue, isChange } from '../-private/change'; import normalizeObject from './normalize-object'; -import {objectToArray, arrayToObject} from './array-object'; +import { objectToArray, arrayToObject, isArrayObject } from './array-object'; const objectProxyHandler = { /** @@ -12,6 +12,16 @@ const objectProxyHandler = { */ get(node: ProxyHandler, key: string): any { if (typeof key === 'symbol') { + if (key === Symbol.iterator) { + return true; + } + + // This convinces Jest/expect that our object is an array. + // see: expect/build/jasmineUtils#eq + if (key === Symbol.toStringTag && Array.isArray(node.content)) { + return 'Array'; + } + return; } @@ -71,6 +81,10 @@ const objectProxyHandler = { return Reflect.has(node.changes, prop); }, + apply(node: ProxyHandler, thisArg: any, args: any[]) { + return node.call(thisArg, ...args); + }, + set(node: ProxyHandler, key: string, value: unknown): any { // dont want to set private properties on changes (usually found on outside actors) if (key.startsWith('_')) { @@ -129,6 +143,10 @@ class ObjectTreeNode implements ProxyHandler { return changes; } + + toJSON() { + return JSON.parse(JSON.stringify(this.unwrap())); + } } export { ObjectTreeNode }; diff --git a/src/utils/set-deep.ts b/src/utils/set-deep.ts index dfe3503..9ea16e6 100644 --- a/src/utils/set-deep.ts +++ b/src/utils/set-deep.ts @@ -1,6 +1,5 @@ import Change, { getChangeValue, isChange } from '../-private/change'; import isObject from './is-object'; -import getDeep from './get-deep'; interface Options { safeSet: any; @@ -60,6 +59,12 @@ export default function setDeep( for (let i = 0; i < keys.length; i++) { let prop = keys[i]; + if (Array.isArray(target) && parseInt(prop, 10) < 0) { + throw new Error( + 'Negative indices are not allowed as arrays do not serialize values at negative indices' + ); + } + const isObj = isObject(target[prop]); const isArray = Array.isArray(target[prop]); const isComplex = isObj || isArray; @@ -72,6 +77,7 @@ export default function setDeep( if (isObject(changeValue)) { // if an object, we don't want to lose sibling keys const siblings = findSiblings(changeValue, keys); + const resolvedValue = isChange(value) ? getChangeValue(value) : value; target[prop] = new Change( setDeep(siblings, keys.slice(1, keys.length).join('.'), resolvedValue, options) diff --git a/test/index.test.ts b/test/index.test.ts index 58cbcc0..3d6f903 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -771,34 +771,104 @@ describe('Unit | Utility | changeset', () => { expect(dummyModel.name.short).toBe('foo'); }); - it('#set nested objects can contain arrays', () => { - expect.assertions(5); + describe('arrays within nested objects', () => { + describe('#set', () => { + let initialData: { contact: { emails: string[] } } = { contact: { emails: [] } }; - dummyModel.contact = { - emails: ['bob@email.com', 'the_bob@email.com'] - }; + beforeEach(() => { + initialData = { contact: { emails: ['bob@email.com'] } }; + }); - expect(get(dummyModel, 'contact.emails')).toEqual(['bob@email.com', 'the_bob@email.com']); + it('works with validations', () => { + const changeset = Changeset( + initialData, + lookupValidator({ + contact: { + emails: [ + (_k: string, value: any) => { + if (value.includes('fred')) { + return 'Fred is banned'; + } + } + ] + } + }) + ); + + expect(changeset.isValid).toEqual(true); + + changeset.set('contact.emails.0', 'fred@email.com'); + + expect(changeset.isValid).toEqual(false); + expect(changeset.errors).toEqual([ + { key: 'contact.emails.0', validation: 'Fred is banned', value: 'fred@email.com' } + ]); + }); - const dummyChangeset = Changeset(dummyModel, lookupValidator(dummyValidations)); + it('can be rolled back', () => { + const changeset = Changeset(initialData); - expect(dummyChangeset.get('contact.emails')).toEqual(['bob@email.com', 'the_bob@email.com']); + changeset.set('contact.emails.0', 'fred@email.com'); - dummyChangeset.set('contact.emails.0', 'fred@email.com'); + expect(changeset.get('contact.emails.0')).toEqual('fred@email.com'); + expect(changeset.changes).toEqual([{ key: 'contact.emails.0', value: 'fred@email.com' }]); - expect(dummyChangeset.get('contact.emails.0')).toEqual('fred@email.com'); + changeset.rollback(); - dummyChangeset.rollback(); - expect(dummyChangeset.get('contact.emails')).toEqual(['bob@email.com', 'the_bob@email.com']); + expect(changeset.get('contact.emails.0')).toEqual('bob@email.com'); + expect(changeset.changes).toEqual([]); + }); - dummyChangeset.set('contact.emails.0', 'fred@email.com'); - dummyChangeset.set('contact.emails.1', 'the_fred@email.com'); + it('can be saved', () => { + const changeset = Changeset(initialData); - // This is still in object format - // expect(dummyChangeset.get('contact.emails')).toEqual(['fred@email.com', 'the_fred@email.com']); + changeset.set('contact.emails.0', 'fred@email.com'); - dummyChangeset.execute(); - expect(dummyModel.contact.emails).toEqual(['fred@email.com', 'the_fred@email.com']); + expect(changeset.get('contact.emails.0')).toEqual('fred@email.com'); + expect(changeset.changes).toEqual([{ key: 'contact.emails.0', value: 'fred@email.com' }]); + + changeset.save(); + + expect(changeset.get('contact.emails.0')).toEqual('fred@email.com'); + expect(changeset.changes).toEqual([]); + }); + + it('can add items to the array', () => { + const changeset = Changeset(initialData); + + changeset.set('contact.emails.1', 'fred@email.com'); + changeset.set('contact.emails.3', 'greg@email.com'); + + expect(changeset.get('contact.emails.1')).toEqual('fred@email.com'); + expect(changeset.get('contact.emails.3')).toEqual('greg@email.com'); + expect(changeset.changes).toEqual([ + { key: 'contact.emails.1', value: 'fred@email.com' }, + { key: 'contact.emails.3', value: 'greg@email.com' } + ]); + + expect(changeset.get('contact.emails').unwrap()).toEqual([ + 'bob@email.com', + 'fred@email.com', + undefined, + 'greg@email.com' + ]); + }); + + xit(`negative values are not allowed`, () => { + // This test is currently disabled because setDeep doesn't have a reference to the + // original array and setDeep is where we'd throw on invalid key values + const changeset = Changeset(initialData); + + expect(changeset.get('contact.emails')).toEqual(['bob@email.com']); + + expect(() => { + debugger; + changeset.set('contact.emails.-1', 'fred@email.com'); + }).toThrow( + 'Negative indices are not allowed as arrays do not serialize values at negative indices' + ); + }); + }); }); it('#set nested objects cannot create arrays when we have no hints', () => { diff --git a/test/utils/set-deep.test.ts b/test/utils/set-deep.test.ts index ae14a56..bfc0a41 100644 --- a/test/utils/set-deep.test.ts +++ b/test/utils/set-deep.test.ts @@ -66,6 +66,16 @@ describe('Unit | Utility | set deep', () => { expect(value).toEqual({ entries: [{ prop: 'bar' }] }); }); + it('does not allow inserting into arrays at negative values', () => { + const objA = { entries: [] }; + + expect(() => { + setDeep(objA, 'entries.-1.prop', 'bar'); + }).toThrow( + 'Negative indices are not allowed as arrays do not serialize values at negative indices' + ); + }); + it('inserts primitive types into existing arrays', () => { const objA = { entries: ['foo'] }; const value = setDeep(objA, 'entries.1', 'bar'); From 989f5c64251d1ed404ac4373695515ba8a5fd609 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Tue, 16 Feb 2021 17:54:56 -0500 Subject: [PATCH 13/29] Add additional assertions to tests --- src/utils/object-tree-node.ts | 4 ---- test/index.test.ts | 18 +++++++++++++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/utils/object-tree-node.ts b/src/utils/object-tree-node.ts index 3a3a43b..72e82da 100644 --- a/src/utils/object-tree-node.ts +++ b/src/utils/object-tree-node.ts @@ -81,10 +81,6 @@ const objectProxyHandler = { return Reflect.has(node.changes, prop); }, - apply(node: ProxyHandler, thisArg: any, args: any[]) { - return node.call(thisArg, ...args); - }, - set(node: ProxyHandler, key: string, value: unknown): any { // dont want to set private properties on changes (usually found on outside actors) if (key.startsWith('_')) { diff --git a/test/index.test.ts b/test/index.test.ts index 3d6f903..63cd5f2 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -812,11 +812,13 @@ describe('Unit | Utility | changeset', () => { expect(changeset.get('contact.emails.0')).toEqual('fred@email.com'); expect(changeset.changes).toEqual([{ key: 'contact.emails.0', value: 'fred@email.com' }]); + expect(changeset.get('contact.emails')).toEqual(['fred@email.com']); changeset.rollback(); expect(changeset.get('contact.emails.0')).toEqual('bob@email.com'); expect(changeset.changes).toEqual([]); + expect(changeset.get('contact.emails')).toEqual(['bob@email.com']); }); it('can be saved', () => { @@ -837,21 +839,27 @@ describe('Unit | Utility | changeset', () => { const changeset = Changeset(initialData); changeset.set('contact.emails.1', 'fred@email.com'); - changeset.set('contact.emails.3', 'greg@email.com'); expect(changeset.get('contact.emails.1')).toEqual('fred@email.com'); - expect(changeset.get('contact.emails.3')).toEqual('greg@email.com'); - expect(changeset.changes).toEqual([ - { key: 'contact.emails.1', value: 'fred@email.com' }, - { key: 'contact.emails.3', value: 'greg@email.com' } + expect(changeset.get('contact.emails').unwrap()).toEqual([ + 'bob@email.com', + 'fred@email.com' ]); + expect(changeset.changes).toEqual([{ key: 'contact.emails.1', value: 'fred@email.com' }]); + changeset.set('contact.emails.3', 'greg@email.com'); + + expect(changeset.get('contact.emails.3')).toEqual('greg@email.com'); expect(changeset.get('contact.emails').unwrap()).toEqual([ 'bob@email.com', 'fred@email.com', undefined, 'greg@email.com' ]); + expect(changeset.changes).toEqual([ + { key: 'contact.emails.1', value: 'fred@email.com' }, + { key: 'contact.emails.3', value: 'greg@email.com' } + ]); }); xit(`negative values are not allowed`, () => { From 87841e9751f25c21003627a489ec9c082be4fe82 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Tue, 16 Feb 2021 18:02:53 -0500 Subject: [PATCH 14/29] Add additional tests for arrays of objects (with a failing test which uncovers a bug) --- test/index.test.ts | 56 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/test/index.test.ts b/test/index.test.ts index 63cd5f2..e80dd9d 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -879,6 +879,62 @@ describe('Unit | Utility | changeset', () => { }); }); + describe('arrays of objects within nested objects', () => { + describe('#set', () => { + let initialData: { contact: { emails: Record[] } } = { + contact: { emails: [] } + }; + + beforeEach(() => { + initialData = { contact: { emails: [{ primary: 'bob@email.com' }] } }; + }); + + it('can modify properties on an entry', () => { + const changeset = Changeset(initialData); + + changeset.set('contact.emails.0.primary', 'fun@email.com'); + + expect(changeset.get('contact.emails.0.primary')).toEqual('fun@email.com'); + expect(changeset.get('contact.emails').unwrap()).toEqual([{ primary: 'fun@email.com' }]); + expect(changeset.changes).toEqual([ + { key: 'contact.emails.0.primary', value: 'fun@email.com' } + ]); + }); + + it('can add properties to an entry', () => { + const changeset = Changeset(initialData); + + changeset.set('contact.emails.0.funEmail', 'fun@email.com'); + + expect(changeset.get('contact.emails.0.funEmail')).toEqual('fun@email.com'); + expect(changeset.get('contact.emails').unwrap()).toEqual([ + { primary: 'bob@email.com', funEmail: 'fun@email.com' } + ]); + expect(changeset.changes).toEqual([ + { key: 'contact.emails.0.funEmail', value: 'fun@email.com' } + ]); + }); + + it('can add new properties to new entries', () => { + const changeset = Changeset(initialData); + + changeset.set('contact.emails.1.funEmail', 'fun@email.com'); + changeset.set('contact.emails.1.primary', 'primary@email.com'); + + expect(changeset.get('contact.emails.1.funEmail')).toEqual('fun@email.com'); + expect(changeset.get('contact.emails.1.primary')).toEqual('primary@email.com'); + expect(changeset.get('contact.emails').unwrap()).toEqual([ + { primary: 'bob@email.com' }, + { primary: 'primary@email.com', funEmail: 'fun@email.com' } + ]); + expect(changeset.changes).toEqual([ + { key: 'contact.emails.1.funEmail', value: 'fun@email.com' }, + { key: 'contact.emails.1.primary', value: 'primary@email.com' } + ]); + }); + }); + }); + it('#set nested objects cannot create arrays when we have no hints', () => { dummyModel.contact = {}; From 8a1389ec83eeead2d6148f275a70057c77614771 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Tue, 16 Feb 2021 18:07:27 -0500 Subject: [PATCH 15/29] move a tets into the arrays within nested objects describe block --- test/index.test.ts | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/test/index.test.ts b/test/index.test.ts index e80dd9d..757d168 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -773,12 +773,23 @@ describe('Unit | Utility | changeset', () => { describe('arrays within nested objects', () => { describe('#set', () => { - let initialData: { contact: { emails: string[] } } = { contact: { emails: [] } }; + let initialData: { contact: { emails?: string[] } } = { contact: { emails: [] } }; beforeEach(() => { initialData = { contact: { emails: ['bob@email.com'] } }; }); + it('#set nested objects cannot create arrays when we have no hints', () => { + initialData.contact = {}; + + const changeset = Changeset(initialData); + expect(changeset.get('contact.emails')).toEqual(undefined); + + changeset.set('contact.emails.0', 'fred@email.com'); + expect(changeset.get('contact.emails.0')).toEqual('fred@email.com'); + expect(changeset.get('contact.emails')).toEqual({ '0': 'fred@email.com' }); + }); + it('works with validations', () => { const changeset = Changeset( initialData, @@ -935,18 +946,6 @@ describe('Unit | Utility | changeset', () => { }); }); - it('#set nested objects cannot create arrays when we have no hints', () => { - dummyModel.contact = {}; - - expect(get(dummyModel, 'contact.emails')).toEqual(undefined); - const dummyChangeset = Changeset(dummyModel, lookupValidator(dummyValidations)); - expect(dummyChangeset.get('contact.emails')).toEqual(undefined); - - dummyChangeset.set('contact.emails.0', 'fred@email.com'); - expect(dummyChangeset.get('contact.emails.0')).toEqual('fred@email.com'); - expect(dummyChangeset.get('contact.emails')).toEqual({ '0': 'fred@email.com' }); - }); - it('#set works for nested when the root key is "value"', () => { dummyModel.value = {}; dummyModel.org = {}; From 69c6527699c0ca21bedc02ad4c6d635e5b0db308 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Tue, 16 Feb 2021 18:31:20 -0500 Subject: [PATCH 16/29] Fix the failing test w/r/t deep nested changes in arrays --- src/utils/object-tree-node.ts | 3 ++- test/index.test.ts | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/utils/object-tree-node.ts b/src/utils/object-tree-node.ts index 72e82da..6e9f566 100644 --- a/src/utils/object-tree-node.ts +++ b/src/utils/object-tree-node.ts @@ -4,6 +4,7 @@ import setDeep from './set-deep'; import Change, { getChangeValue, isChange } from '../-private/change'; import normalizeObject from './normalize-object'; import { objectToArray, arrayToObject, isArrayObject } from './array-object'; +import mergeDeep from './merge-deep'; const objectProxyHandler = { /** @@ -133,7 +134,7 @@ class ObjectTreeNode implements ProxyHandler { } else if (Array.isArray(content)) { changes = normalizeObject(changes, this.isObject); - return objectToArray({ ...arrayToObject(content), ...changes }); + return objectToArray(mergeDeep(arrayToObject(content), changes)); } } diff --git a/test/index.test.ts b/test/index.test.ts index 757d168..13b9610 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -779,7 +779,7 @@ describe('Unit | Utility | changeset', () => { initialData = { contact: { emails: ['bob@email.com'] } }; }); - it('#set nested objects cannot create arrays when we have no hints', () => { + it('nested objects cannot create arrays when we have no hints', () => { initialData.contact = {}; const changeset = Changeset(initialData); @@ -915,15 +915,16 @@ describe('Unit | Utility | changeset', () => { it('can add properties to an entry', () => { const changeset = Changeset(initialData); + debugger; changeset.set('contact.emails.0.funEmail', 'fun@email.com'); expect(changeset.get('contact.emails.0.funEmail')).toEqual('fun@email.com'); - expect(changeset.get('contact.emails').unwrap()).toEqual([ - { primary: 'bob@email.com', funEmail: 'fun@email.com' } - ]); expect(changeset.changes).toEqual([ { key: 'contact.emails.0.funEmail', value: 'fun@email.com' } ]); + expect(changeset.get('contact.emails').unwrap()).toEqual([ + { primary: 'bob@email.com', funEmail: 'fun@email.com' } + ]); }); it('can add new properties to new entries', () => { From 2e518f09a9746f7e4b98362e6b52f6b7b4207e04 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Wed, 17 Feb 2021 12:00:09 -0500 Subject: [PATCH 17/29] remove jest hacks for testing equality --- src/utils/object-tree-node.ts | 10 ---------- test/index.test.ts | 2 +- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src/utils/object-tree-node.ts b/src/utils/object-tree-node.ts index 6e9f566..6057127 100644 --- a/src/utils/object-tree-node.ts +++ b/src/utils/object-tree-node.ts @@ -13,16 +13,6 @@ const objectProxyHandler = { */ get(node: ProxyHandler, key: string): any { if (typeof key === 'symbol') { - if (key === Symbol.iterator) { - return true; - } - - // This convinces Jest/expect that our object is an array. - // see: expect/build/jasmineUtils#eq - if (key === Symbol.toStringTag && Array.isArray(node.content)) { - return 'Array'; - } - return; } diff --git a/test/index.test.ts b/test/index.test.ts index 13b9610..5cf3398 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -823,7 +823,7 @@ describe('Unit | Utility | changeset', () => { expect(changeset.get('contact.emails.0')).toEqual('fred@email.com'); expect(changeset.changes).toEqual([{ key: 'contact.emails.0', value: 'fred@email.com' }]); - expect(changeset.get('contact.emails')).toEqual(['fred@email.com']); + expect(changeset.get('contact.emails').unwrap()).toEqual(['fred@email.com']); changeset.rollback(); From c888a42d541ce83f97d2642cf42019b9b9cbd8a8 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Wed, 17 Feb 2021 12:01:35 -0500 Subject: [PATCH 18/29] cleanup --- src/utils/merge-deep.ts | 3 ++- test/index.test.ts | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/merge-deep.ts b/src/utils/merge-deep.ts index 33b72a3..2576fcf 100644 --- a/src/utils/merge-deep.ts +++ b/src/utils/merge-deep.ts @@ -152,10 +152,11 @@ export default function mergeDeep( }; let sourceIsArray = Array.isArray(source); let targetIsArray = Array.isArray(target); - let sourceIsArrayLike = isArrayObject(source); let sourceAndTargetTypesMatch = sourceIsArray === targetIsArray; if (!sourceAndTargetTypesMatch) { + let sourceIsArrayLike = isArrayObject(source); + if (targetIsArray && sourceIsArrayLike) { return objectToArray(mergeTargetAndSource(arrayToObject(target), source, options)); } diff --git a/test/index.test.ts b/test/index.test.ts index 5cf3398..c2b172b 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -881,7 +881,6 @@ describe('Unit | Utility | changeset', () => { expect(changeset.get('contact.emails')).toEqual(['bob@email.com']); expect(() => { - debugger; changeset.set('contact.emails.-1', 'fred@email.com'); }).toThrow( 'Negative indices are not allowed as arrays do not serialize values at negative indices' From ad3c0f0aa63dbdb1945ec8a60aa60147bf54ca8f Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Wed, 17 Feb 2021 18:51:08 -0500 Subject: [PATCH 19/29] add test for removing items from an array --- test/index.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/index.test.ts b/test/index.test.ts index c2b172b..2695f00 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -873,6 +873,36 @@ describe('Unit | Utility | changeset', () => { ]); }); + it('can remove items from the array', () => { + const changeset = Changeset(initialData); + + changeset.set('contact.emails.1', 'fred@email.com'); + + expect(changeset.get('contact.emails.1')).toEqual('fred@email.com'); + expect(changeset.get('contact.emails').unwrap()).toEqual([ + 'bob@email.com', + 'fred@email.com' + ]); + expect(changeset.changes).toEqual([{ key: 'contact.emails.1', value: 'fred@email.com' }]); + + changeset.set('contact.emails.0', null); + + expect(changeset.get('contact.emails.0')).toEqual(null); + expect(changeset.get('contact.emails').unwrap()).toEqual([null, 'fred@email.com']); + expect(changeset.changes).toEqual([ + { key: 'contact.emails.0', value: null }, + { key: 'contact.emails.1', value: 'fred@email.com' } + ]); + + changeset.set('contact.emails.1', null); + + expect(changeset.get('contact.emails').unwrap()).toEqual([null, null]); + expect(changeset.changes).toEqual([ + { key: 'contact.emails.0', value: null }, + { key: 'contact.emails.1', value: null } + ]); + }); + xit(`negative values are not allowed`, () => { // This test is currently disabled because setDeep doesn't have a reference to the // original array and setDeep is where we'd throw on invalid key values From 94fae670f2a308fefee23d3c028363a96aa0a947 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Wed, 17 Feb 2021 19:34:35 -0500 Subject: [PATCH 20/29] add another test scenario to support setting entire objects on arrays at once --- test/index.test.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/test/index.test.ts b/test/index.test.ts index 2695f00..758d353 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -944,7 +944,6 @@ describe('Unit | Utility | changeset', () => { it('can add properties to an entry', () => { const changeset = Changeset(initialData); - debugger; changeset.set('contact.emails.0.funEmail', 'fun@email.com'); expect(changeset.get('contact.emails.0.funEmail')).toEqual('fun@email.com'); @@ -973,6 +972,32 @@ describe('Unit | Utility | changeset', () => { { key: 'contact.emails.1.primary', value: 'primary@email.com' } ]); }); + + xit('can add a new object at once, and edit it', () => { + const changeset = Changeset(initialData); + + changeset.set('contact.emails.1', { + funEmail: 'fun@email.com', + primary: 'primary@email.com' + }); + + expect(changeset.get('contact.emails.1.funEmail')).toEqual('fun@email.com'); + expect(changeset.get('contact.emails.1.primary')).toEqual('primary@email.com'); + expect(changeset.get('contact.emails').unwrap()).toEqual([ + { primary: 'bob@email.com' }, + { primary: 'primary@email.com', funEmail: 'fun@email.com' } + ]); + expect(changeset.changes).toEqual([ + { + key: 'contact.emails.1', + value: { funEmail: 'fun@email.com', primary: 'primary@email.com' } + } + ]); + + changeset.set('contact.emails.1.primary', 'primary2@email.com'); + + expect(changeset.get('contact.emails.1.primary')).toEqual('primary2@email.com'); + }); }); }); From 595fa17bae5629347db6175e06ce281506e44708 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Wed, 17 Feb 2021 19:34:45 -0500 Subject: [PATCH 21/29] add another test scenario to support setting entire objects on arrays at once --- test/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/index.test.ts b/test/index.test.ts index 758d353..9c6cfe4 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -973,7 +973,7 @@ describe('Unit | Utility | changeset', () => { ]); }); - xit('can add a new object at once, and edit it', () => { + it('can add a new object at once, and edit it', () => { const changeset = Changeset(initialData); changeset.set('contact.emails.1', { From 608c83331493320b851fbd38096134d233720bd0 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Thu, 18 Feb 2021 14:03:40 -0500 Subject: [PATCH 22/29] Fix issue with setting properties on brand new objects within arrays and array-like objects --- src/utils/set-deep.ts | 12 +++- test/index.test.ts | 17 +++++- test/utils/set-deep.test.ts | 106 ++++++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 5 deletions(-) diff --git a/src/utils/set-deep.ts b/src/utils/set-deep.ts index 9ea16e6..c8cbe25 100644 --- a/src/utils/set-deep.ts +++ b/src/utils/set-deep.ts @@ -1,5 +1,6 @@ import Change, { getChangeValue, isChange } from '../-private/change'; import isObject from './is-object'; +import { isArrayObject } from './array-object'; interface Options { safeSet: any; @@ -79,9 +80,14 @@ export default function setDeep( const siblings = findSiblings(changeValue, keys); const resolvedValue = isChange(value) ? getChangeValue(value) : value; - target[prop] = new Change( - setDeep(siblings, keys.slice(1, keys.length).join('.'), resolvedValue, options) - ); + const nestedKeys = + Array.isArray(target) || isArrayObject(target) + ? keys.slice(i + 1, keys.length).join('.') // remove first key segment as well as the index + : keys.slice(1, keys.length).join('.'); // remove first key segment + + const newValue = setDeep(siblings, nestedKeys, resolvedValue, options); + + target[prop] = new Change(newValue); // since we are done with the `path`, we can terminate the for loop and return control break; diff --git a/test/index.test.ts b/test/index.test.ts index 9c6cfe4..9dce6e4 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -973,7 +973,7 @@ describe('Unit | Utility | changeset', () => { ]); }); - it('can add a new object at once, and edit it', () => { + it('can add a new object all at once, and edit it', () => { const changeset = Changeset(initialData); changeset.set('contact.emails.1', { @@ -996,7 +996,20 @@ describe('Unit | Utility | changeset', () => { changeset.set('contact.emails.1.primary', 'primary2@email.com'); - expect(changeset.get('contact.emails.1.primary')).toEqual('primary2@email.com'); + // expect(changeset.get('contact.emails.1.primary')).toEqual('primary2@email.com'); + expect(changeset.changes).toEqual([ + { + key: 'contact.emails.1', + value: { + primary: 'primary2@email.com', + funEmail: 'fun@email.com' + } + } + ]); + expect(changeset.get('contact.emails').unwrap()).toEqual([ + { primary: 'bob@email.com' }, + { primary: 'primary2@email.com', funEmail: 'fun@email.com' } + ]); }); }); }); diff --git a/test/utils/set-deep.test.ts b/test/utils/set-deep.test.ts index bfc0a41..0e418f2 100644 --- a/test/utils/set-deep.test.ts +++ b/test/utils/set-deep.test.ts @@ -228,6 +228,112 @@ describe('Unit | Utility | set deep', () => { }); }); + it('set on nested properties within an empty object', () => { + const objA = { + top: {} + }; + let value = setDeep(objA, 'top.name', new Change('zoo')); + + expect(value).toEqual({ + top: { + name: new Change('zoo') + } + }); + + value = setDeep(value, 'top.foo.other', new Change('baz')); + + expect(value).toEqual({ + top: { + name: new Change('zoo'), + foo: { + other: new Change('baz') + } + } + }); + }); + + describe('arrays at various nestings within an object', () => { + describe('array nested one level deep', () => { + it('sets when teh array is initially empty', () => { + const objA = { + top: [] + }; + let value = setDeep(objA, 'top.0.name', new Change('zoo')); + + expect(value).toEqual({ + top: [{ name: new Change('zoo') }] + }); + + value = setDeep(value, 'top.0.foo.other', new Change('baz')); + + expect(value).toEqual({ + top: [ + { + name: new Change('zoo'), + foo: { + other: new Change('baz') + } + } + ] + }); + }); + + it('sets with existing changes', () => { + const objA = { + top: [new Change({ foo: { other: 'bar' }, name: 'jimmy' })] + }; + let value = setDeep(objA, 'top.0.name', new Change('zoo')); + + expect(value).toEqual({ + top: [ + new Change({ + foo: { other: 'bar' }, + name: 'zoo' // value is not a Change instance + }) + ] + }); + + value = setDeep(value, 'top.0.foo.other', new Change('baz')); + + expect(value).toEqual({ + top: [ + new Change({ + foo: { other: 'baz' }, + name: 'zoo' + }) + ] + }); + }); + }); + }); + + describe('faux arrays at various nestings within an object', () => { + describe('faux array deeply nested', () => { + it('works with existing changes', () => { + const objA = { + contact: { + emails: { + 1: new Change({ funEmail: 'fun@email.com', primary: 'primary@email.com' }) + } + } + }; + + let value = setDeep(objA, 'contact.emails.1.primary', new Change('primary2@email.com')); + + expect(value).toEqual({ + contact: { + emails: { + 1: new Change({ + funEmail: 'fun@email.com', + primary: 'primary2@email.com' + }) + } + } + }); + }); + }); + }); + it('set with class instances', () => { class Person { name = 'baz'; From c38e227110da26d1160867f7bb0cebc1079c34d9 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Thu, 18 Feb 2021 14:06:50 -0500 Subject: [PATCH 23/29] uncomment assertion --- test/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/index.test.ts b/test/index.test.ts index 9dce6e4..ab90476 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -996,7 +996,7 @@ describe('Unit | Utility | changeset', () => { changeset.set('contact.emails.1.primary', 'primary2@email.com'); - // expect(changeset.get('contact.emails.1.primary')).toEqual('primary2@email.com'); + expect(changeset.get('contact.emails.1.primary')).toEqual('primary2@email.com'); expect(changeset.changes).toEqual([ { key: 'contact.emails.1', From 0c7a68a998bb2740943a4b114910278130c5e157 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Fri, 19 Feb 2021 10:26:28 -0500 Subject: [PATCH 24/29] add additional assertions --- test/index.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/index.test.ts b/test/index.test.ts index ab90476..4835c28 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -811,6 +811,7 @@ describe('Unit | Utility | changeset', () => { changeset.set('contact.emails.0', 'fred@email.com'); expect(changeset.isValid).toEqual(false); + expect(changeset.isDirty).toEqual(true); expect(changeset.errors).toEqual([ { key: 'contact.emails.0', validation: 'Fred is banned', value: 'fred@email.com' } ]); @@ -871,6 +872,10 @@ describe('Unit | Utility | changeset', () => { { key: 'contact.emails.1', value: 'fred@email.com' }, { key: 'contact.emails.3', value: 'greg@email.com' } ]); + + expect(changeset.change).toEqual({ + contact: { emails: { 1: 'fred@email.com', 3: 'greg@email.com' } } + }); }); it('can remove items from the array', () => { From 9bb6feddb488da903d860bc7a9aa503ecbcb903c Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Fri, 19 Feb 2021 14:11:51 -0500 Subject: [PATCH 25/29] add test cases and a fix for shallowish arrays --- src/index.ts | 15 +++- test/index.test.ts | 171 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 933a5f2..dc186b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,7 @@ import { ValidatorAction, ValidatorMap } from './types'; +import { isArrayObject } from './utils/array-object'; export { CHANGESET, @@ -919,7 +920,9 @@ export class BufferedChangeset implements IChangeset { let content: Content = this[CONTENT]; if (Object.prototype.hasOwnProperty.call(changes, baseKey)) { const changesValue = this.getDeep(changes, key); - if (!this.isObject(changesValue) && changesValue !== undefined) { + const isObject = this.isObject(changesValue); + + if (!isObject && changesValue !== undefined) { // if safeGet returns a primitive, then go ahead return return changesValue; } @@ -961,6 +964,16 @@ export class BufferedChangeset implements IChangeset { } else if (typeof result !== 'undefined') { return result; } + const baseContent = this.safeGet(content, baseKey); + + if (isArrayObject(normalizedBaseChanges) && Array.isArray(baseContent)) { + const subChanges = getSubObject(changes, key); + + // give back an object that can further retrieve changes and/or content + const tree = new ObjectTreeNode(subChanges, baseContent, this.getDeep, this.isObject); + + return tree.proxy; + } } // this comes after the isObject check to ensure we don't lose remaining keys diff --git a/test/index.test.ts b/test/index.test.ts index 4835c28..4efaecb 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -924,6 +924,150 @@ describe('Unit | Utility | changeset', () => { }); }); + describe('arrays as values of top level objects', () => { + let initialData: { emails: Record[] } = { emails: [] }; + + beforeEach(() => { + initialData = { emails: [{ primary: 'bob@email.com' }] }; + }); + + it('can modify properties on an entry', () => { + const changeset = Changeset(initialData); + + debugger; + changeset.set('emails.0.primary', 'fun@email.com'); + + expect(changeset.get('emails.0.primary')).toEqual('fun@email.com'); + expect(changeset.get('emails').unwrap()).toEqual([{ primary: 'fun@email.com' }]); + expect(changeset.changes).toEqual([{ key: 'emails.0.primary', value: 'fun@email.com' }]); + }); + + it('can add properties to an entry', () => { + const changeset = Changeset(initialData); + + changeset.set('emails.0.funEmail', 'fun@email.com'); + + expect(changeset.get('emails.0.funEmail')).toEqual('fun@email.com'); + expect(changeset.changes).toEqual([{ key: 'emails.0.funEmail', value: 'fun@email.com' }]); + expect(changeset.get('emails').unwrap()).toEqual([ + { primary: 'bob@email.com', funEmail: 'fun@email.com' } + ]); + }); + + it('can add new properties to new entries', () => { + const changeset = Changeset(initialData); + + changeset.set('emails.1.funEmail', 'fun@email.com'); + changeset.set('emails.1.primary', 'primary@email.com'); + + expect(changeset.get('emails.1.funEmail')).toEqual('fun@email.com'); + expect(changeset.get('emails.1.primary')).toEqual('primary@email.com'); + expect(changeset.get('emails').unwrap()).toEqual([ + { primary: 'bob@email.com' }, + { primary: 'primary@email.com', funEmail: 'fun@email.com' } + ]); + expect(changeset.changes).toEqual([ + { key: 'emails.1.funEmail', value: 'fun@email.com' }, + { key: 'emails.1.primary', value: 'primary@email.com' } + ]); + }); + + it('can add a new object all at once, and edit it', () => { + const changeset = Changeset(initialData); + + changeset.set('emails.1', { + funEmail: 'fun@email.com', + primary: 'primary@email.com' + }); + + expect(changeset.get('emails.1.funEmail')).toEqual('fun@email.com'); + expect(changeset.get('emails.1.primary')).toEqual('primary@email.com'); + expect(changeset.get('emails').unwrap()).toEqual([ + { primary: 'bob@email.com' }, + { primary: 'primary@email.com', funEmail: 'fun@email.com' } + ]); + expect(changeset.changes).toEqual([ + { + key: 'emails.1', + value: { funEmail: 'fun@email.com', primary: 'primary@email.com' } + } + ]); + + changeset.set('emails.1.primary', 'primary2@email.com'); + + expect(changeset.get('emails.1.primary')).toEqual('primary2@email.com'); + expect(changeset.changes).toEqual([ + { + key: 'emails.1', + value: { + primary: 'primary2@email.com', + funEmail: 'fun@email.com' + } + } + ]); + expect(changeset.get('emails').unwrap()).toEqual([ + { primary: 'bob@email.com' }, + { primary: 'primary2@email.com', funEmail: 'fun@email.com' } + ]); + }); + + it('can edit a new object that was added after deleting an array entry', () => { + const changeset = Changeset({ + emails: [ + { + fun: 'fun0@email.com', + primary: 'primary0@email.com' + }, + { + fun: 'fun1@email.com', + primary: 'primary1@email.com' + } + ] + }); + + changeset.set('emails.1', null); + + expect(changeset.get('emails').unwrap()).toEqual([ + { + fun: 'fun0@email.com', + primary: 'primary0@email.com' + }, + null + ]); + expect(changeset.changes).toEqual([ + { + key: 'emails.1', + value: null + } + ]); + + changeset.set('emails.1', { + fun: 'brandNew@email.com', + primary: 'brandNewPrimary@email.com' + }); + + expect(changeset.get('emails').unwrap()).toEqual([ + { + fun: 'fun0@email.com', + primary: 'primary0@email.com' + }, + { + fun: 'brandNew@email.com', + primary: 'brandNewPrimary@email.com' + } + ]); + expect(changeset.changes).toEqual([ + { + key: 'emails.1', + value: { + fun: 'brandNew@email.com', + primary: 'brandNewPrimary@email.com' + } + } + ]); + }); + }); + describe('arrays of objects within nested objects', () => { describe('#set', () => { let initialData: { contact: { emails: Record[] } } = { @@ -1016,6 +1160,33 @@ describe('Unit | Utility | changeset', () => { { primary: 'primary2@email.com', funEmail: 'fun@email.com' } ]); }); + + it('can edit a new object that was added after deleting an array entry', () => { + const changeset = Changeset({ + contacts: { + emails: [ + { + fun: 'fun0@email.com', + primary: 'primary0@email.com' + }, + { + fun: 'fun1@email.com', + primary: 'primary1@email.com' + } + ] + } + }); + + changeset.set('contacts.emails.1', null); + + expect(changeset.get('contacts.emails').unwrap()).toEqual([ + { + fun: 'fun0@email.com', + primary: 'primary0@email.com' + }, + null + ]); + }); }); }); From d24716c3238d99bfe680277815333c5c78bc9891 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Fri, 19 Feb 2021 15:03:49 -0500 Subject: [PATCH 26/29] remove debugger --- test/index.test.ts | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/test/index.test.ts b/test/index.test.ts index 4efaecb..a494b2f 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -934,7 +934,6 @@ describe('Unit | Utility | changeset', () => { it('can modify properties on an entry', () => { const changeset = Changeset(initialData); - debugger; changeset.set('emails.0.primary', 'fun@email.com'); expect(changeset.get('emails.0.primary')).toEqual('fun@email.com'); @@ -1066,6 +1065,43 @@ describe('Unit | Utility | changeset', () => { } ]); }); + + it('can edit an object with a key of value after another array entry has been deleted', () => { + const changeset = Changeset({ + emails: [ + { + fun: 'fun0@email.com', + primary: 'primary0@email.com', + value: 'the value' + }, + { + fun: 'fun1@email.com', + primary: 'primary1@email.com', + value: 'some value' + } + ] + }); + + changeset.set('emails.1', null); + + expect(changeset.get('emails').unwrap()).toEqual([ + { + fun: 'fun0@email.com', + primary: 'primary0@email.com', + value: 'the value' + }, + null + ]); + expect(changeset.changes).toEqual([ + { + key: 'emails.1', + value: null + } + ]); + + // does not need to be unwrapped + expect(changeset.get('emails.0.value')).toEqual('the value'); + }); }); describe('arrays of objects within nested objects', () => { From f4d5c228a7e5861e8486b5e0fee2b2b1f4b0d813 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Fri, 19 Feb 2021 17:10:06 -0500 Subject: [PATCH 27/29] leaf nodes are no longer correct when there is an array in the path --- src/index.ts | 4 ++-- src/utils/array-object.ts | 2 ++ test/index.test.ts | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index dc186b9..d4238c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -966,13 +966,13 @@ export class BufferedChangeset implements IChangeset { } const baseContent = this.safeGet(content, baseKey); - if (isArrayObject(normalizedBaseChanges) && Array.isArray(baseContent)) { + if (Array.isArray(baseContent)) { const subChanges = getSubObject(changes, key); // give back an object that can further retrieve changes and/or content const tree = new ObjectTreeNode(subChanges, baseContent, this.getDeep, this.isObject); - return tree.proxy; + return tree; } } diff --git a/src/utils/array-object.ts b/src/utils/array-object.ts index 6b1f936..013588e 100644 --- a/src/utils/array-object.ts +++ b/src/utils/array-object.ts @@ -1,4 +1,6 @@ export function isArrayObject(obj: Record) { + if (!obj) return false; + let maybeIndicies = Object.keys(obj); return maybeIndicies.every(key => Number.isInteger(parseInt(key, 10))); diff --git a/test/index.test.ts b/test/index.test.ts index a494b2f..7eabb88 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1099,6 +1099,8 @@ describe('Unit | Utility | changeset', () => { } ]); + expect(changeset.get('emails.0.fun')).toEqual('fun0@email.com'); + expect(changeset.get('emails.0.primary')).toEqual('primary0@email.com'); // does not need to be unwrapped expect(changeset.get('emails.0.value')).toEqual('the value'); }); From 6cd8c9d406bed20667ca433d6d80fa37aa76dbe3 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Tue, 23 Feb 2021 09:32:49 -0500 Subject: [PATCH 28/29] add additional assertions --- test/index.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/index.test.ts b/test/index.test.ts index 7eabb88..a045c08 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1026,6 +1026,8 @@ describe('Unit | Utility | changeset', () => { changeset.set('emails.1', null); + expect(changeset.get('emails.0.fun')).toEqual('fun0@email.com'); + expect(changeset.get('emails.0.primary')).toEqual('primary0@email.com'); expect(changeset.get('emails').unwrap()).toEqual([ { fun: 'fun0@email.com', From e69073b675ac90948117635f6746d6d355f87c7e Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Tue, 23 Feb 2021 14:28:21 -0500 Subject: [PATCH 29/29] fix access to deep sub changes within an array where there is no change --- src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/index.ts b/src/index.ts index d4238c5..733c576 100644 --- a/src/index.ts +++ b/src/index.ts @@ -969,6 +969,10 @@ export class BufferedChangeset implements IChangeset { if (Array.isArray(baseContent)) { const subChanges = getSubObject(changes, key); + if (!subChanges) { + return this.getDeep(baseContent, remaining.join('.')); + } + // give back an object that can further retrieve changes and/or content const tree = new ObjectTreeNode(subChanges, baseContent, this.getDeep, this.isObject);