diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42a8da4..577bca5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ jobs: strategy: matrix: - node-version: ['6'] + node-version: ['14'] steps: - name: Checkout code 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 8457ffe..8416201 100644 --- a/dist/mobx-spine.cjs.js +++ b/dist/mobx-spine.cjs.js @@ -59,26 +59,38 @@ function relationsToNestedKeys(relations) { // Use output of relationsToNestedKeys to iterate each relation, fn is called on each model and store. function forNestedRelations(model, nestedRelations, fn) { Object.keys(nestedRelations).forEach(function (key) { - if (Object.keys(nestedRelations[key]).length > 0) { - if (model[key].forEach) { - model[key].forEach(function (m) { - forNestedRelations(m, nestedRelations[key], fn); - }); - fn(model); - } else { - forNestedRelations(model[key], nestedRelations[key], fn); + if (!model[key]) { + //check if passed relation is defined in relations + throw new Error('Relation \'' + key + '\' is not defined in relations'); + } else { + if (Object.keys(nestedRelations[key]).length > 0) { + if (model[key].forEach) { + model[key].forEach(function (m) { + forNestedRelations(m, nestedRelations[key], fn); + }); + + fn(model); + } else { + forNestedRelations(model[key], nestedRelations[key], fn); + } } - } - if (model[key].forEach) { - model[key].forEach(fn); - } + if (model[key].forEach) { + model[key].forEach(fn); + } - fn(model[key]); + fn(model[key]); + } }); } +var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { + return typeof obj; +} : function (obj) { + return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; +}; + var classCallCheck = function (instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); @@ -144,6 +156,44 @@ var objectWithoutProperties = function (obj, keys) { return target; }; +var slicedToArray = function () { + function sliceIterator(arr, i) { + var _arr = []; + var _n = true; + var _d = false; + var _e = undefined; + + try { + for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { + _arr.push(_s.value); + + if (i && _arr.length === i) break; + } + } catch (err) { + _d = true; + _e = err; + } finally { + try { + if (!_n && _i["return"]) _i["return"](); + } finally { + if (_d) throw _e; + } + } + + return _arr; + } + + return function (arr, i) { + if (Array.isArray(arr)) { + return arr; + } else if (Symbol.iterator in Object(arr)) { + return sliceIterator(arr, i); + } else { + throw new TypeError("Invalid attempt to destructure non-iterable instance"); + } + }; +}(); + var _class, _descriptor, _descriptor2, _descriptor3, _descriptor4, _descriptor5, _class2, _temp; function _initDefineProp(target, property, descriptor, context) { @@ -860,7 +910,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { /** * 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 + * @returns {*} the id of a model or a negative id if the id is not set */ }, { @@ -873,8 +923,9 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { } /** - * Gives the model the internal id. This is useful if you have a new model that you want to give an id so - * that it can be referred to in a relation. + * 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. */ }, { @@ -887,7 +938,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { * 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 + * @returns {string} the url for a model */ }, { @@ -925,7 +976,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { /** * 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 + * @returns {boolean} True if the model id is not set or a negative integer */ }, { @@ -997,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); } @@ -1045,7 +1106,8 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { 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); })); } @@ -1074,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. @@ -1200,8 +1262,8 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { * 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} + * @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} */ }, { @@ -1247,8 +1309,8 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { /** * 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 + * @param source the model to copy + * @param store the store of the current model, to setChanged if there are changes * @private */ @@ -1340,9 +1402,17 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { key: '__parseRepositoryToData', value: function __parseRepositoryToData(key, repository) { if (lodash.isArray(key)) { - return lodash.filter(repository, function (m) { - return key.includes(m.id); + 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; }); + models.sort(function (l, r) { + return idIndexes[l.id] - idIndexes[r.id]; + }); + return models; } return lodash.find(repository, { id: key }); } @@ -1368,14 +1438,14 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: '__scopeBackendResponse', - value: function __scopeBackendResponse(_ref2) { + 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; @@ -1441,13 +1511,13 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: 'fromBackend', - value: function fromBackend(_ref3) { + 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 @@ -1567,66 +1637,6 @@ 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).catch(function (err) { - throw err; - }); - } - }, { - key: 'save', - value: function save() { - var _this12 = this; - - var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - - this.clearValidationErrors(); - return this.wrapPendingRequestCount(this.__getApi().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: lodash.omit(options, 'url', 'data', 'mapData') - }).then(mobx.action(function (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) { - _this12.parseValidationErrors(err.valErrors); - } - throw err; - }))); - } }, { key: 'setInput', value: function setInput(name, value) { @@ -1705,30 +1715,79 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { } /** - * Validates a model and relations 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 saveAll request, example {relations:['foo'], onlyChanges: true} - */ + * 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: 'validateAll', - value: function validateAll() { + key: 'validate', + value: function validate() { var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - // Add the validate option + // Add the validate parameter if (options.params) { options.params.validate = true; } else { options.params = { validate: true }; } - return this.saveAll(options).catch(function (err) { + + return this.save(options).catch(function (err) { throw err; }); } }, { - key: 'saveAll', - value: function saveAll() { + key: 'save', + value: function save() { + var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; + + if (options.relations && options.relations.length > 0) { + return this._saveAll(options); + } else { + return this._save(options); + } + } + }, { + key: '_save', + value: function _save() { + var _this12 = this; + + var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; + + this.clearValidationErrors(); + return this.wrapPendingRequestCount(this.__getApi().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: lodash.omit(options, 'url', 'data', 'mapData') + }).then(mobx.action(function (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) { + _this12.parseValidationErrors(err.valErrors); + } + throw err; + }))); + } + }, { + key: '_saveAll', + value: function _saveAll() { var _this13 = this; var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; @@ -1910,7 +1969,13 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { var _this19 = this; lodash.forIn(this.__originalAttributes, function (value, key) { - _this19[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) { @@ -1994,7 +2059,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { initializer: function initializer() { return {}; } -}), _applyDecoratedDescriptor$1(_class$1.prototype, 'url', [mobx.computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'url'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'isNew', [mobx.computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'isNew'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'isLoading', [mobx.computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'isLoading'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, '__parseRelations', [mobx.action], Object.getOwnPropertyDescriptor(_class$1.prototype, '__parseRelations'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'hasUserChanges', [mobx.computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'hasUserChanges'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'fieldFilter', [mobx.computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'fieldFilter'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'fromBackend', [mobx.action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'fromBackend'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'parse', [mobx.action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'parse'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'save', [mobx.action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'save'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'setInput', [mobx.action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'setInput'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'saveAll', [mobx.action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'saveAll'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'parseValidationErrors', [mobx.action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'parseValidationErrors'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'clearValidationErrors', [mobx.action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'clearValidationErrors'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'backendValidationErrors', [mobx.computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'backendValidationErrors'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'delete', [mobx.action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'delete'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'fetch', [mobx.action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'fetch'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'clear', [mobx.action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'clear'), _class$1.prototype)), _class$1); +}), _applyDecoratedDescriptor$1(_class$1.prototype, 'url', [mobx.computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'url'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'isNew', [mobx.computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'isNew'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'isLoading', [mobx.computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'isLoading'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, '__parseRelations', [mobx.action], Object.getOwnPropertyDescriptor(_class$1.prototype, '__parseRelations'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'hasUserChanges', [mobx.computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'hasUserChanges'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'fieldFilter', [mobx.computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'fieldFilter'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'fromBackend', [mobx.action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'fromBackend'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'parse', [mobx.action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'parse'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'setInput', [mobx.action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'setInput'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, '_save', [mobx.action], Object.getOwnPropertyDescriptor(_class$1.prototype, '_save'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, '_saveAll', [mobx.action], Object.getOwnPropertyDescriptor(_class$1.prototype, '_saveAll'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'parseValidationErrors', [mobx.action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'parseValidationErrors'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'clearValidationErrors', [mobx.action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'clearValidationErrors'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'backendValidationErrors', [mobx.computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'backendValidationErrors'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'delete', [mobx.action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'delete'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'fetch', [mobx.action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'fetch'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'clear', [mobx.action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'clear'), _class$1.prototype)), _class$1); // Function ripped from Django docs. // See: https://docs.djangoproject.com/en/dev/ref/csrf/#ajax @@ -2004,6 +2069,48 @@ function csrfSafeMethod(method) { ); } +function escapeKey(key) { + return key.toString().replace(/([.\\])/g, '\\$1'); +} + +function extractFiles(data) { + var prefix = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ''; + + var keys = Array.isArray(data) ? lodash.range(data.length) : (typeof data === 'undefined' ? 'undefined' : _typeof(data)) === 'object' && data !== null ? Object.keys(data) : []; + var files = {}; + var _iteratorNormalCompletion = true; + var _didIteratorError = false; + var _iteratorError = undefined; + + try { + for (var _iterator = keys[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) { + var key = _step.value; + + if (data[key] instanceof Blob) { + files[prefix + escapeKey(key)] = data[key]; + data[key] = null; + } else if (_typeof(data[key]) === 'object' && data[key] !== null) { + Object.assign(files, extractFiles(data[key], prefix + escapeKey(key) + '.')); + } + } + } catch (err) { + _didIteratorError = true; + _iteratorError = err; + } finally { + try { + if (!_iteratorNormalCompletion && _iterator.return) { + _iterator.return(); + } + } finally { + if (_didIteratorError) { + throw _iteratorError; + } + } + } + + return files; +} + var BinderApi = function () { function BinderApi() { classCallCheck(this, BinderApi); @@ -2058,6 +2165,42 @@ var BinderApi = function () { }, this.defaultHeaders, options.headers); axiosOptions.headers = headers; + if (axiosOptions.data && !(axiosOptions.data instanceof Blob) && !(axiosOptions.data instanceof FormData)) { + var files = extractFiles(axiosOptions.data); + if (Object.keys(files).length > 0) { + var _data = new FormData(); + _data.append('data', JSON.stringify(axiosOptions.data)); + var _iteratorNormalCompletion2 = true; + var _didIteratorError2 = false; + var _iteratorError2 = undefined; + + try { + for (var _iterator2 = Object.entries(files)[Symbol.iterator](), _step2; !(_iteratorNormalCompletion2 = (_step2 = _iterator2.next()).done); _iteratorNormalCompletion2 = true) { + var _step2$value = slicedToArray(_step2.value, 2), + path = _step2$value[0], + file = _step2$value[1]; + + _data.append('file:' + path, file, file.name); + } + } catch (err) { + _didIteratorError2 = true; + _iteratorError2 = err; + } finally { + try { + if (!_iteratorNormalCompletion2 && _iterator2.return) { + _iterator2.return(); + } + } finally { + if (_didIteratorError2) { + throw _iteratorError2; + } + } + } + + axiosOptions.data = _data; + } + } + var xhr = this.axios(axiosOptions); // We fork the promise tree as we want to have the error traverse to the listeners diff --git a/dist/mobx-spine.es.js b/dist/mobx-spine.es.js index 7646022..8dfa982 100644 --- a/dist/mobx-spine.es.js +++ b/dist/mobx-spine.es.js @@ -1,5 +1,5 @@ import { observable, computed, action, autorun, isObservableProp, extendObservable, isObservableArray, isObservableObject, toJS } from 'mobx'; -import { isArray, map, filter, find, sortBy, forIn, omit, isPlainObject, result, uniqBy, each, mapValues, get, uniqueId, uniq, mapKeys } from 'lodash'; +import { isArray, map, filter, find, sortBy, forIn, omit, isPlainObject, result, uniqBy, each, mapValues, uniqueId, uniq, mapKeys, get, range } from 'lodash'; import axios from 'axios'; import moment from 'moment'; import { DateTime } from 'luxon'; @@ -53,26 +53,38 @@ function relationsToNestedKeys(relations) { // Use output of relationsToNestedKeys to iterate each relation, fn is called on each model and store. function forNestedRelations(model, nestedRelations, fn) { Object.keys(nestedRelations).forEach(function (key) { - if (Object.keys(nestedRelations[key]).length > 0) { - if (model[key].forEach) { - model[key].forEach(function (m) { - forNestedRelations(m, nestedRelations[key], fn); - }); - fn(model); - } else { - forNestedRelations(model[key], nestedRelations[key], fn); + if (!model[key]) { + //check if passed relation is defined in relations + throw new Error('Relation \'' + key + '\' is not defined in relations'); + } else { + if (Object.keys(nestedRelations[key]).length > 0) { + if (model[key].forEach) { + model[key].forEach(function (m) { + forNestedRelations(m, nestedRelations[key], fn); + }); + + fn(model); + } else { + forNestedRelations(model[key], nestedRelations[key], fn); + } } - } - if (model[key].forEach) { - model[key].forEach(fn); - } + if (model[key].forEach) { + model[key].forEach(fn); + } - fn(model[key]); + fn(model[key]); + } }); } +var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { + return typeof obj; +} : function (obj) { + return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; +}; + var classCallCheck = function (instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); @@ -138,6 +150,44 @@ var objectWithoutProperties = function (obj, keys) { return target; }; +var slicedToArray = function () { + function sliceIterator(arr, i) { + var _arr = []; + var _n = true; + var _d = false; + var _e = undefined; + + try { + for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { + _arr.push(_s.value); + + if (i && _arr.length === i) break; + } + } catch (err) { + _d = true; + _e = err; + } finally { + try { + if (!_n && _i["return"]) _i["return"](); + } finally { + if (_d) throw _e; + } + } + + return _arr; + } + + return function (arr, i) { + if (Array.isArray(arr)) { + return arr; + } else if (Symbol.iterator in Object(arr)) { + return sliceIterator(arr, i); + } else { + throw new TypeError("Invalid attempt to destructure non-iterable instance"); + } + }; +}(); + var _class, _descriptor, _descriptor2, _descriptor3, _descriptor4, _descriptor5, _class2, _temp; function _initDefineProp(target, property, descriptor, context) { @@ -854,7 +904,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { /** * 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 + * @returns {*} the id of a model or a negative id if the id is not set */ }, { @@ -867,8 +917,9 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { } /** - * Gives the model the internal id. This is useful if you have a new model that you want to give an id so - * that it can be referred to in a relation. + * 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. */ }, { @@ -881,7 +932,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { * 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 + * @returns {string} the url for a model */ }, { @@ -919,7 +970,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { /** * 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 + * @returns {boolean} True if the model id is not set or a negative integer */ }, { @@ -991,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); } @@ -1039,7 +1100,8 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { 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); })); } @@ -1068,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. @@ -1194,8 +1256,8 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { * 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} + * @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} */ }, { @@ -1241,8 +1303,8 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { /** * 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 + * @param source the model to copy + * @param store the store of the current model, to setChanged if there are changes * @private */ @@ -1334,9 +1396,17 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { key: '__parseRepositoryToData', value: function __parseRepositoryToData(key, repository) { if (isArray(key)) { - return filter(repository, function (m) { - return key.includes(m.id); + 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; }); + models.sort(function (l, r) { + return idIndexes[l.id] - idIndexes[r.id]; + }); + return models; } return find(repository, { id: key }); } @@ -1362,14 +1432,14 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: '__scopeBackendResponse', - value: function __scopeBackendResponse(_ref2) { + 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; @@ -1435,13 +1505,13 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: 'fromBackend', - value: function fromBackend(_ref3) { + 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 @@ -1561,66 +1631,6 @@ 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).catch(function (err) { - throw err; - }); - } - }, { - key: 'save', - value: function save() { - var _this12 = this; - - var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - - this.clearValidationErrors(); - return this.wrapPendingRequestCount(this.__getApi().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(function (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) { - _this12.parseValidationErrors(err.valErrors); - } - throw err; - }))); - } }, { key: 'setInput', value: function setInput(name, value) { @@ -1699,30 +1709,79 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { } /** - * Validates a model and relations 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 saveAll request, example {relations:['foo'], onlyChanges: true} - */ + * 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: 'validateAll', - value: function validateAll() { + key: 'validate', + value: function validate() { var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - // Add the validate option + // Add the validate parameter if (options.params) { options.params.validate = true; } else { options.params = { validate: true }; } - return this.saveAll(options).catch(function (err) { + + return this.save(options).catch(function (err) { throw err; }); } }, { - key: 'saveAll', - value: function saveAll() { + key: 'save', + value: function save() { + var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; + + if (options.relations && options.relations.length > 0) { + return this._saveAll(options); + } else { + return this._save(options); + } + } + }, { + key: '_save', + value: function _save() { + var _this12 = this; + + var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; + + this.clearValidationErrors(); + return this.wrapPendingRequestCount(this.__getApi().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(function (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) { + _this12.parseValidationErrors(err.valErrors); + } + throw err; + }))); + } + }, { + key: '_saveAll', + value: function _saveAll() { var _this13 = this; var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; @@ -1904,7 +1963,13 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { var _this19 = this; forIn(this.__originalAttributes, function (value, key) { - _this19[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) { @@ -1988,7 +2053,7 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { initializer: function initializer() { return {}; } -}), _applyDecoratedDescriptor$1(_class$1.prototype, 'url', [computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'url'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'isNew', [computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'isNew'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'isLoading', [computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'isLoading'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, '__parseRelations', [action], Object.getOwnPropertyDescriptor(_class$1.prototype, '__parseRelations'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'hasUserChanges', [computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'hasUserChanges'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'fieldFilter', [computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'fieldFilter'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'fromBackend', [action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'fromBackend'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'parse', [action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'parse'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'save', [action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'save'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'setInput', [action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'setInput'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'saveAll', [action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'saveAll'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'parseValidationErrors', [action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'parseValidationErrors'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'clearValidationErrors', [action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'clearValidationErrors'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'backendValidationErrors', [computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'backendValidationErrors'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'delete', [action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'delete'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'fetch', [action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'fetch'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'clear', [action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'clear'), _class$1.prototype)), _class$1); +}), _applyDecoratedDescriptor$1(_class$1.prototype, 'url', [computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'url'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'isNew', [computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'isNew'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'isLoading', [computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'isLoading'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, '__parseRelations', [action], Object.getOwnPropertyDescriptor(_class$1.prototype, '__parseRelations'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'hasUserChanges', [computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'hasUserChanges'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'fieldFilter', [computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'fieldFilter'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'fromBackend', [action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'fromBackend'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'parse', [action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'parse'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'setInput', [action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'setInput'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, '_save', [action], Object.getOwnPropertyDescriptor(_class$1.prototype, '_save'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, '_saveAll', [action], Object.getOwnPropertyDescriptor(_class$1.prototype, '_saveAll'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'parseValidationErrors', [action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'parseValidationErrors'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'clearValidationErrors', [action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'clearValidationErrors'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'backendValidationErrors', [computed], Object.getOwnPropertyDescriptor(_class$1.prototype, 'backendValidationErrors'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'delete', [action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'delete'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'fetch', [action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'fetch'), _class$1.prototype), _applyDecoratedDescriptor$1(_class$1.prototype, 'clear', [action], Object.getOwnPropertyDescriptor(_class$1.prototype, 'clear'), _class$1.prototype)), _class$1); // Function ripped from Django docs. // See: https://docs.djangoproject.com/en/dev/ref/csrf/#ajax @@ -1998,6 +2063,48 @@ function csrfSafeMethod(method) { ); } +function escapeKey(key) { + return key.toString().replace(/([.\\])/g, '\\$1'); +} + +function extractFiles(data) { + var prefix = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ''; + + var keys = Array.isArray(data) ? range(data.length) : (typeof data === 'undefined' ? 'undefined' : _typeof(data)) === 'object' && data !== null ? Object.keys(data) : []; + var files = {}; + var _iteratorNormalCompletion = true; + var _didIteratorError = false; + var _iteratorError = undefined; + + try { + for (var _iterator = keys[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) { + var key = _step.value; + + if (data[key] instanceof Blob) { + files[prefix + escapeKey(key)] = data[key]; + data[key] = null; + } else if (_typeof(data[key]) === 'object' && data[key] !== null) { + Object.assign(files, extractFiles(data[key], prefix + escapeKey(key) + '.')); + } + } + } catch (err) { + _didIteratorError = true; + _iteratorError = err; + } finally { + try { + if (!_iteratorNormalCompletion && _iterator.return) { + _iterator.return(); + } + } finally { + if (_didIteratorError) { + throw _iteratorError; + } + } + } + + return files; +} + var BinderApi = function () { function BinderApi() { classCallCheck(this, BinderApi); @@ -2052,6 +2159,42 @@ var BinderApi = function () { }, this.defaultHeaders, options.headers); axiosOptions.headers = headers; + if (axiosOptions.data && !(axiosOptions.data instanceof Blob) && !(axiosOptions.data instanceof FormData)) { + var files = extractFiles(axiosOptions.data); + if (Object.keys(files).length > 0) { + var _data = new FormData(); + _data.append('data', JSON.stringify(axiosOptions.data)); + var _iteratorNormalCompletion2 = true; + var _didIteratorError2 = false; + var _iteratorError2 = undefined; + + try { + for (var _iterator2 = Object.entries(files)[Symbol.iterator](), _step2; !(_iteratorNormalCompletion2 = (_step2 = _iterator2.next()).done); _iteratorNormalCompletion2 = true) { + var _step2$value = slicedToArray(_step2.value, 2), + path = _step2$value[0], + file = _step2$value[1]; + + _data.append('file:' + path, file, file.name); + } + } catch (err) { + _didIteratorError2 = true; + _iteratorError2 = err; + } finally { + try { + if (!_iteratorNormalCompletion2 && _iterator2.return) { + _iterator2.return(); + } + } finally { + if (_didIteratorError2) { + throw _iteratorError2; + } + } + } + + axiosOptions.data = _data; + } + } + var xhr = this.axios(axiosOptions); // We fork the promise tree as we want to have the error traverse to the listeners diff --git a/package.json b/package.json index 45e19dd..03b693e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mobx-spine", - "version": "0.28.4", + "version": "0.30.0", "license": "ISC", "author": "Kees Kluskens ", "description": "MobX with support for models, relations and an API.", @@ -65,7 +65,7 @@ "moment": "^2.22.0" }, "jest": { - "testEnvironment": "node", + "testEnvironment": "jsdom", "roots": [ "./src" ], diff --git a/src/BinderApi.js b/src/BinderApi.js index b1be24e..dcc42d5 100644 --- a/src/BinderApi.js +++ b/src/BinderApi.js @@ -1,4 +1,4 @@ -import { get } from 'lodash'; +import { get, range } from 'lodash'; import axios from 'axios'; // Function ripped from Django docs. @@ -8,6 +8,30 @@ function csrfSafeMethod(method) { return /^(GET|HEAD|OPTIONS|TRACE)$/i.test(method); } +function escapeKey(key) { + return key.toString().replace(/([.\\])/g, '\\$1'); +} + +function extractFiles(data, prefix = '') { + const keys = ( + Array.isArray(data) + ? range(data.length) + : typeof data === 'object' && data !== null + ? Object.keys(data) + : [] + ); + let files = {}; + for (const key of keys) { + if (data[key] instanceof Blob) { + files[prefix + escapeKey(key)] = data[key]; + data[key] = null; + } else if (typeof data[key] === 'object' && data[key] !== null) { + Object.assign(files, extractFiles(data[key], prefix + escapeKey(key) + '.')); + } + } + return files; +} + export default class BinderApi { baseUrl = null; csrfToken = null; @@ -63,10 +87,26 @@ export default class BinderApi { 'X-Csrftoken': useCsrfToken, }, this.defaultHeaders, - options.headers + options.headers, ); axiosOptions.headers = headers; + if ( + axiosOptions.data && + !(axiosOptions.data instanceof Blob) && + !(axiosOptions.data instanceof FormData) + ) { + const files = extractFiles(axiosOptions.data); + if (Object.keys(files).length > 0) { + const data = new FormData(); + data.append('data', JSON.stringify(axiosOptions.data)); + for (const [path, file] of Object.entries(files)) { + data.append('file:' + path, file, file.name); + } + axiosOptions.data = data; + } + } + const xhr = this.axios(axiosOptions); // We fork the promise tree as we want to have the error traverse to the listeners diff --git a/src/Model.js b/src/Model.js index c7ca810..501bb2c 100644 --- a/src/Model.js +++ b/src/Model.js @@ -14,7 +14,6 @@ import { mapValues, find, filter, - get, isPlainObject, isArray, omit, @@ -23,7 +22,6 @@ import { uniqBy, mapKeys, result, - pick, } from 'lodash'; import Store from './Store'; import { invariant, snakeToCamel, camelToSnake, relationsToNestedKeys, forNestedRelations } from './utils'; @@ -111,7 +109,7 @@ export default class Model { /** * 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 + * @returns {*} the id of a model or a negative id if the id is not set */ getInternalId() { if (!this[this.constructor.primaryKey]) { @@ -121,8 +119,9 @@ export default class Model { } /** - * Gives the model the internal id. This is useful if you have a new model that you want to give an id so - * that it can be referred to in a relation. + * 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() @@ -132,7 +131,7 @@ export default class Model { * 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 + * @returns {string} the url for a model */ @computed get url() { @@ -142,7 +141,7 @@ export default class Model { /** * 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 + * @returns {boolean} True if the model id is not set or a negative integer */ @computed get isNew() { @@ -212,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); } @@ -265,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); }) ); } @@ -442,8 +452,8 @@ export default class 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} + * @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; @@ -484,8 +494,8 @@ export default class Model { /** * 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 + * @param source the model to copy + * @param store the store of the current model, to setChanged if there are changes * @private */ __copyChanges(source, store) { @@ -565,7 +575,10 @@ export default class Model { __parseRepositoryToData(key, repository) { if (isArray(key)) { - return filter(repository, m => key.includes(m.id)); + const idIndexes = Object.fromEntries(key.map((id, index) => [id, index])); + const models = repository.filter(({ id }) => idIndexes[id] !== undefined); + models.sort((l, r) => idIndexes[l.id] - idIndexes[r.id]); + return models; } return find(repository, { id: key }); } @@ -846,7 +859,7 @@ 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} + * @param options same as for a normal save request, example: {onlyChanges: true} */ validate(options = {}){ // Add the validate parameter @@ -1090,7 +1103,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 => { diff --git a/src/__tests__/BinderApi.js b/src/__tests__/BinderApi.js index a20c862..4388bae 100644 --- a/src/__tests__/BinderApi.js +++ b/src/__tests__/BinderApi.js @@ -269,3 +269,83 @@ test('Failing request with onRequestError and skipRequestError option', () => { expect(api.onRequestError).not.toHaveBeenCalled(); }); }); + +test('Blobs in json get converted to form data', () => { + const foo = new Blob(['foo'], { type: 'text/plain' }); + const bar = new Blob(['bar'], { type: 'text/plain' }); + + mock.onAny().replyOnce(config => { + expect(config.url).toBe('/api/test/'); + expect(config.method).toBe('put'); + expect(config.params).toEqual(undefined); + expect(config.data).toBeInstanceOf(FormData); + + const keys = Array.from(config.data.keys()).sort(); + expect(keys).toEqual(['data', 'file:bar.2', 'file:foo']); + + const data = JSON.parse(config.data.get('data')); + expect(data).toEqual({ + foo: null, + bar: [1, 'test', null], + }); + + const foo = config.data.get('file:foo'); + expect(foo).toBeInstanceOf(Blob); + + const bar = config.data.get('file:bar.2'); + expect(bar).toBeInstanceOf(Blob); + + return [200, {}]; + }); + + const api = new BinderApi(); + return api.put('/api/test/', { + foo, + bar: [1, 'test', bar], + }); +}); + +test('FormData is left intact', () => { + const foo = new Blob(['foo'], { type: 'text/plain' }); + const bar = new Blob(['bar'], { type: 'text/plain' }); + const data = new FormData(); + data.set('foo', foo); + data.set('bar', bar); + + mock.onAny().replyOnce(config => { + expect(config.url).toBe('/api/test/'); + expect(config.method).toBe('put'); + expect(config.params).toEqual(undefined); + expect(config.data).toBeInstanceOf(FormData); + + const keys = Array.from(config.data.keys()).sort(); + expect(keys).toEqual(['bar', 'foo']); + + const foo = config.data.get('foo'); + expect(foo).toBeInstanceOf(Blob); + + const bar = config.data.get('bar'); + expect(bar).toBeInstanceOf(Blob); + + return [200, {}]; + }); + + const api = new BinderApi(); + return api.put('/api/test/', data); +}); + +test('Blob is left intact', () => { + const data = new Blob(['foo'], { type: 'text/plain' }); + + mock.onAny().replyOnce(config => { + expect(config.url).toBe('/api/test/'); + expect(config.method).toBe('put'); + expect(config.params).toEqual(undefined); + expect(config.data).toBeInstanceOf(Blob); + + return [200, {}]; + }); + + const api = new BinderApi(); + return api.put('/api/test/', data); +}); diff --git a/src/__tests__/Model.js b/src/__tests__/Model.js index fd44e67..a9e7da5 100644 --- a/src/__tests__/Model.js +++ b/src/__tests__/Model.js @@ -2,7 +2,7 @@ import axios from 'axios'; import { toJS, observable } from 'mobx'; import MockAdapter from 'axios-mock-adapter'; import _ from 'lodash'; -import { Model, BinderApi, Casts } from '../'; +import { Model, BinderApi } from '../'; import { compareObjectsIgnoringNegativeIds } from "./helpers/helpers"; import { Animal, @@ -20,6 +20,8 @@ import { Person, PersonStore, Location, + File, + FileCabinet, } from './fixtures/Animal'; import { Customer, Location as CLocation } from './fixtures/Customer'; import animalKindBreedData from './fixtures/animal-with-kind-breed.json'; @@ -32,6 +34,7 @@ import customerWithoutTownRestaurants from './fixtures/customer-without-town-res import customersLocationBestCookWorkPlaces from './fixtures/customers-location-best-cook-work-places.json'; import saveFailData from './fixtures/save-fail.json'; import saveNewFailData from './fixtures/save-new-fail.json'; +import inconsistentOrderingWith from './fixtures/inconsistent-ordering-with.json'; beforeEach(() => { // Refresh lodash's `_.uniqueId` internal state for every test @@ -63,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(''); }); @@ -106,7 +109,6 @@ 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(); @@ -534,9 +536,6 @@ test('toBackend with omit fields', () => { const serialized = model.toBackend(); - const expected = { - weight: 32 - } expect(serialized).toEqual({ color: 'red', id: 1 @@ -634,7 +633,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(); }); @@ -662,7 +661,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(); }); @@ -683,7 +682,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(); }); @@ -700,7 +699,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(); }); @@ -719,7 +718,7 @@ test('toBackendAll with deep nested relation', () => { }); const serialized = animal.toBackendAll({ - nestedRelations: { kind: { location: {}, breed: { location: {} } } }, + nestedRelations: {kind: { location: {}, breed: { location: {} }}}, }); expect(serialized).toMatchSnapshot(); }); @@ -744,7 +743,7 @@ test('toBackendAll with nested store relation', () => { ]); const serialized = animal.toBackendAll({ - nestedRelations: { pastOwners: { town: {} } }, + nestedRelations: {pastOwners: { town: {} }}, }); expect(serialized).toMatchSnapshot(); }); @@ -773,7 +772,7 @@ test('toBackendAll with `backendResourceName` property model', () => { }); const serialized = animal.toBackendAll({ - nestedRelations: { blaat: {}, owners: {}, pastOwners: {} }, + nestedRelations: {blaat: {}, owners: {}, pastOwners: {}}, }); expect(serialized).toMatchSnapshot(); }); @@ -822,6 +821,7 @@ test('toBackend with observable array', () => { expect(animal.toBackend()).toEqual({ foo: ['q', 'a'], + id: -1, }); }); @@ -833,7 +833,7 @@ test('clear with basic attribute', () => { animal.clear(); - expect(animal.id).toBe(null); + expect(animal.id).toBeLessThan(0); expect(animal.name).toBe(''); }); @@ -957,7 +957,6 @@ 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; } @@ -1045,6 +1044,73 @@ describe('requests', () => { }); }); + test('Save model with file', () => { + const file = new File({ id: 5 }); + const dataFile = new Blob(['foo'], { type: 'text/plain' }); + file.setInput('dataFile', dataFile); + + mock.onAny().replyOnce(config => { + expect(config.method).toBe('patch'); + + expect(config.data).toBeInstanceOf(FormData); + + const keys = Array.from(config.data.keys()).sort(); + expect(keys).toEqual(['data', 'file:data_file']); + + const data = JSON.parse(config.data.get('data')); + expect(data).toEqual({ + data_file: null, + id: 5, + }); + return [200, { id: 5, data_file: '/api/dataFile' } ]; + }); + + file.save().then(() => { + expect(file.id).toBe(5); + expect(file.dataFile).toBe('/api/dataFile'); + }); + }); + + test('Save model with relations and multiple files', () => { + const fileCabinet = new FileCabinet({ id: 5 },{relations: ['files']}); + fileCabinet.files.add([ + { dataFile: new Blob(['bar'], { type: 'text/plain' }) }, + { dataFile: new Blob(['foo'], { type: 'text/plain' }) }, + { dataFile: new Blob(['baz'], { type: 'text/plain' }) }, + ]); + + mock.onAny().replyOnce(config => { + expect(config.method).toBe('put'); + + expect(config.data).toBeInstanceOf(FormData); + + const keys = Array.from(config.data.keys()).sort(); + expect(keys).toEqual([ + 'data', + 'file:with.files.0.data_file', + 'file:with.files.1.data_file', + 'file:with.files.2.data_file']); + + const data = JSON.parse(config.data.get('data')); + expect(data).toEqual({ + data: [{ + id: 5, + files: [-2, -3, -4], + }], + with: { + files: [ + { id: -2, data_file: null }, + { id: -3, data_file: null }, + { id: -4, data_file: null }, + ], + }, + }); + return [200, {}]; + }); + + fileCabinet.save({ relations: ['files'] }); + }); + test('fetch with relations', () => { const animal = new Animal( { id: 2 }, @@ -1160,7 +1226,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' }]; }); @@ -1180,12 +1246,12 @@ describe('requests', () => { expect(config.params).toEqual({ validate: true }); expect(config.url).toBe('/api/animal/'); expect(config.method).toBe('post'); - expect(config.data).toBe('{"id":null,"name":"Doggo"}'); + expect(config.data).toBe('{"id":-1,"name":"Doggo"}'); return [201, { id: 10, name: 'Doggo' }]; }); return animal.validate().then(() => { - expect(animal.id).toBe(null); + expect(animal.id).toBe(-1); expect(spy).not.toHaveBeenCalled(); spy.mockReset(); @@ -1297,7 +1363,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, {}]; }); @@ -1571,6 +1637,27 @@ describe('requests', () => { return animal.save({ relations: ['kind'] }); }); + test('save all with not defined relation error', () => { + const animal = new Animal( + { id: 10, name: 'Doggo', kind: { name: 'Dog' } }, + { relations: ['kind'] } + ); + + mock.onAny().replyOnce(config => { + expect(config.url).toBe('/api/animal/'); + expect(config.method).toBe('put'); + const putData = JSON.parse(config.data); + expect(putData).toMatchSnapshot(); + return [201, animalMultiPutResponse]; + }); + + return animal.save({ relations: ['kind', 'owner'] }).catch((e) => { + const error = 'Relation \'owner\' is not defined in relations' + expect(e.message).toEqual(error); + }) + }); + + test('save all with empty response from backend', () => { const animal = new Animal( { name: 'Doggo', kind: { name: 'Dog' } }, @@ -2510,46 +2597,140 @@ describe('copy with changes', () => { }); }); -// test('validate', () => { -// const customer = new Customer(null, { -// relations: ['oldTowns.bestCook.workPlaces'], -// }); -// -// customer.fromBackend({ -// data: customersWithTownCookRestaurant.data, -// repos: customersWithTownCookRestaurant.with, -// relMapping: customersWithTownCookRestaurant.with_mapping, -// }); -// -// customer.oldTowns.models[0].bestCook.workPlaces.models[0].setInput('name', "Italian"); -// -// const customerCopyNoChanges = new Customer(); -// customerCopyNoChanges.copy(customer, {copyChanges: true}) -// -// -// // Clone without changes should give the same toBackend result as the cloned object when only changes is false -// expect(customerCopyNoChanges.toBackendAll({onlyChanges: false})).toEqual(customer.toBackendAll({onlyChanges: false})) -// }); -// -// test('validateAll', () => { -// const customer = new Customer(null, { -// relations: ['oldTowns.bestCook.workPlaces'], -// }); -// -// customer.fromBackend({ -// data: customersWithTownCookRestaurant.data, -// repos: customersWithTownCookRestaurant.with, -// relMapping: customersWithTownCookRestaurant.with_mapping, -// }); -// -// customer.oldTowns.models[0].bestCook.workPlaces.models[0].setInput('name', "Italian"); -// -// const customerCopyNoChanges = new Customer(); -// customerCopyNoChanges.copy(customer, {copyChanges: true}) -// -// -// // Clone without changes should give the same toBackend result as the cloned object when only changes is false -// expect(customerCopyNoChanges.toBackendAll({onlyChanges: false})).toEqual(customer.toBackendAll({onlyChanges: false})) -// }); +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. + * + * i.e. + * + * data { + * "past_owners": [ + * 55, + * 66 + * ], + * } + * with: {past_owners: [ + * {id:66}, + * {id:55} + * ]) + * + * Past owners will be sorted 55, 66, and not the other way around + * + * + */ +test('Parsing inconsistent ordering of withs', () => { + + const animal = new Animal(null, { + relations: ['pastOwners.town'], + }); + + expect(animal.pastOwners).not.toBeUndefined(); + expect(animal.pastOwners).toBeInstanceOf(PersonStore); + + animal.fromBackend({ + data: inconsistentOrderingWith.data, + repos: inconsistentOrderingWith.with, + relMapping: inconsistentOrderingWith.with_mapping, + }); + + expect(animal.pastOwners.map('id')).toEqual([55, 66]); +}); + +}); 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__/__snapshots__/Model.js.snap b/src/__tests__/__snapshots__/Model.js.snap index 971893e..7dd91ed 100644 --- a/src/__tests__/__snapshots__/Model.js.snap +++ b/src/__tests__/__snapshots__/Model.js.snap @@ -20,6 +20,26 @@ Object { } `; +exports[`requests save all with not defined relation error 1`] = ` +Object { + "data": Array [ + Object { + "id": 10, + "kind": -2, + "name": "Doggo", + }, + ], + "with": Object { + "kind": Array [ + Object { + "id": -2, + "name": "Dog", + }, + ], + }, +} +`; + exports[`toBackendAll should de-duplicate relations 1`] = ` Object { "data": Array [ diff --git a/src/__tests__/fixtures/Animal.js b/src/__tests__/fixtures/Animal.js index b475652..d66e182 100644 --- a/src/__tests__/fixtures/Animal.js +++ b/src/__tests__/fixtures/Animal.js @@ -7,6 +7,32 @@ export class Location extends Model { @observable name = ''; } +export class File extends Model { + urlRoot = '/api/file/'; + api = new BinderApi(); + static backendResourceName = 'file'; + @observable id = null; + @observable dataFile = null; +} + +export class FileStore extends Store { + Model = File +} + +export class FileCabinet extends Model { + urlRoot = '/api/file_cabinet/'; + api = new BinderApi(); + static backendResourceName = 'file_cabinet'; + @observable id = null; + + relations() { + return { + files: FileStore, + } + } +} + + export class Breed extends Model { @observable id = null; @observable name = ''; diff --git a/src/__tests__/fixtures/inconsistent-ordering-with.json b/src/__tests__/fixtures/inconsistent-ordering-with.json new file mode 100644 index 0000000..b0c981c --- /dev/null +++ b/src/__tests__/fixtures/inconsistent-ordering-with.json @@ -0,0 +1,32 @@ +{ + "data": { + "color": "Brown", + "past_owners": [ + 55, + 66 + ], + "name": "Cat", + "kind": 2, + "id": 2, + "deleted": false + }, + "with": { + "animal_owner": [ + { + "name": "Bobbie", + "id": 66, + "town": 10 + }, + { + "name": "Oessein", + "id": 55, + "town": 11 + } + ] + }, + "meta": { + }, + "with_mapping": { + "past_owners": "animal_owner" + } +} diff --git a/src/__tests__/helpers/helpers.js b/src/__tests__/helpers/helpers.js index f6f72e0..b23342c 100644 --- a/src/__tests__/helpers/helpers.js +++ b/src/__tests__/helpers/helpers.js @@ -41,10 +41,10 @@ function modifyListNegativeIdCheck(expected){ /** * 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) + * @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 diff --git a/src/utils.js b/src/utils.js index f8850a6..0cc5282 100644 --- a/src/utils.js +++ b/src/utils.js @@ -41,22 +41,29 @@ export function relationsToNestedKeys(relations) { // Use output of relationsToNestedKeys to iterate each relation, fn is called on each model and store. export function forNestedRelations(model, nestedRelations, fn) { Object.keys(nestedRelations).forEach(key => { - if (Object.keys(nestedRelations[key]).length > 0) { - if (model[key].forEach) { - model[key].forEach(m => { - forNestedRelations(m, nestedRelations[key], fn); - }); - fn(model); - } else { - forNestedRelations(model[key], nestedRelations[key], fn); + if (!model[key]) { + //check if passed relation is defined in relations + throw new Error(`Relation '${key}' is not defined in relations`); + } else { + if (Object.keys(nestedRelations[key]).length > 0) { + if (model[key].forEach) { + model[key].forEach(m => { + forNestedRelations(m, nestedRelations[key], fn); + }); + + fn(model); + } else { + forNestedRelations(model[key], nestedRelations[key], fn); + } + } + + if (model[key].forEach) { + model[key].forEach(fn); } - } - if (model[key].forEach) { - model[key].forEach(fn); + fn(model[key]); } - fn(model[key]); }); }