diff --git a/README.md b/README.md index 7f0cb9a..e86ecb0 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ import { Model } from 'mobx-spine'; // Define a class Animal, with 2 observed properties `id` and `name`. class Animal extends Model { - @observable id = null; // Default value is null. + @observable id; // Default value is undefined, a new negative integer will automatically be generated for a new model. @observable name = ''; // Default value is ''. } ``` @@ -56,7 +56,7 @@ If we instantiate a new animal without arguments it will create an empty animal // Create an empty instance of an Animal. const lion = new Animal(); -console.log(lion.id); // null +console.log(lion.id); // -1, a unique negative integer for this model console.log(lion.name); // '' ``` @@ -67,6 +67,7 @@ You can also supply data when creating a new instance: const cat = new Animal({ id: 1, name: 'Cat' }); console.log(cat.name); // Cat +console.log(cat.id); // 1 ``` When data is supplied in the constructor, these can be reset by calling `clear`: @@ -90,6 +91,13 @@ cat.name = ''; console.log(cat.undefinedProperty); // undefined ``` +### New models +A new model is a model that exists in the store, but not on the backend. A new model either has a negative id, or the id is `null`. Checking if a model is new can be done with `model.isNew()` which returns a boolean `true` when the model is new. + +By default, a new model will be initialized with a negative id, this way the model can be used as a related model. When a model is initialized with a negative id it will automatically generate a new negative id for the model on a clear. In some cases a `null` id might be preferred, in this case a model can be forced to get a `null` id by passing `{id: null}` in the constructor. In this case the id will also be reset to `null` on a clear. A model with a `null` id functions the same as a model with a negative id other than that the model cannot be used in a relation. + +Some projects might still use the legacy method of checking for new models by checking if `!model.id`. This does not work with the default negative IDs. To migrate a project to the default negative IDs implementation you should search and replace the whole project for occurrences of `.id` and fix all lines that check if an id is set to use the `isNew()` property. + ### Constructor: options |key|default| | | @@ -308,6 +316,9 @@ class animal = new Animal({ id: 2, name: 'Rova', breed: { id: 3, name: 'Main Coo console.log(animal.breed.name); // Throws cannot read property name from undefined. ``` +### Negative IDs for related models +A related model will always be initialized with a `null` id. When the id of the related model is set to `null` it indicates to django-binder that the field is empty. + ### Pick fields You can pick fields by either defining a static `pickFields` variable or a `pickFields` function. Keep in mind that `id` is mandatory, so it will always be included. diff --git a/dist/mobx-spine.cjs.js b/dist/mobx-spine.cjs.js index 3c60d4c..5f6a20c 100644 --- a/dist/mobx-spine.cjs.js +++ b/dist/mobx-spine.cjs.js @@ -907,14 +907,40 @@ 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, meaning that it will keep the set id of the model or will receive a negative + * id if the id is null. 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() { @@ -945,12 +971,18 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { key: 'url', get: function get$$1() { 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$$1() { - return !this[this.constructor.primaryKey]; + return !this[this.constructor.primaryKey] || this[this.constructor.primaryKey] < 0; } }, { key: 'isLoading', @@ -1016,6 +1048,16 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { if (options.relations) { this.__parseRelations(options.relations); } + + // The model will automatically be assigned a negative id, the id will still be overridden if it is supplied in the data + this.assignInternalId(); + + // We want our id to remain negative on a clear, only if it was not created with the id set to null + // which is usually the case when the object is a related model in which case we want the id to be reset to null + if (data && data[this.constructor.primaryKey] !== null || !data) { + this.__originalAttributes[this.constructor.primaryKey] = this[this.constructor.primaryKey]; + } + if (data) { this.parse(data); } @@ -1036,7 +1078,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); @@ -1054,14 +1096,18 @@ 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 }; if (RelModel.prototype instanceof Store) { return new RelModel(options); } - return new RelModel(null, options); + // If we have a related model, we want to force the related model to have id null as that means there is no model set + return new RelModel(defineProperty({}, RelModel.primaryKey, null), options); })); } @@ -1090,15 +1136,15 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { value: function toBackend() { var _this4 = this; - var _ref = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; + var _ref2 = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - var _ref$data = _ref.data, - data = _ref$data === undefined ? {} : _ref$data, - _ref$mapData = _ref.mapData, - mapData = _ref$mapData === undefined ? function (x) { + var _ref2$data = _ref2.data, + data = _ref2$data === undefined ? {} : _ref2$data, + _ref2$mapData = _ref2.mapData, + mapData = _ref2$mapData === undefined ? function (x) { return x; - } : _ref$mapData, - options = objectWithoutProperties(_ref, ['data', 'mapData']); + } : _ref2$mapData, + options = objectWithoutProperties(_ref2, ['data', 'mapData']); var output = {}; // By default we'll include all fields (attributes+relations), but sometimes you might want to specify the fields to be included. @@ -1208,18 +1254,126 @@ 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 + // 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(); } @@ -1245,12 +1399,17 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { key: '__parseRepositoryToData', value: function __parseRepositoryToData(key, repository) { if (lodash.isArray(key)) { - var models = key.map(function (k) { - return lodash.find(repository, { id: k }); + var idIndexes = Object.fromEntries(key.map(function (id, index) { + return [id, index]; + })); + var models = repository.filter(function (_ref3) { + var id = _ref3.id; + return idIndexes[id] !== undefined; }); - return lodash.filter(models, function (m) { - return m; + models.sort(function (l, r) { + return idIndexes[l.id] - idIndexes[r.id]; }); + return models; } return lodash.find(repository, { id: key }); } @@ -1276,14 +1435,14 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: '__scopeBackendResponse', - value: function __scopeBackendResponse(_ref2) { - var _this7 = this; + value: function __scopeBackendResponse(_ref4) { + var _this8 = this; - var data = _ref2.data, - targetRelName = _ref2.targetRelName, - repos = _ref2.repos, - mapping = _ref2.mapping, - reverseMapping = _ref2.reverseMapping; + var data = _ref4.data, + targetRelName = _ref4.targetRelName, + repos = _ref4.repos, + mapping = _ref4.mapping, + reverseMapping = _ref4.reverseMapping; var scopedData = null; var relevant = false; @@ -1299,18 +1458,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) { @@ -1349,13 +1508,13 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: 'fromBackend', - value: function fromBackend(_ref3) { - var _this8 = this; + value: function fromBackend(_ref5) { + var _this9 = this; - var data = _ref3.data, - repos = _ref3.repos, - relMapping = _ref3.relMapping, - reverseRelMapping = _ref3.reverseRelMapping; + var data = _ref5.data, + repos = _ref5.repos, + relMapping = _ref5.relMapping, + reverseRelMapping = _ref5.reverseRelMapping; // We handle the fromBackend recursively. On each relation of the source model // fromBackend gets called as well, but with data scoped for itself @@ -1363,8 +1522,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, @@ -1406,22 +1565,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(); } } }); @@ -1441,7 +1600,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); @@ -1452,16 +1611,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]; @@ -1475,6 +1634,27 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { value: function saveFiles() { return Promise.all(this.fileFields().filter(this.fieldFilter).map(this.saveFile)); } + + /** + * 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); + } }, { key: 'setInput', value: function setInput(name, value) { @@ -1565,7 +1745,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] : {}; @@ -1581,17 +1761,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; }))); @@ -1599,7 +1782,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] : {}; @@ -1615,31 +1798,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; }))); @@ -1651,19 +1837,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); }); } @@ -1675,7 +1861,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; @@ -1688,24 +1874,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(); }); } @@ -1723,12 +1909,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(); @@ -1754,7 +1940,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] : {}; @@ -1766,7 +1952,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); }))); return promise; @@ -1774,26 +1960,32 @@ 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; + // If it is our primary key, and the primary key is negative, we generate a new negative pk, else we set it + // to the value + if (key === _this19.constructor.primaryKey && value < 0) { + _this19[key] = -1 * lodash.uniqueId(); + } else { + _this19[key] = value; + } }); this.__activeCurrentRelations.forEach(function (currentRel) { - _this18[currentRel].clear(); + _this19[currentRel].clear(); }); } }, { key: 'hasUserChanges', get: function get$$1() { - 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; }); } }, { diff --git a/dist/mobx-spine.es.js b/dist/mobx-spine.es.js index 39d4226..ea33fc2 100644 --- a/dist/mobx-spine.es.js +++ b/dist/mobx-spine.es.js @@ -901,14 +901,40 @@ 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, meaning that it will keep the set id of the model or will receive a negative + * id if the id is null. 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() { @@ -939,12 +965,18 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { key: 'url', get: function get$$1() { 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$$1() { - return !this[this.constructor.primaryKey]; + return !this[this.constructor.primaryKey] || this[this.constructor.primaryKey] < 0; } }, { key: 'isLoading', @@ -1010,6 +1042,16 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { if (options.relations) { this.__parseRelations(options.relations); } + + // The model will automatically be assigned a negative id, the id will still be overridden if it is supplied in the data + this.assignInternalId(); + + // We want our id to remain negative on a clear, only if it was not created with the id set to null + // which is usually the case when the object is a related model in which case we want the id to be reset to null + if (data && data[this.constructor.primaryKey] !== null || !data) { + this.__originalAttributes[this.constructor.primaryKey] = this[this.constructor.primaryKey]; + } + if (data) { this.parse(data); } @@ -1030,7 +1072,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); @@ -1048,14 +1090,18 @@ 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 }; if (RelModel.prototype instanceof Store) { return new RelModel(options); } - return new RelModel(null, options); + // If we have a related model, we want to force the related model to have id null as that means there is no model set + return new RelModel(defineProperty({}, RelModel.primaryKey, null), options); })); } @@ -1084,15 +1130,15 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { value: function toBackend() { var _this4 = this; - var _ref = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; + var _ref2 = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - var _ref$data = _ref.data, - data = _ref$data === undefined ? {} : _ref$data, - _ref$mapData = _ref.mapData, - mapData = _ref$mapData === undefined ? function (x) { + var _ref2$data = _ref2.data, + data = _ref2$data === undefined ? {} : _ref2$data, + _ref2$mapData = _ref2.mapData, + mapData = _ref2$mapData === undefined ? function (x) { return x; - } : _ref$mapData, - options = objectWithoutProperties(_ref, ['data', 'mapData']); + } : _ref2$mapData, + options = objectWithoutProperties(_ref2, ['data', 'mapData']); var output = {}; // By default we'll include all fields (attributes+relations), but sometimes you might want to specify the fields to be included. @@ -1202,18 +1248,126 @@ 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 + // 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$$1() { - 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(); } @@ -1239,12 +1393,17 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { key: '__parseRepositoryToData', value: function __parseRepositoryToData(key, repository) { if (isArray(key)) { - var models = key.map(function (k) { - return find(repository, { id: k }); + var idIndexes = Object.fromEntries(key.map(function (id, index) { + return [id, index]; + })); + var models = repository.filter(function (_ref3) { + var id = _ref3.id; + return idIndexes[id] !== undefined; }); - return filter(models, function (m) { - return m; + models.sort(function (l, r) { + return idIndexes[l.id] - idIndexes[r.id]; }); + return models; } return find(repository, { id: key }); } @@ -1270,14 +1429,14 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: '__scopeBackendResponse', - value: function __scopeBackendResponse(_ref2) { - var _this7 = this; + value: function __scopeBackendResponse(_ref4) { + var _this8 = this; - var data = _ref2.data, - targetRelName = _ref2.targetRelName, - repos = _ref2.repos, - mapping = _ref2.mapping, - reverseMapping = _ref2.reverseMapping; + var data = _ref4.data, + targetRelName = _ref4.targetRelName, + repos = _ref4.repos, + mapping = _ref4.mapping, + reverseMapping = _ref4.reverseMapping; var scopedData = null; var relevant = false; @@ -1293,18 +1452,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) { @@ -1343,13 +1502,13 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: 'fromBackend', - value: function fromBackend(_ref3) { - var _this8 = this; + value: function fromBackend(_ref5) { + var _this9 = this; - var data = _ref3.data, - repos = _ref3.repos, - relMapping = _ref3.relMapping, - reverseRelMapping = _ref3.reverseRelMapping; + var data = _ref5.data, + repos = _ref5.repos, + relMapping = _ref5.relMapping, + reverseRelMapping = _ref5.reverseRelMapping; // We handle the fromBackend recursively. On each relation of the source model // fromBackend gets called as well, but with data scoped for itself @@ -1357,8 +1516,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, @@ -1400,22 +1559,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(); } } }); @@ -1435,7 +1594,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); @@ -1446,16 +1605,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]; @@ -1469,6 +1628,27 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { value: function saveFiles() { return Promise.all(this.fileFields().filter(this.fieldFilter).map(this.saveFile)); } + + /** + * 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); + } }, { key: 'setInput', value: function setInput(name, value) { @@ -1559,7 +1739,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] : {}; @@ -1575,17 +1755,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; }))); @@ -1593,7 +1776,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] : {}; @@ -1609,31 +1792,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; }))); @@ -1645,19 +1831,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); }); } @@ -1669,7 +1855,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; @@ -1682,24 +1868,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(); }); } @@ -1717,12 +1903,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(); @@ -1748,7 +1934,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] : {}; @@ -1760,7 +1946,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); }))); return promise; @@ -1768,26 +1954,32 @@ 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; + // If it is our primary key, and the primary key is negative, we generate a new negative pk, else we set it + // to the value + if (key === _this19.constructor.primaryKey && value < 0) { + _this19[key] = -1 * uniqueId(); + } else { + _this19[key] = value; + } }); this.__activeCurrentRelations.forEach(function (currentRel) { - _this18[currentRel].clear(); + _this19[currentRel].clear(); }); } }, { key: 'hasUserChanges', get: function get$$1() { - 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; }); } }, { diff --git a/package.json b/package.json index 761d8aa..c61e6c2 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,8 @@ "./src" ], "testPathIgnorePatterns": [ - "/fixtures/" + "/fixtures/", + "/helpers.js" ] } } diff --git a/src/Model.js b/src/Model.js index 081195b..9fba6e0 100644 --- a/src/Model.js +++ b/src/Model.js @@ -107,22 +107,45 @@ 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, meaning that it will keep the set id of the model or will receive a negative + * id if the id is null. 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 @@ -188,6 +211,16 @@ export default class Model { if (options.relations) { this.__parseRelations(options.relations); } + + // The model will automatically be assigned a negative id, the id will still be overridden if it is supplied in the data + this.assignInternalId() + + // We want our id to remain negative on a clear, only if it was not created with the id set to null + // which is usually the case when the object is a related model in which case we want the id to be reset to null + if ((data && data[this.constructor.primaryKey] !== null) || !data){ + this.__originalAttributes[this.constructor.primaryKey] = this[this.constructor.primaryKey] + } + if (data) { this.parse(data); } @@ -205,7 +238,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); @@ -228,9 +261,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, @@ -240,7 +274,8 @@ export default class Model { if (RelModel.prototype instanceof Store) { return new RelModel(options); } - return new RelModel(null, options); + // If we have a related model, we want to force the related model to have id null as that means there is no model set + return new RelModel({ [RelModel.primaryKey]: null }, options); }) ); } @@ -410,6 +445,104 @@ 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 + // 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 => { @@ -663,6 +796,22 @@ export default class Model { ); } + /** + * 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); + } + @action setInput(name, value) { invariant( @@ -719,6 +868,7 @@ export default class Model { return Promise.all(promises); } + save(options = {}) { if (options.relations && options.relations.length > 0) { return this._saveAll(options); @@ -732,39 +882,43 @@ export default class Model { this.clearValidationErrors(); return this.wrapPendingRequestCount( this.__getApi() - .saveModel({ - url: options.url || this.url, - data: this.toBackend({ + .saveModel({ + url: options.url || this.url, + data: this.toBackend({ data: options.data, mapData: options.mapData, fields: options.fields, onlyChanges: options.onlyChanges, }), - isNew: this.isNew, - 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); - }); - })) - .catch( - action(err => { - if (err.valErrors) { - this.parseValidationErrors(err.valErrors); - } - throw err; + isNew: this.isNew, + requestOptions: omit(options, 'url', 'data', 'mapData') }) - ) + .then(action(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 => { + if (err.valErrors) { + this.parseValidationErrors(err.valErrors); + } + throw err; + }) + ) ); } + @action _saveAll(options = {}) { this.clearValidationErrors(); @@ -782,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 => { @@ -944,7 +1101,13 @@ export default class Model { @action clear() { forIn(this.__originalAttributes, (value, key) => { - this[key] = value; + // If it is our primary key, and the primary key is negative, we generate a new negative pk, else we set it + // to the value + if (key === this.constructor.primaryKey && value < 0){ + this[key] = -1 * uniqueId(); + } else { + this[key] = value; + } }); this.__activeCurrentRelations.forEach(currentRel => { @@ -952,3 +1115,4 @@ export default class Model { }); } } + diff --git a/src/__tests__/Model.js b/src/__tests__/Model.js index ed89d5d..5d9ee6a 100644 --- a/src/__tests__/Model.js +++ b/src/__tests__/Model.js @@ -3,6 +3,7 @@ import { toJS, observable } from 'mobx'; import MockAdapter from 'axios-mock-adapter'; import _ from 'lodash'; import { Model, BinderApi } from '../'; +import { compareObjectsIgnoringNegativeIds } from "./helpers"; import { Animal, AnimalStore, @@ -65,7 +66,7 @@ test('Initialize model with invalid data', () => { test('Initialize model without data', () => { const animal = new Animal(null); - expect(animal.id).toBeNull(); + expect(animal.id).toBeLessThan(0); expect(animal.name).toBe(''); }); @@ -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 }); @@ -616,7 +634,7 @@ test('toBackendAll with model relation', () => { animal.kind.parse({ id: 5 }); const serialized = animal.toBackendAll({ - nestedRelations: {kind: { breed: {}}, owner: {}}, + nestedRelations: { kind: { breed: {} }, owner: {} }, }); expect(serialized).toMatchSnapshot(); }); @@ -644,7 +662,7 @@ test('toBackendAll with partial relations', () => { }, { relations: ['kind', 'owner.town'] } ); - const serialized = animal.toBackendAll({ nestedRelations: {owner: {}} }); + const serialized = animal.toBackendAll({ nestedRelations: { owner: {} } }); expect(serialized).toMatchSnapshot(); }); @@ -665,7 +683,7 @@ test('toBackendAll with store relation', () => { { id: 10, name: 'R' }, ]); - const serialized = animal.toBackendAll({ nestedRelations: {pastOwners: {}} }); + const serialized = animal.toBackendAll({ nestedRelations: { pastOwners: {} } }); expect(serialized).toMatchSnapshot(); }); @@ -682,7 +700,7 @@ test('toBackendAll should de-duplicate relations', () => { expect(animalBar.cid).toBe(animal.pastOwners.at(1).cid); const serialized = animal.toBackendAll({ - nestedRelations: {pastOwners: {town: {}}}, + nestedRelations: { pastOwners: { town: {} } }, }); expect(serialized).toMatchSnapshot(); }); @@ -701,7 +719,7 @@ test('toBackendAll with deep nested relation', () => { }); const serialized = animal.toBackendAll({ - nestedRelations: {kind: { location: {}, breed: { location: {} }}}, + nestedRelations: { kind: { location: {}, breed: { location: {} } } }, }); expect(serialized).toMatchSnapshot(); }); @@ -726,7 +744,7 @@ test('toBackendAll with nested store relation', () => { ]); const serialized = animal.toBackendAll({ - nestedRelations: {pastOwners: { town: {} }}, + nestedRelations: { pastOwners: { town: {} } }, }); expect(serialized).toMatchSnapshot(); }); @@ -755,7 +773,7 @@ test('toBackendAll with `backendResourceName` property model', () => { }); const serialized = animal.toBackendAll({ - nestedRelations: {blaat: {}, owners: {}, pastOwners: {}}, + nestedRelations: { blaat: {}, owners: {}, pastOwners: {} }, }); expect(serialized).toMatchSnapshot(); }); @@ -804,6 +822,7 @@ test('toBackend with observable array', () => { expect(animal.toBackend()).toEqual({ foo: ['q', 'a'], + id: -1, }); }); @@ -815,7 +834,7 @@ test('clear with basic attribute', () => { animal.clear(); - expect(animal.id).toBe(null); + expect(animal.id).toBeLessThan(0); expect(animal.name).toBe(''); }); @@ -939,6 +958,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 +1195,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; @@ -1207,7 +1228,7 @@ describe('requests', () => { mock.onAny().replyOnce(config => { expect(config.url).toBe('/api/animal/'); expect(config.method).toBe('post'); - expect(config.data).toBe('{"id":null,"name":"Doggo"}'); + expect(config.data).toBe('{"id":-1,"name":"Doggo"}'); return [201, { id: 10, name: 'Doggo' }]; }); @@ -1220,6 +1241,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":-1,"name":"Doggo"}'); + return [201, { id: 10, name: 'Doggo' }]; + }); + + return animal.validate().then(() => { + expect(animal.id).toBe(-1); + 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 => { @@ -1245,6 +1286,38 @@ 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('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); @@ -1257,6 +1330,36 @@ 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 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, {}); @@ -1267,6 +1370,32 @@ 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('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 => { @@ -1280,7 +1409,7 @@ describe('requests', () => { test('save with custom data', () => { const animal = new Animal(); mock.onAny().replyOnce(config => { - expect(JSON.parse(config.data)).toEqual({ id: null, name: '', extra_data: 'can be saved' }); + expect(JSON.parse(config.data)).toEqual({ id: -1, name: '', extra_data: 'can be saved' }); return [201, {}]; }); @@ -1335,6 +1464,64 @@ 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('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( { @@ -1640,10 +1827,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'] } ); @@ -1664,10 +1853,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'] } ); @@ -1687,10 +1878,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'] } ); @@ -1761,7 +1954,7 @@ describe('changes', () => { const output = animal.toBackendAll({ // The `owner` relation is just here to verify that it is not included - nestedRelations: {kind: {breed: {}}, pastOwners: {}}, + nestedRelations: { kind: { breed: {} }, pastOwners: {} }, onlyChanges: true, }); expect(output).toEqual({ @@ -1807,7 +2000,7 @@ describe('changes', () => { const output = animal.toBackendAll({ // The `kind` and `breed` relations are just here to verify that they are not included - nestedRelations: {kind: {breed: {}}, pastOwners: {}}, + nestedRelations: { kind: { breed: {} }, pastOwners: {} }, onlyChanges: true, }); expect(output).toEqual({ @@ -1817,7 +2010,6 @@ describe('changes', () => { }); - test('toBackendAll should detect removed models', () => { const animal = new Animal( { @@ -1836,7 +2028,7 @@ describe('changes', () => { const output = animal.toBackendAll({ // The `kind` and `breed` relations are just here to verify that they are not included - nestedRelations: {kind: {breed: {}}, pastOwners: {}}, + nestedRelations: { kind: { breed: {} }, pastOwners: {} }, onlyChanges: true, }); expect(output).toEqual({ @@ -1861,7 +2053,7 @@ describe('changes', () => { { relations: ['kind.breed', 'owner', 'pastOwners'] } ); const output = animal.toBackendAll({ - nestedRelations: {kind: {breed: {}}, pastOwners: {}}, + nestedRelations: { kind: { breed: {} }, pastOwners: {} }, onlyChanges: false, }); expect(output).toEqual({ @@ -1924,6 +2116,582 @@ 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.at(0).bestCook.workPlaces.at(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); + }); + }); +}); + +describe('negative id instead of null', () => { + + test('new model instance should have a negative id instead of null', () => { + const animal = new Animal(); + expect(animal.id).toBeLessThan(0); + }); + + test('new model instance should have a null id instead of negative when supplied in data', () => { + const animal = new Animal({ id: null }); + expect(animal.id).toBeNull(); + }); + + test('new model instance should not have negative id if a positive id was supplied in data', () => { + const animal = new Animal({ id: 5 }); + expect(animal.id).toBe(5); + }); + + test('new model should keep negative id on clear', () => { + const animal = new Animal(); + animal.clear(); + expect(animal.id).toBeLessThan(0); + }); + + test('new model should keep null id on clear when created with id null', () => { + const animal = new Animal({id: null}); + animal.clear(); + expect(animal.id).toBeNull(); + }); + + test('new model should keep negative id on clear, when created with an id', () => { + const animal = new Animal({id: 5}); + animal.clear(); + expect(animal.id).toBeLessThan(0); + }); + + test('related model should get null id if not initialized', () => { + const animal = new Animal({id: 5}, {relations: ['kind']}); + + expect(animal.kind.id).toBeNull(); + }); + + test('related model should get null id on clear', () => { + const animal = new Animal({id: 5, kind: {id: 5}}, {relations: ['kind']}); + + expect(animal.kind.id).toBe(5); + animal.clear(); + expect(animal.kind.id).toBeNull(); + }); + + test('related model should get null id on related model clear', () => { + const animal = new Animal({id: 5, kind: {id: 5}}, {relations: ['kind']}); + + expect(animal.kind.id).toBe(5); + animal.kind.clear(); + expect(animal.kind.id).toBeNull(); + }); + + test('model initialized with null should get negative id when clearing after copy', () => { + const animal = new Animal({id: null}); + + const copiedAnimal = animal.copy() + copiedAnimal.clear(); + expect(copiedAnimal.id).toBeLessThan(0); + }); + + test('model should get negative id when clearing after copy', () => { + const animal = new Animal(); + + const copiedAnimal = animal.copy() + copiedAnimal.clear(); + expect(copiedAnimal.id).toBeLessThan(0); + }); + + test('model should get null id when clearing after copy if it is instantiated with a null id', () => { + const animal = new Animal(); + + const copiedAnimal = new Animal({id: null}); + copiedAnimal.copy(animal) + copiedAnimal.clear(); + expect(copiedAnimal.id).toBeNull(); + }); + + test('related model should get null id on clear after copy', () => { + const animal = new Animal({ id: 5, kind: { id: 5 } }, { relations: ['kind'] }); + + const copiedAnimal = animal.copy() + copiedAnimal.clear(); + expect(copiedAnimal.kind.id).toBeNull(); + }); + + test('copying a related model should get a negative id when clear() is called on copied model', () => { + const animal = new Animal({ id: 5, kind: { id: 5 } }, { relations: ['kind'] }); + + const copiedKind = animal.kind.copy() + copiedKind.clear(); + expect(copiedKind.id).toBeLessThan(0); + }); + +}); + /** * 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__/Store.js b/src/__tests__/Store.js index 397a8a8..fa0810a 100644 --- a/src/__tests__/Store.js +++ b/src/__tests__/Store.js @@ -1010,3 +1010,27 @@ describe('Pagination', () => { }); }); }); + +test('New model after adding should have a negative id when not supplied', () => { + const animalStore = new AnimalStore( ); + animalStore.add({ name: 'Cee' }); + + expect(animalStore.at(0).id).toBeLessThan(0); +}); + +test('New model after adding should have the supplied id', () => { + const animalStore = new AnimalStore( ); + animalStore.add({ id: 12, name: 'Cee' }); + + expect(animalStore.at(0).id).toBe(12); +}); + +test('Adding multiple models should get different ids', () => { + const animalStore = new AnimalStore( ); + animalStore.add({ name: 'Cee' }); + animalStore.add({ name: 'Bee' }); + + expect(animalStore.at(0).id).toBeLessThan(0); + expect(animalStore.at(1).id).toBeLessThan(0); + expect(animalStore.at(0).id).not.toBe(animalStore.at(1).id); +}); diff --git a/src/__tests__/helpers.js b/src/__tests__/helpers.js new file mode 100644 index 0000000..b23342c --- /dev/null +++ b/src/__tests__/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); + } +}