├── .eslintignore ├── .mocharc.json ├── .gitattributes ├── .idea ├── encodings.xml ├── watcherTasks.xml ├── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml ├── misc.xml ├── vcs.xml ├── inspectionProfiles │ └── Project_Default.xml ├── modules.xml ├── jsLibraryMappings.xml ├── mobx-form-store.iml ├── codeStyleSettings.xml ├── markdown-navigator.xml └── dbnavigator.xml ├── wallaby.js ├── .gitignore ├── .babelrc ├── LICENSE ├── .eslintrc ├── test ├── mockServer.js └── FormStore.spec.js ├── package.json ├── README.md ├── src └── FormStore.js └── lib └── FormStore.js /.eslintignore: -------------------------------------------------------------------------------- 1 | **/test/**/*.js 2 | **/lib/**/*.js 3 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": ["@babel/register", "@babel/polyfill", "mock-local-storage"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.js text eol=lf 2 | *.json text eol=lf 3 | *.jsx text eol=lf 4 | *.xml text eol=lf 5 | *.css text eol=lf 6 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/watcherTasks.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /wallaby.js: -------------------------------------------------------------------------------- 1 | module.exports = function (wallaby) { 2 | return { 3 | files: [ 4 | 'src/**', 5 | 'test/mockServer.js', 6 | ], 7 | 8 | tests: [ 9 | 'test/**/*.spec.js', 10 | ], 11 | 12 | env: { 13 | type: 'node', 14 | }, 15 | 16 | testFramework: 'mocha', 17 | 18 | compilers: { 19 | '**/*.js*': wallaby.compilers.babel(), 20 | }, 21 | 22 | bootstrap() { 23 | require('mock-local-storage'); 24 | }, 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | # Gitbook generated static pages 30 | _book 31 | 32 | /.idea/dbnavigator.xml 33 | /.idea/workspace.xml 34 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ], 5 | "plugins": [ 6 | ["@babel/plugin-proposal-decorators", {"legacy": true}], 7 | ["@babel/plugin-proposal-private-methods", { "loose": true }], 8 | ["@babel/plugin-proposal-class-properties", { "loose": true }], 9 | "@babel/plugin-proposal-export-default-from", 10 | ["module:fast-async", { 11 | "spec": true, 12 | "env": { 13 | "augmentObject": false, 14 | "dontMapStackTraces": true, 15 | "asyncStackTrace": false, 16 | "dontInstallRequireHook": false 17 | }, 18 | "compiler": { 19 | "promises": true, 20 | "generators": false 21 | }, 22 | "runtimePattern": null, 23 | "useRuntimeModule": false 24 | }] 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.idea/mobx-form-store.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Alex Hisen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "parser": "babel-eslint", 4 | "rules": { 5 | "comma-dangle": [2, { 6 | "arrays": "always-multiline", 7 | "objects": "always-multiline", 8 | "imports": "always-multiline", 9 | "exports": "always-multiline", 10 | "functions": "ignore" 11 | }], 12 | "no-console": 2, 13 | "camelcase": 0, 14 | "no-use-before-define": [1, "nofunc"], 15 | "no-param-reassign": 0, 16 | "max-len": [1, 150], 17 | "default-case": 0, 18 | "no-continue": 0, 19 | "arrow-body-style": 0, 20 | "no-unused-expressions": [2, { "allowShortCircuit": true }], 21 | "no-underscore-dangle": 0, 22 | "prefer-template": 1, 23 | "no-trailing-spaces":0, 24 | "key-spacing":0, 25 | "no-multi-spaces":0, 26 | "new-cap": [2, { "capIsNewExceptions": ["Router"] }], 27 | "generator-star-spacing": 0, 28 | "react/sort-comp": [2, { 29 | "order": [ 30 | "static-methods", 31 | "lifecycle", 32 | "everything-else", 33 | "/^render.+$/", 34 | "render" 35 | ] 36 | }], 37 | "react/no-unused-prop-types": [2, { "skipShapeProps": true }], 38 | "react/prefer-stateless-function": 0, 39 | "class-methods-use-this": 0, 40 | "arrow-parens": 0, 41 | "no-bitwise": 0, 42 | "no-plusplus": 0, 43 | "no-restricted-syntax": 0 44 | }, 45 | "env": { 46 | "browser": true, 47 | "es6": true, 48 | "node": true 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test/mockServer.js: -------------------------------------------------------------------------------- 1 | import extend from "just-extend"; 2 | 3 | /* eslint-disable import/no-mutable-exports */ 4 | let server; 5 | /* eslint-enable */ 6 | 7 | const record = { 8 | id: null, 9 | firstName: null, 10 | lastName: null, 11 | name: null, 12 | email: null, 13 | phone: null, 14 | birthdate: null, 15 | hobbies: [], 16 | attributes: { 17 | weight: null, 18 | height: null, // we prevent changes to this one 19 | }, 20 | }; 21 | 22 | function getRecord() { 23 | const deep = true; 24 | return JSON.parse(localStorage.getItem('mockServerUser')) || extend(deep, {}, record); 25 | } 26 | 27 | function setRecord(data) { 28 | localStorage.setItem('mockServerUser', JSON.stringify(data)); 29 | } 30 | 31 | class MockServer { 32 | async get() { 33 | const data = getRecord(); 34 | Object.keys(data).filter((key) => key.match(/date/i)).forEach((key) => { 35 | data[key] = data[key] && new Date(data[key]); 36 | }); 37 | return data; 38 | } 39 | 40 | async create(info) { 41 | const data = { id: 123 }; 42 | setRecord(Object.assign(getRecord(), info, data)); 43 | return { data }; 44 | } 45 | 46 | async set(info) { 47 | const response = {}; 48 | if (info.email && !info.email.match(/@/)) { 49 | info = Object.assign({}, info); 50 | delete info.email; 51 | response.status = 'error'; 52 | response.error = { 53 | email: 'Invalid email address', 54 | }; 55 | } 56 | if (info.attributes) { 57 | delete info.attributes.height; 58 | } 59 | setRecord(Object.assign(getRecord(), info)); 60 | return response; 61 | } 62 | 63 | delete() { 64 | localStorage.removeItem('mockServerUser'); 65 | } 66 | } 67 | 68 | server = new MockServer(); // eslint-disable-line prefer-const 69 | // Store Singleton in window for ease of debugging: 70 | if (typeof window !== 'undefined') { 71 | window.server = server; 72 | } 73 | export default server; 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mobx-form-store", 3 | "version": "3.0.0", 4 | "description": "MobX Backing Store for Forms with optional Auto/Incremental Save", 5 | "main": "lib/FormStore.js", 6 | "scripts": { 7 | "build": "babel ./src --out-dir ./lib", 8 | "dev": "webpack --progress --colors --watch --mode=dev", 9 | "test": "mocha --colors ./test/*.spec.js", 10 | "test:watch": "mocha --colors -w ./test/*.spec.js", 11 | "prepublish": "npm build && npm test" 12 | }, 13 | "dependencies": { 14 | "@babel/polyfill": "^7.12.1", 15 | "just-extend": "^4.2.1", 16 | "mobx": "^2.2.0 || ^3.0.0 || ^4.0.0 || ^5.0.0" 17 | }, 18 | "devDependencies": { 19 | "@babel/cli": "^7.14.5", 20 | "@babel/core": "^7.14.6", 21 | "@babel/plugin-proposal-class-properties": "^7.14.5", 22 | "@babel/plugin-proposal-decorators": "^7.14.5", 23 | "@babel/plugin-proposal-export-default-from": "^7.14.5", 24 | "@babel/plugin-transform-runtime": "^7.14.5", 25 | "@babel/preset-env": "^7.14.7", 26 | "@babel/preset-stage-0": "^7.8.3", 27 | "@babel/register": "^7.14.5", 28 | "babel-plugin-rewire": "^1.2.0", 29 | "babel-eslint": "^6.1.2", 30 | "chai": "^3.5.0", 31 | "eslint": "^3.7.1", 32 | "eslint-config-airbnb": "^14.0.0", 33 | "eslint-plugin-import": "^2.0.0", 34 | "eslint-plugin-jsx-a11y": "^3.0.2", 35 | "eslint-plugin-react": "^6.3.0", 36 | "fast-async": "^6.3.8", 37 | "mocha": "^7.2.0", 38 | "mock-local-storage": "^1.0.2" 39 | }, 40 | "peerDependencies": { 41 | "@babel/polyfill": "^7.12.1", 42 | "mobx": "^2.2.0 || ^3.0.0 || ^4.0.0 || ^5.0.0" 43 | }, 44 | "files": [ 45 | "lib", 46 | "LICENSE" 47 | ], 48 | "repository": { 49 | "type": "git", 50 | "url": "https://github.com/alexhisen/mobx-form-store.git" 51 | }, 52 | "keywords": [ 53 | "mobx", 54 | "form", 55 | "autosave" 56 | ], 57 | "author": "Alex Hisen ", 58 | "contributors": [ 59 | "Pawel Rychlik ", 60 | "Mateusz Mrowiec " 61 | ], 62 | "license": "MIT", 63 | "bugs": { 64 | "url": "https://github.com/alexhisen/mobx-form-store/issues" 65 | }, 66 | "homepage": "https://github.com/alexhisen/mobx-form-store" 67 | } 68 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 61 | -------------------------------------------------------------------------------- /.idea/codeStyleSettings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 65 | 67 | -------------------------------------------------------------------------------- /.idea/markdown-navigator.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 33 | 34 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MobX FormStore 2 | 3 | 4 | 5 | FormStore is part of a collection of loosely-coupled components for managing, rendering and validating forms in MobX-based apps. 6 | 7 | ## Detailed Documentation: 8 | https://alexhisen.gitbooks.io/mobx-forms/ 9 | 10 | ## FormStore Overview 11 | 12 | Instances of FormStore \(models\) store the data entered by the user into form fields and provide observables for managing validation error messages. They also track changes and provide facilities for \(auto-\)saving the \(changed\) data. 13 | 14 | ## Features 15 | 16 | * Tracks changes in each data property and by default saves only the changes. 17 | * Will not deem a property as changed for string/number, etc datatype changes or different Date objects with the same date or different Arrays \(in v1.4+\) or Objects \(in v3.0+\) with the same content. 18 | * Optionally auto-saves incremental changes \(if there are any\) every X milliseconds \(see [autoSaveInterval](https://alexhisen.gitbooks.io/mobx-forms/formstore-constructor.html)\). 19 | * By default, will not \(auto-\)save data properties that failed validation or that are still being edited by the user \(note that FormStore only provides the facilities to track this - validation, etc is done in different components\). 20 | * In a long-running app, can prevent unnecessary server requests to refresh data in the model by limiting them to not occur more often than Y milliseconds \(see [minRefreshInterval](https://alexhisen.gitbooks.io/mobx-forms/formstore-constructor.html)\). 21 | * Will auto-save any unsaved data when attempting to refresh to prevent loss of user-entered data. 22 | * Provides observable properties to drive things like loading indicators, enabling/disabling form or save button, error and confirmation messages, etc. 23 | * Server [responses](https://alexhisen.gitbooks.io/mobx-forms/formstore-server-errors.html) to save requests can drive error / validation messaging and discard invalid values. 24 | * Can differentiate between 'create' and 'update' save operations. A model that has not yet been created on the server will not try to refresh from server \(this works right in v2.0+\). 25 | * Saves are queued automatically, never concurrent. 26 | * \(NEW in v1.3\) Auto-save can be dynamically configured and enabled or disabled 27 | 28 | ## Breaking change in Version 2.0 29 | Previously, server.create() was called (instead of server.set()) only when the property defined as the idProperty in store.data was falsy. 30 | This worked well if the idProperty was only returned by the server and was not user-enterable. 31 | Now whether server.create() is called is driven by a new store.status.mustCreate property which is true only when the idProperty has not yet been returned by the server / saved even if it already has a value in store.data. 32 | Note that MobxSchemaForm v.1.14+ supports a readOnlyCondition property that can be set to "!model.status.mustCreate" to allow an id property to be entered but not modified. 33 | 34 | ## Breaking changes in Version 3.0 35 | * FormStore now deep-clones \(and merges\) objects and arrays \(plain or observable\) when storing data coming from server and in the updates object sent to server. 36 | * It's no longer published as a webpack-compiled and minified module. 37 | 38 | ## Requirements 39 | 40 | FormStore only requires [MobX](https://mobx.js.org/) 2.2+, 3.x, 4.x or 5.x. _MobX strict mode is currently not supported._ **FormStore does not implement the actual server requests, it only calls methods that you provide with the data to be sent to the server.** 41 | 42 | ## Installation 43 | 44 | ``` 45 | npm install --save mobx-form-store 46 | ``` 47 | 48 | ## Minimal Usage Example 49 | 50 | myStore.js \(a Singleton\): 51 | 52 | ```js 53 | import FormStore from 'mobx-form-store'; 54 | 55 | const model = new FormStore({ 56 | server: { 57 | // Example uses ES5 with https://github.com/github/fetch API and Promises 58 | get: function() { 59 | return fetch('myServerRefreshUrl').then(function(result) { return result.json() }); 60 | }, 61 | 62 | // Example uses ES6, fetch and async await 63 | set: async (info) => { 64 | const result = await fetch('myServerSaveUrl', { 65 | method: 'POST', 66 | headers: { 67 | Accept: 'application/json', 68 | 'Content-Type': 'application/json', 69 | }, 70 | body: JSON.stringify(info), 71 | }); 72 | return await result.json() || {}; // MUST return an object 73 | } 74 | }, 75 | }); 76 | 77 | export default model; 78 | ``` 79 | 80 | > IMPORTANT: Your server.get method MUST return an object with ALL properties that need to be rendered in the form. If the model does not yet exist on the server, each property should have a null value but it must exist in the object or it cannot be observed with MobX. 81 | 82 | ## Example of using FormStore in a React form 83 | 84 | myForm.jsx \(this is _without_ MobxSchemaForm \(with it there is even less code\)\). 85 | 86 | ```js 87 | import React from 'react'; 88 | import { observer } from 'mobx-react'; 89 | import model from './myStore.js' 90 | 91 | @observer class MyForm extends React.Component { 92 | componentDidMount() { 93 | model.refresh(); 94 | } 95 | 96 | onChange = (e) => { 97 | model.data[e.target.name] = e.target.value; 98 | model.dataErrors[e.target.name] = myCustomValidationPassed ? null : "error message"; 99 | } 100 | 101 | onSaveClick = () => { 102 | if (!model.status.canSave || !model.status.hasChanges) return; 103 | if (myCustomValidationPassed) model.save(); 104 | } 105 | 106 | render() { 107 | return ( 108 | {/* ... more fields / labels ... */} 109 | 110 | 111 | 119 |

{model.dataErrors.myProperty}

120 | 121 | 127 | ); 128 | } 129 | } 130 | ``` 131 | 132 | -------------------------------------------------------------------------------- /test/FormStore.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import server from './mockServer'; 3 | import { extendObservable } from "mobx"; 4 | 5 | let FormStore; 6 | const npmScript = process.env.npm_lifecycle_event; 7 | if (npmScript === 'test') { 8 | console.log('Testing compiled version'); 9 | FormStore = require('../lib/FormStore').default; 10 | } else { 11 | FormStore = require('../src/FormStore').default; 12 | } 13 | 14 | chai.expect(); 15 | 16 | const expect = chai.expect; 17 | 18 | let store; 19 | 20 | const delay = (time = 2) => { 21 | return new Promise((resolve) => setTimeout(resolve, time)); 22 | }; 23 | 24 | describe('FormStore with idProperty and id', function () { 25 | before(function () { 26 | store = new FormStore({ name: 'FormStore with idProperty and id', idProperty: 'id', server, /* log: console.log.bind(console) */}); 27 | }); 28 | 29 | it('should return the store name', () => { 30 | expect(store.options.name).to.be.equal('FormStore with idProperty and id'); 31 | }); 32 | 33 | describe('after getting the mock data', function () { 34 | before(async function () { 35 | server.delete(); 36 | await store.refresh(); 37 | }); 38 | 39 | it('should have an email property in data', () => { 40 | expect(store.data).to.haveOwnProperty('email'); 41 | }); 42 | 43 | it('should have a status.isReady of true', () => { 44 | expect(store.status.isReady).to.be.true; 45 | }); 46 | }); 47 | 48 | describe('after saving bad email', function () { 49 | before(async function () { 50 | server.delete(); 51 | await store.refresh(); 52 | store.dataServer.id = '1'; 53 | store.data.id = '1'; // only mockServer.create validates email, .set does not. 54 | store.data.email = 'bad'; 55 | await store.save(); 56 | }); 57 | 58 | it('should have the email in data', () => { 59 | expect(store.data.email).to.equal('bad'); 60 | }); 61 | 62 | it('should have an error message', () => { 63 | expect(store.dataErrors.email).to.be.ok; 64 | }); 65 | 66 | it('should have a status.canSave of false', () => { 67 | expect(store.status.canSave).to.be.false; 68 | }); 69 | }); 70 | }); 71 | 72 | describe('FormStore with idProperty', function () { 73 | before(function () { 74 | store = new FormStore({ name: 'FormStore with idProperty', idProperty: 'id', server, /* log: console.log.bind(console) */}); 75 | }); 76 | 77 | describe('after saving for first time', function () { 78 | before(async function () { 79 | server.delete(); 80 | await store.refresh(); 81 | expect(store.status.mustCreate).to.be.true; 82 | store.data.email = 'test@domain.com'; 83 | await store.save({ allowCreate: true }); 84 | }); 85 | 86 | it('should have an id', () => { 87 | expect(store.data.id).to.be.ok; 88 | expect(store.status.mustCreate).to.be.false; 89 | }); 90 | }); 91 | 92 | describe('refresh with unsaved data', function () { 93 | before(async function () { 94 | server.delete(); 95 | await store.refresh(); 96 | store.data.firstName = 'test'; 97 | await store.refresh(); 98 | }); 99 | 100 | it('should save it so it\'s returned in refresh', () => { 101 | expect(store.data.firstName).to.equal('test'); 102 | }); 103 | }); 104 | }); 105 | 106 | describe('FormStore without idProperty', function () { 107 | const birthdate1 = new Date('2001-01-01'); 108 | const birthdate2 = new Date('2001-01-01'); // same as 1 109 | const birthdate3 = new Date('2002-01-01'); 110 | 111 | before(function () { 112 | store = new FormStore({ name: 'FormStore without idProperty', server, /* log: console.log.bind(console) */}); 113 | }); 114 | 115 | describe('after saving a date', function () { 116 | before(async function () { 117 | server.delete(); 118 | await store.refresh(); 119 | store.data.birthdate = birthdate1; 120 | await store.save(); 121 | }); 122 | 123 | it('should have a status.hasChanges of false', () => { 124 | expect(store.status.hasChanges).to.be.false; 125 | }); 126 | }); 127 | 128 | describe('after setting same date', function () { 129 | before(async function () { 130 | store.data.birthdate = birthdate2; 131 | }); 132 | 133 | it('should have a status.hasChanges of false', () => { 134 | expect(store.status.hasChanges).to.be.false; 135 | }); 136 | }); 137 | 138 | describe('after changing multiple keys and saving', function () { 139 | before(async function () { 140 | store.data.firstName = 'test'; 141 | store.data.birthdate = birthdate3; 142 | await store.save(); 143 | }); 144 | 145 | it('should have a status.hasChanges of false', () => { 146 | expect(store.status.hasChanges).to.be.false; 147 | }); 148 | }); 149 | }); 150 | 151 | describe('AutoSaving FormStore', function () { 152 | before(function () { 153 | store = new FormStore({ name: 'AutoSaving FormStore', idProperty: 'id', autoSaveInterval: 1, server, /* log: console.log.bind(console) */}); 154 | }); 155 | 156 | describe('after auto-saving bad email for first time', function () { 157 | before(async function () { 158 | server.delete(); 159 | await store.refresh(); 160 | store.dataServer.id = '1'; 161 | store.data.id = '1'; // only mockServer.create validates email, .set does not. 162 | store.data.email = 'bad'; 163 | await delay(); // may not be necessary 164 | }); 165 | 166 | it('should have an error message', () => { 167 | expect(store.dataErrors.email).to.be.ok; 168 | }); 169 | }); 170 | 171 | // relies on id set by test above 172 | describe('edits in progress', function () { 173 | it('should not save while editing', async () => { 174 | store.startEditing('firstName'); 175 | store.data.firstName = 'first'; 176 | await delay(); 177 | // await store.save({ skipPropertyBeingEdited: true, keepServerError: true }); // same as auto-save 178 | const serverData = await server.get(); 179 | expect(serverData.firstName).to.be.null; 180 | }); 181 | 182 | it('should save when done editing', async () => { 183 | store.stopEditing(); 184 | await delay(); 185 | // await store.save({ skipPropertyBeingEdited: true, keepServerError: true }); // same as auto-save 186 | const serverData = await server.get(); 187 | expect(serverData.firstName).to.equal('first'); 188 | }); 189 | 190 | it('field in error should remain unsaved', async () => { 191 | const serverData = await server.get(); 192 | expect(serverData.email).to.be.null; 193 | }); 194 | 195 | it('should save updated field when no longer in error', async () => { 196 | store.dataErrors.email = null; 197 | store.data.email = 'test@domain.com'; 198 | await delay(); 199 | // await store.save({ skipPropertyBeingEdited: true, keepServerError: true }); // same as auto-save 200 | const serverData = await server.get(); 201 | expect(serverData.email).to.equal('test@domain.com'); 202 | }); 203 | }); 204 | }); 205 | 206 | describe('FormStore with minRefreshInterval', function () { 207 | beforeEach(function () { 208 | store = new FormStore({ 209 | name: 'FormStore with minRefreshInterval', 210 | server, 211 | minRefreshInterval: 5000, 212 | /* log: console.log.bind(console) */ 213 | }); 214 | }); 215 | 216 | it('should not perform a refresh right after prior refresh', async () => { 217 | server.delete(); 218 | let result = await store.refresh(); 219 | expect(result).to.be.true; 220 | result = await store.refresh(); 221 | expect(result).to.be.false; 222 | }); 223 | 224 | it('should not perform a refresh during another refresh', async () => { 225 | server.delete(); 226 | store.refresh(); 227 | const result = await store.refresh(); 228 | expect(result).to.be.false; 229 | }); 230 | }); 231 | 232 | describe('FormStore with a computed and nested data', function () { 233 | beforeEach(function () { 234 | store = new FormStore({ 235 | name: 'FormStore with a computed and nested data', 236 | server, 237 | afterRefresh: async (store) => { 238 | delete store.data.name; 239 | extendObservable(store.data, { 240 | get name() { 241 | return (store.data.firstName || store.data.lastName) && `${store.data.firstName || ''} ${store.data.lastName || ''}`.trim(); 242 | }, 243 | }); 244 | }, 245 | /* log: console.log.bind(console) */ 246 | }); 247 | }); 248 | 249 | it('should update and save computed', async () => { 250 | server.delete(); 251 | await store.refresh(); 252 | store.data.firstName = 'first'; 253 | store.data.lastName = 'last'; 254 | expect(store.data.name).to.equal('first last'); 255 | await store.save(); 256 | expect(store.dataServer.name).to.equal('first last'); 257 | }); 258 | 259 | it('should save computed in saveAll', async () => { 260 | server.delete(); 261 | await store.refresh(); 262 | store.data.firstName = 'first'; 263 | store.data.lastName = 'lastname'; 264 | await store.save({ saveAll: true }); 265 | expect(store.dataServer.name).to.equal('first lastname'); 266 | }); 267 | 268 | it('should save arrays and objects in saveAll', async () => { 269 | server.delete(); 270 | await store.refresh(); 271 | store.data.hobbies = ['chess']; 272 | store.data.attributes = { 273 | weight: 100, 274 | }; 275 | await store.save({ saveAll: true }); 276 | expect(store.dataServer.hobbies[0]).to.equal('chess'); 277 | expect(store.dataServer.attributes.weight).to.equal(100); 278 | }); 279 | 280 | it('should detect and save whole array changes', async () => { 281 | server.delete(); 282 | await store.refresh(); 283 | store.data.hobbies = ['chess']; 284 | expect(store.status.hasChanges).to.be.true; 285 | await store.save(); 286 | expect(store.dataServer.hobbies[0]).to.equal('chess'); 287 | }); 288 | 289 | it('should detect and save whole object changes if contents differs', async () => { 290 | server.delete(); 291 | await store.refresh(); 292 | store.data.attributes = { 293 | weight: 100, 294 | height: 150, 295 | }; 296 | expect(store.status.hasChanges).to.be.true; 297 | await store.save(); 298 | expect(store.dataServer.attributes.weight).to.equal(100); 299 | expect(store.dataServer.attributes.height).to.equal(null); // server blocks this 300 | expect(store.data.attributes.height).to.equal(150); // confirms that removal from updates does not affect data 301 | expect(store.status.hasChanges).to.be.true; 302 | store.data.attributes = { 303 | height: null, 304 | weight: 100, 305 | }; 306 | expect(store.status.hasChanges).to.be.false; 307 | }); 308 | }); 309 | -------------------------------------------------------------------------------- /.idea/dbnavigator.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | -------------------------------------------------------------------------------- /src/FormStore.js: -------------------------------------------------------------------------------- 1 | import { observable, observe, autorun, autorunAsync, action, computed, asMap, isComputedProp, isObservableArray } from 'mobx'; 2 | import extend from 'just-extend'; 3 | 4 | const DEFAULT_SERVER_ERROR_MESSAGE = 'Lost connection to server'; 5 | 6 | function isObject(obj) { 7 | return {}.toString.call(obj) === '[object Object]'; 8 | } 9 | 10 | function isSame(val1, val2) { 11 | /* eslint-disable eqeqeq */ 12 | return ( 13 | val1 == val2 || 14 | (val1 instanceof Date && val2 instanceof Date && val1.valueOf() == val2.valueOf()) || 15 | ((Array.isArray(val1) || isObservableArray(val1)) && 16 | (Array.isArray(val2) || isObservableArray(val2)) && 17 | val1.toString() === val2.toString() 18 | ) || 19 | (isObject(val1) && isObject(val2) && compareObjects(val1, val2)) 20 | ); 21 | /* eslint-enable eqeqeq */ 22 | } 23 | 24 | // Based on https://github.com/angus-c/just/blob/master/packages/collection-compare/index.js 25 | function compareObjects(val1, val2) { 26 | const keys1 = Object.getOwnPropertyNames(val1).filter((key) => key[0] !== '$').sort(); 27 | const keys2 = Object.getOwnPropertyNames(val2).filter((key) => key[0] !== '$').sort(); 28 | const len = keys1.length; 29 | if (len !== keys2.length) { 30 | return false; 31 | } 32 | for (let i = 0; i < len; i++) { 33 | const key1 = keys1[i]; 34 | const key2 = keys2[i]; 35 | if (!(key1 === key2 && isSame(val1[key1], val2[key2]))) { 36 | return false; 37 | } 38 | } 39 | return true; 40 | } 41 | 42 | /** 43 | * Observes data and if changes come, add them to dataChanges, 44 | * unless it resets back to dataServer value, then clear that change 45 | * @this {FormStore} 46 | * @param {Object} change 47 | * @param {String} change.name - name of property that changed 48 | * @param {*} change.newValue 49 | */ 50 | function observableChanged(change) { 51 | const store = this; 52 | action(() => { 53 | store.dataChanges.set(change.name, change.newValue); 54 | 55 | if (store.isSame(store.dataChanges.get(change.name), store.dataServer[change.name])) { 56 | store.dataChanges.delete(change.name); 57 | } 58 | })(); 59 | } 60 | 61 | /** 62 | * Sets up observation on all computed data properties, if any 63 | * @param {FormStore} store 64 | */ 65 | function observeComputedProperties(store) { 66 | store.observeComputedPropertiesDisposers.forEach((f) => f()); 67 | store.observeComputedPropertiesDisposers = []; 68 | action(() => { 69 | Object.getOwnPropertyNames(store.data).forEach((key) => { 70 | if (isComputedProp(store.data, key)) { 71 | store.options.log(`[${store.options.name}] Observing computed property: ${key}`); 72 | const disposer = observe(store.data, key, ({ newValue }) => store.storeDataChanged({ name: key, newValue })); 73 | store.observeComputedPropertiesDisposers.push(disposer); 74 | // add or delete from dataChanges depending on whether value is same as in dataServer: 75 | store.storeDataChanged({ name: key, newValue: store.data[key] }); 76 | } 77 | }); 78 | })(); 79 | } 80 | 81 | /** 82 | * Records successfully saved data as saved 83 | * and reverts fields server indicates to be in error 84 | * @param {FormStore} store 85 | * @param {Object} updates - what we sent to the server 86 | * @param {Object} response 87 | * @param {String} [response.data] - optional updated data to merge into the store (server.create can return id here) 88 | * @param {String} [response.status] - 'error' indicates one or more fields were invalid and not saved. 89 | * @param {String|Object} [response.error] - either a single error message to show to user if string or field-specific error messages if object 90 | * @param {String|Array} [response.error_field] - name of the field (or array of field names) in error 91 | * If autoSave is enabled, any field in error_field for which there is no error message in response.error will be reverted 92 | * to prevent autoSave from endlessly trying to save the changed field. 93 | * @returns response.status 94 | */ 95 | async function processSaveResponse(store, updates, response) { 96 | store.options.log(`[${store.options.name}] Response received from server.`); 97 | 98 | if (response.status === 'error') { 99 | action(() => { 100 | let errorFields = []; 101 | if (response.error) { 102 | if (typeof response.error === 'string') { 103 | store.serverError = response.error; 104 | } else { 105 | Object.assign(store.dataErrors, response.error); 106 | errorFields = Object.keys(response.error); 107 | } 108 | } 109 | 110 | // Supports an array of field names in error_field or a string 111 | errorFields = errorFields.concat(response.error_field); 112 | errorFields.forEach((field) => { 113 | if (store.options.autoSaveInterval && !store.dataErrors[field] && store.isSame(updates[field], store.data[field])) { 114 | store.data[field] = store.dataServer[field]; // revert or it'll keep trying to autosave it 115 | } 116 | delete updates[field]; // don't save it as the new dataServer value 117 | }); 118 | })(); 119 | } else { 120 | store.serverError = null; 121 | } 122 | 123 | const deep = true; 124 | extend(deep, store.dataServer, updates); 125 | 126 | action(() => { 127 | if (response.data) { 128 | extend(deep, store.dataServer, response.data); 129 | extend(deep, store.data, response.data); 130 | } 131 | 132 | for (const [key, value] of Array.from(store.dataChanges)) { 133 | if (store.isSame(value, store.dataServer[key])) { 134 | store.dataChanges.delete(key); 135 | } 136 | } 137 | })(); 138 | 139 | if (typeof store.options.afterSave === 'function') { 140 | await store.options.afterSave(store, updates, response); 141 | } 142 | 143 | return response.status; 144 | } 145 | 146 | /** 147 | * @param {FormStore} store 148 | * @param {Error} err 149 | */ 150 | function handleError(store, err) { 151 | if (typeof store.options.server.errorMessage === 'function') { 152 | store.serverError = store.options.server.errorMessage(err); 153 | } else { 154 | store.serverError = store.options.server.errorMessage; 155 | } 156 | 157 | store.options.logError(err); 158 | } 159 | 160 | class FormStore { 161 | /** @private */ 162 | options = { 163 | name: 'FormStore', // used in log statements 164 | idProperty: null, 165 | autoSaveOptions: { skipPropertyBeingEdited: true, keepServerError: true }, 166 | autoSaveInterval: 0, // in ms 167 | minRefreshInterval: 0, // in ms 168 | saveNotificationStatusOnError: null, 169 | log: function noop() {}, 170 | logError: console.error.bind(console), // eslint-disable-line 171 | /** @type {Boolean|function(object): Boolean} passed status object */ 172 | isReadOnly: (status) => !status.isReady, 173 | server: { 174 | /** @type {undefined|function: Promise|Object} - MUST resolve to an object with all data properties present even if all have null values */ 175 | get: undefined, 176 | /** @type {undefined|function(object): Promise|Object} passed updates object - see processSaveResponse for expected error response properties */ 177 | set: undefined, 178 | /** @type {undefined|function(object}: Promise|Object} passed updates object - see processSaveResponse for expected error response properties */ 179 | create: undefined, 180 | /** @type {String|function(error): String} passed error object */ 181 | errorMessage: DEFAULT_SERVER_ERROR_MESSAGE, 182 | }, 183 | /** @type {undefined|function(FormStore): Promise|Boolean} passed store instance - if it returns false, no refresh will be performed */ 184 | beforeRefresh: undefined, 185 | /** @type {undefined|function(FormStore): Promise} passed store instance */ 186 | afterRefresh: undefined, 187 | /** @type {undefined|function(FormStore, object, object): Promise|Boolean} passed store instance, updates object and saveOptions object, 188 | * (i.e. with skipPropertyBeingEdited, etc booleans) - if it returns false, no save will be performed */ 189 | beforeSave: undefined, 190 | /** @type {undefined|function(FormStore, object, object): Promise} passed store instance, updates object and response object 191 | * - updates object will already have fields removed from it that response indicates are in error */ 192 | afterSave: undefined, 193 | }; 194 | 195 | /** 196 | * @private 197 | * @type {null|Date} 198 | */ 199 | lastSync = null; 200 | /** @private */ 201 | saveQueue = Promise.resolve(); 202 | /** @private */ 203 | observeDataObjectDisposer; 204 | /** @private */ 205 | observeDataPropertiesDisposer; 206 | /** 207 | * @private 208 | * @type {Array} 209 | */ 210 | observeComputedPropertiesDisposers = []; 211 | /** @private */ 212 | autorunDisposer; 213 | 214 | /** @private */ 215 | @observable isReady = false; // true after initial data load (refresh) has completed 216 | /** @private */ 217 | @observable isLoading = false; 218 | /** @private */ 219 | @observable isSaving = false; 220 | /** @private */ 221 | @observable serverError = null; // stores both communication error and any explicit response.error returned to save 222 | 223 | /** @private */ 224 | // To support both Mobx 2.2+ and 3+, this is now done in constructor: 225 | // @observable dataChanges = asMap(); // changes that will be sent to server 226 | 227 | /** @private */ 228 | dataServer = {}; // data returned by the server (kept for checking old values) 229 | 230 | @observable data = {}; 231 | // stores validation error message if any for each field (data structure is identical to data) 232 | @observable dataErrors = {}; 233 | // active is set to true right after a save is completed and status is set to response.status 234 | // this allows a confirmation message to be shown to user and to drive its dismissal, 235 | // UI can set this observable's active property back to false. 236 | @observable saveNotification = { active: false, status: null }; 237 | @observable propertyBeingEdited = null; // property currently being edited as set by startEditing() 238 | 239 | isSame = isSame; 240 | 241 | constructor(options, data) { 242 | const store = this; 243 | Object.assign(store.options, options); 244 | if (!data && typeof store.options.server.get !== 'function') { 245 | throw new Error('options must specify server get function or supply initial data object to constructor'); 246 | } 247 | if (!typeof store.options.server.create !== 'function' && typeof store.options.server.set !== 'function') { 248 | throw new Error('options must specify server set and/or create function(s)'); 249 | } 250 | store.options.server.errorMessage = store.options.server.errorMessage || DEFAULT_SERVER_ERROR_MESSAGE; 251 | 252 | // Supports both Mobx 3+ (observable.map) and 2.x (asMap) without deprecation warnings: 253 | store.dataChanges = observable.map ? observable.map() : asMap(); // changes that will be sent to server 254 | 255 | // register observe for changes to properties in store.data as well as to complete replacement of store.data object 256 | store.storeDataChanged = observableChanged.bind(store); 257 | store.observeDataPropertiesDisposer = observe(store.data, store.storeDataChanged); 258 | store.observeDataObjectDisposer = observe(store, 'data', () => { 259 | store.observeDataPropertiesDisposer && store.observeDataPropertiesDisposer(); 260 | store.observeDataPropertiesDisposer = observe(store.data, store.storeDataChanged); 261 | 262 | store.dataChanges.clear(); 263 | action(() => { 264 | Object.keys(store.data).forEach((key) => { 265 | const value = store.data[key]; 266 | if (!store.isSame(value, store.dataServer[key])) { 267 | store.dataChanges.set(key, value); 268 | } 269 | }); 270 | observeComputedProperties(store); 271 | })(); 272 | }); 273 | 274 | store.configAutoSave(store.options.autoSaveInterval, store.options.autoSaveOptions); 275 | 276 | if (data) { 277 | store.reset(data); 278 | } 279 | } 280 | 281 | /** 282 | * disposes of all internal observation/autoruns so this instance can be garbage-collected. 283 | */ 284 | dispose() { 285 | const store = this; 286 | store.autorunDisposer && store.autorunDisposer(); 287 | store.observeDataObjectDisposer && store.observeDataObjectDisposer(); 288 | store.observeDataPropertiesDisposer && store.observeDataPropertiesDisposer(); 289 | store.observeComputedPropertiesDisposers.forEach((f) => f()); 290 | store.autorunDisposer = undefined; 291 | store.observeDataObjectDisposer = undefined; 292 | store.observeDataPropertiesDisposer = undefined; 293 | store.observeComputedPropertiesDisposers = []; 294 | } 295 | 296 | /** 297 | * Configures and enables or disables auto-save 298 | * @param {Number} autoSaveInterval - (in ms) - if non-zero autosave will be enabled, otherwise disabled 299 | * @param {Object} [autoSaveOptions] - overrides the default autoSaveOptions if provided 300 | */ 301 | configAutoSave(autoSaveInterval, autoSaveOptions) { 302 | const store = this; 303 | store.autorunDisposer && store.autorunDisposer(); 304 | store.options.autoSaveInterval = autoSaveInterval; 305 | store.options.autoSaveOptions = autoSaveOptions || store.options.autoSaveOptions; 306 | 307 | // auto-save by observing dataChanges keys 308 | if (store.options.autoSaveInterval) { 309 | // Supports both Mobx <=3 (autorunAsync) and Mobx 4+ 310 | // (ObservableMap keys no longer returning an Array is used to detect Mobx 4+, 311 | // because in non-production build autorunAsync exists in 4.x to issue deprecation error) 312 | const asyncAutorun = Array.isArray(store.dataChanges.keys()) ? autorunAsync : (fn, delay) => autorun(fn, { delay }); 313 | 314 | store.autorunDisposer = asyncAutorun(() => { 315 | if (!store.status.mustCreate && Array.from(store.dataChanges).length) { 316 | store.options.log(`[${store.options.name}] Auto-save started...`); 317 | store.save(store.options.autoSaveOptions); 318 | } 319 | }, store.options.autoSaveInterval); 320 | } else { 321 | store.autorunDisposer = undefined; 322 | } 323 | } 324 | 325 | /** 326 | * Marks data property as edit-in-progress and therefore it should not be autosaved - to be called on field focus 327 | * @param {String|Array} name - field/property name (Array format supports json schema forms) 328 | */ 329 | startEditing(name) { 330 | const store = this; 331 | store.propertyBeingEdited = Array.isArray(name) ? name[0] : name; 332 | } 333 | 334 | // to be called on field blur, any field name parameter is ignored 335 | stopEditing() { 336 | const store = this; 337 | store.propertyBeingEdited = null; 338 | if (store.status.hasChanges) { 339 | // This will trigger autorun in case it already ran while we were editing: 340 | action(() => { 341 | // In MobX 4+, ObservableMap.keys() returns an Iterable, not an array 342 | const key = Array.from(store.dataChanges)[0][0]; 343 | const value = store.dataChanges.get(key); 344 | store.dataChanges.delete(key); 345 | store.dataChanges.set(key, value); 346 | })(); 347 | } 348 | } 349 | 350 | /** 351 | * Returns the value of a field/property, optionally returning the last saved value for not validated/in progress fields 352 | * Without validated:true, using this function is not necessary, can just access store.data[name]. 353 | * @param {String|Array} name - field/property name (Array format supports json schema forms) 354 | * @param {Boolean} [validated] - only return validated value, i.e. if it's in error, fallback to dataServer 355 | * @param {Boolean} [skipPropertyBeingEdited] - used only when validated is true to again fallback to dataServer 356 | * @returns {*} 357 | */ 358 | getValue(name, validated, skipPropertyBeingEdited) { 359 | const store = this; 360 | const prop = Array.isArray(name) ? name[0] : name; 361 | if (validated) { 362 | // check if property is being edited or invalid 363 | if ((skipPropertyBeingEdited && prop === store.propertyBeingEdited) || store.dataErrors[prop]) { 364 | return store.dataServer[prop]; 365 | } 366 | } 367 | return store.data[prop]; 368 | } 369 | 370 | // Returns the last saved (or server-provided) set of data 371 | // - in an afterSave callback it already includes merged updates that were not in error 372 | getSavedData() { 373 | const store = this; 374 | return store.dataServer; 375 | } 376 | 377 | /** 378 | * @returns {{errors: Array, isReady: Boolean, isInProgress: Boolean, canSave: Boolean, hasChanges: Boolean, isReadOnly: Boolean}} 379 | * errors is an array of any serverError plus all the error messages from all fields (in no particular order) 380 | * (serverError is either the string returned in response.error or a communication error and is cleared on every refresh and save) 381 | * isReady indicates initial data load (refresh) has been completed and user can start entering data 382 | * isInProgress indicates either a refresh or a save is in progress 383 | * canSave is true when no refresh or save is in progress and there are no validation errors 384 | * hasChanges is true when one or more data properties has a value that's different from last-saved/server-loaded data. 385 | * isReadOnly by default is true when isReady is false but can be set to the return value of an 386 | * optional callback to which this status object (without isReadOnly) is passed 387 | */ 388 | @computed get status() { 389 | const store = this; 390 | let errors = []; 391 | 392 | if (store.serverError) { 393 | errors = [store.serverError]; 394 | } 395 | 396 | Object.keys(store.dataErrors).forEach((key) => { 397 | if (store.dataErrors[key]) { 398 | errors.push(store.dataErrors[key]); 399 | } 400 | }); 401 | 402 | const status = { 403 | errors, 404 | isReady: store.isReady, 405 | isInProgress: store.isLoading || store.isSaving, 406 | canSave: !store.isLoading && !store.isSaving && (store.serverError ? errors.length === 1 : errors.length === 0), 407 | hasChanges: !!store.dataChanges.size, 408 | mustCreate: !!(store.options.idProperty && !store.dataServer[store.options.idProperty]), 409 | }; 410 | if (typeof store.options.isReadOnly === 'function') { 411 | status.isReadOnly = store.options.isReadOnly(status); 412 | } else { 413 | status.isReadOnly = store.options.isReadOnly; 414 | } 415 | return status; 416 | } 417 | 418 | /** 419 | * Copies dataServer into data and resets the error observable and lastSync. 420 | * Mostly for internal use by constructor and refresh(). 421 | * @param {Object} [data] If provided, dataServer will be set to it and store.isReady will be set to true 422 | */ 423 | reset(data) { 424 | const store = this; 425 | 426 | action(() => { 427 | if (data) { 428 | store.dataServer = data; 429 | } 430 | const deep = true; 431 | store.data = extend(deep, {}, store.dataServer); 432 | 433 | // setup error observable 434 | const temp = {}; 435 | Object.keys(store.data).forEach((key) => { 436 | temp[key] = null; 437 | }); 438 | store.dataErrors = temp; 439 | 440 | store.lastSync = null; 441 | observeComputedProperties(store); 442 | if (data && !store.isReady) store.isReady = true; 443 | })(); 444 | } 445 | 446 | /** 447 | * Loads data from server unless a refresh was performed within the last minRefreshInterval (i.e. 15 minutes). 448 | * If there are pending (and ready to save) changes, triggers save instead and 'resets the clock' on minRefreshInterval. 449 | * For a store with idProperty defined, if that data property is falsy in data received from server, 450 | * loads from server only the very first time refresh() is called unless called with allowIfMustCreate=true option. 451 | * @param {Object} [refreshOptions] 452 | * @param {Boolean} [refreshOptions.allowIfMustCreate=false] 453 | * @param {Boolean} [refreshOptions.ignoreMinRefreshInterval=false] 454 | * @returns {Promise|Boolean} resolves to true if refresh actually performed, false if skipped 455 | */ 456 | async refresh(refreshOptions = {}) { 457 | // for some reason this syntax is erroring in tests: 458 | // const { allowIfMustCreate = false, ignoreMinRefreshInterval = false } = refreshOptions; 459 | const allowIfMustCreate = refreshOptions.allowIfMustCreate || false; 460 | const ignoreMinRefreshInterval = refreshOptions.ignoreMinRefreshInterval || false; 461 | const store = this; 462 | if (!store.options.server.get || (store.isReady && store.status.mustCreate && !allowIfMustCreate)) { 463 | return false; 464 | } 465 | store.options.log(`[${store.options.name}] Starting data refresh...`); 466 | 467 | if (store.isLoading) { 468 | store.options.log(`[${store.options.name}] Data is already being refreshed.`); 469 | return false; 470 | } 471 | 472 | const now = new Date(); 473 | const past = new Date(Date.now() - store.options.minRefreshInterval); 474 | 475 | // check if lastSync is between now and 15 minutes ago 476 | if (!ignoreMinRefreshInterval && past < store.lastSync && store.lastSync <= now) { 477 | store.options.log(`[${store.options.name}] Data refreshed within last ${store.options.minRefreshInterval / 1000} seconds.`); 478 | return false; 479 | } 480 | 481 | if (store.status.hasChanges && !store.status.mustCreate) { 482 | store.options.log(`[${store.options.name}] Unsaved changes detected...`); 483 | 484 | if (await store.save()) { 485 | store.options.log(`[${store.options.name}] Postponing refresh for ${store.options.minRefreshInterval / 1000} seconds.`); 486 | store.lastSync = new Date(); 487 | return false; 488 | } 489 | } 490 | 491 | if (typeof store.options.beforeRefresh === 'function') { 492 | if (await store.options.beforeRefresh(store) === false) { 493 | return false; 494 | } 495 | } 496 | 497 | store.options.log(`[${store.options.name}] Refreshing data...`); 498 | store.isLoading = true; 499 | 500 | try { 501 | const result = await store.options.server.get(); 502 | store.options.log(`[${store.options.name}] Data received from server.`); 503 | 504 | action(() => { 505 | store.dataServer = result; 506 | store.serverError = null; 507 | store.reset(); 508 | store.lastSync = new Date(); 509 | })(); 510 | 511 | if (typeof store.options.afterRefresh === 'function') { 512 | await store.options.afterRefresh(store); 513 | observeComputedProperties(store); // again, in case afterRefresh added some 514 | } 515 | 516 | store.options.log(`[${store.options.name}] Refresh finished.`); 517 | if (!store.isReady) store.isReady = true; 518 | } catch (err) { 519 | handleError(store, err); 520 | } 521 | 522 | store.isLoading = false; 523 | return true; 524 | } 525 | 526 | /** 527 | * Sends ready-to-save data changes to the server (normally using server.set unless it's undefined, then with server.create) 528 | * For a store with idProperty defined when that property is falsy in the data received from server 529 | * and allowCreate=true, uses server.create instead. 530 | * Calls to save() while one is in progress are queued. 531 | * @param {Object} saveOptions - the object as a whole is also passed to the beforeSave callback 532 | * @param {Boolean} [saveOptions.allowCreate=false] - for a store with idProperty defined, this must be true 533 | * for the save to actually be performed when that property is falsy. 534 | * @param {Boolean} [saveOptions.saveAll=false] - normally save only sends changes and if no changes, no save is done. 535 | * if saveAll=true, sends the full data object regardless of changes. 536 | * @param {Boolean} [saveOptions.skipPropertyBeingEdited=false] - true in an auto-save 537 | * @param {Boolean} [saveOptions.keepServerError=false] - true in an auto-save, otherwise will also deactivate saveNotification prior to save 538 | * @returns {Promise|Boolean} resolves to true if save actually performed, false if skipped 539 | */ 540 | save(saveOptions = {}) { 541 | const { allowCreate = false, saveAll = false, skipPropertyBeingEdited = false, keepServerError = false } = saveOptions; 542 | const store = this; 543 | 544 | store.saveQueue = store.saveQueue.then( 545 | async () => { 546 | if (store.status.mustCreate && !allowCreate) { 547 | return false; 548 | } 549 | store.options.log(`[${store.options.name}] Starting data save...`); 550 | 551 | const deep = true; 552 | let updates; 553 | if (saveAll) { 554 | updates = {}; 555 | Object.getOwnPropertyNames(store.data).forEach((property) => { 556 | if (property[0] === '$') { 557 | return; 558 | } 559 | if (isObservableArray(store.data[property])) { 560 | updates[property] = store.data[property].slice(); 561 | return; 562 | } 563 | updates[property] = store.data[property]; 564 | }); 565 | updates = extend(deep, {}, updates); 566 | } else { 567 | // Mobx 4+ toJS() exports a Map, not an Object and toJSON is the 'legacy' method to export an Object 568 | updates = store.dataChanges.toJSON ? store.dataChanges.toJSON() : store.dataChanges.toJS(); 569 | 570 | if (Object.keys(updates).length === 0) { 571 | store.options.log(`[${store.options.name}] No changes to save.`); 572 | return false; 573 | } 574 | 575 | // check if we have property currently being edited in changes 576 | // or if a property has an error and clone (observable or regular) 577 | // Arrays and Objects to plain ones 578 | Object.keys(updates).forEach((property) => { 579 | if (skipPropertyBeingEdited && property === store.propertyBeingEdited) { 580 | store.options.log(`[${store.options.name}] Property "${property}" is being edited.`); 581 | delete updates[property]; 582 | return; 583 | } 584 | 585 | if (store.dataErrors[property]) { 586 | store.options.log(`[${store.options.name}] Property "${property}" is not validated.`); 587 | delete updates[property]; 588 | return; 589 | } 590 | 591 | if (store.isSame(updates[property], store.dataServer[property])) { 592 | store.options.log(`[${store.options.name}] Property "${property}" is same as on the server.`); 593 | delete updates[property]; 594 | store.dataChanges.delete(property); 595 | return; 596 | } 597 | 598 | if (Array.isArray(updates[property]) || isObservableArray(updates[property])) { 599 | updates[property] = updates[property].slice(); 600 | } else if (isObject(updates[property])) { 601 | updates[property] = extend(deep, {}, updates[property]); 602 | } 603 | }); 604 | 605 | if (Object.keys(updates).length === 0) { 606 | store.options.log(`[${store.options.name}] No changes ready to save.`); 607 | return false; 608 | } 609 | } 610 | 611 | if (typeof store.options.beforeSave === 'function') { 612 | if (await store.options.beforeSave(store, updates, saveOptions) === false) { 613 | return false; 614 | } 615 | } 616 | 617 | store.options.log(`[${store.options.name}] Saving data...`); 618 | store.options.log(updates); 619 | store.isSaving = true; 620 | 621 | try { 622 | if (!keepServerError) { 623 | store.saveNotification.active = false; 624 | store.serverError = null; 625 | } 626 | 627 | let response; 628 | if (store.options.server.set && (!store.options.server.create || !store.status.mustCreate)) { 629 | response = await store.options.server.set(updates); 630 | } else { 631 | response = await store.options.server.create(updates); 632 | } 633 | 634 | store.saveNotification.status = await processSaveResponse(store, updates, response); 635 | store.saveNotification.active = true; 636 | 637 | store.options.log(`[${store.options.name}] Save finished.`); 638 | } catch (err) { 639 | handleError(store, err); 640 | if (store.options.saveNotificationStatusOnError) { 641 | store.saveNotification.status = store.options.saveNotificationStatusOnError; 642 | store.saveNotification.active = true; 643 | } 644 | } 645 | 646 | store.isSaving = false; 647 | return true; 648 | } 649 | ); 650 | 651 | return store.saveQueue; 652 | } 653 | } 654 | 655 | export default FormStore; 656 | -------------------------------------------------------------------------------- /lib/FormStore.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports["default"] = void 0; 7 | 8 | var _mobx = require("mobx"); 9 | 10 | var _justExtend = _interopRequireDefault(require("just-extend")); 11 | 12 | var _class, _descriptor, _descriptor2, _descriptor3, _descriptor4, _descriptor5, _descriptor6, _descriptor7, _descriptor8; 13 | 14 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; } 15 | 16 | function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest(); } 17 | 18 | function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } 19 | 20 | function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); } 21 | 22 | function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; } 23 | 24 | function _iterableToArrayLimit(arr, i) { var _i = arr == null ? null : typeof Symbol !== "undefined" && arr[Symbol.iterator] || arr["@@iterator"]; if (_i == null) return; var _arr = []; var _n = true; var _d = false; var _s, _e; try { for (_i = _i.call(arr); !(_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"] != null) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } 25 | 26 | function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; } 27 | 28 | function _typeof(obj) { "@babel/helpers - typeof"; if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } 29 | 30 | function _initializerDefineProperty(target, property, descriptor, context) { if (!descriptor) return; Object.defineProperty(target, property, { enumerable: descriptor.enumerable, configurable: descriptor.configurable, writable: descriptor.writable, value: descriptor.initializer ? descriptor.initializer.call(context) : void 0 }); } 31 | 32 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 33 | 34 | function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } 35 | 36 | function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } 37 | 38 | function _applyDecoratedDescriptor(target, property, decorators, descriptor, context) { var desc = {}; Object.keys(descriptor).forEach(function (key) { desc[key] = descriptor[key]; }); desc.enumerable = !!desc.enumerable; desc.configurable = !!desc.configurable; if ('value' in desc || desc.initializer) { desc.writable = true; } desc = decorators.slice().reverse().reduce(function (desc, decorator) { return decorator(target, property, desc) || desc; }, desc); if (context && desc.initializer !== void 0) { desc.value = desc.initializer ? desc.initializer.call(context) : void 0; desc.initializer = undefined; } if (desc.initializer === void 0) { Object.defineProperty(target, property, desc); desc = null; } return desc; } 39 | 40 | function _initializerWarningHelper(descriptor, context) { throw new Error('Decorating class property failed. Please ensure that ' + 'proposal-class-properties is enabled and runs after the decorators transform.'); } 41 | 42 | function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } } 43 | 44 | function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; } 45 | 46 | var DEFAULT_SERVER_ERROR_MESSAGE = 'Lost connection to server'; 47 | 48 | function isObject(obj) { 49 | return {}.toString.call(obj) === '[object Object]'; 50 | } 51 | 52 | function isSame(val1, val2) { 53 | /* eslint-disable eqeqeq */ 54 | return val1 == val2 || val1 instanceof Date && val2 instanceof Date && val1.valueOf() == val2.valueOf() || (Array.isArray(val1) || (0, _mobx.isObservableArray)(val1)) && (Array.isArray(val2) || (0, _mobx.isObservableArray)(val2)) && val1.toString() === val2.toString() || isObject(val1) && isObject(val2) && compareObjects(val1, val2); 55 | /* eslint-enable eqeqeq */ 56 | } // Based on https://github.com/angus-c/just/blob/master/packages/collection-compare/index.js 57 | 58 | 59 | function compareObjects(val1, val2) { 60 | var keys1 = Object.getOwnPropertyNames(val1).filter(function (key) { 61 | return key[0] !== '$'; 62 | }).sort(); 63 | var keys2 = Object.getOwnPropertyNames(val2).filter(function (key) { 64 | return key[0] !== '$'; 65 | }).sort(); 66 | var len = keys1.length; 67 | 68 | if (len !== keys2.length) { 69 | return false; 70 | } 71 | 72 | for (var i = 0; i < len; i++) { 73 | var key1 = keys1[i]; 74 | var key2 = keys2[i]; 75 | 76 | if (!(key1 === key2 && isSame(val1[key1], val2[key2]))) { 77 | return false; 78 | } 79 | } 80 | 81 | return true; 82 | } 83 | /** 84 | * Observes data and if changes come, add them to dataChanges, 85 | * unless it resets back to dataServer value, then clear that change 86 | * @this {FormStore} 87 | * @param {Object} change 88 | * @param {String} change.name - name of property that changed 89 | * @param {*} change.newValue 90 | */ 91 | 92 | 93 | function observableChanged(change) { 94 | var store = this; 95 | (0, _mobx.action)(function () { 96 | store.dataChanges.set(change.name, change.newValue); 97 | 98 | if (store.isSame(store.dataChanges.get(change.name), store.dataServer[change.name])) { 99 | store.dataChanges["delete"](change.name); 100 | } 101 | })(); 102 | } 103 | /** 104 | * Sets up observation on all computed data properties, if any 105 | * @param {FormStore} store 106 | */ 107 | 108 | 109 | function observeComputedProperties(store) { 110 | store.observeComputedPropertiesDisposers.forEach(function (f) { 111 | return f(); 112 | }); 113 | store.observeComputedPropertiesDisposers = []; 114 | (0, _mobx.action)(function () { 115 | Object.getOwnPropertyNames(store.data).forEach(function (key) { 116 | if ((0, _mobx.isComputedProp)(store.data, key)) { 117 | store.options.log("[".concat(store.options.name, "] Observing computed property: ").concat(key)); 118 | var disposer = (0, _mobx.observe)(store.data, key, function (_ref) { 119 | var newValue = _ref.newValue; 120 | return store.storeDataChanged({ 121 | name: key, 122 | newValue: newValue 123 | }); 124 | }); 125 | store.observeComputedPropertiesDisposers.push(disposer); // add or delete from dataChanges depending on whether value is same as in dataServer: 126 | 127 | store.storeDataChanged({ 128 | name: key, 129 | newValue: store.data[key] 130 | }); 131 | } 132 | }); 133 | })(); 134 | } 135 | /** 136 | * Records successfully saved data as saved 137 | * and reverts fields server indicates to be in error 138 | * @param {FormStore} store 139 | * @param {Object} updates - what we sent to the server 140 | * @param {Object} response 141 | * @param {String} [response.data] - optional updated data to merge into the store (server.create can return id here) 142 | * @param {String} [response.status] - 'error' indicates one or more fields were invalid and not saved. 143 | * @param {String|Object} [response.error] - either a single error message to show to user if string or field-specific error messages if object 144 | * @param {String|Array} [response.error_field] - name of the field (or array of field names) in error 145 | * If autoSave is enabled, any field in error_field for which there is no error message in response.error will be reverted 146 | * to prevent autoSave from endlessly trying to save the changed field. 147 | * @returns response.status 148 | */ 149 | 150 | 151 | function processSaveResponse(_x, _x2, _x3) { 152 | return _processSaveResponse.apply(this, arguments); 153 | } 154 | /** 155 | * @param {FormStore} store 156 | * @param {Error} err 157 | */ 158 | 159 | 160 | function _processSaveResponse() { 161 | _processSaveResponse = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee3(store, updates, response) { 162 | var deep; 163 | return regeneratorRuntime.wrap(function _callee3$(_context3) { 164 | while (1) { 165 | switch (_context3.prev = _context3.next) { 166 | case 0: 167 | store.options.log("[".concat(store.options.name, "] Response received from server.")); 168 | 169 | if (response.status === 'error') { 170 | (0, _mobx.action)(function () { 171 | var errorFields = []; 172 | 173 | if (response.error) { 174 | if (typeof response.error === 'string') { 175 | store.serverError = response.error; 176 | } else { 177 | Object.assign(store.dataErrors, response.error); 178 | errorFields = Object.keys(response.error); 179 | } 180 | } // Supports an array of field names in error_field or a string 181 | 182 | 183 | errorFields = errorFields.concat(response.error_field); 184 | errorFields.forEach(function (field) { 185 | if (store.options.autoSaveInterval && !store.dataErrors[field] && store.isSame(updates[field], store.data[field])) { 186 | store.data[field] = store.dataServer[field]; // revert or it'll keep trying to autosave it 187 | } 188 | 189 | delete updates[field]; // don't save it as the new dataServer value 190 | }); 191 | })(); 192 | } else { 193 | store.serverError = null; 194 | } 195 | 196 | deep = true; 197 | (0, _justExtend["default"])(deep, store.dataServer, updates); 198 | (0, _mobx.action)(function () { 199 | if (response.data) { 200 | (0, _justExtend["default"])(deep, store.dataServer, response.data); 201 | (0, _justExtend["default"])(deep, store.data, response.data); 202 | } 203 | 204 | for (var _i = 0, _Array$from = Array.from(store.dataChanges); _i < _Array$from.length; _i++) { 205 | var _Array$from$_i = _slicedToArray(_Array$from[_i], 2), 206 | key = _Array$from$_i[0], 207 | value = _Array$from$_i[1]; 208 | 209 | if (store.isSame(value, store.dataServer[key])) { 210 | store.dataChanges["delete"](key); 211 | } 212 | } 213 | })(); 214 | 215 | if (!(typeof store.options.afterSave === 'function')) { 216 | _context3.next = 8; 217 | break; 218 | } 219 | 220 | _context3.next = 8; 221 | return store.options.afterSave(store, updates, response); 222 | 223 | case 8: 224 | return _context3.abrupt("return", response.status); 225 | 226 | case 9: 227 | case "end": 228 | return _context3.stop(); 229 | } 230 | } 231 | }, _callee3); 232 | })); 233 | return _processSaveResponse.apply(this, arguments); 234 | } 235 | 236 | function handleError(store, err) { 237 | if (typeof store.options.server.errorMessage === 'function') { 238 | store.serverError = store.options.server.errorMessage(err); 239 | } else { 240 | store.serverError = store.options.server.errorMessage; 241 | } 242 | 243 | store.options.logError(err); 244 | } 245 | 246 | var FormStore = (_class = /*#__PURE__*/function () { 247 | /** @private */ 248 | 249 | /** 250 | * @private 251 | * @type {null|Date} 252 | */ 253 | 254 | /** @private */ 255 | 256 | /** @private */ 257 | 258 | /** @private */ 259 | 260 | /** 261 | * @private 262 | * @type {Array} 263 | */ 264 | 265 | /** @private */ 266 | 267 | /** @private */ 268 | // true after initial data load (refresh) has completed 269 | 270 | /** @private */ 271 | 272 | /** @private */ 273 | 274 | /** @private */ 275 | // stores both communication error and any explicit response.error returned to save 276 | 277 | /** @private */ 278 | // To support both Mobx 2.2+ and 3+, this is now done in constructor: 279 | // @observable dataChanges = asMap(); // changes that will be sent to server 280 | 281 | /** @private */ 282 | // data returned by the server (kept for checking old values) 283 | // stores validation error message if any for each field (data structure is identical to data) 284 | // active is set to true right after a save is completed and status is set to response.status 285 | // this allows a confirmation message to be shown to user and to drive its dismissal, 286 | // UI can set this observable's active property back to false. 287 | // property currently being edited as set by startEditing() 288 | function FormStore(options, data) { 289 | _classCallCheck(this, FormStore); 290 | 291 | this.options = { 292 | name: 'FormStore', 293 | // used in log statements 294 | idProperty: null, 295 | autoSaveOptions: { 296 | skipPropertyBeingEdited: true, 297 | keepServerError: true 298 | }, 299 | autoSaveInterval: 0, 300 | // in ms 301 | minRefreshInterval: 0, 302 | // in ms 303 | saveNotificationStatusOnError: null, 304 | log: function noop() {}, 305 | logError: console.error.bind(console), 306 | // eslint-disable-line 307 | 308 | /** @type {Boolean|function(object): Boolean} passed status object */ 309 | isReadOnly: function isReadOnly(status) { 310 | return !status.isReady; 311 | }, 312 | server: { 313 | /** @type {undefined|function: Promise|Object} - MUST resolve to an object with all data properties present even if all have null values */ 314 | get: undefined, 315 | 316 | /** @type {undefined|function(object): Promise|Object} passed updates object - see processSaveResponse for expected error response properties */ 317 | set: undefined, 318 | 319 | /** @type {undefined|function(object}: Promise|Object} passed updates object - see processSaveResponse for expected error response properties */ 320 | create: undefined, 321 | 322 | /** @type {String|function(error): String} passed error object */ 323 | errorMessage: DEFAULT_SERVER_ERROR_MESSAGE 324 | }, 325 | 326 | /** @type {undefined|function(FormStore): Promise|Boolean} passed store instance - if it returns false, no refresh will be performed */ 327 | beforeRefresh: undefined, 328 | 329 | /** @type {undefined|function(FormStore): Promise} passed store instance */ 330 | afterRefresh: undefined, 331 | 332 | /** @type {undefined|function(FormStore, object, object): Promise|Boolean} passed store instance, updates object and saveOptions object, 333 | * (i.e. with skipPropertyBeingEdited, etc booleans) - if it returns false, no save will be performed */ 334 | beforeSave: undefined, 335 | 336 | /** @type {undefined|function(FormStore, object, object): Promise} passed store instance, updates object and response object 337 | * - updates object will already have fields removed from it that response indicates are in error */ 338 | afterSave: undefined 339 | }; 340 | this.lastSync = null; 341 | this.saveQueue = Promise.resolve(); 342 | this.observeDataObjectDisposer = void 0; 343 | this.observeDataPropertiesDisposer = void 0; 344 | this.observeComputedPropertiesDisposers = []; 345 | this.autorunDisposer = void 0; 346 | 347 | _initializerDefineProperty(this, "isReady", _descriptor, this); 348 | 349 | _initializerDefineProperty(this, "isLoading", _descriptor2, this); 350 | 351 | _initializerDefineProperty(this, "isSaving", _descriptor3, this); 352 | 353 | _initializerDefineProperty(this, "serverError", _descriptor4, this); 354 | 355 | this.dataServer = {}; 356 | 357 | _initializerDefineProperty(this, "data", _descriptor5, this); 358 | 359 | _initializerDefineProperty(this, "dataErrors", _descriptor6, this); 360 | 361 | _initializerDefineProperty(this, "saveNotification", _descriptor7, this); 362 | 363 | _initializerDefineProperty(this, "propertyBeingEdited", _descriptor8, this); 364 | 365 | this.isSame = isSame; 366 | var store = this; 367 | Object.assign(store.options, options); 368 | 369 | if (!data && typeof store.options.server.get !== 'function') { 370 | throw new Error('options must specify server get function or supply initial data object to constructor'); 371 | } 372 | 373 | if (!_typeof(store.options.server.create) !== 'function' && typeof store.options.server.set !== 'function') { 374 | throw new Error('options must specify server set and/or create function(s)'); 375 | } 376 | 377 | store.options.server.errorMessage = store.options.server.errorMessage || DEFAULT_SERVER_ERROR_MESSAGE; // Supports both Mobx 3+ (observable.map) and 2.x (asMap) without deprecation warnings: 378 | 379 | store.dataChanges = _mobx.observable.map ? _mobx.observable.map() : (0, _mobx.asMap)(); // changes that will be sent to server 380 | // register observe for changes to properties in store.data as well as to complete replacement of store.data object 381 | 382 | store.storeDataChanged = observableChanged.bind(store); 383 | store.observeDataPropertiesDisposer = (0, _mobx.observe)(store.data, store.storeDataChanged); 384 | store.observeDataObjectDisposer = (0, _mobx.observe)(store, 'data', function () { 385 | store.observeDataPropertiesDisposer && store.observeDataPropertiesDisposer(); 386 | store.observeDataPropertiesDisposer = (0, _mobx.observe)(store.data, store.storeDataChanged); 387 | store.dataChanges.clear(); 388 | (0, _mobx.action)(function () { 389 | Object.keys(store.data).forEach(function (key) { 390 | var value = store.data[key]; 391 | 392 | if (!store.isSame(value, store.dataServer[key])) { 393 | store.dataChanges.set(key, value); 394 | } 395 | }); 396 | observeComputedProperties(store); 397 | })(); 398 | }); 399 | store.configAutoSave(store.options.autoSaveInterval, store.options.autoSaveOptions); 400 | 401 | if (data) { 402 | store.reset(data); 403 | } 404 | } 405 | /** 406 | * disposes of all internal observation/autoruns so this instance can be garbage-collected. 407 | */ 408 | 409 | 410 | _createClass(FormStore, [{ 411 | key: "dispose", 412 | value: function dispose() { 413 | var store = this; 414 | store.autorunDisposer && store.autorunDisposer(); 415 | store.observeDataObjectDisposer && store.observeDataObjectDisposer(); 416 | store.observeDataPropertiesDisposer && store.observeDataPropertiesDisposer(); 417 | store.observeComputedPropertiesDisposers.forEach(function (f) { 418 | return f(); 419 | }); 420 | store.autorunDisposer = undefined; 421 | store.observeDataObjectDisposer = undefined; 422 | store.observeDataPropertiesDisposer = undefined; 423 | store.observeComputedPropertiesDisposers = []; 424 | } 425 | /** 426 | * Configures and enables or disables auto-save 427 | * @param {Number} autoSaveInterval - (in ms) - if non-zero autosave will be enabled, otherwise disabled 428 | * @param {Object} [autoSaveOptions] - overrides the default autoSaveOptions if provided 429 | */ 430 | 431 | }, { 432 | key: "configAutoSave", 433 | value: function configAutoSave(autoSaveInterval, autoSaveOptions) { 434 | var store = this; 435 | store.autorunDisposer && store.autorunDisposer(); 436 | store.options.autoSaveInterval = autoSaveInterval; 437 | store.options.autoSaveOptions = autoSaveOptions || store.options.autoSaveOptions; // auto-save by observing dataChanges keys 438 | 439 | if (store.options.autoSaveInterval) { 440 | // Supports both Mobx <=3 (autorunAsync) and Mobx 4+ 441 | // (ObservableMap keys no longer returning an Array is used to detect Mobx 4+, 442 | // because in non-production build autorunAsync exists in 4.x to issue deprecation error) 443 | var asyncAutorun = Array.isArray(store.dataChanges.keys()) ? _mobx.autorunAsync : function (fn, delay) { 444 | return (0, _mobx.autorun)(fn, { 445 | delay: delay 446 | }); 447 | }; 448 | store.autorunDisposer = asyncAutorun(function () { 449 | if (!store.status.mustCreate && Array.from(store.dataChanges).length) { 450 | store.options.log("[".concat(store.options.name, "] Auto-save started...")); 451 | store.save(store.options.autoSaveOptions); 452 | } 453 | }, store.options.autoSaveInterval); 454 | } else { 455 | store.autorunDisposer = undefined; 456 | } 457 | } 458 | /** 459 | * Marks data property as edit-in-progress and therefore it should not be autosaved - to be called on field focus 460 | * @param {String|Array} name - field/property name (Array format supports json schema forms) 461 | */ 462 | 463 | }, { 464 | key: "startEditing", 465 | value: function startEditing(name) { 466 | var store = this; 467 | store.propertyBeingEdited = Array.isArray(name) ? name[0] : name; 468 | } // to be called on field blur, any field name parameter is ignored 469 | 470 | }, { 471 | key: "stopEditing", 472 | value: function stopEditing() { 473 | var store = this; 474 | store.propertyBeingEdited = null; 475 | 476 | if (store.status.hasChanges) { 477 | // This will trigger autorun in case it already ran while we were editing: 478 | (0, _mobx.action)(function () { 479 | // In MobX 4+, ObservableMap.keys() returns an Iterable, not an array 480 | var key = Array.from(store.dataChanges)[0][0]; 481 | var value = store.dataChanges.get(key); 482 | store.dataChanges["delete"](key); 483 | store.dataChanges.set(key, value); 484 | })(); 485 | } 486 | } 487 | /** 488 | * Returns the value of a field/property, optionally returning the last saved value for not validated/in progress fields 489 | * Without validated:true, using this function is not necessary, can just access store.data[name]. 490 | * @param {String|Array} name - field/property name (Array format supports json schema forms) 491 | * @param {Boolean} [validated] - only return validated value, i.e. if it's in error, fallback to dataServer 492 | * @param {Boolean} [skipPropertyBeingEdited] - used only when validated is true to again fallback to dataServer 493 | * @returns {*} 494 | */ 495 | 496 | }, { 497 | key: "getValue", 498 | value: function getValue(name, validated, skipPropertyBeingEdited) { 499 | var store = this; 500 | var prop = Array.isArray(name) ? name[0] : name; 501 | 502 | if (validated) { 503 | // check if property is being edited or invalid 504 | if (skipPropertyBeingEdited && prop === store.propertyBeingEdited || store.dataErrors[prop]) { 505 | return store.dataServer[prop]; 506 | } 507 | } 508 | 509 | return store.data[prop]; 510 | } // Returns the last saved (or server-provided) set of data 511 | // - in an afterSave callback it already includes merged updates that were not in error 512 | 513 | }, { 514 | key: "getSavedData", 515 | value: function getSavedData() { 516 | var store = this; 517 | return store.dataServer; 518 | } 519 | /** 520 | * @returns {{errors: Array, isReady: Boolean, isInProgress: Boolean, canSave: Boolean, hasChanges: Boolean, isReadOnly: Boolean}} 521 | * errors is an array of any serverError plus all the error messages from all fields (in no particular order) 522 | * (serverError is either the string returned in response.error or a communication error and is cleared on every refresh and save) 523 | * isReady indicates initial data load (refresh) has been completed and user can start entering data 524 | * isInProgress indicates either a refresh or a save is in progress 525 | * canSave is true when no refresh or save is in progress and there are no validation errors 526 | * hasChanges is true when one or more data properties has a value that's different from last-saved/server-loaded data. 527 | * isReadOnly by default is true when isReady is false but can be set to the return value of an 528 | * optional callback to which this status object (without isReadOnly) is passed 529 | */ 530 | 531 | }, { 532 | key: "status", 533 | get: function get() { 534 | var store = this; 535 | var errors = []; 536 | 537 | if (store.serverError) { 538 | errors = [store.serverError]; 539 | } 540 | 541 | Object.keys(store.dataErrors).forEach(function (key) { 542 | if (store.dataErrors[key]) { 543 | errors.push(store.dataErrors[key]); 544 | } 545 | }); 546 | var status = { 547 | errors: errors, 548 | isReady: store.isReady, 549 | isInProgress: store.isLoading || store.isSaving, 550 | canSave: !store.isLoading && !store.isSaving && (store.serverError ? errors.length === 1 : errors.length === 0), 551 | hasChanges: !!store.dataChanges.size, 552 | mustCreate: !!(store.options.idProperty && !store.dataServer[store.options.idProperty]) 553 | }; 554 | 555 | if (typeof store.options.isReadOnly === 'function') { 556 | status.isReadOnly = store.options.isReadOnly(status); 557 | } else { 558 | status.isReadOnly = store.options.isReadOnly; 559 | } 560 | 561 | return status; 562 | } 563 | /** 564 | * Copies dataServer into data and resets the error observable and lastSync. 565 | * Mostly for internal use by constructor and refresh(). 566 | * @param {Object} [data] If provided, dataServer will be set to it and store.isReady will be set to true 567 | */ 568 | 569 | }, { 570 | key: "reset", 571 | value: function reset(data) { 572 | var store = this; 573 | (0, _mobx.action)(function () { 574 | if (data) { 575 | store.dataServer = data; 576 | } 577 | 578 | var deep = true; 579 | store.data = (0, _justExtend["default"])(deep, {}, store.dataServer); // setup error observable 580 | 581 | var temp = {}; 582 | Object.keys(store.data).forEach(function (key) { 583 | temp[key] = null; 584 | }); 585 | store.dataErrors = temp; 586 | store.lastSync = null; 587 | observeComputedProperties(store); 588 | if (data && !store.isReady) store.isReady = true; 589 | })(); 590 | } 591 | /** 592 | * Loads data from server unless a refresh was performed within the last minRefreshInterval (i.e. 15 minutes). 593 | * If there are pending (and ready to save) changes, triggers save instead and 'resets the clock' on minRefreshInterval. 594 | * For a store with idProperty defined, if that data property is falsy in data received from server, 595 | * loads from server only the very first time refresh() is called unless called with allowIfMustCreate=true option. 596 | * @param {Object} [refreshOptions] 597 | * @param {Boolean} [refreshOptions.allowIfMustCreate=false] 598 | * @param {Boolean} [refreshOptions.ignoreMinRefreshInterval=false] 599 | * @returns {Promise|Boolean} resolves to true if refresh actually performed, false if skipped 600 | */ 601 | 602 | }, { 603 | key: "refresh", 604 | value: function () { 605 | var _refresh = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee() { 606 | var refreshOptions, 607 | allowIfMustCreate, 608 | ignoreMinRefreshInterval, 609 | store, 610 | now, 611 | past, 612 | result, 613 | _args = arguments; 614 | return regeneratorRuntime.wrap(function _callee$(_context) { 615 | while (1) { 616 | switch (_context.prev = _context.next) { 617 | case 0: 618 | refreshOptions = _args.length > 0 && _args[0] !== undefined ? _args[0] : {}; 619 | // for some reason this syntax is erroring in tests: 620 | // const { allowIfMustCreate = false, ignoreMinRefreshInterval = false } = refreshOptions; 621 | allowIfMustCreate = refreshOptions.allowIfMustCreate || false; 622 | ignoreMinRefreshInterval = refreshOptions.ignoreMinRefreshInterval || false; 623 | store = this; 624 | 625 | if (!(!store.options.server.get || store.isReady && store.status.mustCreate && !allowIfMustCreate)) { 626 | _context.next = 6; 627 | break; 628 | } 629 | 630 | return _context.abrupt("return", false); 631 | 632 | case 6: 633 | store.options.log("[".concat(store.options.name, "] Starting data refresh...")); 634 | 635 | if (!store.isLoading) { 636 | _context.next = 10; 637 | break; 638 | } 639 | 640 | store.options.log("[".concat(store.options.name, "] Data is already being refreshed.")); 641 | return _context.abrupt("return", false); 642 | 643 | case 10: 644 | now = new Date(); 645 | past = new Date(Date.now() - store.options.minRefreshInterval); // check if lastSync is between now and 15 minutes ago 646 | 647 | if (!(!ignoreMinRefreshInterval && past < store.lastSync && store.lastSync <= now)) { 648 | _context.next = 15; 649 | break; 650 | } 651 | 652 | store.options.log("[".concat(store.options.name, "] Data refreshed within last ").concat(store.options.minRefreshInterval / 1000, " seconds.")); 653 | return _context.abrupt("return", false); 654 | 655 | case 15: 656 | if (!(store.status.hasChanges && !store.status.mustCreate)) { 657 | _context.next = 23; 658 | break; 659 | } 660 | 661 | store.options.log("[".concat(store.options.name, "] Unsaved changes detected...")); 662 | _context.next = 19; 663 | return store.save(); 664 | 665 | case 19: 666 | if (!_context.sent) { 667 | _context.next = 23; 668 | break; 669 | } 670 | 671 | store.options.log("[".concat(store.options.name, "] Postponing refresh for ").concat(store.options.minRefreshInterval / 1000, " seconds.")); 672 | store.lastSync = new Date(); 673 | return _context.abrupt("return", false); 674 | 675 | case 23: 676 | if (!(typeof store.options.beforeRefresh === 'function')) { 677 | _context.next = 29; 678 | break; 679 | } 680 | 681 | _context.next = 26; 682 | return store.options.beforeRefresh(store); 683 | 684 | case 26: 685 | _context.t0 = _context.sent; 686 | 687 | if (!(_context.t0 === false)) { 688 | _context.next = 29; 689 | break; 690 | } 691 | 692 | return _context.abrupt("return", false); 693 | 694 | case 29: 695 | store.options.log("[".concat(store.options.name, "] Refreshing data...")); 696 | store.isLoading = true; 697 | _context.prev = 31; 698 | _context.next = 34; 699 | return store.options.server.get(); 700 | 701 | case 34: 702 | result = _context.sent; 703 | store.options.log("[".concat(store.options.name, "] Data received from server.")); 704 | (0, _mobx.action)(function () { 705 | store.dataServer = result; 706 | store.serverError = null; 707 | store.reset(); 708 | store.lastSync = new Date(); 709 | })(); 710 | 711 | if (!(typeof store.options.afterRefresh === 'function')) { 712 | _context.next = 41; 713 | break; 714 | } 715 | 716 | _context.next = 40; 717 | return store.options.afterRefresh(store); 718 | 719 | case 40: 720 | observeComputedProperties(store); // again, in case afterRefresh added some 721 | 722 | case 41: 723 | store.options.log("[".concat(store.options.name, "] Refresh finished.")); 724 | if (!store.isReady) store.isReady = true; 725 | _context.next = 48; 726 | break; 727 | 728 | case 45: 729 | _context.prev = 45; 730 | _context.t1 = _context["catch"](31); 731 | handleError(store, _context.t1); 732 | 733 | case 48: 734 | store.isLoading = false; 735 | return _context.abrupt("return", true); 736 | 737 | case 50: 738 | case "end": 739 | return _context.stop(); 740 | } 741 | } 742 | }, _callee, this, [[31, 45]]); 743 | })); 744 | 745 | function refresh() { 746 | return _refresh.apply(this, arguments); 747 | } 748 | 749 | return refresh; 750 | }() 751 | /** 752 | * Sends ready-to-save data changes to the server (normally using server.set unless it's undefined, then with server.create) 753 | * For a store with idProperty defined when that property is falsy in the data received from server 754 | * and allowCreate=true, uses server.create instead. 755 | * Calls to save() while one is in progress are queued. 756 | * @param {Object} saveOptions - the object as a whole is also passed to the beforeSave callback 757 | * @param {Boolean} [saveOptions.allowCreate=false] - for a store with idProperty defined, this must be true 758 | * for the save to actually be performed when that property is falsy. 759 | * @param {Boolean} [saveOptions.saveAll=false] - normally save only sends changes and if no changes, no save is done. 760 | * if saveAll=true, sends the full data object regardless of changes. 761 | * @param {Boolean} [saveOptions.skipPropertyBeingEdited=false] - true in an auto-save 762 | * @param {Boolean} [saveOptions.keepServerError=false] - true in an auto-save, otherwise will also deactivate saveNotification prior to save 763 | * @returns {Promise|Boolean} resolves to true if save actually performed, false if skipped 764 | */ 765 | 766 | }, { 767 | key: "save", 768 | value: function save() { 769 | var saveOptions = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; 770 | var _saveOptions$allowCre = saveOptions.allowCreate, 771 | allowCreate = _saveOptions$allowCre === void 0 ? false : _saveOptions$allowCre, 772 | _saveOptions$saveAll = saveOptions.saveAll, 773 | saveAll = _saveOptions$saveAll === void 0 ? false : _saveOptions$saveAll, 774 | _saveOptions$skipProp = saveOptions.skipPropertyBeingEdited, 775 | skipPropertyBeingEdited = _saveOptions$skipProp === void 0 ? false : _saveOptions$skipProp, 776 | _saveOptions$keepServ = saveOptions.keepServerError, 777 | keepServerError = _saveOptions$keepServ === void 0 ? false : _saveOptions$keepServ; 778 | var store = this; 779 | store.saveQueue = store.saveQueue.then( /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee2() { 780 | var deep, updates, response; 781 | return regeneratorRuntime.wrap(function _callee2$(_context2) { 782 | while (1) { 783 | switch (_context2.prev = _context2.next) { 784 | case 0: 785 | if (!(store.status.mustCreate && !allowCreate)) { 786 | _context2.next = 2; 787 | break; 788 | } 789 | 790 | return _context2.abrupt("return", false); 791 | 792 | case 2: 793 | store.options.log("[".concat(store.options.name, "] Starting data save...")); 794 | deep = true; 795 | 796 | if (!saveAll) { 797 | _context2.next = 10; 798 | break; 799 | } 800 | 801 | updates = {}; 802 | Object.getOwnPropertyNames(store.data).forEach(function (property) { 803 | if (property[0] === '$') { 804 | return; 805 | } 806 | 807 | if ((0, _mobx.isObservableArray)(store.data[property])) { 808 | updates[property] = store.data[property].slice(); 809 | return; 810 | } 811 | 812 | updates[property] = store.data[property]; 813 | }); 814 | updates = (0, _justExtend["default"])(deep, {}, updates); 815 | _context2.next = 18; 816 | break; 817 | 818 | case 10: 819 | // Mobx 4+ toJS() exports a Map, not an Object and toJSON is the 'legacy' method to export an Object 820 | updates = store.dataChanges.toJSON ? store.dataChanges.toJSON() : store.dataChanges.toJS(); 821 | 822 | if (!(Object.keys(updates).length === 0)) { 823 | _context2.next = 14; 824 | break; 825 | } 826 | 827 | store.options.log("[".concat(store.options.name, "] No changes to save.")); 828 | return _context2.abrupt("return", false); 829 | 830 | case 14: 831 | // check if we have property currently being edited in changes 832 | // or if a property has an error and clone (observable or regular) 833 | // Arrays and Objects to plain ones 834 | Object.keys(updates).forEach(function (property) { 835 | if (skipPropertyBeingEdited && property === store.propertyBeingEdited) { 836 | store.options.log("[".concat(store.options.name, "] Property \"").concat(property, "\" is being edited.")); 837 | delete updates[property]; 838 | return; 839 | } 840 | 841 | if (store.dataErrors[property]) { 842 | store.options.log("[".concat(store.options.name, "] Property \"").concat(property, "\" is not validated.")); 843 | delete updates[property]; 844 | return; 845 | } 846 | 847 | if (store.isSame(updates[property], store.dataServer[property])) { 848 | store.options.log("[".concat(store.options.name, "] Property \"").concat(property, "\" is same as on the server.")); 849 | delete updates[property]; 850 | store.dataChanges["delete"](property); 851 | return; 852 | } 853 | 854 | if (Array.isArray(updates[property]) || (0, _mobx.isObservableArray)(updates[property])) { 855 | updates[property] = updates[property].slice(); 856 | } else if (isObject(updates[property])) { 857 | updates[property] = (0, _justExtend["default"])(deep, {}, updates[property]); 858 | } 859 | }); 860 | 861 | if (!(Object.keys(updates).length === 0)) { 862 | _context2.next = 18; 863 | break; 864 | } 865 | 866 | store.options.log("[".concat(store.options.name, "] No changes ready to save.")); 867 | return _context2.abrupt("return", false); 868 | 869 | case 18: 870 | if (!(typeof store.options.beforeSave === 'function')) { 871 | _context2.next = 24; 872 | break; 873 | } 874 | 875 | _context2.next = 21; 876 | return store.options.beforeSave(store, updates, saveOptions); 877 | 878 | case 21: 879 | _context2.t0 = _context2.sent; 880 | 881 | if (!(_context2.t0 === false)) { 882 | _context2.next = 24; 883 | break; 884 | } 885 | 886 | return _context2.abrupt("return", false); 887 | 888 | case 24: 889 | store.options.log("[".concat(store.options.name, "] Saving data...")); 890 | store.options.log(updates); 891 | store.isSaving = true; 892 | _context2.prev = 27; 893 | 894 | if (!keepServerError) { 895 | store.saveNotification.active = false; 896 | store.serverError = null; 897 | } 898 | 899 | if (!(store.options.server.set && (!store.options.server.create || !store.status.mustCreate))) { 900 | _context2.next = 35; 901 | break; 902 | } 903 | 904 | _context2.next = 32; 905 | return store.options.server.set(updates); 906 | 907 | case 32: 908 | response = _context2.sent; 909 | _context2.next = 38; 910 | break; 911 | 912 | case 35: 913 | _context2.next = 37; 914 | return store.options.server.create(updates); 915 | 916 | case 37: 917 | response = _context2.sent; 918 | 919 | case 38: 920 | _context2.next = 40; 921 | return processSaveResponse(store, updates, response); 922 | 923 | case 40: 924 | store.saveNotification.status = _context2.sent; 925 | store.saveNotification.active = true; 926 | store.options.log("[".concat(store.options.name, "] Save finished.")); 927 | _context2.next = 49; 928 | break; 929 | 930 | case 45: 931 | _context2.prev = 45; 932 | _context2.t1 = _context2["catch"](27); 933 | handleError(store, _context2.t1); 934 | 935 | if (store.options.saveNotificationStatusOnError) { 936 | store.saveNotification.status = store.options.saveNotificationStatusOnError; 937 | store.saveNotification.active = true; 938 | } 939 | 940 | case 49: 941 | store.isSaving = false; 942 | return _context2.abrupt("return", true); 943 | 944 | case 51: 945 | case "end": 946 | return _context2.stop(); 947 | } 948 | } 949 | }, _callee2, null, [[27, 45]]); 950 | }))); 951 | return store.saveQueue; 952 | } 953 | }]); 954 | 955 | return FormStore; 956 | }(), (_descriptor = _applyDecoratedDescriptor(_class.prototype, "isReady", [_mobx.observable], { 957 | configurable: true, 958 | enumerable: true, 959 | writable: true, 960 | initializer: function initializer() { 961 | return false; 962 | } 963 | }), _descriptor2 = _applyDecoratedDescriptor(_class.prototype, "isLoading", [_mobx.observable], { 964 | configurable: true, 965 | enumerable: true, 966 | writable: true, 967 | initializer: function initializer() { 968 | return false; 969 | } 970 | }), _descriptor3 = _applyDecoratedDescriptor(_class.prototype, "isSaving", [_mobx.observable], { 971 | configurable: true, 972 | enumerable: true, 973 | writable: true, 974 | initializer: function initializer() { 975 | return false; 976 | } 977 | }), _descriptor4 = _applyDecoratedDescriptor(_class.prototype, "serverError", [_mobx.observable], { 978 | configurable: true, 979 | enumerable: true, 980 | writable: true, 981 | initializer: function initializer() { 982 | return null; 983 | } 984 | }), _descriptor5 = _applyDecoratedDescriptor(_class.prototype, "data", [_mobx.observable], { 985 | configurable: true, 986 | enumerable: true, 987 | writable: true, 988 | initializer: function initializer() { 989 | return {}; 990 | } 991 | }), _descriptor6 = _applyDecoratedDescriptor(_class.prototype, "dataErrors", [_mobx.observable], { 992 | configurable: true, 993 | enumerable: true, 994 | writable: true, 995 | initializer: function initializer() { 996 | return {}; 997 | } 998 | }), _descriptor7 = _applyDecoratedDescriptor(_class.prototype, "saveNotification", [_mobx.observable], { 999 | configurable: true, 1000 | enumerable: true, 1001 | writable: true, 1002 | initializer: function initializer() { 1003 | return { 1004 | active: false, 1005 | status: null 1006 | }; 1007 | } 1008 | }), _descriptor8 = _applyDecoratedDescriptor(_class.prototype, "propertyBeingEdited", [_mobx.observable], { 1009 | configurable: true, 1010 | enumerable: true, 1011 | writable: true, 1012 | initializer: function initializer() { 1013 | return null; 1014 | } 1015 | }), _applyDecoratedDescriptor(_class.prototype, "status", [_mobx.computed], Object.getOwnPropertyDescriptor(_class.prototype, "status"), _class.prototype)), _class); 1016 | var _default = FormStore; 1017 | exports["default"] = _default; --------------------------------------------------------------------------------