diff --git a/dist/mobx-spine.cjs.js b/dist/mobx-spine.cjs.js index 13ac9a7..1a4527e 100644 --- a/dist/mobx-spine.cjs.js +++ b/dist/mobx-spine.cjs.js @@ -924,14 +924,39 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { value: function getNegativeId() { return -parseInt(this.cid.replace('m', '')); } + + /** + * Get InternalId returns the id of a model or a negative id if the id is not set + * @returns {*} - the id of a model or a negative id if the id is not set + */ + }, { key: 'getInternalId', value: function getInternalId() { - if (this.isNew) { + if (!this[this.constructor.primaryKey]) { return this.getNegativeId(); } return this[this.constructor.primaryKey]; } + + /** + * Gives the model the internal id. This is useful if you have a new model that you want to give an id so + * that it can be referred to in a relation. + */ + + }, { + key: 'assignInternalId', + value: function assignInternalId() { + this[this.constructor.primaryKey] = this.getInternalId(); + } + + /** + * The get url returns the url for a model., it appends the id if there is one. If the model is new it should not + * append an id. + * + * @returns {string} - the url for a model + */ + }, { key: 'casts', value: function casts() { @@ -962,12 +987,18 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { key: 'url', get: function get() { var id = this[this.constructor.primaryKey]; - return '' + lodash.result(this, 'urlRoot') + (id ? id + '/' : ''); + return '' + lodash.result(this, 'urlRoot') + (!this.isNew ? id + '/' : ''); } + + /** + * A model is considered new if it does not have an id, or if the id is a negative integer. + * @returns {boolean} True if the model id is not set or a negative integer + */ + }, { key: 'isNew', get: function get() { - return !this[this.constructor.primaryKey]; + return !this[this.constructor.primaryKey] || this[this.constructor.primaryKey] < 0; } }, { key: 'isLoading', @@ -1062,7 +1093,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { activeRelations.forEach(function (aRel) { // If aRel is null, this relation is already defined by another aRel // IE.: town.restaurants.chef && town - if (aRel === null) { + if (aRel === null || !!_this3[aRel]) { return; } var relNames = aRel.match(RE_SPLIT_FIRST_RELATION); @@ -1080,7 +1111,10 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { _this3.__activeCurrentRelations.push(currentRel); } }); - mobx.extendObservable(this, lodash.mapValues(relModels, function (otherRelNames, relName) { + // extendObservable where we omit the fields that are already created from other relations + mobx.extendObservable(this, lodash.mapValues(lodash.omit(relModels, Object.keys(relModels).filter(function (rel) { + return !!_this3[rel]; + })), function (otherRelNames, relName) { var RelModel = relations[relName]; invariant(RelModel, 'Specified relation "' + relName + '" does not exist on model.'); var options = { relations: otherRelNames }; @@ -1234,18 +1268,129 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { return { data: [data], relations: relations }; } + + /** + * Makes this model a copy of the specified model + * or returns a copy of the current model when no model to copy is given + * It also clones the changes that were in the specified model. + * Cloning the changes requires recursion over all related models that have changes or are related to a model with changes. + * Cloning + * + * @param source {Model} - The model that should be copied + * @param options {{}} - Options, {copyChanges - only copy the changed attributes, requires recursion over all related objects with changes} + */ + + }, { + key: 'copy', + value: function copy() { + var source = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : undefined; + var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : { copyChanges: true }; + + var copiedModel = void 0; + // If our source is not a model it is 'probably' the options + if (source !== undefined && !(source instanceof Model)) { + options = source; + source = undefined; + } + + // Make sure that we have the correct model + if (source === undefined) { + source = this; + copiedModel = new source.constructor({ relations: source.__activeRelations }); + } else if (this.constructor !== source.constructor) { + copiedModel = new source.constructor({ relations: source.__activeRelations }); + } else { + copiedModel = this; + } + + var copyChanges = options.copyChanges; + + // Maintain the relations after copy + // this.__activeRelations = source.__activeRelations; + + copiedModel.__parseRelations(source.__activeRelations); + // Copy all fields and values from the specified model + copiedModel.parse(source.toJS()); + + // Set only the changed attributes + if (copyChanges) { + copiedModel.__copyChanges(source); + } + + return copiedModel; + } + + /** + * Goes over model and all related models to set the changed values and notify the store + * + * @param source - the model to copy + * @param store - the store of the current model, to setChanged if there are changes + * @private + */ + + }, { + key: '__copyChanges', + value: function __copyChanges(source, store) { + var _this6 = this; + + // Maintain the relations after copy + this.__parseRelations(source.__activeRelations); + + // Copy all changed fields and notify the store that there are changes + if (source.__changes.length > 0) { + if (store) { + store.__setChanged = true; + } else if (this.__store) { + this.__store.__setChanged = true; + } + + source.__changes.forEach(function (changedAttribute) { + _this6.setInput(changedAttribute, source[changedAttribute]); + }); + } + // Undefined safety + if (source.__activeCurrentRelations.length > 0) { + // Set the changes for all related models with changes + source.__activeCurrentRelations.forEach(function (relation) { + if (relation && source[relation]) { + if (_this6[relation]) { + if (source[relation].hasUserChanges) { + if (source[relation].models) { + // If related item is a store + if (source[relation].models.length === _this6[relation].models.length) { + // run only if the store shares the same amount of items + // Check if the store has some changes + _this6[relation].__setChanged = source[relation].__setChanged; + // Set the changes for all related models with changes + source[relation].models.forEach(function (relatedModel, index) { + _this6[relation].models[index].__copyChanges(relatedModel, _this6[relation]); + }); + } + } else { + // Set the changes for the related model + _this6[relation].__copyChanges(source[relation], undefined); + } + } + } else { + // Related object not in relations of the model we are copying + console.warn('Found related object ' + source.constructor.backendResourceName + ' with relation ' + relation + ',\n which is not defined in the relations of the model you are copying. Skipping ' + relation + '.'); + } + } + }); + } + } }, { key: 'toJS', value: function toJS() { - var _this6 = this; + var _this7 = this; var output = {}; this.__attributes.forEach(function (attr) { - output[attr] = _this6.__toJSAttr(attr, _this6[attr]); + output[attr] = _this7.__toJSAttr(attr, _this7[attr]); }); this.__activeCurrentRelations.forEach(function (currentRel) { - var model = _this6[currentRel]; + var model = _this7[currentRel]; if (model) { output[currentRel] = model.toJS(); } @@ -1308,7 +1453,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: '__scopeBackendResponse', value: function __scopeBackendResponse(_ref3) { - var _this7 = this; + var _this8 = this; var data = _ref3.data, targetRelName = _ref3.targetRelName, @@ -1330,18 +1475,18 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { var repository = repos[repoName]; // For backwards compatibility, reverseMapping is optional (for now) var reverseRelName = reverseMapping ? reverseMapping[backendRelName] : null; - var relName = _this7.constructor.fromBackendAttrKey(backendRelName); + var relName = _this8.constructor.fromBackendAttrKey(backendRelName); if (targetRelName === relName) { - var relKey = data[_this7.constructor.toBackendAttrKey(relName)]; + var relKey = data[_this8.constructor.toBackendAttrKey(relName)]; if (relKey !== undefined) { relevant = true; - scopedData = _this7.__parseRepositoryToData(relKey, repository); + scopedData = _this8.__parseRepositoryToData(relKey, repository); } else if (repository && reverseRelName) { - var pk = data[_this7.constructor.primaryKey]; + var pk = data[_this8.constructor.primaryKey]; relevant = true; - scopedData = _this7.__parseReverseRepositoryToData(reverseRelName, pk, repository); - if (_this7.relations(relName).prototype instanceof Model) { + scopedData = _this8.__parseReverseRepositoryToData(reverseRelName, pk, repository); + if (_this8.relations(relName).prototype instanceof Model) { if (scopedData.length === 0) { scopedData = null; } else if (scopedData.length === 1) { @@ -1381,7 +1526,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: 'fromBackend', value: function fromBackend(_ref4) { - var _this8 = this; + var _this9 = this; var data = _ref4.data, repos = _ref4.repos, @@ -1394,8 +1539,8 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { // So when we have a model with a `town.restaurants.chef` relation, // we call fromBackend on the `town` relation. lodash.each(this.__activeCurrentRelations, function (relName) { - var rel = _this8[relName]; - var resScoped = _this8.__scopeBackendResponse({ + var rel = _this9[relName]; + var resScoped = _this9.__scopeBackendResponse({ data: data, targetRelName: relName, repos: repos, @@ -1437,22 +1582,22 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: 'parse', value: function parse(data) { - var _this9 = this; + var _this10 = this; invariant(lodash.isPlainObject(data), 'Parameter supplied to `parse()` is not an object, got: ' + JSON.stringify(data)); lodash.forIn(data, function (value, key) { - var attr = _this9.constructor.fromBackendAttrKey(key); - if (_this9.__attributes.includes(attr)) { - _this9[attr] = _this9.__parseAttr(attr, value); - } else if (_this9.__activeCurrentRelations.includes(attr)) { + var attr = _this10.constructor.fromBackendAttrKey(key); + if (_this10.__attributes.includes(attr)) { + _this10[attr] = _this10.__parseAttr(attr, value); + } else if (_this10.__activeCurrentRelations.includes(attr)) { // In Binder, a relation property is an `int` or `[int]`, referring to its ID. // However, it can also be an object if there are nested relations (non flattened). if (lodash.isPlainObject(value) || Array.isArray(value) && value.every(lodash.isPlainObject)) { - _this9[attr].parse(value); + _this10[attr].parse(value); } else if (value === null) { // The relation is cleared. - _this9[attr].clear(); + _this10[attr].clear(); } } }); @@ -1472,7 +1617,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: 'saveFile', value: function saveFile(name) { - var _this10 = this; + var _this11 = this; var snakeName = camelToSnake(name); @@ -1483,16 +1628,16 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { data.append(name, file, file.name); return this.api.post('' + this.url + snakeName + '/', data, { headers: { 'Content-Type': 'multipart/form-data' } }).then(mobx.action(function (res) { - _this10.__fileExists[name] = true; - delete _this10.__fileChanges[name]; - _this10.saveFromBackend(res); + _this11.__fileExists[name] = true; + delete _this11.__fileChanges[name]; + _this11.saveFromBackend(res); })); } else if (this.__fileDeletions[name]) { if (this.__fileExists[name]) { return this.api.delete('' + this.url + snakeName + '/').then(mobx.action(function () { - _this10.__fileExists[name] = false; - delete _this10.__fileDeletions[name]; - _this10.saveFromBackend({ data: defineProperty({}, snakeName, null) }); + _this11.__fileExists[name] = false; + delete _this11.__fileDeletions[name]; + _this11.saveFromBackend({ data: defineProperty({}, snakeName, null) }); })); } else { delete this.__fileDeletions[name]; @@ -1582,6 +1727,30 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { return Promise.all(promises); } + + /** + * Validates a model by sending a save request to binder with the validate header set. Binder will return the validation + * errors without actually committing the save + * + * @param options - same as for a normal save request, example: {onlyChanges: true} + */ + + }, { + key: 'validate', + value: function validate() { + var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; + + // Add the validate parameter + if (options.params) { + options.params.validate = true; + } else { + options.params = { validate: true }; + } + + return this.save(options).catch(function (err) { + throw err; + }); + } }, { key: 'save', value: function save() { @@ -1596,7 +1765,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: '_save', value: function _save() { - var _this11 = this; + var _this12 = this; var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; @@ -1612,17 +1781,20 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { isNew: this.isNew, requestOptions: lodash.omit(options, 'url', 'data', 'mapData') }).then(mobx.action(function (res) { - _this11.saveFromBackend(_extends({}, res, { - data: lodash.omit(res.data, _this11.fileFields().map(camelToSnake)) - })); - _this11.clearUserFieldChanges(); - return _this11.saveFiles().then(function () { - _this11.clearUserFileChanges(); - return Promise.resolve(res); - }); + // Only update the model when we are actually trying to save + if (!options.params || !options.params.validate) { + _this12.saveFromBackend(_extends({}, res, { + data: lodash.omit(res.data, _this12.fileFields().map(camelToSnake)) + })); + _this12.clearUserFieldChanges(); + return _this12.saveFiles().then(function () { + _this12.clearUserFileChanges(); + return Promise.resolve(res); + }); + } })).catch(mobx.action(function (err) { if (err.valErrors) { - _this11.parseValidationErrors(err.valErrors); + _this12.parseValidationErrors(err.valErrors); } throw err; }))); @@ -1630,7 +1802,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: '_saveAll', value: function _saveAll() { - var _this12 = this; + var _this13 = this; var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; @@ -1646,31 +1818,34 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }), requestOptions: lodash.omit(options, 'relations', 'data', 'mapData') }).then(mobx.action(function (res) { - _this12.saveFromBackend(res); - _this12.clearUserFieldChanges(); - - forNestedRelations(_this12, relationsToNestedKeys(options.relations || []), function (relation) { - if (relation instanceof Model) { - relation.clearUserFieldChanges(); - } else { - relation.clearSetChanges(); - } - }); + // Only update the models if we are actually trying to save + if (!options.params || !options.params.validate) { + _this13.saveFromBackend(res); + _this13.clearUserFieldChanges(); - return _this12.saveAllFiles(relationsToNestedKeys(options.relations || [])).then(function () { - _this12.clearUserFileChanges(); - - forNestedRelations(_this12, relationsToNestedKeys(options.relations || []), function (relation) { + forNestedRelations(_this13, relationsToNestedKeys(options.relations || []), function (relation) { if (relation instanceof Model) { - relation.clearUserFileChanges(); + relation.clearUserFieldChanges(); + } else { + relation.clearSetChanges(); } }); - return res; - }); + return _this13.saveAllFiles(relationsToNestedKeys(options.relations || [])).then(function () { + _this13.clearUserFileChanges(); + + forNestedRelations(_this13, relationsToNestedKeys(options.relations || []), function (relation) { + if (relation instanceof Model) { + relation.clearUserFileChanges(); + } + }); + + return res; + }); + } })).catch(mobx.action(function (err) { if (err.valErrors) { - _this12.parseValidationErrors(err.valErrors); + _this13.parseValidationErrors(err.valErrors); } throw err; }))); @@ -1682,19 +1857,19 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: '__parseNewIds', value: function __parseNewIds(idMaps) { - var _this13 = this; + var _this14 = this; var bName = this.constructor.backendResourceName; if (bName && idMaps[bName]) { var idMap = idMaps[bName].find(function (ids) { - return ids[0] === _this13.getInternalId(); + return ids[0] === _this14.getInternalId(); }); if (idMap) { this[this.constructor.primaryKey] = idMap[1]; } } lodash.each(this.__activeCurrentRelations, function (relName) { - var rel = _this13[relName]; + var rel = _this14[relName]; rel.__parseNewIds(idMaps); }); } @@ -1706,7 +1881,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: 'parseValidationErrors', value: function parseValidationErrors(valErrors) { - var _this14 = this; + var _this15 = this; var bname = this.constructor.backendResourceName; @@ -1719,24 +1894,24 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { return snakeToCamel(key); }); var formattedErrors = lodash.mapValues(camelCasedErrors, function (valError) { - return valError.map(_this14.validationErrorFormatter); + return valError.map(_this15.validationErrorFormatter); }); this.__backendValidationErrors = formattedErrors; } } this.__activeCurrentRelations.forEach(function (currentRel) { - _this14[currentRel].parseValidationErrors(valErrors); + _this15[currentRel].parseValidationErrors(valErrors); }); } }, { key: 'clearValidationErrors', value: function clearValidationErrors() { - var _this15 = this; + var _this16 = this; this.__backendValidationErrors = {}; this.__activeCurrentRelations.forEach(function (currentRel) { - _this15[currentRel].clearValidationErrors(); + _this16[currentRel].clearValidationErrors(); }); } @@ -1754,12 +1929,12 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: 'delete', value: function _delete() { - var _this16 = this; + var _this17 = this; var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; var removeFromStore = function removeFromStore() { - return _this16.__store ? _this16.__store.remove(_this16) : null; + return _this17.__store ? _this17.__store.remove(_this17) : null; }; if (options.immediate || this.isNew) { removeFromStore(); @@ -1785,7 +1960,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: 'fetch', value: function fetch() { - var _this17 = this; + var _this18 = this; var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; @@ -1801,7 +1976,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { data: data, requestOptions: lodash.omit(options, ['data', 'url']) }).then(mobx.action(function (res) { - _this17.fromBackend(res); + _this18.fromBackend(res); })).catch(function (e) { if (Axios.isCancel(e)) { return null; @@ -1815,14 +1990,14 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: 'clear', value: function clear() { - var _this18 = this; + var _this19 = this; lodash.forIn(this.__originalAttributes, function (value, key) { - _this18[key] = value; + _this19[key] = value; }); this.__activeCurrentRelations.forEach(function (currentRel) { - _this18[currentRel].clear(); + _this19[currentRel].clear(); }); } @@ -1843,13 +2018,13 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: 'hasUserChanges', get: function get() { - var _this19 = this; + var _this20 = this; if (this.__changes.length > 0) { return true; } return this.__activeCurrentRelations.some(function (rel) { - return _this19[rel].hasUserChanges; + return _this20[rel].hasUserChanges; }); } }, { @@ -2257,7 +2432,7 @@ function checkLuxonDateTime(attr, value) { } var LUXON_DATE_FORMAT = 'yyyy-LL-dd'; -var LUXON_DATETIME_FORMAT = "yyyy'-'LL'-'dd'T'HH':'mm':'ssZZ"; +var LUXON_DATETIME_FORMAT = 'yyyy\'-\'LL\'-\'dd\'T\'HH\':\'mm\':\'ssZZ'; var CASTS = { momentDate: { diff --git a/dist/mobx-spine.es.js b/dist/mobx-spine.es.js index 44436ae..3636d14 100644 --- a/dist/mobx-spine.es.js +++ b/dist/mobx-spine.es.js @@ -918,14 +918,39 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { value: function getNegativeId() { return -parseInt(this.cid.replace('m', '')); } + + /** + * Get InternalId returns the id of a model or a negative id if the id is not set + * @returns {*} - the id of a model or a negative id if the id is not set + */ + }, { key: 'getInternalId', value: function getInternalId() { - if (this.isNew) { + if (!this[this.constructor.primaryKey]) { return this.getNegativeId(); } return this[this.constructor.primaryKey]; } + + /** + * Gives the model the internal id. This is useful if you have a new model that you want to give an id so + * that it can be referred to in a relation. + */ + + }, { + key: 'assignInternalId', + value: function assignInternalId() { + this[this.constructor.primaryKey] = this.getInternalId(); + } + + /** + * The get url returns the url for a model., it appends the id if there is one. If the model is new it should not + * append an id. + * + * @returns {string} - the url for a model + */ + }, { key: 'casts', value: function casts() { @@ -956,12 +981,18 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { key: 'url', get: function get() { var id = this[this.constructor.primaryKey]; - return '' + result(this, 'urlRoot') + (id ? id + '/' : ''); + return '' + result(this, 'urlRoot') + (!this.isNew ? id + '/' : ''); } + + /** + * A model is considered new if it does not have an id, or if the id is a negative integer. + * @returns {boolean} True if the model id is not set or a negative integer + */ + }, { key: 'isNew', get: function get() { - return !this[this.constructor.primaryKey]; + return !this[this.constructor.primaryKey] || this[this.constructor.primaryKey] < 0; } }, { key: 'isLoading', @@ -1056,7 +1087,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { activeRelations.forEach(function (aRel) { // If aRel is null, this relation is already defined by another aRel // IE.: town.restaurants.chef && town - if (aRel === null) { + if (aRel === null || !!_this3[aRel]) { return; } var relNames = aRel.match(RE_SPLIT_FIRST_RELATION); @@ -1074,7 +1105,10 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { _this3.__activeCurrentRelations.push(currentRel); } }); - extendObservable(this, mapValues(relModels, function (otherRelNames, relName) { + // extendObservable where we omit the fields that are already created from other relations + extendObservable(this, mapValues(omit(relModels, Object.keys(relModels).filter(function (rel) { + return !!_this3[rel]; + })), function (otherRelNames, relName) { var RelModel = relations[relName]; invariant(RelModel, 'Specified relation "' + relName + '" does not exist on model.'); var options = { relations: otherRelNames }; @@ -1228,18 +1262,129 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { return { data: [data], relations: relations }; } + + /** + * Makes this model a copy of the specified model + * or returns a copy of the current model when no model to copy is given + * It also clones the changes that were in the specified model. + * Cloning the changes requires recursion over all related models that have changes or are related to a model with changes. + * Cloning + * + * @param source {Model} - The model that should be copied + * @param options {{}} - Options, {copyChanges - only copy the changed attributes, requires recursion over all related objects with changes} + */ + + }, { + key: 'copy', + value: function copy() { + var source = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : undefined; + var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : { copyChanges: true }; + + var copiedModel = void 0; + // If our source is not a model it is 'probably' the options + if (source !== undefined && !(source instanceof Model)) { + options = source; + source = undefined; + } + + // Make sure that we have the correct model + if (source === undefined) { + source = this; + copiedModel = new source.constructor({ relations: source.__activeRelations }); + } else if (this.constructor !== source.constructor) { + copiedModel = new source.constructor({ relations: source.__activeRelations }); + } else { + copiedModel = this; + } + + var copyChanges = options.copyChanges; + + // Maintain the relations after copy + // this.__activeRelations = source.__activeRelations; + + copiedModel.__parseRelations(source.__activeRelations); + // Copy all fields and values from the specified model + copiedModel.parse(source.toJS()); + + // Set only the changed attributes + if (copyChanges) { + copiedModel.__copyChanges(source); + } + + return copiedModel; + } + + /** + * Goes over model and all related models to set the changed values and notify the store + * + * @param source - the model to copy + * @param store - the store of the current model, to setChanged if there are changes + * @private + */ + + }, { + key: '__copyChanges', + value: function __copyChanges(source, store) { + var _this6 = this; + + // Maintain the relations after copy + this.__parseRelations(source.__activeRelations); + + // Copy all changed fields and notify the store that there are changes + if (source.__changes.length > 0) { + if (store) { + store.__setChanged = true; + } else if (this.__store) { + this.__store.__setChanged = true; + } + + source.__changes.forEach(function (changedAttribute) { + _this6.setInput(changedAttribute, source[changedAttribute]); + }); + } + // Undefined safety + if (source.__activeCurrentRelations.length > 0) { + // Set the changes for all related models with changes + source.__activeCurrentRelations.forEach(function (relation) { + if (relation && source[relation]) { + if (_this6[relation]) { + if (source[relation].hasUserChanges) { + if (source[relation].models) { + // If related item is a store + if (source[relation].models.length === _this6[relation].models.length) { + // run only if the store shares the same amount of items + // Check if the store has some changes + _this6[relation].__setChanged = source[relation].__setChanged; + // Set the changes for all related models with changes + source[relation].models.forEach(function (relatedModel, index) { + _this6[relation].models[index].__copyChanges(relatedModel, _this6[relation]); + }); + } + } else { + // Set the changes for the related model + _this6[relation].__copyChanges(source[relation], undefined); + } + } + } else { + // Related object not in relations of the model we are copying + console.warn('Found related object ' + source.constructor.backendResourceName + ' with relation ' + relation + ',\n which is not defined in the relations of the model you are copying. Skipping ' + relation + '.'); + } + } + }); + } + } }, { key: 'toJS', value: function toJS() { - var _this6 = this; + var _this7 = this; var output = {}; this.__attributes.forEach(function (attr) { - output[attr] = _this6.__toJSAttr(attr, _this6[attr]); + output[attr] = _this7.__toJSAttr(attr, _this7[attr]); }); this.__activeCurrentRelations.forEach(function (currentRel) { - var model = _this6[currentRel]; + var model = _this7[currentRel]; if (model) { output[currentRel] = model.toJS(); } @@ -1302,7 +1447,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: '__scopeBackendResponse', value: function __scopeBackendResponse(_ref3) { - var _this7 = this; + var _this8 = this; var data = _ref3.data, targetRelName = _ref3.targetRelName, @@ -1324,18 +1469,18 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { var repository = repos[repoName]; // For backwards compatibility, reverseMapping is optional (for now) var reverseRelName = reverseMapping ? reverseMapping[backendRelName] : null; - var relName = _this7.constructor.fromBackendAttrKey(backendRelName); + var relName = _this8.constructor.fromBackendAttrKey(backendRelName); if (targetRelName === relName) { - var relKey = data[_this7.constructor.toBackendAttrKey(relName)]; + var relKey = data[_this8.constructor.toBackendAttrKey(relName)]; if (relKey !== undefined) { relevant = true; - scopedData = _this7.__parseRepositoryToData(relKey, repository); + scopedData = _this8.__parseRepositoryToData(relKey, repository); } else if (repository && reverseRelName) { - var pk = data[_this7.constructor.primaryKey]; + var pk = data[_this8.constructor.primaryKey]; relevant = true; - scopedData = _this7.__parseReverseRepositoryToData(reverseRelName, pk, repository); - if (_this7.relations(relName).prototype instanceof Model) { + scopedData = _this8.__parseReverseRepositoryToData(reverseRelName, pk, repository); + if (_this8.relations(relName).prototype instanceof Model) { if (scopedData.length === 0) { scopedData = null; } else if (scopedData.length === 1) { @@ -1375,7 +1520,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: 'fromBackend', value: function fromBackend(_ref4) { - var _this8 = this; + var _this9 = this; var data = _ref4.data, repos = _ref4.repos, @@ -1388,8 +1533,8 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { // So when we have a model with a `town.restaurants.chef` relation, // we call fromBackend on the `town` relation. each(this.__activeCurrentRelations, function (relName) { - var rel = _this8[relName]; - var resScoped = _this8.__scopeBackendResponse({ + var rel = _this9[relName]; + var resScoped = _this9.__scopeBackendResponse({ data: data, targetRelName: relName, repos: repos, @@ -1431,22 +1576,22 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: 'parse', value: function parse(data) { - var _this9 = this; + var _this10 = this; invariant(isPlainObject(data), 'Parameter supplied to `parse()` is not an object, got: ' + JSON.stringify(data)); forIn(data, function (value, key) { - var attr = _this9.constructor.fromBackendAttrKey(key); - if (_this9.__attributes.includes(attr)) { - _this9[attr] = _this9.__parseAttr(attr, value); - } else if (_this9.__activeCurrentRelations.includes(attr)) { + var attr = _this10.constructor.fromBackendAttrKey(key); + if (_this10.__attributes.includes(attr)) { + _this10[attr] = _this10.__parseAttr(attr, value); + } else if (_this10.__activeCurrentRelations.includes(attr)) { // In Binder, a relation property is an `int` or `[int]`, referring to its ID. // However, it can also be an object if there are nested relations (non flattened). if (isPlainObject(value) || Array.isArray(value) && value.every(isPlainObject)) { - _this9[attr].parse(value); + _this10[attr].parse(value); } else if (value === null) { // The relation is cleared. - _this9[attr].clear(); + _this10[attr].clear(); } } }); @@ -1466,7 +1611,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: 'saveFile', value: function saveFile(name) { - var _this10 = this; + var _this11 = this; var snakeName = camelToSnake(name); @@ -1477,16 +1622,16 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { data.append(name, file, file.name); return this.api.post('' + this.url + snakeName + '/', data, { headers: { 'Content-Type': 'multipart/form-data' } }).then(action(function (res) { - _this10.__fileExists[name] = true; - delete _this10.__fileChanges[name]; - _this10.saveFromBackend(res); + _this11.__fileExists[name] = true; + delete _this11.__fileChanges[name]; + _this11.saveFromBackend(res); })); } else if (this.__fileDeletions[name]) { if (this.__fileExists[name]) { return this.api.delete('' + this.url + snakeName + '/').then(action(function () { - _this10.__fileExists[name] = false; - delete _this10.__fileDeletions[name]; - _this10.saveFromBackend({ data: defineProperty({}, snakeName, null) }); + _this11.__fileExists[name] = false; + delete _this11.__fileDeletions[name]; + _this11.saveFromBackend({ data: defineProperty({}, snakeName, null) }); })); } else { delete this.__fileDeletions[name]; @@ -1576,6 +1721,30 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { return Promise.all(promises); } + + /** + * Validates a model by sending a save request to binder with the validate header set. Binder will return the validation + * errors without actually committing the save + * + * @param options - same as for a normal save request, example: {onlyChanges: true} + */ + + }, { + key: 'validate', + value: function validate() { + var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; + + // Add the validate parameter + if (options.params) { + options.params.validate = true; + } else { + options.params = { validate: true }; + } + + return this.save(options).catch(function (err) { + throw err; + }); + } }, { key: 'save', value: function save() { @@ -1590,7 +1759,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: '_save', value: function _save() { - var _this11 = this; + var _this12 = this; var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; @@ -1606,17 +1775,20 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { isNew: this.isNew, requestOptions: omit(options, 'url', 'data', 'mapData') }).then(action(function (res) { - _this11.saveFromBackend(_extends({}, res, { - data: omit(res.data, _this11.fileFields().map(camelToSnake)) - })); - _this11.clearUserFieldChanges(); - return _this11.saveFiles().then(function () { - _this11.clearUserFileChanges(); - return Promise.resolve(res); - }); + // Only update the model when we are actually trying to save + if (!options.params || !options.params.validate) { + _this12.saveFromBackend(_extends({}, res, { + data: omit(res.data, _this12.fileFields().map(camelToSnake)) + })); + _this12.clearUserFieldChanges(); + return _this12.saveFiles().then(function () { + _this12.clearUserFileChanges(); + return Promise.resolve(res); + }); + } })).catch(action(function (err) { if (err.valErrors) { - _this11.parseValidationErrors(err.valErrors); + _this12.parseValidationErrors(err.valErrors); } throw err; }))); @@ -1624,7 +1796,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: '_saveAll', value: function _saveAll() { - var _this12 = this; + var _this13 = this; var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; @@ -1640,31 +1812,34 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }), requestOptions: omit(options, 'relations', 'data', 'mapData') }).then(action(function (res) { - _this12.saveFromBackend(res); - _this12.clearUserFieldChanges(); - - forNestedRelations(_this12, relationsToNestedKeys(options.relations || []), function (relation) { - if (relation instanceof Model) { - relation.clearUserFieldChanges(); - } else { - relation.clearSetChanges(); - } - }); + // Only update the models if we are actually trying to save + if (!options.params || !options.params.validate) { + _this13.saveFromBackend(res); + _this13.clearUserFieldChanges(); - return _this12.saveAllFiles(relationsToNestedKeys(options.relations || [])).then(function () { - _this12.clearUserFileChanges(); - - forNestedRelations(_this12, relationsToNestedKeys(options.relations || []), function (relation) { + forNestedRelations(_this13, relationsToNestedKeys(options.relations || []), function (relation) { if (relation instanceof Model) { - relation.clearUserFileChanges(); + relation.clearUserFieldChanges(); + } else { + relation.clearSetChanges(); } }); - return res; - }); + return _this13.saveAllFiles(relationsToNestedKeys(options.relations || [])).then(function () { + _this13.clearUserFileChanges(); + + forNestedRelations(_this13, relationsToNestedKeys(options.relations || []), function (relation) { + if (relation instanceof Model) { + relation.clearUserFileChanges(); + } + }); + + return res; + }); + } })).catch(action(function (err) { if (err.valErrors) { - _this12.parseValidationErrors(err.valErrors); + _this13.parseValidationErrors(err.valErrors); } throw err; }))); @@ -1676,19 +1851,19 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: '__parseNewIds', value: function __parseNewIds(idMaps) { - var _this13 = this; + var _this14 = this; var bName = this.constructor.backendResourceName; if (bName && idMaps[bName]) { var idMap = idMaps[bName].find(function (ids) { - return ids[0] === _this13.getInternalId(); + return ids[0] === _this14.getInternalId(); }); if (idMap) { this[this.constructor.primaryKey] = idMap[1]; } } each(this.__activeCurrentRelations, function (relName) { - var rel = _this13[relName]; + var rel = _this14[relName]; rel.__parseNewIds(idMaps); }); } @@ -1700,7 +1875,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: 'parseValidationErrors', value: function parseValidationErrors(valErrors) { - var _this14 = this; + var _this15 = this; var bname = this.constructor.backendResourceName; @@ -1713,24 +1888,24 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { return snakeToCamel(key); }); var formattedErrors = mapValues(camelCasedErrors, function (valError) { - return valError.map(_this14.validationErrorFormatter); + return valError.map(_this15.validationErrorFormatter); }); this.__backendValidationErrors = formattedErrors; } } this.__activeCurrentRelations.forEach(function (currentRel) { - _this14[currentRel].parseValidationErrors(valErrors); + _this15[currentRel].parseValidationErrors(valErrors); }); } }, { key: 'clearValidationErrors', value: function clearValidationErrors() { - var _this15 = this; + var _this16 = this; this.__backendValidationErrors = {}; this.__activeCurrentRelations.forEach(function (currentRel) { - _this15[currentRel].clearValidationErrors(); + _this16[currentRel].clearValidationErrors(); }); } @@ -1748,12 +1923,12 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: 'delete', value: function _delete() { - var _this16 = this; + var _this17 = this; var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; var removeFromStore = function removeFromStore() { - return _this16.__store ? _this16.__store.remove(_this16) : null; + return _this17.__store ? _this17.__store.remove(_this17) : null; }; if (options.immediate || this.isNew) { removeFromStore(); @@ -1779,7 +1954,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: 'fetch', value: function fetch() { - var _this17 = this; + var _this18 = this; var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; @@ -1795,7 +1970,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { data: data, requestOptions: omit(options, ['data', 'url']) }).then(action(function (res) { - _this17.fromBackend(res); + _this18.fromBackend(res); })).catch(function (e) { if (Axios.isCancel(e)) { return null; @@ -1809,14 +1984,14 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: 'clear', value: function clear() { - var _this18 = this; + var _this19 = this; forIn(this.__originalAttributes, function (value, key) { - _this18[key] = value; + _this19[key] = value; }); this.__activeCurrentRelations.forEach(function (currentRel) { - _this18[currentRel].clear(); + _this19[currentRel].clear(); }); } @@ -1837,13 +2012,13 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: 'hasUserChanges', get: function get() { - var _this19 = this; + var _this20 = this; if (this.__changes.length > 0) { return true; } return this.__activeCurrentRelations.some(function (rel) { - return _this19[rel].hasUserChanges; + return _this20[rel].hasUserChanges; }); } }, { @@ -2251,7 +2426,7 @@ function checkLuxonDateTime(attr, value) { } var LUXON_DATE_FORMAT = 'yyyy-LL-dd'; -var LUXON_DATETIME_FORMAT = "yyyy'-'LL'-'dd'T'HH':'mm':'ssZZ"; +var LUXON_DATETIME_FORMAT = 'yyyy\'-\'LL\'-\'dd\'T\'HH\':\'mm\':\'ssZZ'; var CASTS = { momentDate: { diff --git a/package.json b/package.json index b7231b3..1a045cc 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,8 @@ "./src" ], "testPathIgnorePatterns": [ - "/fixtures/" + "/fixtures/", + "/helpers/" ] } } diff --git a/src/Model.js b/src/Model.js index fba8bde..9803e66 100644 --- a/src/Model.js +++ b/src/Model.js @@ -110,22 +110,44 @@ export default class Model { return -parseInt(this.cid.replace('m', '')); } + /** + * Get InternalId returns the id of a model or a negative id if the id is not set + * @returns {*} - the id of a model or a negative id if the id is not set + */ getInternalId() { - if (this.isNew) { + if (!this[this.constructor.primaryKey]) { return this.getNegativeId(); } return this[this.constructor.primaryKey]; } + /** + * Gives the model the internal id. This is useful if you have a new model that you want to give an id so + * that it can be referred to in a relation. + */ + assignInternalId() { + this[this.constructor.primaryKey] = this.getInternalId() + } + + /** + * The get url returns the url for a model., it appends the id if there is one. If the model is new it should not + * append an id. + * + * @returns {string} - the url for a model + */ @computed get url() { const id = this[this.constructor.primaryKey]; - return `${result(this, 'urlRoot')}${id ? `${id}/` : ''}`; + return `${result(this, 'urlRoot')}${!this.isNew ? `${id}/` : ''}`; } + /** + * A model is considered new if it does not have an id, or if the id is a negative integer. + * @returns {boolean} True if the model id is not set or a negative integer + */ @computed get isNew() { - return !this[this.constructor.primaryKey]; + return !this[this.constructor.primaryKey] || this[this.constructor.primaryKey] < 0; } @computed @@ -215,7 +237,7 @@ export default class Model { activeRelations.forEach(aRel => { // If aRel is null, this relation is already defined by another aRel // IE.: town.restaurants.chef && town - if (aRel === null) { + if (aRel === null || !!this[aRel]) { return; } const relNames = aRel.match(RE_SPLIT_FIRST_RELATION); @@ -238,9 +260,10 @@ export default class Model { this.__activeCurrentRelations.push(currentRel); } }); + // extendObservable where we omit the fields that are already created from other relations extendObservable( this, - mapValues(relModels, (otherRelNames, relName) => { + mapValues(omit(relModels, Object.keys(relModels).filter(rel => !!this[rel])), (otherRelNames, relName) => { const RelModel = relations[relName]; invariant( RelModel, @@ -420,6 +443,106 @@ export default class Model { return { data: [data], relations }; } + /** + * Makes this model a copy of the specified model + * or returns a copy of the current model when no model to copy is given + * It also clones the changes that were in the specified model. + * Cloning the changes requires recursion over all related models that have changes or are related to a model with changes. + * Cloning + * + * @param source {Model} - The model that should be copied + * @param options {{}} - Options, {copyChanges - only copy the changed attributes, requires recursion over all related objects with changes} + */ + copy(source= undefined, options = {copyChanges: true}){ + let copiedModel; + // If our source is not a model it is 'probably' the options + if (source !== undefined && !(source instanceof Model)){ + options = source; + source = undefined; + } + + // Make sure that we have the correct model + if (source === undefined){ + source = this; + copiedModel = new source.constructor({relations: source.__activeRelations}); + } else if (this.constructor !== source.constructor) { + copiedModel = new source.constructor({relations: source.__activeRelations}); + } else { + copiedModel = this; + } + + const copyChanges = options.copyChanges; + + // Maintain the relations after copy + // this.__activeRelations = source.__activeRelations; + + copiedModel.__parseRelations(source.__activeRelations); + // Copy all fields and values from the specified model + copiedModel.parse(source.toJS()); + + + // Set only the changed attributes + if (copyChanges) { + copiedModel.__copyChanges(source) + } + + return copiedModel; + } + + /** + * Goes over model and all related models to set the changed values and notify the store + * + * @param source - the model to copy + * @param store - the store of the current model, to setChanged if there are changes + * @private + */ + __copyChanges(source, store) { + // Maintain the relations after copy + this.__parseRelations(source.__activeRelations); + + // Copy all changed fields and notify the store that there are changes + if (source.__changes.length > 0) { + if (store) { + store.__setChanged = true; + } else if (this.__store) { + this.__store.__setChanged = true; + } + + source.__changes.forEach((changedAttribute) => { + this.setInput(changedAttribute, source[changedAttribute]) + }) + } + // Undefined safety + if (source.__activeCurrentRelations.length > 0) { + // Set the changes for all related models with changes + source.__activeCurrentRelations.forEach((relation) => { + if (relation && source[relation]) { + if (this[relation]) { + if (source[relation].hasUserChanges) { + if (source[relation].models) { // If related item is a store + if (source[relation].models.length === this[relation].models.length) { // run only if the store shares the same amount of items + // Check if the store has some changes + this[relation].__setChanged = source[relation].__setChanged; + // Set the changes for all related models with changes + source[relation].models.forEach((relatedModel, index) => { + this[relation].models[index].__copyChanges(relatedModel, this[relation]); + }); + } + } else { + // Set the changes for the related model + this[relation].__copyChanges(source[relation], undefined) + } + } + } else { + // Related object not in relations of the model we are copying + console.warn(`Found related object ${source.constructor.backendResourceName} with relation ${relation}, + which is not defined in the relations of the model you are copying. Skipping ${relation}.`) + } + } + }); + } + } + toJS() { const output = {}; this.__attributes.forEach(attr => { @@ -673,6 +796,7 @@ export default class Model { ); } + @action setInput(name, value) { invariant( @@ -729,6 +853,23 @@ export default class Model { return Promise.all(promises); } + /** + * Validates a model by sending a save request to binder with the validate header set. Binder will return the validation + * errors without actually committing the save + * + * @param options - same as for a normal save request, example: {onlyChanges: true} + */ + validate(options = {}){ + // Add the validate parameter + if (options.params){ + options.params.validate = true + } else { + options.params = { validate: true }; + } + + return this.save(options).catch((err)=>{throw err}); + } + save(options = {}) { if (options.relations && options.relations.length > 0) { return this._saveAll(options); @@ -754,15 +895,18 @@ export default class Model { requestOptions: omit(options, 'url', 'data', 'mapData') }) .then(action(res => { - this.saveFromBackend({ - ...res, - data: omit(res.data, this.fileFields().map(camelToSnake)), - }); - this.clearUserFieldChanges(); - return this.saveFiles().then(() => { - this.clearUserFileChanges(); - return Promise.resolve(res); - }); + // Only update the model when we are actually trying to save + if (!options.params || !options.params.validate) { + this.saveFromBackend({ + ...res, + data: omit(res.data, this.fileFields().map(camelToSnake)), + }); + this.clearUserFieldChanges(); + return this.saveFiles().then(() => { + this.clearUserFileChanges(); + return Promise.resolve(res); + }); + } })) .catch( action(err => { @@ -792,28 +936,31 @@ export default class Model { requestOptions: omit(options, 'relations', 'data', 'mapData'), }) .then(action(res => { - this.saveFromBackend(res); - this.clearUserFieldChanges(); - - forNestedRelations(this, relationsToNestedKeys(options.relations || []), relation => { - if (relation instanceof Model) { - relation.clearUserFieldChanges(); - } else { - relation.clearSetChanges(); - } - }); - - return this.saveAllFiles(relationsToNestedKeys(options.relations || [])).then(() => { - this.clearUserFileChanges(); + // Only update the models if we are actually trying to save + if (!options.params || !options.params.validate) { + this.saveFromBackend(res); + this.clearUserFieldChanges(); forNestedRelations(this, relationsToNestedKeys(options.relations || []), relation => { if (relation instanceof Model) { - relation.clearUserFileChanges(); + relation.clearUserFieldChanges(); + } else { + relation.clearSetChanges(); } }); - return res; - }); + return this.saveAllFiles(relationsToNestedKeys(options.relations || [])).then(() => { + this.clearUserFileChanges(); + + forNestedRelations(this, relationsToNestedKeys(options.relations || []), relation => { + if (relation instanceof Model) { + relation.clearUserFileChanges(); + } + }); + + return res; + }); + } })) .catch( action(err => { diff --git a/src/__tests__/Model.js b/src/__tests__/Model.js index 2ff1a88..658fd36 100644 --- a/src/__tests__/Model.js +++ b/src/__tests__/Model.js @@ -2,7 +2,8 @@ import axios from 'axios'; import { toJS, observable } from 'mobx'; import MockAdapter from 'axios-mock-adapter'; import _ from 'lodash'; -import { Model, BinderApi } from '../'; +import { Model, BinderApi, Casts } from '../'; +import { compareObjectsIgnoringNegativeIds } from "./helpers/helpers"; import { Animal, AnimalStore, @@ -108,6 +109,7 @@ test('property defined as both attribute and relation should throw error', () => test('initialize() method should be called', () => { const initMock = jest.fn(); + class Zebra extends Model { initialize() { initMock(); @@ -150,6 +152,22 @@ test('isNew should be true for new model', () => { expect(animal.isNew).toBe(true); }); +test('isNew should be true for a model with negative id', () => { + const animal = new Animal(); + animal.id = animal.getInternalId(); + + expect(animal.isNew).toBe(true); +}); + +test('isNew should be true for a model that we assign an internal id', () => { + const animal = new Animal(); + animal.assignInternalId(); + + expect(animal.isNew).toBe(true); + expect(animal.id).toBeLessThan(0); +}); + + test('isNew should be false for existing model', () => { const animal = new Animal({ id: 2 }); @@ -939,6 +957,7 @@ test('setInput to clear backend validation errors', () => { test('allow custom validationErrorFormatter', () => { const location = new class extends Location { static backendResourceName = 'location'; + validationErrorFormatter(obj) { return obj.msg; } @@ -1175,6 +1194,7 @@ describe('requests', () => { const myApi = new BinderApi(); mock.onAny().replyOnce(200, {}); const spy = jest.spyOn(myApi, 'get'); + class Zebra extends Model { static backendResourceName = 'zebra'; api = myApi; @@ -1248,6 +1268,26 @@ describe('requests', () => { }); }); + test('validate new with basic properties, should not save', () => { + const animal = new Animal({ name: 'Doggo' }); + const spy = jest.spyOn(animal, 'saveFromBackend'); + mock.onAny().replyOnce(config => { + expect(config.params).toEqual({ validate: true }); + expect(config.url).toBe('/api/animal/'); + expect(config.method).toBe('post'); + expect(config.data).toBe('{"id":null,"name":"Doggo"}'); + return [201, { id: 10, name: 'Doggo' }]; + }); + + return animal.validate().then(() => { + expect(animal.id).toBe(null); + expect(spy).not.toHaveBeenCalled(); + + spy.mockReset(); + spy.mockRestore(); + }); + }); + test('save existing with basic properties', () => { const animal = new Animal({ id: 12, name: 'Burhan' }); mock.onAny().replyOnce(config => { @@ -1273,6 +1313,22 @@ describe('requests', () => { }); }); + test('validation error with basic properties', () => { + const animal = new Animal({ name: 'Nope' }); + mock.onAny().replyOnce(config => { + expect(config.params).toEqual({ validate: true }); + return [400, saveFailData] + }); + + return animal.validate().catch(() => { + const valErrors = toJS(animal.backendValidationErrors); + expect(valErrors).toEqual({ + name: ['required'], + kind: ['blank'], + }); + }); + }); + test('save new model fail with basic properties', () => { const animal = new Animal({ name: 'Nope' }); mock.onAny().replyOnce(400, saveNewFailData); @@ -1285,6 +1341,21 @@ describe('requests', () => { }); }); + test('save new model validation error with basic properties', () => { + const animal = new Animal({ name: 'Nope' }); + mock.onAny().replyOnce(config => { + expect(config.params).toEqual({ validate: true }); + return [400, saveNewFailData] + }); + + return animal.validate().catch(() => { + const valErrors = toJS(animal.backendValidationErrors); + expect(valErrors).toEqual({ + name: ['invalid'], + }); + }); + }); + test('save fail with 500', () => { const animal = new Animal({ name: 'Nope' }); mock.onAny().replyOnce(500, {}); @@ -1295,6 +1366,19 @@ describe('requests', () => { }); }); + test('validation fail with 500', () => { + const animal = new Animal({ name: 'Nope' }); + mock.onAny().replyOnce(config => { + expect(config.params).toEqual({ validate: true }); + return [500, {}] + }); + + return animal.validate().catch(() => { + const valErrors = toJS(animal.backendValidationErrors); + expect(valErrors).toEqual({}); + }); + }); + test('save with params', () => { const animal = new Animal(); mock.onAny().replyOnce(config => { @@ -1363,6 +1447,35 @@ describe('requests', () => { }); }); + test('validate all with relations', () => { + const animal = new Animal( + { + name: 'Doggo', + kind: { name: 'Dog' }, + pastOwners: [{ name: 'Henk' }], + }, + { relations: ['kind', 'pastOwners'] } + ); + const spy = jest.spyOn(animal, 'saveFromBackend'); + mock.onAny().replyOnce(config => { + expect(config.params).toEqual({ validate: true }); + expect(config.url).toBe('/api/animal/'); + expect(config.method).toBe('put'); + return [201, animalMultiPutResponse]; + }); + + return animal.validate({ relations: ['kind'] }).then(response => { + expect(spy).not.toHaveBeenCalled(); + expect(animal.id).toBe(10); + expect(animal.kind.id).toBe(4); + expect(animal.pastOwners.at(0).id).toBe(100); + // expect(response).toEqual(animalMultiPutResponse); + + spy.mockReset(); + spy.mockRestore(); + }); + }); + test('save all with relations - verify ids are mapped correctly', () => { const animal = new Animal( { @@ -1399,7 +1512,46 @@ describe('requests', () => { }); return animal.save({ relations: ['kind'] }).then( - () => {}, + () => { + }, + err => { + if (!err.response) { + throw err; + } + expect(toJS(animal.backendValidationErrors).name).toEqual([ + 'blank', + ]); + expect(toJS(animal.kind.backendValidationErrors).name).toEqual([ + 'required', + ]); + expect( + toJS(animal.pastOwners.at(0).backendValidationErrors).name + ).toEqual(['required']); + expect( + toJS(animal.pastOwners.at(0).town.backendValidationErrors) + .name + ).toEqual(['maxlength']); + } + ); + }); + + test('validate all with errors', () => { + const animal = new Animal( + { + name: 'Doggo', + kind: { name: 'Dog' }, + pastOwners: [{ name: 'Jo', town: { id: 5, name: '' } }], + }, + { relations: ['kind', 'pastOwners.town'] } + ); + mock.onAny().replyOnce(config => { + expect(config.params).toEqual({ validate: true }); + return [400, animalMultiPutError]; + }); + + return animal.validate({ relations: ['kind'] }).then( + () => { + }, err => { if (!err.response) { throw err; @@ -1438,7 +1590,47 @@ describe('requests', () => { const options = { relations: ['pastOwners.town'] }; return animal.save(options).then( - () => {}, + () => { + }, + err => { + if (!err.response) { + throw err; + } + mock.onAny().replyOnce(200, { idmap: [] }); + return animal.save(options).then(() => { + const valErrors1 = toJS( + animal.pastOwners.at(0).backendValidationErrors + ); + expect(valErrors1).toEqual({}); + const valErrors2 = toJS( + animal.pastOwners.at(0).town.backendValidationErrors + ); + expect(valErrors2).toEqual({}); + }); + } + ); + }); + + test('validate all with validation errors and check if it clears them', () => { + const animal = new Animal( + { + name: 'Doggo', + pastOwners: [{ name: 'Jo', town: { id: 5, name: '' } }], + }, + { relations: ['pastOwners.town'] } + ); + + // We first trigger a save with validation errors from the backend, then we trigger a second save which fixes those validation errors, + // then we check if the errors get cleared. + mock.onAny().replyOnce(config => { + expect(config.params).toEqual({ validate: true }); + return [400, animalMultiPutError]; + }); + + const options = { relations: ['pastOwners.town'] }; + return animal.validate(options).then( + () => { + }, err => { if (!err.response) { throw err; @@ -1666,10 +1858,12 @@ describe('requests', () => { test('hasUserChanges should not clear changes in non-saved models relations', () => { const animal = new Animal( - { id: 1, pastOwners: [ - { id: 2 }, - { id: 3 }, - ] }, + { + id: 1, pastOwners: [ + { id: 2 }, + { id: 3 }, + ] + }, { relations: ['pastOwners', 'kind.breed'] } ); @@ -1690,10 +1884,12 @@ describe('requests', () => { test('hasUserChanges should clear set changes in saved relations', () => { const animal = new Animal( - { id: 1, pastOwners: [ - { id: 2 }, - { id: 3 }, - ] }, + { + id: 1, pastOwners: [ + { id: 2 }, + { id: 3 }, + ] + }, { relations: ['pastOwners', 'kind.breed'] } ); @@ -1713,10 +1909,12 @@ describe('requests', () => { test('hasUserChanges should not clear set changes in non-saved relations', () => { const animal = new Animal( - { id: 1, pastOwners: [ - { id: 2 }, - { id: 3 }, - ] }, + { + id: 1, pastOwners: [ + { id: 2 }, + { id: 3 }, + ] + }, { relations: ['pastOwners', 'kind.breed'] } ); @@ -1843,7 +2041,6 @@ describe('changes', () => { }); - test('toBackendAll should detect removed models', () => { const animal = new Animal( { @@ -1950,6 +2147,527 @@ describe('changes', () => { }); }); + +test('copy (with changes)', () => { + const customer = new Customer(null, { + relations: ['oldTowns.bestCook.workPlaces'], + }); + + customer.fromBackend({ + data: customersWithTownCookRestaurant.data, + repos: customersWithTownCookRestaurant.with, + relMapping: customersWithTownCookRestaurant.with_mapping, + }); + + + customer.oldTowns.models[0].bestCook.workPlaces.models[0].setInput('name', "Italian"); + + const customerCopyWithChanges = new Customer(); + customerCopyWithChanges.copy(customer) + + // Clone with changes should give the same toBackend result as the cloned object + expect(customerCopyWithChanges.toBackendAll({ onlyChanges: true })).toEqual(customer.toBackendAll({ onlyChanges: true })) +}); + +test('copy (with changes without instantiating model)', () => { + const customer = new Customer(null, { + relations: ['oldTowns.bestCook.workPlaces'], + }); + + customer.fromBackend({ + data: customersWithTownCookRestaurant.data, + repos: customersWithTownCookRestaurant.with, + relMapping: customersWithTownCookRestaurant.with_mapping, + }); + + + customer.oldTowns.models[0].bestCook.workPlaces.models[0].setInput('name', "Italian"); + + const customerCopyWithChanges = customer.copy({ copyChanges: true }) + + // Clone with changes should give the same toBackend result as the cloned object + expect(customerCopyWithChanges.toBackendAll({ onlyChanges: true })).toEqual(customer.toBackendAll({ onlyChanges: true })) +}); + +test('copy (without instantiating model)', () => { + const customer = new Customer(null, { + relations: ['oldTowns.bestCook.workPlaces'], + }); + + customer.fromBackend({ + data: customersWithTownCookRestaurant.data, + repos: customersWithTownCookRestaurant.with, + relMapping: customersWithTownCookRestaurant.with_mapping, + }); + + + customer.oldTowns.models[0].bestCook.workPlaces.models[0].setInput('name', "Italian"); + + const customerCopyWithChanges = customer.copy() + + // Clone with changes should give the same toBackend result as the cloned object + expect(customerCopyWithChanges.toBackendAll({ onlyChanges: true })).toEqual(customer.toBackendAll({ onlyChanges: true })) +}); + +test('copy (without changes)', () => { + const customer = new Customer(null, { + relations: ['oldTowns.bestCook.workPlaces'], + }); + + customer.fromBackend({ + data: customersWithTownCookRestaurant.data, + repos: customersWithTownCookRestaurant.with, + relMapping: customersWithTownCookRestaurant.with_mapping, + }); + + customer.oldTowns.models[0].bestCook.workPlaces.models[0].setInput('name', "Italian"); + + const customerCopyNoChanges = new Customer(); + customerCopyNoChanges.copy(customer, { copyChanges: true }) + + + // Clone without changes should give the same toBackend result as the cloned object when only changes is false + expect(customerCopyNoChanges.toBackendAll({ onlyChanges: false })).toEqual(customer.toBackendAll({ onlyChanges: false })) +}); + +test('copy with store relation', () => { + const animal = new Animal({}, { relations: ['pastOwners'] }); + + animal.pastOwners.parse([ + { name: 'Bar' }, + { name: 'Foo' }, + { id: 10, name: 'R' }, + ]); + + [animal.copy(), new Animal().copy(animal)].forEach((copiedAnimal) => { + + let serialized = copiedAnimal.toBackendAll({ nestedRelations: { pastOwners: {} } }); + let expected = animal.toBackendAll({ nestedRelations: { pastOwners: {} } }); + compareObjectsIgnoringNegativeIds(serialized, expected, expect) + + const animalAlternativeCopy = new Animal(); + animalAlternativeCopy.copy(animal); + + serialized = copiedAnimal.toBackendAll({ nestedRelations: { pastOwners: {} } }); + expected = animal.toBackendAll({ nestedRelations: { pastOwners: {} } }); + compareObjectsIgnoringNegativeIds(serialized, expected, expect) + }); +}); + +test('de-duplicate relations should not work after copy', () => { + const animal = new Animal({}, { relations: ['pastOwners.town'] }); + + animal.pastOwners.parse([{ name: 'Bar' }, { name: 'Foo' }]); + + // This is something you should never do, so maybe this is a bad test? + const animalBar = animal.pastOwners.at(0); + animal.pastOwners.models[1] = animalBar; + + // This isn't the real test, just a check. + expect(animalBar.cid).toBe(animal.pastOwners.at(1).cid); + + [animal.copy(), new Animal().copy(animal)].forEach((copiedAnimal) => { + + let serialized = copiedAnimal.toBackendAll({ + nestedRelations: { pastOwners: { town: {} } }, + }); + let expected = animal.toBackendAll({ + nestedRelations: { pastOwners: { town: {} } }, + }); + // We should not copy cid's therefore it should not equal expected + compareObjectsIgnoringNegativeIds(serialized, expected, expect, false) + }); +}); + +test('copy with deep nested relation', () => { + // It's very important to test what happens when the same relation ('location') is used twice + is nested. + const animal = new Animal( + {}, + { relations: ['kind.location', 'kind.breed.location'] } + ); + + animal.kind.parse({ + name: 'Aap', + location: { name: 'Apenheul' }, + breed: { name: 'MyBreed', location: { name: 'Amerika' } }, + }); + + [animal.copy(), new Animal().copy(animal)].forEach((copiedAnimal) => { + + const expected = animal.toBackendAll({ + nestedRelations: { kind: { location: {}, breed: { location: {} } } }, + }); + const serialized = copiedAnimal.toBackendAll({ + nestedRelations: { kind: { location: {}, breed: { location: {} } } }, + }); + compareObjectsIgnoringNegativeIds(serialized, expected, expect) + }); +}); + +test('copy with nested store relation', () => { + // It's very important to test what happens when the same relation ('location') is used twice + is nested. + const animal = new Animal({}, { relations: ['pastOwners.town'] }); + + animal.pastOwners.parse([ + { + name: 'Henk', + town: { + name: 'Eindhoven', + }, + }, + { + name: 'Krol', + town: { + name: 'Breda', + }, + }, + ]); + + [animal.copy(), new Animal().copy(animal)].forEach((copiedAnimal) => { + + + const expected = animal.toBackendAll({ + nestedRelations: { pastOwners: { town: {} } }, + }); + const serialized = copiedAnimal.toBackendAll({ + nestedRelations: { pastOwners: { town: {} } }, + }); + compareObjectsIgnoringNegativeIds(serialized, expected, expect) + }); +}); + +test('toBackendAll with `backendResourceName` property model', () => { + const animal = new AnimalResourceName( + {}, + { relations: ['blaat', 'owners', 'pastOwners'] } + ); + + animal.parse({ + id: 1, + blaat: { + id: 2, + }, + owners: [ + { + id: 3, + }, + ], + pastOwners: [ + { + id: 4, + }, + ], + }); + + const copiedAnimal = animal.copy(); + const expected = animal.toBackendAll({ + nestedRelations: { blaat: {}, owners: {}, pastOwners: {} }, + }); + const serialized = copiedAnimal.toBackendAll({ + nestedRelations: { blaat: {}, owners: {}, pastOwners: {} }, + }); + compareObjectsIgnoringNegativeIds(serialized, expected, expect) + +}); + +describe('copy with changes', () => { + test('toBackend of copy should detect changes', () => { + const animal = new Animal( + { id: 1, name: 'Lino', kind: { id: 2 } }, + { relations: ['kind'] } + ); + + const output = animal.toBackend({ onlyChanges: true }); + expect(output).toEqual({ id: 1 }); + + animal.setInput('name', 'Lion'); + + // Should work for both copy methods + [animal.copy(), new Animal().copy(animal)].forEach((copiedAnimal) => { + + expect(toJS(copiedAnimal.__changes)).toEqual(['name']); + const output2 = copiedAnimal.toBackend({ onlyChanges: true }); + // `kind: 2` should not appear in here. + expect(output2).toEqual({ + id: 1, + name: 'Lion', + }); + }); + }); + + test('toBackend should detect changes - but not twice', () => { + const animal = new Animal({ id: 1 }); + + animal.setInput('name', 'Lino'); + animal.setInput('name', 'Lion'); + + // Should work for both copy methods + [animal.copy(), new Animal().copy(animal)].forEach((copiedAnimal) => { + expect(toJS(copiedAnimal.__changes)).toEqual(['name']); + const output = copiedAnimal.toBackend({ onlyChanges: true }); + expect(output).toEqual({ + id: 1, + name: 'Lion', + }); + }) + }); + + test('toBackendAll should detect changes', () => { + const animal = new Animal( + { + id: 1, + name: 'Lino', + kind: { + id: 2, + owner: { id: 4 }, + }, + pastOwners: [{ id: 5, name: 'Henk' }, { id: 6, name: 'Piet' }], + }, + { relations: ['kind.breed', 'owner', 'pastOwners'] } + ); + + animal.pastOwners.at(1).setInput('name', 'Jan'); + animal.kind.breed.setInput('name', 'Cat'); + + const output = animal.toBackendAll({ + // The `owner` relation is just here to verify that it is not included + nestedRelations: { kind: { breed: {} }, pastOwners: {} }, + onlyChanges: true, + }); + expect(output).toEqual({ + data: [{ id: 1, }], + relations: { + kind: [ + { + id: 2, + breed: -3, + }, + ], + breed: [ + { + id: -3, + name: 'Cat', + }, + ], + past_owners: [ + { + id: 6, + name: 'Jan', + } + ], + }, + }); + }); + + test('toBackendAll should detect added models', () => { + const animal = new Animal( + { + id: 1, + name: 'Lino', + kind: { + id: 2, + owner: { id: 4 }, + }, + pastOwners: [{ id: 5, name: 'Henk' }], + }, + { relations: ['kind.breed', 'owner', 'pastOwners'] } + ); + + animal.pastOwners.add({ id: 6 }); + + // Should work for both copy methods + [animal.copy(), new Animal().copy(animal)].forEach((copiedAnimal) => { + + const output = copiedAnimal.toBackendAll({ + // The `kind` and `breed` relations are just here to verify that they are not included + nestedRelations: { kind: { breed: {} }, pastOwners: {} }, + onlyChanges: true, + }); + expect(output).toEqual({ + data: [{ id: 1, past_owners: [5, 6] }], + relations: {}, + }); + }); + }); + + + test('toBackendAll should detect removed models', () => { + const animal = new Animal( + { + id: 1, + name: 'Lino', + kind: { + id: 2, + owner: { id: 4 }, + }, + pastOwners: [{ id: 5, name: 'Henk' }, { id: 6, name: 'Piet' }], + }, + { relations: ['kind.breed', 'owner', 'pastOwners'] } + ); + + animal.pastOwners.removeById(6); + + // Should work for both copy methods + [animal.copy(), new Animal().copy(animal)].forEach((copiedAnimal) => { + + const output = copiedAnimal.toBackendAll({ + // The `kind` and `breed` relations are just here to verify that they are not included + nestedRelations: { kind: { breed: {} }, pastOwners: {} }, + onlyChanges: true, + }); + expect(output).toEqual({ + data: [{ id: 1, past_owners: [5] }], + relations: {}, + }); + }); + }); + + + test('toBackendAll without onlyChanges should serialize all relations', () => { + const animal = new Animal( + { + id: 1, + name: 'Lino', + kind: { + id: 2, + breed: { name: 'Cat' }, + owner: { id: 4 }, + }, + pastOwners: [{ id: 5, name: 'Henk' }], + }, + { relations: ['kind.breed', 'owner', 'pastOwners'] } + ); + + // Should work for both copy methods + [animal.copy(), new Animal().copy(animal)].forEach((copiedAnimal, index) => { + const output = copiedAnimal.toBackendAll({ + nestedRelations: { kind: { breed: {} }, pastOwners: {} }, + onlyChanges: false, + }); + expect(output).toEqual({ + data: [{ + id: 1, + name: 'Lino', + kind: 2, + owner: null, + past_owners: [5] + }], + relations: { + kind: [ + { + id: 2, + // We don't care that our other copy gets a different id, as long as they are not the same + breed: index === 0 ? -8 : -13, + name: '', + }, + ], + breed: [ + { + id: index === 0 ? -8 : -13, + name: 'Cat', + }, + ], + past_owners: [{ + id: 5, + name: 'Henk' + }], + }, + }); + }); + }); + + test('hasUserChanges should detect changes in current fields', () => { + const animal = new Animal({ id: 1 }); + // Should work for both copy methods + [animal.copy(), new Animal().copy(animal)].forEach((copiedAnimal) => { + expect(copiedAnimal.hasUserChanges).toBe(false); + }); + + animal.setInput('name', 'Lino'); + // Should work for both copy methods + [animal.copy(), new Animal().copy(animal)].forEach((copiedAnimal) => { + expect(copiedAnimal.hasUserChanges).toBe(true); + }); + }); + + test('hasUserChanges should detect changes in model relations', () => { + const animal = new Animal({ id: 1 }, { relations: ['kind.breed'] }); + // Should work for both copy methods + [animal.copy(), new Animal().copy(animal)].forEach((copiedAnimal) => { + expect(copiedAnimal.hasUserChanges).toBe(false); + }); + + animal.kind.breed.setInput('name', 'Katachtige'); + // Should work for both copy methods + [animal.copy(), new Animal().copy(animal)].forEach((copiedAnimal) => { + expect(copiedAnimal.hasUserChanges).toBe(true); + }); + }); + + test('hasUserChanges should detect changes in store relations', () => { + const animal = new Animal( + { id: 1, pastOwners: [{ id: 1 }] }, + { relations: ['pastOwners'] } + ); + + // Should work for both copy methods + [animal.copy(), new Animal().copy(animal)].forEach((copiedAnimal) => { + expect(copiedAnimal.hasUserChanges).toBe(false); + }); + + animal.pastOwners.at(0).setInput('name', 'Henk'); + + // Should work for both copy methods + [animal.copy(), new Animal().copy(animal)].forEach((copiedAnimal) => { + expect(copiedAnimal.hasUserChanges).toBe(true); + }); + }); +}); + +// test('validate', () => { +// const customer = new Customer(null, { +// relations: ['oldTowns.bestCook.workPlaces'], +// }); +// +// customer.fromBackend({ +// data: customersWithTownCookRestaurant.data, +// repos: customersWithTownCookRestaurant.with, +// relMapping: customersWithTownCookRestaurant.with_mapping, +// }); +// +// customer.oldTowns.models[0].bestCook.workPlaces.models[0].setInput('name', "Italian"); +// +// const customerCopyNoChanges = new Customer(); +// customerCopyNoChanges.copy(customer, {copyChanges: true}) +// +// +// // Clone without changes should give the same toBackend result as the cloned object when only changes is false +// expect(customerCopyNoChanges.toBackendAll({onlyChanges: false})).toEqual(customer.toBackendAll({onlyChanges: false})) +// }); +// +// test('validateAll', () => { +// const customer = new Customer(null, { +// relations: ['oldTowns.bestCook.workPlaces'], +// }); +// +// customer.fromBackend({ +// data: customersWithTownCookRestaurant.data, +// repos: customersWithTownCookRestaurant.with, +// relMapping: customersWithTownCookRestaurant.with_mapping, +// }); +// +// customer.oldTowns.models[0].bestCook.workPlaces.models[0].setInput('name', "Italian"); +// +// const customerCopyNoChanges = new Customer(); +// customerCopyNoChanges.copy(customer, {copyChanges: true}) +// +// +// // Clone without changes should give the same toBackend result as the cloned object when only changes is false +// expect(customerCopyNoChanges.toBackendAll({onlyChanges: false})).toEqual(customer.toBackendAll({onlyChanges: false})) +// }); + + + /** * Test that for withs, the ordering is taken from the ids on the main model, and not in the withs. * diff --git a/src/__tests__/helpers/helpers.js b/src/__tests__/helpers/helpers.js new file mode 100644 index 0000000..f6f72e0 --- /dev/null +++ b/src/__tests__/helpers/helpers.js @@ -0,0 +1,57 @@ +/** + * Takes an object and changes all negative numbers to be a check for a negative number so that you can check if two + * objects are the same except for the generated negative ids which can be different. + * + * @param expected + */ +export function modifyObjectNegativeIdCheck(object){ + Object.keys(object).forEach((key) => { + if (object[key] < 0){ + // If value + object[key] = expect.any(Number); + } else if (Array.isArray(object[key])) { + // If list + modifyListNegativeIdCheck(object[key]); + } else if (typeof object[key] === 'object' && object[key] !== null){ + // If object + modifyObjectNegativeIdCheck(object[key]); + } + }) +} + +/** + * Takes a list and changes all negative numbers to be a check for a negative number so that you can check if two + * lists or the lists inside of an object are the same except for the generated negative ids which can be different. + * @param expected + */ +function modifyListNegativeIdCheck(expected){ + Array.prototype.forEach.call(expected,(item) => { + if (item < 0){ + // If value + expected[expected.indexOf(item)] = expect.any(Number); + } else if (Array.isArray(item)) { + // If list + modifyListNegativeIdCheck(item); + } else if (typeof item === 'object' && item !== null){ + // If object + modifyObjectNegativeIdCheck(item); + } + }) +} + +/** + * Checks if 2 objects are the same ignoring negative ids + * @param object - The first object you want to compare + * @param toEqual - The second object you want to compare the first object to + * @param expect - The expect of the test to do the actual comparison + * @param bool - True if the objects should be the same, false otherwise (default: true) + */ +export function compareObjectsIgnoringNegativeIds(object, toEqual, expect, bool = true){ + const expected = toEqual + modifyObjectNegativeIdCheck(expected); + if (bool === true) { + expect(object).toMatchObject(expected); + } else { + expect(object).not.toMatchObject(expected); + } +}