├── .editorconfig ├── .ember-cli ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .travis.yml ├── .watchmanconfig ├── LICENSE.md ├── README.md ├── addon ├── index.js ├── initializer.js ├── mixins │ └── keep-only-changed.js ├── model-ext.js ├── tracker.js ├── transforms │ ├── json.js │ └── object.js └── utilities.js ├── app ├── initializers │ └── ember-data-change-tracker.js ├── mixins │ └── change-serializer.js └── transforms │ ├── json.js │ └── object.js ├── config ├── ember-try.js └── environment.js ├── ember-cli-build.js ├── index.js ├── package.json ├── testem.js ├── tests ├── .eslintrc.js ├── dummy │ ├── app │ │ ├── adapters │ │ │ └── application.js │ │ ├── app.js │ │ ├── components │ │ │ └── .gitkeep │ │ ├── controllers │ │ │ └── .gitkeep │ │ ├── helpers │ │ │ └── .gitkeep │ │ ├── index.html │ │ ├── models │ │ │ ├── big-company.js │ │ │ ├── cat.js │ │ │ ├── company.js │ │ │ ├── detail.js │ │ │ ├── dog.js │ │ │ ├── location.js │ │ │ ├── perf-model-tracked.js │ │ │ ├── perf-model-untracked.js │ │ │ ├── pet.js │ │ │ ├── profile.js │ │ │ ├── project.js │ │ │ ├── small-company.js │ │ │ └── user.js │ │ ├── resolver.js │ │ ├── router.js │ │ ├── routes │ │ │ └── .gitkeep │ │ ├── serializers │ │ │ ├── project.js │ │ │ └── user.js │ │ ├── styles │ │ │ └── app.css │ │ ├── templates │ │ │ └── components │ │ │ │ └── .gitkeep │ │ └── tests │ │ │ └── factories │ │ │ ├── big-company.js │ │ │ ├── cat.js │ │ │ ├── company.js │ │ │ ├── detail.js │ │ │ ├── dog.js │ │ │ ├── location.js │ │ │ ├── perf-model-tracked.js │ │ │ ├── perf-model-untracked.js │ │ │ ├── pet.js │ │ │ ├── profile.js │ │ │ ├── project.js │ │ │ ├── small-company.js │ │ │ └── user.js │ ├── config │ │ ├── environment.js │ │ └── optional-features.json │ └── public │ │ ├── crossdomain.xml │ │ └── robots.txt ├── helpers │ ├── destroy-app.js │ ├── module-for-acceptance.js │ ├── resolver.js │ └── start-app.js ├── index.html ├── integration │ └── .gitkeep ├── test-helper.js └── unit │ ├── model-test.js │ ├── tracker-test.js │ └── utilities-test.js ├── vendor └── .gitkeep └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [*.hbs] 17 | insert_final_newline = false 18 | 19 | [*.{diff,md}] 20 | trim_trailing_whitespace = false 21 | -------------------------------------------------------------------------------- /.ember-cli: -------------------------------------------------------------------------------- 1 | { 2 | /** 3 | Ember CLI sends analytics information by default. The data is completely 4 | anonymous, but there are times when you might want to disable this behavior. 5 | 6 | Setting `disableAnalytics` to true will prevent any data from being sent. 7 | */ 8 | "disableAnalytics": false 9 | } 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* global module */ 2 | module.exports = { 3 | root: true, 4 | parserOptions: { 5 | ecmaVersion: 2017, 6 | sourceType: 'module' 7 | }, 8 | extends: 'eslint:recommended', 9 | env: { 10 | browser: true 11 | }, 12 | rules: { 13 | "no-console": "off" 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | 7 | # dependencies 8 | /node_modules 9 | /bower_components 10 | 11 | # misc 12 | /.sass-cache 13 | /connect.lock 14 | /coverage/* 15 | /libpeerconnection.log 16 | npm-debug.log* 17 | testem.log 18 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /bower_components 2 | /config/ember-try.js 3 | /dist 4 | /tests 5 | /tmp 6 | **/.gitkeep 7 | .bowerrc 8 | .editorconfig 9 | .ember-cli 10 | .gitignore 11 | .jshintrc 12 | .watchmanconfig 13 | .travis.yml 14 | bower.json 15 | ember-cli-build.js 16 | testem.js 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: node_js 3 | sudo: false 4 | dist: trusty 5 | node_js: 6 | - "10" 7 | 8 | addons: 9 | chrome: stable 10 | 11 | cache: 12 | yarn: true 13 | 14 | env: 15 | - EMBER_TRY_SCENARIO=ember-3.8 16 | - EMBER_TRY_SCENARIO=ember-beta 17 | 18 | matrix: 19 | fast_finish: true 20 | allow_failures: 21 | - env: EMBER_TRY_SCENARIO=ember-beta 22 | 23 | before_install: 24 | - curl -o- -L https://yarnpkg.com/install.sh | bash 25 | - export PATH=$HOME/.yarn/bin:$PATH 26 | 27 | install: 28 | - yarn install 29 | 30 | script: 31 | # Usually, it's ok to finish the test scenario without reverting 32 | # to the addon's original dependency state, skipping "cleanup". 33 | - ember try:one $EMBER_TRY_SCENARIO test --skip-cleanup 34 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ignore_dirs": ["tmp", "dist"] 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ember-data-change-tracker 2 | 3 | [![Build Status](https://secure.travis-ci.org/danielspaniel/ember-data-change-tracker.png?branch=master)](http://travis-ci.org/danielspaniel/ember-data-change-tracker) [![Ember Observer Score](http://emberobserver.com/badges/ember-data-change-tracker.svg)](http://emberobserver.com/addons/ember-data-change-tracker) [![npm version](https://badge.fury.io/js/ember-data-change-tracker.svg)](http://badge.fury.io/js/ember-data-change-tracker) 4 | 5 | **New** 6 | - Experimental feature 7 | - isDirty, hasDirtyRelations computed properties 8 | - Set up in [configuration](https://github.com/danielspaniel/ember-data-change-tracker#configuration) as { enableIsDirty: true } 9 | - It is experimental and a has one crippling defect, it can not track object type 10 | attributes. But if you don't have object types it works fine. 11 | 12 | This addon aims to fill in the gaps in the change tracking / rollback that ember data does now. 13 | 14 | - Currently ember-data 15 | - tracks changes for numbers/strings/date/boolean attributes 16 | - has a ```changedAttributes()``` method to see what changed => [ last, current ] 17 | - has a ```rollbackAttributes()``` method to rollback attributes 18 | - has a ```hasDirtyAttributes``` computed property 19 | 20 | - This addon: 21 | - tracks modifications in attributes that are object/json/custom type 22 | - tracks replacement of belongsTo associations 23 | - tracks replacement/changes in hasMany associations 24 | - adds a ```modelChanges()``` method to DS.Model 25 | - adds a ```rollback()``` method to DS.Model 26 | - adds a ```isDirty``` computed property to DS.Model ( only if enabled in configuration ) 27 | - adds a ```hasDirtyRelations``` computed property to DS.Model ( only if enabled in configuration ) 28 | - Only works with 29 | - ember-data versions 2.7+ ( if you have polymphic relationships ) 30 | - ember-data versions 2.5+ ( if you don't ) 31 | - Can be used in two modes 32 | - auto track mode 33 | - manual track mode ( the default ) 34 | 35 | ## Installation 36 | 37 | * `ember install ember-data-change-tracker` 38 | 39 | ## Why? 40 | 41 | Say there is a user model like this: 42 | 43 | ```javascript 44 | export default Model.extend({ 45 | name: attr('string'), // ember-data tracks this already 46 | info: attr('object'), // ember-data does not track modifications 47 | json: attr(), // ember-data does not track modifications if this is object 48 | company: belongsTo('company', { async: false, polymorphic: true }), // ember-data does not track replacement 49 | profile: belongsTo('profile', { async: true }), // ember-data does not track replacement 50 | projects: hasMany('project', { async: false }), // ember-data does not track additions/deletions 51 | pets: hasMany('pet', { async: true, polymorphic: true }) // ember-data does not track additions/deletions 52 | }); 53 | ``` 54 | 55 | You can not currently rollback the info, json if they are modified 56 | or company, profile, projects and pets if they change. 57 | 58 | 59 | ### model changes 60 | 61 | - The method ```modelChanges()``` is added to model 62 | - Shows you any changes in an object attribute type 63 | - whether modified or replacing the value 64 | - attr() will default to 'object' type 65 | - works with any custom type you have created 66 | - Shows when you replace a belongsTo association 67 | - Shows when you add to a hasMany association 68 | - Shows when you delete from a hasMany association 69 | - Merges ember-data `changeAttribute()` information into one unified change object 70 | - Unlike ember-data no last and current value is shown, just the boolean => true 71 | - Though you will see [last value, current value] for the attributes that ember-data tracks 72 | 73 | Example: ( remove from a hasMany ) 74 | ```javascript 75 | user.get('projects').removeObject(firstProject); // remove project1 76 | user.modelChanges() //=> {projects: true } 77 | ``` 78 | 79 | 80 | ### Rollback 81 | 82 | - The method ```rollback()``` is added to model 83 | - If you're not using auto track you have to call ```startTrack()``` before editing 84 | - Performace wise, it's way faster than you think it should be. 85 | - Tested on model with hundreds of items in a hasMany association. 86 | - Though you might want to think twice when tracking one with thousands 87 | 88 | Usage: 89 | 90 | - make and makeList are from [ember-data-factory-guy](https://github.com/danielspaniel/ember-data-factory-guy). 91 | - they create and push models ( based on factories ) into the ember-data store 92 | 93 | ```javascript 94 | let info = {foo: 1}; 95 | let projects = makeList('project', 2); 96 | let [project1] = projects; 97 | let pets = makeList('cat', 4); 98 | let [cat, cat2] = pets; 99 | let bigCompany = make('big-company'); 100 | let smallCompany = make('small-company'); 101 | 102 | let user = make('user', { profile: profile1, company: bigCompany, pets, projects }); 103 | 104 | // manual tracking model means you have to explicitly call => startTrack 105 | // to save the current state of things before you edit 106 | user.startTrack(); 107 | 108 | // edit things 109 | user.setProperties({ 110 | 'info.foo': 3, 111 | company: smallCompany, 112 | profile: profile2, 113 | projects: [project1], 114 | pets: [cat1, cat2] 115 | }); 116 | 117 | user.rollback(); 118 | 119 | // it's all back to the way it was 120 | user.get('info') //=> {foo: 1} 121 | user.get('profile') //=> profile1 122 | user.get('company') //=> bigCompany 123 | user.get('projects') //=> first 2 projects 124 | user.get('pets') //=> back to the same 4 pets 125 | 126 | ``` 127 | 128 | ### isDirty, hasDirtyRelations 129 | - Computed properties to check if the model has changed 130 | - Not enabled by default 131 | - Need to set enableIsDirty ( true ) on model or global [configuration](https://github.com/danielspaniel/ember-data-change-tracker#configuration) 132 | - The only attributes that can NOT be tracked with isDirty are object/array 133 | attributes 134 | 135 | Usage: 136 | 137 | ```javascript 138 | 139 | let info = {foo: 1}; 140 | let pets = makeList('cat', 4); 141 | let [cat, cat2] = pets; 142 | let bigCompany = make('big-company'); 143 | let smallCompany = make('small-company'); 144 | 145 | let user = make('user', { company: bigCompany, pets }); 146 | 147 | user.startTrack(); 148 | 149 | // edit things 150 | user.set('name', "new name"); 151 | user.get('isDirty'); //=> true 152 | 153 | user.rollback(); 154 | user.get('isDirty'); //=> false 155 | 156 | user.set('company', smallCompany); 157 | user.get('hasDirtyRelations'); //=> true 158 | user.get('isDirty'); //=> true 159 | 160 | user.rollback(); 161 | user.get('isDirty'); //=> false 162 | 163 | user.set('pets', [cat, cat2]); 164 | user.get('hasDirtyRelations'); //=> true 165 | user.get('isDirty'); //=> true 166 | 167 | user.rollback(); 168 | user.get('isDirty'); //=> false 169 | 170 | // things that don't work 171 | user.set('info.foo', 3); 172 | user.get('isDirty'); //=> false ( object/array attributes don't work for computed isDirty ) 173 | 174 | ``` 175 | 176 | ### Configuration 177 | 178 | - Global configuration 179 | - By default the global settings are: 180 | - { **trackHasMany**: *true*, **auto**: *false*, **enableIsDirty**: *false* } 181 | - Essentially this says, track everything in the model but only when I tell you 182 | - Since this is manual mode you probably want to track everything 183 | since you are focused on one edit at a time, hence trackHasMany is on 184 | - The options available are: 185 | - **trackHasMany** : should hasMany associations be tracked? ( _true_ is default ) 186 | - this is just a shortcut to exclude all the hasMany relations 187 | - **auto** : should tracking be turned on by default? ( _false_ is default ) 188 | - auto tracking means when any model is saved/updated/reloaded the tracker will save 189 | the current state, allowing you to rollback anytime 190 | - **enableIsDirty** : sets up computed properties on a model 191 | - ```hasDirtyRelations``` for checking on changed relationships 192 | - ```isDirty``` for checking on any changes 193 | - NOTE: not working for object type attributes, since those are too 194 | difficult to observe for the purpose of computed properties 195 | 196 | - Model configuration 197 | - Takes precedence over global 198 | - So, globally auto track could be off, but on one model you can turn it on 199 | - The options available are: 200 | - **trackHasMany** : same as global trackHasMany 201 | - **auto** : same as global auto 202 | - **only** : limit the attributes/associations tracked on this model to just these 203 | - **except** : don't include these attributes/associations 204 | - You can use 'only' and 'except' at the same time, but you could also clean your nose with a pipe cleaner 205 | 206 | ```javascript 207 | // file config/environment.js 208 | var ENV = { 209 | modulePrefix: 'dummy', 210 | environment: environment, 211 | rootURL: '/', 212 | locationType: 'auto', 213 | changeTracker: { trackHasMany: true, auto: true }, 214 | EmberENV: { 215 | ... rest of config 216 | 217 | ``` 218 | - Set options on the model 219 | 220 | ```javascript 221 | // file app/models/user.js 222 | export default Model.extend({ 223 | changeTracker: {only: ['info', 'company', 'pets']}, // settings for user models 224 | 225 | name: attr('string'), 226 | info: attr('object'), 227 | json: attr(), 228 | company: belongsTo('company', { async: false, polymorphic: true }), 229 | profile: belongsTo('profile', { async: true }), 230 | projects: hasMany('project', { async: false }), 231 | pets: hasMany('pet', { async: true, polymorphic: true }) 232 | }); 233 | ``` 234 | 235 | ### Serializer extras 236 | - Mixin is provided that will allow you to remove any attributes/associations 237 | that did not change from the serialized json 238 | - Useful when you want to reduce the size of a json payload 239 | - removing unchanged values can be big reduction at times 240 | 241 | Example: 242 | 243 | Let's say you set up the user model's serializer with keep-only-changed mixin 244 | 245 | ```javascript 246 | // file: app/serializers/user.js 247 | import DS from 'ember-data'; 248 | import keepOnlyChanged from 'ember-data-change-tracker/mixins/keep-only-changed'; 249 | 250 | export default DS.RESTSerializer.extend(keepOnlyChanged); 251 | ``` 252 | 253 | Then when you are updating the user model 254 | 255 | ```javascript 256 | user.set('info.foo', 1); 257 | user.serialize(); //=> '{ info: {"foo:1"} }' 258 | ``` 259 | 260 | Without this mixin enabled the json would look like: 261 | ```javascript 262 | { name: "dude", info: {"foo:1"}, company: "1" companyType: "company", profile: "1" } 263 | ``` 264 | where all the attributes and association are included whether they changed or not 265 | 266 | 267 | ## Extra's 268 | - Adds a few more helpful methods to ember data model 269 | - ```didChange(key) ``` 270 | - did the value on this key change? 271 | - ```savedTrackerValue(key)``` 272 | - this is the value that the key had after it was created/saved and 273 | before any modifications 274 | 275 | Usage: 276 | ```javascript 277 | user.startTrack(); // saves all keys that are being tracked 278 | user.savedTrackerValue('info') //=> {foo: 1} original value of info 279 | user.set('info.foo', 8) 280 | user.didChange('info') //=> true 281 | user.savedTrackerValue('info') //=> {foo: 1} original value of info 282 | ``` 283 | 284 | ## Known Issues 285 | - When pushing data to the store directly to create a model ( usually done when using 286 | websockets .. but same issue if using factory guy) you need to call ```model.saveTrackerChanges()``` 287 | manually after creating that new model 288 | - Testing 289 | - In unit / integration tests you have to manually initialize change-tracker 290 | if you are testing anything that requires the addon to be enabled 291 | 292 | For example: 293 | 294 | ```javascript 295 | 296 | import {moduleForModel, test} from 'ember-qunit'; 297 | import {make, manualSetup} from 'ember-data-factory-guy'; 298 | import {initializer as changeInitializer} from 'ember-data-change-tracker'; 299 | 300 | moduleForModel('project', 'Unit | Model | project', { 301 | 302 | beforeEach() { 303 | manualSetup(this.container); 304 | changeInitializer(); 305 | } 306 | }); 307 | 308 | ``` 309 | -------------------------------------------------------------------------------- /addon/index.js: -------------------------------------------------------------------------------- 1 | import {initializer} from './initializer'; 2 | import keepOnlyChanged from './mixins/keep-only-changed'; 3 | 4 | export {keepOnlyChanged, initializer}; -------------------------------------------------------------------------------- /addon/initializer.js: -------------------------------------------------------------------------------- 1 | /* global require */ 2 | export function initializer() { 3 | require('ember-data-change-tracker/model-ext'); 4 | } -------------------------------------------------------------------------------- /addon/mixins/keep-only-changed.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | // EmberData does not serialize hasMany relationships by default 4 | export default Ember.Mixin.create({ 5 | keepValue(record, key) { 6 | return record.get('isNew') || record.didChange(key); 7 | }, 8 | 9 | serializeAttribute: function(snapshot, json, key) { 10 | if (this.keepValue(snapshot.record, key)) { 11 | return this._super(...arguments); 12 | } 13 | }, 14 | 15 | serializeBelongsTo: function(snapshot, json, relationship) { 16 | if (this.keepValue(snapshot.record, relationship.key)) { 17 | return this._super(...arguments); 18 | } 19 | }, 20 | 21 | serializeHasMany: function(snapshot, json, relationship) { 22 | if (this.keepValue(snapshot.record, relationship.key)) { 23 | return this._super(...arguments); 24 | } 25 | } 26 | }); -------------------------------------------------------------------------------- /addon/model-ext.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import Model from 'ember-data/model'; 3 | import Tracker from './tracker'; 4 | 5 | Model.reopen({ 6 | 7 | init(){ 8 | this._super(...arguments); 9 | if (Tracker.isAutoSaveEnabled(this)) { 10 | this.initTracking(); 11 | } 12 | if (Tracker.isIsDirtyEnabled(this)) { 13 | // this is experimental 14 | Tracker.initializeDirtiness(this); 15 | } 16 | 17 | this.setupTrackerMetaData(); 18 | this.setupUnknownRelationshipLoadObservers(); 19 | }, 20 | 21 | /** 22 | * Did an attribute/association change? 23 | * 24 | * @param {String} key the attribute/association name 25 | * @param {Object} changed optional ember-data changedAttribute object 26 | * @returns {Boolean} true if value changed 27 | */ 28 | didChange(key, changed, options) { 29 | return Tracker.didChange(this, key, changed, options); 30 | }, 31 | 32 | /** 33 | * Did any attribute/association change? 34 | * 35 | * returns object with: 36 | * {key: value} = {attribute: true} 37 | * 38 | * If the the attribute changed, it will be included in this object 39 | * 40 | * @returns {*} 41 | */ 42 | modelChanges() { 43 | let changed = Ember.assign({}, this.changedAttributes()); 44 | let trackerInfo = Tracker.metaInfo(this); 45 | for (let key in trackerInfo) { 46 | if (!changed[key] && trackerInfo.hasOwnProperty(key)) { 47 | if (this.didChange(key, changed)) { 48 | changed[key] = true; 49 | } 50 | } 51 | } 52 | return changed; 53 | }, 54 | 55 | /** 56 | * Rollback all the changes on this model, for the keys you are 57 | * tracking. 58 | * 59 | * NOTE: Be sure you understand what keys you are tracking. 60 | * By default, tracker will save all keys, but if you set up 61 | * a model to 'only' track a limited set of keys, then the rollback 62 | * will only be limited to those keys 63 | * 64 | */ 65 | rollback() { 66 | const isNew = this.get('isNew'); 67 | this.rollbackAttributes(); 68 | if (isNew) { return; } 69 | let trackerInfo = Tracker.metaInfo(this); 70 | let rollbackData = Tracker.rollbackData(this, trackerInfo); 71 | let normalized = Tracker.normalize(this, rollbackData); 72 | this.store.push(normalized); 73 | }, 74 | 75 | // alias for saveChanges method 76 | startTrack() { 77 | this.initTracking(); 78 | this.saveChanges(); 79 | }, 80 | 81 | // Ember Data DS.Model events 82 | // http://api.emberjs.com/ember-data/3.10/classes/DS.Model/events 83 | // 84 | // Replaces deprecated Ember.Evented usage: 85 | // https://github.com/emberjs/rfcs/blob/master/text/0329-deprecated-ember-evented-in-ember-data.md 86 | // Related: https://github.com/emberjs/rfcs/pull/329 87 | 88 | initTracking(){ 89 | 90 | this.didCreate = () => { 91 | this.saveOnCreate(); 92 | } 93 | 94 | this.didUpdate = () => { 95 | this.saveOnUpdate(); 96 | } 97 | 98 | this.didDelete = () => { 99 | this.clearSavedAttributes(); 100 | } 101 | 102 | this.ready = () => { 103 | this.setupTrackerMetaData(); 104 | this.setupUnknownRelationshipLoadObservers(); 105 | }, 106 | 107 | Tracker.setupTracking(this); 108 | }, 109 | 110 | /** 111 | * Save the current state of the model 112 | * 113 | * NOTE: This is needed when manually pushing data 114 | * to the store and ussing Ember < 2.10 115 | * 116 | * options like => {except: 'company'} 117 | * 118 | * @param {Object} options 119 | */ 120 | saveChanges(options) { 121 | Tracker.setupTracking(this); 122 | Tracker.saveChanges(this, options); 123 | Tracker.triggerIsDirtyReset(this); 124 | }, 125 | 126 | saveTrackerChanges(options) { 127 | this.saveChanges(options); 128 | }, 129 | 130 | /** 131 | * Get value of the last known value tracker is saving for this key 132 | * 133 | * @param {String} key attribute/association name 134 | * @returns {*} 135 | */ 136 | savedTrackerValue(key) { 137 | return Tracker.lastValue(this, key); 138 | }, 139 | 140 | // save state when model is loaded or created if using auto save 141 | setupTrackerMetaData() { 142 | if (Tracker.isIsDirtyEnabled(this)) { 143 | // this is experimental 144 | Tracker.initializeDirtiness(this); 145 | } 146 | if (Tracker.isAutoSaveEnabled(this)) { 147 | this.saveChanges(); 148 | } 149 | }, 150 | 151 | // watch for relationships loaded with data via links 152 | setupUnknownRelationshipLoadObservers() { 153 | this.eachRelationship((key) => { 154 | this.addObserver(key, this, 'observeUnknownRelationshipLoaded'); 155 | }); 156 | }, 157 | 158 | // when model updates, update the tracked state if using auto save 159 | saveOnUpdate() { 160 | if (Tracker.isAutoSaveEnabled(this) || Tracker.isIsDirtyEnabled(this)) { 161 | this.saveChanges(); 162 | } 163 | }, 164 | 165 | // when model creates, update the tracked state if using auto save 166 | saveOnCreate() { 167 | if (Tracker.isAutoSaveEnabled(this) || Tracker.isIsDirtyEnabled(this)) { 168 | this.saveChanges(); 169 | } 170 | }, 171 | 172 | // There is no didReload callback on models, so have to override reload 173 | reload() { 174 | let promise = this._super(...arguments); 175 | promise.then(() => { 176 | if (Tracker.isAutoSaveEnabled(this)) { 177 | this.saveChanges(); 178 | } 179 | }); 180 | return promise; 181 | }, 182 | 183 | // when model deletes, remove any tracked state 184 | clearSavedAttributes() { 185 | Tracker.clear(this); 186 | }, 187 | 188 | observeUnknownRelationshipLoaded(sender, key/*, value, rev*/) { 189 | if (Tracker.trackingIsSetup(this) && Tracker.isTracking(this, key)) { 190 | let saved = Tracker.saveLoadedRelationship(this, key); 191 | if (saved) { 192 | this.removeObserver(key, this, 'observeUnknownRelationshipLoaded'); 193 | } 194 | } 195 | } 196 | }); 197 | -------------------------------------------------------------------------------- /addon/tracker.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import { didModelChange, didModelsChange, relationShipTransform, relationshipKnownState } from './utilities'; 3 | 4 | const assign = Ember.assign || Ember.merge; 5 | export const ModelTrackerKey = '-change-tracker'; 6 | export const RelationshipsKnownTrackerKey = '-change-tracker-relationships-known'; 7 | const alreadyTrackedRegex = /^-mf-|string|boolean|date|^number$/, 8 | knownTrackerOpts = Ember.A(['only', 'auto', 'except', 'trackHasMany', 'enableIsDirty']), 9 | defaultOpts = {trackHasMany: true, auto: false, enableIsDirty: false}; 10 | 11 | /** 12 | * Helper class for change tracking models 13 | */ 14 | export default class Tracker { 15 | 16 | /** 17 | * Get Ember application container 18 | * 19 | * @param {DS.Model} model 20 | * @returns {*} 21 | */ 22 | static container(model) { 23 | return Ember.getOwner ? Ember.getOwner(model.store) : model.store.container; 24 | } 25 | 26 | /** 27 | * Get tracker configuration from Ember application configuration 28 | * 29 | * @param {DS.Model} model 30 | * @returns {*|{}} 31 | */ 32 | static envConfig(model) { 33 | let config = this.container(model).resolveRegistration('config:environment'); 34 | // sometimes the config is not available ?? not sure why 35 | return config && config.changeTracker || {}; 36 | } 37 | 38 | /** 39 | * Get tracker configuration that is set on the model 40 | * 41 | * @param {DS.Model} model 42 | * @returns {*|{}} 43 | */ 44 | static modelConfig(model) { 45 | return model.changeTracker || {}; 46 | } 47 | 48 | /** 49 | * Is this model in auto save mode? 50 | * 51 | * @param model 52 | * @returns {Boolean} 53 | */ 54 | static isAutoSaveEnabled(model) { 55 | if (model.constructor.trackerAutoSave === undefined) { 56 | let options = this.options(model); 57 | model.constructor.trackerAutoSave = options.auto; 58 | } 59 | return model.constructor.trackerAutoSave; 60 | } 61 | 62 | /** 63 | * Is this model have isDirty option enabled? 64 | * 65 | * @param model 66 | * @returns {Boolean} 67 | */ 68 | static isIsDirtyEnabled(model) { 69 | if (model.constructor.trackerEnableIsDirty === undefined) { 70 | let options = this.options(model); 71 | model.constructor.trackerEnableIsDirty = options.enableIsDirty; 72 | } 73 | return model.constructor.trackerEnableIsDirty; 74 | } 75 | 76 | /** 77 | * A custom attribute should have a transform function associated with it. 78 | * If not, use object transform. 79 | * 80 | * A transform function is required for serializing and deserializing 81 | * the attribute in order to save past values and then renew them on rollback 82 | * 83 | * @param {DS.Model} model 84 | * @param {String} attributeType like: 'object', 'json' or could be undefined 85 | * @returns {*} 86 | */ 87 | static transformFn(model, attributeType) { 88 | let transformType = attributeType || 'object'; 89 | return this.container(model).lookup(`transform:${transformType}`); 90 | } 91 | 92 | /** 93 | * The rollback data will be an object with keys as attribute and relationship names 94 | * with values for those keys. 95 | * 96 | * For example: 97 | * 98 | * { id: 1, name: 'Acme Inc', company: 1, pets: [1,2] } 99 | * 100 | * Basically a REST style payload. So, convert that to JSONAPI so it can be 101 | * pushed to the store 102 | * 103 | * @param {DS.Model} model 104 | * @param {Object} data rollback data 105 | */ 106 | static normalize(model, data) { 107 | let container = this.container(model); 108 | let serializer = container.lookup('serializer:-rest'); 109 | serializer.set('store', model.store); 110 | return serializer.normalize(model.constructor, data); 111 | } 112 | 113 | /** 114 | * Find the meta data for all keys or a single key (attributes/association) 115 | * that tracker is tracking on this model 116 | * 117 | * @param {DS.Model} model 118 | * @param {string} [key] only this key's info and no other 119 | * @returns {*} all the meta info on this model that tracker is tracking 120 | */ 121 | static metaInfo(model, key = null) { 122 | let info = (model.constructor.trackerKeys || {}); 123 | if (key) { 124 | return info[key]; 125 | } 126 | return info; 127 | } 128 | 129 | /** 130 | * Find whether this key is currently being tracked. 131 | * 132 | * @param {DS.Model} model 133 | * @param {string} [key] 134 | * @returns {boolean} true if this key is being tracked. false otherwise 135 | */ 136 | static isTracking(model, key) { 137 | let info = (model.constructor.trackerKeys || {}); 138 | return !!info[key]; 139 | } 140 | 141 | /** 142 | * On the model you can set options like: 143 | * 144 | * changeTracker: {auto: true} 145 | * changeTracker: {auto: true, enableIsDirty: true} 146 | * changeTracker: {auto: true, only: ['info']} 147 | * changeTracker: {except: ['info']} 148 | * changeTracker: {except: ['info'], trackHasMany: true} 149 | * 150 | * In config environment you can set options like: 151 | * 152 | * changeTracker: {auto: true, trackHasMany: false, enableIsDirty: true} 153 | * // default is: {auto: false, trackHasMany: true, enableIsDirty: false} 154 | * 155 | * The default is set to trackHasMany but not auto track, since 156 | * that is the most do nothing approach and when you do call `model.startTrack()` 157 | * it is assumed you want to track everything. 158 | * 159 | * Also, by default the isDirty computed property is not setup. You have to enable 160 | * it globally or on a model 161 | * 162 | * @param {DS.Model} model 163 | * @returns {*} 164 | */ 165 | static options(model) { 166 | let envConfig = this.envConfig(model); 167 | let modelConfig = this.modelConfig(model); 168 | let opts = assign({}, defaultOpts, envConfig, modelConfig); 169 | 170 | let unknownOpts = Object.keys(opts).filter((v) => !knownTrackerOpts.includes(v)); 171 | Ember.assert(`[ember-data-change-tracker] changeTracker options can have 172 | 'only', 'except' , 'auto', 'enableIsDirty' or 'trackHasMany' but you are declaring: ${unknownOpts}`, 173 | Ember.isEmpty(unknownOpts) 174 | ); 175 | 176 | return opts; 177 | } 178 | 179 | // has tracking already been setup on this model? 180 | static trackingIsSetup(model) { 181 | return model.constructor.alreadySetupTrackingMeta; 182 | } 183 | 184 | /** 185 | * Setup tracking meta data for this model, 186 | * unless it's already been setup 187 | * 188 | * @param {DS.Model} model 189 | */ 190 | static setupTracking(model) { 191 | if (!this.trackingIsSetup(model)) { 192 | model.constructor.alreadySetupTrackingMeta = true; 193 | let info = Tracker.getTrackerInfo(model); 194 | model.constructor.trackerKeys = info.keyMeta; 195 | model.constructor.trackerAutoSave = info.autoSave; 196 | model.constructor.trackerEnableIsDirty = info.enableIsDirty; 197 | } 198 | } 199 | 200 | /** 201 | * Get the tracker meta data associated with this model 202 | * 203 | * @param {DS.Model} model 204 | * @returns {{autoSave, keyMeta: {}}} 205 | */ 206 | static getTrackerInfo(model) { 207 | let [trackableInfo, hasManyList] = this.extractKeys(model); 208 | let trackerOpts = this.options(model); 209 | 210 | let all = Object.keys(trackableInfo); 211 | let except = trackerOpts.except || []; 212 | let only = trackerOpts.only || [...all]; 213 | 214 | if (!trackerOpts.trackHasMany) { 215 | except = [...except, ...hasManyList]; 216 | } 217 | 218 | all = [...all].filter(a => !except.includes(a)); 219 | all = [...all].filter(a => only.includes(a)); 220 | 221 | let keyMeta = {}; 222 | Object.keys(trackableInfo).forEach(key => { 223 | if (all.includes(key)) { 224 | let info = trackableInfo[key]; 225 | info.transform = this.getTransform(model, key, info); 226 | keyMeta[key] = info; 227 | } 228 | }); 229 | 230 | let {enableIsDirty} = trackerOpts; 231 | return {autoSave: trackerOpts.auto, enableIsDirty, keyMeta}; 232 | } 233 | 234 | /** 235 | * Go through the models attributes and relationships so see 236 | * which of these keys could be trackable 237 | * 238 | * @param {DS.Model} model 239 | * @returns {[*,*]} meta data about possible keys to track 240 | */ 241 | static extractKeys(model) { 242 | let {constructor} = model; 243 | let trackerKeys = {}; 244 | let hasManyList = []; 245 | 246 | constructor.eachAttribute((attribute, meta) => { 247 | if (!alreadyTrackedRegex.test(meta.type)) { 248 | trackerKeys[attribute] = {type: 'attribute', name: meta.type}; 249 | } 250 | }); 251 | 252 | constructor.eachRelationship((key, relationship) => { 253 | trackerKeys[key] = { 254 | type: relationship.kind, 255 | polymorphic: relationship.options.polymorphic, 256 | knownState: relationshipKnownState[relationship.kind] 257 | }; 258 | if (relationship.kind === 'hasMany') { 259 | hasManyList.push(key); 260 | } 261 | }); 262 | 263 | return [trackerKeys, hasManyList]; 264 | } 265 | 266 | /** 267 | * Get the transform for an attribute or association. 268 | * The attribute transforms are held by ember-data, and 269 | * the tracker uses custom transform for relationships 270 | * 271 | * @param {DS.Model} model 272 | * @param {String} key attribute/association name 273 | * @param {Object} info tracker meta data for this key 274 | * @returns {*} 275 | */ 276 | static getTransform(model, key, info) { 277 | let transform; 278 | 279 | if (info.type === 'attribute') { 280 | transform = this.transformFn(model, info.name); 281 | 282 | Ember.assert(`[ember-data-change-tracker] changeTracker could not find 283 | a ${info.name} transform function for the attribute '${key}' in 284 | model '${model.constructor.modelName}'. 285 | If you are in a unit test, be sure to include it in the list of needs`, 286 | transform 287 | ); 288 | } else { 289 | transform = relationShipTransform[info.type]; 290 | } 291 | 292 | return transform; 293 | } 294 | 295 | /** 296 | * Did the key change since the last time state was saved? 297 | * 298 | * @param {DS.Model} model 299 | * @param {String} key attribute/association name 300 | * @param {Object} [changed] changed object 301 | * @param {Object} [info] model tracker meta data object 302 | * @returns {*} 303 | */ 304 | static didChange(model, key, changed, info) { 305 | changed = changed || model.changedAttributes(); 306 | if (changed[key]) { 307 | return true; 308 | } 309 | let keyInfo = info && info[key] || this.metaInfo(model, key); 310 | if (keyInfo) { 311 | let current = this.serialize(model, key, keyInfo); 312 | let last = this.lastValue(model, key); 313 | switch (keyInfo.type) { 314 | case 'attribute': 315 | case 'belongsTo': 316 | return didModelChange(current, last, keyInfo.polymorphic); 317 | case 'hasMany': 318 | return didModelsChange(current, last, keyInfo.polymorphic); 319 | } 320 | } 321 | } 322 | 323 | /** 324 | * Serialize the value to be able to tell if the value changed. 325 | * 326 | * For attributes, using the transform function that each custom 327 | * attribute should have. 328 | * 329 | * For belongsTo, and hasMany using using custom transform 330 | * 331 | * @param {DS.Model} model 332 | * @param {String} key attribute/association name 333 | */ 334 | static serialize(model, key, keyInfo) { 335 | let info = keyInfo || this.metaInfo(model, key); 336 | let value; 337 | if (info.type === 'attribute') { 338 | value = info.transform.serialize(model.get(key)); 339 | if (typeof value !== 'string') { 340 | value = JSON.stringify(value); 341 | } 342 | } else { 343 | value = info.transform.serialize(model, key, info); 344 | } 345 | return value; 346 | } 347 | 348 | /** 349 | * Determine if the key represents data that the client knows about. 350 | * 351 | * For relationships that are async links it may be that they are yet to be 352 | * loaded and so a determination of 'changed' cannot be known 353 | * 354 | * @param {DS.Model} model 355 | * @param {String} key attribute/association name 356 | */ 357 | static isKnown(model, key, keyInfo) { 358 | let info = keyInfo || this.metaInfo(model, key); 359 | let value; 360 | if (info.type === 'attribute') { 361 | value = true; 362 | } else { 363 | value = info.knownState.isKnown(model, key); 364 | } 365 | return value; 366 | } 367 | 368 | /** 369 | * Retrieve the last known value for this model key 370 | * 371 | * @param {DS.Model} model 372 | * @param {String} key attribute/association name 373 | * @returns {*} 374 | */ 375 | static lastValue(model, key) { 376 | return (model.get(ModelTrackerKey) || {})[key]; 377 | } 378 | 379 | /** 380 | * Retrieve the last known state for this model key 381 | * 382 | * @param {DS.Model} model 383 | * @param {String} key attribute/association name 384 | * @returns {*} 385 | */ 386 | static lastKnown(model, key) { 387 | return (model.get(RelationshipsKnownTrackerKey) || {})[key]; 388 | } 389 | 390 | /** 391 | * Gather all the rollback data 392 | * 393 | * @param {DS.Model} model 394 | * @param trackerInfo 395 | * @returns {{*}} 396 | */ 397 | static rollbackData(model, trackerInfo) { 398 | let data = {id: model.id}; 399 | Object.keys(trackerInfo).forEach((key) => { 400 | let keyInfo = trackerInfo[key]; 401 | if (this.didChange(model, key, null, trackerInfo)) { 402 | // For now, blow away the hasMany relationship before resetting it 403 | // since just pushing new data is not resetting the relationship. 404 | // This slows down the hasMany rollback by about 25%, but still 405 | // fast => (~100ms) with 500 items in a hasMany 406 | if (keyInfo.type === 'hasMany') { 407 | model.set(key, []); 408 | } 409 | let lastValue = Tracker.lastValue(model, key); 410 | if (keyInfo.type === 'attribute' && !keyInfo.name) { // attr() undefined type 411 | lastValue = keyInfo.transform.deserialize(lastValue); 412 | } 413 | data[key] = lastValue; 414 | } 415 | }); 416 | return data; 417 | } 418 | 419 | /** 420 | * Save change tracker attributes 421 | * 422 | * @param {DS.Model} model 423 | * @param {Object} options 424 | * except array of keys to exclude 425 | */ 426 | static saveChanges(model, {except = []} = {}) { 427 | let metaInfo = this.metaInfo(model); 428 | let keys = Object.keys(metaInfo).filter(key => !except.includes(key)); 429 | Tracker.saveKeys(model, keys); 430 | } 431 | 432 | /** 433 | * Save the current relationship value into the hash only if it was previously 434 | * unknown (i.e. to be loaded async via a link) 435 | * 436 | * @param {DS.Model} model 437 | * @param {String} key association name 438 | * @returns {boolean} true if the current relationship value was saved, false otherwise 439 | */ 440 | static saveLoadedRelationship(model, key) { 441 | let saved = false; 442 | if (!Tracker.lastKnown(model, key)) { 443 | let keyInfo = this.metaInfo(model, key); 444 | if (Tracker.isKnown(model, key, keyInfo)) { 445 | Tracker.saveKey(model, key); 446 | saved = true; 447 | } 448 | } 449 | return saved; 450 | } 451 | 452 | /** 453 | * Manually trigger the isDirty properties to refresh themselves 454 | * 455 | * @param {DS.Model} model 456 | */ 457 | static triggerIsDirtyReset(model) { 458 | model.notifyPropertyChange('hasDirtyAttributes'); 459 | model.notifyPropertyChange('hasDirtyRelations'); 460 | } 461 | 462 | /** 463 | * Save the value from an array of keys model's tracker hash 464 | * and save the relationship states if keys represents a relationship 465 | * 466 | * @param {DS.Model} model 467 | * @param {Array} keys to save 468 | */ 469 | 470 | static saveKeys(model, keys){ 471 | let modelTracker = model.get(ModelTrackerKey) || {}, 472 | relationshipsKnownTracker = model.get(RelationshipsKnownTrackerKey) || {}, 473 | isNew = model.get('isNew'); 474 | 475 | keys.forEach(key => { 476 | modelTracker[key] = isNew ? undefined : this.serialize(model, key); 477 | relationshipsKnownTracker[key] = isNew ? true : this.isKnown(model, key); 478 | }) 479 | model.setProperties({[ModelTrackerKey]:modelTracker, [RelationshipsKnownTrackerKey]: relationshipsKnownTracker}) 480 | } 481 | 482 | /** 483 | * Save current model key value in model's tracker hash 484 | * and save the relationship state if key represents a relationship 485 | * 486 | * @param {DS.Model} model 487 | * @param {String} key attribute/association name 488 | */ 489 | static saveKey(model, key) { 490 | this.saveKeys(model, [key]); 491 | } 492 | 493 | /** 494 | * Remove tracker hashes from the model's state 495 | * 496 | * @param {DS.Model} model 497 | */ 498 | static clear(model) { 499 | model.set(ModelTrackerKey, undefined); 500 | model.set(RelationshipsKnownTrackerKey, undefined); 501 | } 502 | 503 | /** 504 | * Set up the computed properties: 505 | * 506 | * 'isDirty', 'hasDirtyAttributes', 'hasDirtyRelations' 507 | * 508 | * only if the application or model configuration has opted into 509 | * enable these properties, with the enableIsDirty flag 510 | * 511 | * @param {DS.Model} model 512 | */ 513 | static initializeDirtiness(model) { 514 | const relations = []; 515 | const relationsObserver = []; 516 | const attrs = []; 517 | 518 | model.eachRelationship((name, descriptor) => { 519 | if (descriptor.kind === 'hasMany') { 520 | relations.push(descriptor.key); 521 | if (descriptor.options.async) { 522 | relationsObserver.push(descriptor.key + '.content.@each.id'); 523 | } else { 524 | relationsObserver.push(descriptor.key + '.@each.id'); 525 | } 526 | } else { 527 | relations.push(descriptor.key); 528 | relationsObserver.push(descriptor.key + '.content'); 529 | } 530 | }); 531 | 532 | model.eachAttribute(name => { 533 | return attrs.push(name); 534 | }); 535 | 536 | const hasDirtyRelations = function() { 537 | const changed = model.modelChanges(); 538 | return !!relations.find(key => changed[key]); 539 | }; 540 | 541 | const hasDirtyAttributes = function() { 542 | const changed = model.modelChanges(); 543 | return !!attrs.find(key => changed[key]); 544 | }; 545 | 546 | const isDirty = function() { 547 | return model.get('hasDirtyAttributes') || model.get('hasDirtyRelations'); 548 | }; 549 | 550 | Ember.defineProperty( 551 | model, 552 | 'hasDirtyAttributes', 553 | Ember.computed.apply(Ember, attrs.concat([hasDirtyAttributes])) 554 | ); 555 | 556 | Ember.defineProperty( 557 | model, 558 | 'hasDirtyRelations', 559 | Ember.computed.apply(Ember, relationsObserver.concat([hasDirtyRelations])) 560 | ); 561 | 562 | Ember.defineProperty( 563 | model, 564 | 'isDirty', 565 | Ember.computed.apply(Ember, ['hasDirtyAttributes', 'hasDirtyRelations', isDirty]) 566 | ); 567 | 568 | } 569 | 570 | } 571 | -------------------------------------------------------------------------------- /addon/transforms/json.js: -------------------------------------------------------------------------------- 1 | import Transform from "ember-data/transform"; 2 | /** 3 | * This transform does not serializes to string, 4 | * with JSON.stringify, but leaves the object as is. 5 | * 6 | * The data often does not need to be stringified 7 | * so it's a valid case 8 | */ 9 | export default Transform.extend({ 10 | serialize: function(value) { 11 | return value; 12 | }, 13 | 14 | deserialize: function(json) { 15 | if (typeof json === "string") { 16 | json = JSON.parse(json); 17 | } 18 | return json; 19 | } 20 | }); -------------------------------------------------------------------------------- /addon/transforms/object.js: -------------------------------------------------------------------------------- 1 | import DS from 'ember-data'; 2 | import Ember from 'ember'; 3 | 4 | export default DS.Transform.extend({ 5 | serialize: function(value) { 6 | return value && JSON.stringify(value); 7 | }, 8 | 9 | deserialize: function(value) { 10 | if (Ember.isEmpty(value)) { 11 | return {}; 12 | } 13 | if (Ember.typeOf(value) === "object") { 14 | return value; 15 | } 16 | if (Ember.typeOf(value) === 'string') { 17 | return JSON.parse(value); 18 | } 19 | } 20 | }); 21 | -------------------------------------------------------------------------------- /addon/utilities.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export const modelTransform = function(model, polymorphic) { 4 | if (polymorphic) { 5 | return { id: model.id, type: model.modelName || model.constructor.modelName }; 6 | } 7 | return model.id; 8 | }; 9 | 10 | export const relationShipTransform = { 11 | belongsTo: { 12 | serialize(model, key, options) { 13 | let relationship = model.belongsTo(key).belongsToRelationship; 14 | let value = relationship.hasOwnProperty('inverseRecordData') ? relationship.inverseRecordData: relationship.canonicalState; 15 | return value && modelTransform(value, options.polymorphic); 16 | }, 17 | 18 | deserialize() { 19 | } 20 | }, 21 | hasMany: { 22 | serialize(model, key, options) { 23 | let relationship = model.hasMany(key).hasManyRelationship; 24 | let value = relationship.currentState; 25 | return value && value.map(item => modelTransform(item, options.polymorphic)); 26 | }, 27 | 28 | deserialize() { 29 | } 30 | } 31 | }; 32 | 33 | export const relationshipKnownState = { 34 | belongsTo: { 35 | isKnown(model, key) { 36 | let belongsTo = model.belongsTo(key); 37 | let relationship = belongsTo.belongsToRelationship; 38 | return !relationship.relationshipIsStale; 39 | } 40 | }, 41 | hasMany: { 42 | isKnown(model, key) { 43 | let hasMany = model.hasMany(key); 44 | let relationship = hasMany.hasManyRelationship; 45 | return !relationship.relationshipIsStale; 46 | } 47 | } 48 | }; 49 | 50 | export const isEmpty = function(value) { 51 | if (Ember.typeOf(value) === 'object') { 52 | return Object.keys(value).length === 0; 53 | } 54 | return Ember.isEmpty(value); 55 | }; 56 | 57 | export const didSerializedModelChange = function(one, other, polymorphic) { 58 | if (polymorphic) { 59 | return one.id !== other.id || one.type !== other.type; 60 | } 61 | return one !== other; 62 | }; 63 | 64 | export const didModelsChange = function(one, other, polymorphic) { 65 | if (isEmpty(one) && isEmpty(other)) { 66 | return false; 67 | } 68 | 69 | if ((one && one.length) !== (other && other.length)) { 70 | return true; 71 | } 72 | 73 | for (let i = 0, len = one.length; i < len; i++) { 74 | if (didSerializedModelChange(one[i], other[i], polymorphic)) { 75 | return true; 76 | } 77 | } 78 | 79 | return false; 80 | }; 81 | 82 | export const didModelChange = function(one, other, polymorphic) { 83 | if (isEmpty(one) && isEmpty(other)) { 84 | return false; 85 | } 86 | 87 | if (!one && other || one && !other) { 88 | return true; 89 | } 90 | 91 | return didSerializedModelChange(one, other, polymorphic); 92 | }; 93 | -------------------------------------------------------------------------------- /app/initializers/ember-data-change-tracker.js: -------------------------------------------------------------------------------- 1 | import {initializer as initialize} from 'ember-data-change-tracker'; 2 | 3 | export default { 4 | name: 'ember-data-change-tracker', 5 | after: 'ember-data', 6 | initialize 7 | }; 8 | -------------------------------------------------------------------------------- /app/mixins/change-serializer.js: -------------------------------------------------------------------------------- 1 | export { default } from 'ember-data-change-tracker/mixins/keep-only-changed'; 2 | -------------------------------------------------------------------------------- /app/transforms/json.js: -------------------------------------------------------------------------------- 1 | export { default } from 'ember-data-change-tracker/transforms/json'; 2 | -------------------------------------------------------------------------------- /app/transforms/object.js: -------------------------------------------------------------------------------- 1 | export { default } from 'ember-data-change-tracker/transforms/object'; 2 | -------------------------------------------------------------------------------- /config/ember-try.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict'; 3 | 4 | const getChannelURL = require('ember-source-channel-url'); 5 | 6 | module.exports = function() { 7 | return Promise.all([ 8 | getChannelURL('release'), 9 | getChannelURL('beta'), 10 | getChannelURL('canary'), 11 | ]).then(urls => { 12 | return { 13 | useYarn: true, 14 | scenarios: [ 15 | { 16 | name: 'ember-3.8', 17 | npm: { 18 | devDependencies: { 19 | 'ember-data': '3.8.0', 20 | 'ember-source': '3.8.0', 21 | } 22 | } 23 | }, 24 | { 25 | name: 'ember-release', 26 | npm: { 27 | devDependencies: { 28 | 'ember-data': 'release', 29 | 'ember-source': urls[0], 30 | } 31 | } 32 | }, 33 | { 34 | name: 'ember-beta', 35 | npm: { 36 | devDependencies: { 37 | 'ember-data': 'beta', 38 | 'ember-source': urls[1], 39 | } 40 | } 41 | }, 42 | { 43 | name: 'ember-canary', 44 | npm: { 45 | devDependencies: { 46 | 'ember-data': 'canary', 47 | 'ember-source': urls[2], 48 | } 49 | } 50 | } 51 | ] 52 | }; 53 | }); 54 | }; 55 | -------------------------------------------------------------------------------- /config/environment.js: -------------------------------------------------------------------------------- 1 | /*jshint node:true*/ 2 | 'use strict'; 3 | 4 | module.exports = function(/* environment, appConfig */) { 5 | return { }; 6 | }; 7 | -------------------------------------------------------------------------------- /ember-cli-build.js: -------------------------------------------------------------------------------- 1 | /* global require, module */ 2 | var EmberAddon = require('ember-cli/lib/broccoli/ember-addon'); 3 | 4 | module.exports = function(defaults) { 5 | var app = new EmberAddon(defaults, { 6 | 'ember-cli-babel': { 7 | includePolyfill: true 8 | } 9 | }); 10 | 11 | /* 12 | This build file specifies the options for the dummy test app of this 13 | addon, located in `/tests/dummy` 14 | This build file does *not* influence how the addon or the app using it 15 | behave. You most likely want to be modifying `./index.js` or app's build file 16 | */ 17 | 18 | return app.toTree(); 19 | }; 20 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict'; 3 | 4 | module.exports = { 5 | name: 'ember-data-change-tracker' 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ember-data-change-tracker", 3 | "version": "0.10.1", 4 | "description": "Track changes and rollback object attributes and relationships. Ember data 2.5+", 5 | "keywords": [ 6 | "ember-addon", 7 | "ember-data", 8 | "change tracking", 9 | "dirty tracking", 10 | "rollback" 11 | ], 12 | "license": "MIT", 13 | "author": "Daniel Sudol ", 14 | "directories": { 15 | "doc": "doc", 16 | "test": "tests" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/danielspaniel/ember-data-change-tracker" 21 | }, 22 | "homepage": "https://github.com/danielspaniel/ember-data-change-tracker", 23 | "scripts": { 24 | "build": "ember build", 25 | "start": "ember server", 26 | "test": "ember try:each" 27 | }, 28 | "dependencies": { 29 | "ember-cli-babel": "^7.4.0" 30 | }, 31 | "devDependencies": { 32 | "@ember/optional-features": "^0.7.0", 33 | "broccoli-asset-rev": "^2.7.0", 34 | "ember-fetch": "^6.5.1", 35 | "ember-cli": "3.8", 36 | "ember-cli-app-version": "^2.0.0", 37 | "ember-cli-dependency-checker": "^3.2.0", 38 | "ember-cli-eslint": "^4.2.3", 39 | "ember-cli-htmlbars": "3.0.1", 40 | "ember-cli-htmlbars-inline-precompile": "^2.1.0", 41 | "ember-cli-inject-live-reload": "^1.7.0", 42 | "ember-cli-qunit": "^4.4.0", 43 | "ember-cli-release": "^0.2.9", 44 | "ember-cli-sri": "^2.1.1", 45 | "ember-cli-uglify": "^1.2.0", 46 | "ember-data": "3.8", 47 | "ember-data-factory-guy": "3.9.4", 48 | "ember-data-model-fragments": "4.0.0", 49 | "ember-disable-prototype-extensions": "^1.1.0", 50 | "ember-export-application-global": "^2.0.0", 51 | "ember-load-initializers": "^2.0.0", 52 | "ember-resolver": "5.0.1", 53 | "ember-sinon": "4.0.0", 54 | "ember-source": "3.8", 55 | "ember-source-channel-url": "^1.1.0", 56 | "ember-try": "^0.2.23", 57 | "loader.js": "4.7.0" 58 | }, 59 | "engines": { 60 | "node": ">= 10" 61 | }, 62 | "ember-addon": { 63 | "configPath": "tests/dummy/config" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /testem.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | framework: 'qunit', 4 | test_page: 'tests/index.html?hidepassed', 5 | disable_watching: true, 6 | reporter: 'dot', 7 | launch_in_ci: ['Chrome'], 8 | launch_in_dev: ['Chrome'], 9 | browser_args: { 10 | Chrome: { 11 | mode: 'ci', 12 | args: [ 13 | '--headless', 14 | '--disable-dev-shm-usage', 15 | '--disable-software-rasterizer', 16 | '--mute-audio', 17 | '--remote-debugging-port=0', 18 | '--window-size=1440,900', 19 | '--no-sandbox', 20 | ], 21 | }, 22 | }, 23 | 24 | }; 25 | -------------------------------------------------------------------------------- /tests/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | embertest: true 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /tests/dummy/app/adapters/application.js: -------------------------------------------------------------------------------- 1 | import DS from 'ember-data'; 2 | import AdapterFetch from 'ember-fetch/mixins/adapter-fetch'; 3 | 4 | export default DS.JSONAPIAdapter.extend(AdapterFetch); 5 | -------------------------------------------------------------------------------- /tests/dummy/app/app.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import Resolver from './resolver'; 3 | import loadInitializers from 'ember-load-initializers'; 4 | import config from './config/environment'; 5 | 6 | let App; 7 | 8 | App = Ember.Application.extend({ 9 | modulePrefix: config.modulePrefix, 10 | podModulePrefix: config.podModulePrefix, 11 | Resolver 12 | }); 13 | 14 | loadInitializers(App, config.modulePrefix); 15 | 16 | export default App; 17 | -------------------------------------------------------------------------------- /tests/dummy/app/components/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielspaniel/ember-data-change-tracker/f7b1684efbf923f21509f3113030128361969b3d/tests/dummy/app/components/.gitkeep -------------------------------------------------------------------------------- /tests/dummy/app/controllers/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielspaniel/ember-data-change-tracker/f7b1684efbf923f21509f3113030128361969b3d/tests/dummy/app/controllers/.gitkeep -------------------------------------------------------------------------------- /tests/dummy/app/helpers/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielspaniel/ember-data-change-tracker/f7b1684efbf923f21509f3113030128361969b3d/tests/dummy/app/helpers/.gitkeep -------------------------------------------------------------------------------- /tests/dummy/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dummy 7 | 8 | 9 | 10 | {{content-for "head"}} 11 | 12 | 13 | 14 | 15 | {{content-for "head-footer"}} 16 | 17 | 18 | {{content-for "body"}} 19 | 20 | 21 | 22 | 23 | {{content-for "body-footer"}} 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/dummy/app/models/big-company.js: -------------------------------------------------------------------------------- 1 | import Company from './company'; 2 | 3 | export default Company.extend(); 4 | -------------------------------------------------------------------------------- /tests/dummy/app/models/cat.js: -------------------------------------------------------------------------------- 1 | import Pet from './pet'; 2 | 3 | export default Pet.extend(); 4 | -------------------------------------------------------------------------------- /tests/dummy/app/models/company.js: -------------------------------------------------------------------------------- 1 | import Model from 'ember-data/model'; 2 | import attr from 'ember-data/attr'; 3 | import {hasMany} from 'ember-data/relationships'; 4 | 5 | export default Model.extend({ 6 | type: attr('string'), 7 | name: attr('string'), 8 | blob: attr(), 9 | 10 | users: hasMany('user', { async: true, inverse: 'company' }), 11 | }); 12 | -------------------------------------------------------------------------------- /tests/dummy/app/models/detail.js: -------------------------------------------------------------------------------- 1 | import Model from 'ember-data/model'; 2 | import attr from 'ember-data/attr'; 3 | //import { belongsTo } from 'ember-data/relationships'; 4 | 5 | export default Model.extend({ 6 | name: attr('string'), 7 | // TODO: figure out why this declaration prevents the project serializer from 8 | // serializing details. 9 | // project: belongsTo('project') 10 | }); 11 | -------------------------------------------------------------------------------- /tests/dummy/app/models/dog.js: -------------------------------------------------------------------------------- 1 | import Pet from './pet'; 2 | 3 | export default Pet.extend(); 4 | -------------------------------------------------------------------------------- /tests/dummy/app/models/location.js: -------------------------------------------------------------------------------- 1 | import attr from 'ember-data/attr'; 2 | import Fragment from 'ember-data-model-fragments/fragment'; 3 | 4 | export default Fragment.extend({ 5 | place: attr('string'), 6 | number: attr('number') 7 | }); 8 | -------------------------------------------------------------------------------- /tests/dummy/app/models/perf-model-tracked.js: -------------------------------------------------------------------------------- 1 | import Model from 'ember-data/model'; 2 | import attr from 'ember-data/attr'; 3 | 4 | export default Model.extend({ 5 | changeTracker: {trackHasMany: true, auto: true, enableIsDirty: true}, 6 | 7 | name: attr('string'), 8 | propA: attr('string'), 9 | propB: attr('string'), 10 | propC: attr('string'), 11 | propD: attr('string'), 12 | propE: attr('string'), 13 | propF: attr('string'), 14 | propG: attr('string'), 15 | propH: attr('string'), 16 | propI: attr('string'), 17 | propJ: attr('string'), 18 | propK: attr('string'), 19 | propL: attr('string'), 20 | propM: attr('string'), 21 | propN: attr('string'), 22 | propO: attr('string'), 23 | propP: attr('string'), 24 | propQ: attr('string'), 25 | propR: attr('string'), 26 | propS: attr('string'), 27 | propT: attr('string'), 28 | propU: attr('string'), 29 | propV: attr('string'), 30 | propW: attr('string'), 31 | propX: attr('string'), 32 | propY: attr('string'), 33 | propZ: attr('string') 34 | }); 35 | -------------------------------------------------------------------------------- /tests/dummy/app/models/perf-model-untracked.js: -------------------------------------------------------------------------------- 1 | import Model from 'ember-data/model'; 2 | import attr from 'ember-data/attr'; 3 | 4 | export default Model.extend({ 5 | changeTracker: {trackHasMany: false, auto: false, enableIsDirty: false}, 6 | 7 | name: attr('string'), 8 | propA: attr('string'), 9 | propB: attr('string'), 10 | propC: attr('string'), 11 | propD: attr('string'), 12 | propE: attr('string'), 13 | propF: attr('string'), 14 | propG: attr('string'), 15 | propH: attr('string'), 16 | propI: attr('string'), 17 | propJ: attr('string'), 18 | propK: attr('string'), 19 | propL: attr('string'), 20 | propM: attr('string'), 21 | propN: attr('string'), 22 | propO: attr('string'), 23 | propP: attr('string'), 24 | propQ: attr('string'), 25 | propR: attr('string'), 26 | propS: attr('string'), 27 | propT: attr('string'), 28 | propU: attr('string'), 29 | propV: attr('string'), 30 | propW: attr('string'), 31 | propX: attr('string'), 32 | propY: attr('string'), 33 | propZ: attr('string') 34 | }); 35 | -------------------------------------------------------------------------------- /tests/dummy/app/models/pet.js: -------------------------------------------------------------------------------- 1 | import Model from 'ember-data/model'; 2 | import attr from 'ember-data/attr'; 3 | import { belongsTo } from 'ember-data/relationships'; 4 | 5 | export default Model.extend({ 6 | name: attr('string'), 7 | owner: belongsTo('user', { async: false }) 8 | }); -------------------------------------------------------------------------------- /tests/dummy/app/models/profile.js: -------------------------------------------------------------------------------- 1 | import Model from 'ember-data/model'; 2 | import attr from 'ember-data/attr'; 3 | import {belongsTo} from 'ember-data/relationships'; 4 | 5 | export default Model.extend({ 6 | created_at: attr('date'), 7 | description: attr('string'), 8 | user: belongsTo('user', { async: true }) 9 | }); -------------------------------------------------------------------------------- /tests/dummy/app/models/project.js: -------------------------------------------------------------------------------- 1 | import Model from 'ember-data/model'; 2 | import attr from 'ember-data/attr'; 3 | import {belongsTo, hasMany} from 'ember-data/relationships'; 4 | 5 | export default Model.extend({ 6 | changeTracker: { trackHasMany: true, auto: false, enableIsDirty: true }, 7 | title: attr('string'), 8 | blob: attr(), 9 | company: belongsTo('company', { async: true }), 10 | details: hasMany('detail'), 11 | 12 | init(){ 13 | this._super(...arguments); 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /tests/dummy/app/models/small-company.js: -------------------------------------------------------------------------------- 1 | import Company from './company'; 2 | 3 | export default Company.extend(); 4 | -------------------------------------------------------------------------------- /tests/dummy/app/models/user.js: -------------------------------------------------------------------------------- 1 | import Model from 'ember-data/model'; 2 | import attr from 'ember-data/attr'; 3 | import { belongsTo, hasMany } from 'ember-data/relationships'; 4 | import {array, fragment, fragmentArray} from 'ember-data-model-fragments/attributes'; 5 | 6 | export default Model.extend({ 7 | changeTracker: {trackHasMany: true, auto: true, enableIsDirty: true}, 8 | name: attr('string'), 9 | style: attr('string'), 10 | // object type 11 | info: attr('object'), 12 | blob: attr('json'), 13 | // fragments 14 | list: array('number'), 15 | location: fragment('location'), 16 | things: fragmentArray('things'), 17 | // associations 18 | company: belongsTo('company', { async: true, polymorphic: true }), 19 | profile: belongsTo('profile', { async: false }), 20 | projects: hasMany('project', { async: true }), 21 | pets: hasMany('pet', { async: false, polymorphic: true }) 22 | }); 23 | -------------------------------------------------------------------------------- /tests/dummy/app/resolver.js: -------------------------------------------------------------------------------- 1 | import Resolver from 'ember-resolver'; 2 | 3 | export default Resolver; 4 | -------------------------------------------------------------------------------- /tests/dummy/app/router.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import config from './config/environment'; 3 | 4 | const Router = Ember.Router.extend({ 5 | location: config.locationType, 6 | rootURL: config.rootURL 7 | }); 8 | 9 | Router.map(function() { 10 | }); 11 | 12 | export default Router; 13 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielspaniel/ember-data-change-tracker/f7b1684efbf923f21509f3113030128361969b3d/tests/dummy/app/routes/.gitkeep -------------------------------------------------------------------------------- /tests/dummy/app/serializers/project.js: -------------------------------------------------------------------------------- 1 | import DS from 'ember-data'; 2 | import keepOnlyChanged from 'ember-data-change-tracker/mixins/keep-only-changed'; 3 | 4 | export default DS.JSONAPISerializer.extend(keepOnlyChanged); 5 | //export default DS.RESTSerializer.extend(keepOnlyChanged); -------------------------------------------------------------------------------- /tests/dummy/app/serializers/user.js: -------------------------------------------------------------------------------- 1 | import DS from 'ember-data'; 2 | 3 | export default DS.JSONAPISerializer.extend(); 4 | -------------------------------------------------------------------------------- /tests/dummy/app/styles/app.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielspaniel/ember-data-change-tracker/f7b1684efbf923f21509f3113030128361969b3d/tests/dummy/app/styles/app.css -------------------------------------------------------------------------------- /tests/dummy/app/templates/components/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielspaniel/ember-data-change-tracker/f7b1684efbf923f21509f3113030128361969b3d/tests/dummy/app/templates/components/.gitkeep -------------------------------------------------------------------------------- /tests/dummy/app/tests/factories/big-company.js: -------------------------------------------------------------------------------- 1 | import FactoryGuy from 'ember-data-factory-guy'; 2 | import './company'; 3 | 4 | FactoryGuy.define("big-company", { 5 | extends: 'company', 6 | 7 | default: { 8 | type: 'BigCompany', 9 | name: 'Big Corp' 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /tests/dummy/app/tests/factories/cat.js: -------------------------------------------------------------------------------- 1 | import FactoryGuy from 'ember-data-factory-guy'; 2 | 3 | FactoryGuy.define("cat", { 4 | default: { 5 | name: (f)=> `Cat${f.id}` 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /tests/dummy/app/tests/factories/company.js: -------------------------------------------------------------------------------- 1 | import FactoryGuy from 'ember-data-factory-guy'; 2 | 3 | FactoryGuy.define("company", { 4 | default: { 5 | type: 'Company', 6 | name: (f)=>`Company${f.id}`, 7 | }, 8 | 9 | traits: { 10 | with_projects: { 11 | // projects: FactoryGuy.hasMany('project', 2) 12 | } 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /tests/dummy/app/tests/factories/detail.js: -------------------------------------------------------------------------------- 1 | import FactoryGuy from 'ember-data-factory-guy'; 2 | 3 | FactoryGuy.define("detail", { 4 | default: { 5 | name: (f)=> `Detail${f.id}` 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /tests/dummy/app/tests/factories/dog.js: -------------------------------------------------------------------------------- 1 | import FactoryGuy from 'ember-data-factory-guy'; 2 | 3 | FactoryGuy.define("dog", { 4 | default: { 5 | name: (f)=> `Dog${f.id}` 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /tests/dummy/app/tests/factories/location.js: -------------------------------------------------------------------------------- 1 | import FactoryGuy from 'ember-data-factory-guy'; 2 | 3 | FactoryGuy.define("location", { 4 | default: { 5 | place: (f)=>`Place${f.id}`, 6 | number: (f)=> f.id *10 7 | } 8 | }); 9 | -------------------------------------------------------------------------------- /tests/dummy/app/tests/factories/perf-model-tracked.js: -------------------------------------------------------------------------------- 1 | import FactoryGuy from 'ember-data-factory-guy'; 2 | 3 | const randomString = () => Math.random().toString(36).substring(2, 11) + Math.random().toString(36).substring(2, 15); 4 | 5 | export const defaultTraits = { 6 | style: 'normal', 7 | name: (f)=>`User${f.id}`, 8 | propA: randomString, 9 | propB: randomString, 10 | propC: randomString, 11 | propD: randomString, 12 | propE: randomString, 13 | propF: randomString, 14 | propG: randomString, 15 | propH: randomString, 16 | propI: randomString, 17 | propJ: randomString, 18 | propK: randomString, 19 | propL: randomString, 20 | propM: randomString, 21 | propN: randomString, 22 | propO: randomString, 23 | propP: randomString, 24 | propQ: randomString, 25 | propR: randomString, 26 | propS: randomString, 27 | propT: randomString, 28 | propU: randomString, 29 | propV: randomString, 30 | propW: randomString, 31 | propX: randomString, 32 | propY: randomString, 33 | propZ: randomString 34 | } 35 | 36 | FactoryGuy.define('perf-model-tracked', { 37 | default: defaultTraits, 38 | }); 39 | 40 | -------------------------------------------------------------------------------- /tests/dummy/app/tests/factories/perf-model-untracked.js: -------------------------------------------------------------------------------- 1 | import FactoryGuy from 'ember-data-factory-guy'; 2 | import { defaultTraits } from './perf-model-tracked'; 3 | 4 | FactoryGuy.define('perf-model-untracked', { 5 | 6 | default: defaultTraits 7 | }); 8 | 9 | -------------------------------------------------------------------------------- /tests/dummy/app/tests/factories/pet.js: -------------------------------------------------------------------------------- 1 | import FactoryGuy from 'ember-data-factory-guy'; 2 | 3 | FactoryGuy.define("pet", { 4 | default: { 5 | name: (f)=> `Fido${f.id}` 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /tests/dummy/app/tests/factories/profile.js: -------------------------------------------------------------------------------- 1 | import FactoryGuy from 'ember-data-factory-guy'; 2 | 3 | FactoryGuy.define('profile', { 4 | default: { 5 | description: (f)=> `Text for profile #${f.id}` 6 | }, 7 | 8 | traits: { 9 | goofy_description: { 10 | description: 'goofy' 11 | } 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /tests/dummy/app/tests/factories/project.js: -------------------------------------------------------------------------------- 1 | import FactoryGuy from 'ember-data-factory-guy'; 2 | 3 | FactoryGuy.define("project", { 4 | sequences: { 5 | title: function(num) {return 'Project' + num;} 6 | }, 7 | 8 | // default values for 'project' attributes 9 | default: { 10 | title: FactoryGuy.generate('title') 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /tests/dummy/app/tests/factories/small-company.js: -------------------------------------------------------------------------------- 1 | import FactoryGuy from 'ember-data-factory-guy'; 2 | 3 | FactoryGuy.define("small-company", { 4 | extends: 'company', 5 | default: { 6 | type: 'SmallCompany', 7 | name: (f)=>`Small Corp${f.id}`, 8 | blob: (f)=> { return {num: f.id}; } 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /tests/dummy/app/tests/factories/user.js: -------------------------------------------------------------------------------- 1 | import FactoryGuy from 'ember-data-factory-guy'; 2 | 3 | FactoryGuy.define('user', { 4 | 5 | default: { 6 | style: 'normal', 7 | name: (f)=>`User${f.id}`, 8 | info: (f)=> { return {foo: f.id}; } 9 | }, 10 | 11 | traits: { 12 | empty: { 13 | style: null, 14 | name: null, 15 | info: null, 16 | blob: null, 17 | list: null, 18 | location: null, 19 | things: null, 20 | projects: [], 21 | pets: [] 22 | }, 23 | silly: { 24 | style: 'silly' 25 | }, 26 | withCompany: { 27 | company: {} 28 | }, 29 | withProfile: { 30 | profile: {} 31 | } 32 | } 33 | }); 34 | 35 | -------------------------------------------------------------------------------- /tests/dummy/config/environment.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = function(environment) { 3 | var ENV = { 4 | modulePrefix: 'dummy', 5 | environment: environment, 6 | rootURL: '/', 7 | locationType: 'auto', 8 | EmberENV: { 9 | FEATURES: { 10 | // Here you can enable experimental features on an ember canary build 11 | // e.g. 'with-controller': true 12 | }, 13 | EXTEND_PROTOTYPES: { 14 | // Prevent Ember Data from overriding Date.parse. 15 | Date: false 16 | } 17 | }, 18 | 19 | APP: { 20 | // Here you can pass flags/options to your application instance 21 | // when it is created 22 | } 23 | }; 24 | 25 | if (environment === 'development') { 26 | // ENV.APP.LOG_RESOLVER = true; 27 | // ENV.APP.LOG_ACTIVE_GENERATION = true; 28 | // ENV.APP.LOG_TRANSITIONS = true; 29 | // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; 30 | // ENV.APP.LOG_VIEW_LOOKUPS = true; 31 | } 32 | 33 | if (environment === 'test') { 34 | // Testem prefers this... 35 | ENV.locationType = 'none'; 36 | 37 | // keep test console output quieter 38 | ENV.APP.LOG_ACTIVE_GENERATION = false; 39 | ENV.APP.LOG_VIEW_LOOKUPS = false; 40 | 41 | ENV.APP.rootElement = '#ember-testing'; 42 | } 43 | 44 | if (environment === 'production') { 45 | 46 | } 47 | 48 | return ENV; 49 | }; 50 | -------------------------------------------------------------------------------- /tests/dummy/config/optional-features.json: -------------------------------------------------------------------------------- 1 | { 2 | "jquery-integration": false 3 | } 4 | -------------------------------------------------------------------------------- /tests/dummy/public/crossdomain.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 15 | 16 | -------------------------------------------------------------------------------- /tests/dummy/public/robots.txt: -------------------------------------------------------------------------------- 1 | # http://www.robotstxt.org 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /tests/helpers/destroy-app.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default function destroyApp(application) { 4 | Ember.run(application, 'destroy'); 5 | } 6 | -------------------------------------------------------------------------------- /tests/helpers/module-for-acceptance.js: -------------------------------------------------------------------------------- 1 | import { module } from 'qunit'; 2 | import Ember from 'ember'; 3 | import startApp from '../helpers/start-app'; 4 | import destroyApp from '../helpers/destroy-app'; 5 | 6 | const { RSVP: { Promise } } = Ember; 7 | 8 | export default function(name, options = {}) { 9 | module(name, { 10 | beforeEach() { 11 | this.application = startApp(); 12 | 13 | if (options.beforeEach) { 14 | return options.beforeEach.apply(this, arguments); 15 | } 16 | }, 17 | 18 | afterEach() { 19 | let afterEach = options.afterEach && options.afterEach.apply(this, arguments); 20 | return Promise.resolve(afterEach).then(() => destroyApp(this.application)); 21 | } 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /tests/helpers/resolver.js: -------------------------------------------------------------------------------- 1 | import Resolver from '../../resolver'; 2 | import config from '../../config/environment'; 3 | 4 | const resolver = Resolver.create(); 5 | 6 | resolver.namespace = { 7 | modulePrefix: config.modulePrefix, 8 | podModulePrefix: config.podModulePrefix 9 | }; 10 | 11 | export default resolver; 12 | -------------------------------------------------------------------------------- /tests/helpers/start-app.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import Application from '../../app'; 3 | import config from '../../config/environment'; 4 | 5 | export default function startApp(attrs) { 6 | let application; 7 | 8 | // use defaults, but you can override 9 | let attributes = Ember.assign({}, config.APP, attrs); 10 | 11 | Ember.run(() => { 12 | application = Application.create(attributes); 13 | application.setupForTesting(); 14 | application.injectTestHelpers(); 15 | }); 16 | 17 | return application; 18 | } 19 | -------------------------------------------------------------------------------- /tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dummy Tests 7 | 8 | 9 | 10 | {{content-for "head"}} 11 | {{content-for "test-head"}} 12 | 13 | 14 | 15 | 16 | 17 | {{content-for "head-footer"}} 18 | {{content-for "test-head-footer"}} 19 | 20 | 21 | {{content-for "body"}} 22 | {{content-for "test-body"}} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {{content-for "body-footer"}} 31 | {{content-for "test-body-footer"}} 32 | 33 | 34 | -------------------------------------------------------------------------------- /tests/integration/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielspaniel/ember-data-change-tracker/f7b1684efbf923f21509f3113030128361969b3d/tests/integration/.gitkeep -------------------------------------------------------------------------------- /tests/test-helper.js: -------------------------------------------------------------------------------- 1 | import resolver from './helpers/resolver'; 2 | import { setResolver } from '@ember/test-helpers'; 3 | import { start } from 'ember-cli-qunit'; 4 | 5 | setResolver(resolver); 6 | start(); 7 | -------------------------------------------------------------------------------- /tests/unit/model-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupTest } from 'ember-qunit'; 3 | import { run } from '@ember/runloop'; 4 | import FactoryGuy, { 5 | build, buildList, make, makeList, mockUpdate, mockFindRecord, mockReload, 6 | mockDelete, manualSetup, mockCreate, mock 7 | } from 'ember-data-factory-guy'; 8 | import { initializer as modelInitializer } from 'ember-data-change-tracker'; 9 | import Tracker, { ModelTrackerKey, RelationshipsKnownTrackerKey } from 'ember-data-change-tracker/tracker'; 10 | import sinon from 'sinon'; 11 | import EmberObject, { get, observer } from '@ember/object'; 12 | import { settled } from '@ember/test-helpers' 13 | modelInitializer(); 14 | 15 | const setModel = (model, attr, value) => { 16 | run(() => model.set(attr, value)); 17 | }; 18 | 19 | const assertMetaKey = (data, expectedType, expectedName, assert) => { 20 | assert.equal(data.type, expectedType); 21 | assert.equal(data.name, expectedName); 22 | assert.equal(typeof data.transform.serialize, 'function'); 23 | assert.equal(typeof data.transform.deserialize, 'function'); 24 | }; 25 | 26 | module('Unit | Model', function(hooks) { 27 | setupTest(hooks); 28 | 29 | hooks.beforeEach(function() { 30 | manualSetup(this); 31 | }); 32 | 33 | test('only sets up tracking meta data once on model type', async function(assert) { 34 | sinon.stub(Tracker, 'options').returns({auto: true}); 35 | let getTrackerInfo = sinon.stub(Tracker, 'getTrackerInfo').returns({autoSave: true}); 36 | 37 | let dog = make('dog'); 38 | await settled(); 39 | assert.ok(dog.constructor.alreadySetupTrackingMeta, 'auto save set up metaData'); 40 | 41 | Tracker.setupTracking(dog); // try and setup again 42 | dog.saveChanges(); // and again 43 | 44 | Tracker.getTrackerInfo.restore(); 45 | Tracker.options.restore(); 46 | 47 | assert.ok(getTrackerInfo.calledOnce); 48 | }); 49 | 50 | test('clears all saved keys on delete', async function(assert) { 51 | let user = make('user', {info: {d: 2}}); 52 | 53 | assert.ok(!!user.get(ModelTrackerKey)); 54 | assert.ok(!!user.get(RelationshipsKnownTrackerKey)); 55 | mockDelete(user); 56 | 57 | await run(async () => user.destroyRecord()); 58 | assert.ok(!user.get(ModelTrackerKey)); 59 | assert.ok(!user.get(RelationshipsKnownTrackerKey)); 60 | }); 61 | 62 | 63 | test('#setupTracking sets correct trackerKeys on constructor', function(assert) { 64 | let user = make('user'); 65 | let metaData = Tracker.metaInfo(user); 66 | 67 | assert.deepEqual(Object.keys(metaData), 'info blob company profile projects pets'.split(' ')); 68 | assertMetaKey(metaData.info, 'attribute', 'object', assert); 69 | assertMetaKey(metaData.company, 'belongsTo', undefined, assert); 70 | assertMetaKey(metaData.profile, 'belongsTo', undefined, assert); 71 | assertMetaKey(metaData.projects, 'hasMany', undefined, assert); 72 | assertMetaKey(metaData.pets, 'hasMany', undefined, assert); 73 | }); 74 | 75 | module('#saveChanges', function() { 76 | 77 | test('when model is ready on ajax load', async function(assert) { 78 | let info = {dude: 1}, 79 | company = make('company'), 80 | profile = make('profile'), 81 | projects = makeList('project', 2), 82 | pets = makeList('pet', 1); 83 | 84 | let json = build('user', { 85 | info, 86 | profile: profile.get('id'), 87 | company: {id: company.get('id'), type: 'company'}, 88 | projects, 89 | pets 90 | }); 91 | 92 | mockFindRecord('user').returns({json}); 93 | 94 | let user = await run(async () => FactoryGuy.store.find('user', json.get('id'))); 95 | assert.deepEqual(user.savedTrackerValue('info'), JSON.stringify(info)); 96 | assert.deepEqual(user.savedTrackerValue('company'), {id: company.id, type: 'company'}); 97 | assert.deepEqual(user.savedTrackerValue('profile'), profile.id); 98 | assert.deepEqual(user.savedTrackerValue('projects'), projects.map(v => v.id)); 99 | assert.deepEqual(user.savedTrackerValue('pets'), [{id: pets[0].id, type: 'pet'}]); 100 | }); 101 | 102 | test('when model is ready on model reload', async function(assert) { 103 | let info = {dude: 1}, 104 | company = make('company'), 105 | profile = make('profile'), 106 | projects = makeList('project', 2), 107 | pets = makeList('pet', 1); 108 | 109 | let user = make('user', { 110 | info, 111 | profile: profile.get('id'), 112 | company: {id: company.get('id'), type: 'company'}, 113 | projects, 114 | pets 115 | }); 116 | 117 | let info2 = {dude: 2}, 118 | company2 = make('company'), 119 | profile2 = make('profile'), 120 | projects2 = makeList('project', 2), 121 | pets2 = makeList('pet', 1); 122 | 123 | let newUserAttrs = build('user', { 124 | id: user.get('id'), 125 | info: info2, 126 | profile: profile2.get('id'), 127 | company: {id: company2.get('id'), type: 'company'}, 128 | projects: projects2, 129 | pets: pets2 130 | }); 131 | 132 | mockReload(user).returns({json: newUserAttrs}); 133 | 134 | await run(async () => user.reload()); 135 | assert.deepEqual(user.savedTrackerValue('info'), JSON.stringify(info2)); 136 | assert.deepEqual(user.savedTrackerValue('company'), {id: company2.id, type: 'company'}); 137 | assert.deepEqual(user.savedTrackerValue('profile'), profile2.id); 138 | assert.deepEqual(user.savedTrackerValue('projects'), projects2.map(v => v.id)); 139 | assert.deepEqual(user.savedTrackerValue('pets'), [{id: pets2[0].id, type: 'pet'}]); 140 | }); 141 | 142 | test('when model info is pushed to store', function(assert) { 143 | let company = make('company'), 144 | profile = make('profile'), 145 | projects = makeList('project', 1), 146 | pets = makeList('pet', 1), 147 | info = {dude: 1}; 148 | 149 | let userJson = build('user', { 150 | info, 151 | profile: profile.get('id'), 152 | company: {id: company.get('id'), type: 'company'}, 153 | projects, 154 | pets 155 | }); 156 | 157 | let normalized = Tracker.normalize(make('user'), userJson.get()); 158 | 159 | let user = run(() => FactoryGuy.store.push(normalized)); 160 | assert.deepEqual(user.savedTrackerValue('info'), JSON.stringify(info)); 161 | assert.deepEqual(user.savedTrackerValue('company'), {id: company.id, type: 'company'}); 162 | assert.deepEqual(user.savedTrackerValue('profile'), profile.id); 163 | assert.deepEqual(user.savedTrackerValue('projects'), projects.map(v => v.id)); 164 | assert.deepEqual(user.savedTrackerValue('pets'), [{id: pets[0].id, type: 'pet'}]); 165 | }); 166 | 167 | test('when model newly created', function(assert) { 168 | let company = make('company'), 169 | profile = make('profile'), 170 | projects = makeList('project', 1), 171 | pets = makeList('pet', 1), 172 | info = {dude: 1}, 173 | params = {info, profile, company, projects, pets}, 174 | user = run(() => FactoryGuy.store.createRecord('user', params)); 175 | 176 | assert.deepEqual(user.savedTrackerValue('info'), undefined, 'sets object attr to undefined'); 177 | assert.deepEqual(user.savedTrackerValue('company'), undefined, 'sets async belongsTo to undefined'); 178 | assert.deepEqual(user.savedTrackerValue('profile'), undefined, 'sets non async belongsTo to undefined'); 179 | assert.deepEqual(user.savedTrackerValue('projects'), undefined, 'sets async hasMany to undefined'); 180 | assert.deepEqual(user.savedTrackerValue('pets'), undefined, 'sets non async hasMany to undefined'); 181 | }); 182 | 183 | test('when newly created model is saved', async function(assert) { 184 | let info = {dude: 1}, 185 | params = {info}, 186 | user = run(() => FactoryGuy.store.createRecord('user', params)); 187 | 188 | mockCreate(user); 189 | assert.ok(user.get('isDirty'), 'object attribute causes model to be dirty'); 190 | await run(async () => user.save()); 191 | 192 | assert.notOk(user.get('isDirty'), 'resets isDirty'); 193 | 194 | assert.notOk(Object.keys(user.modelChanges()).length, 'clears model changes'); 195 | }); 196 | 197 | test('with except keys', function(assert) { 198 | let [company1, company2] = makeList('company', 2), 199 | startInfo = {e: 'Duck'}, 200 | newInfo = {f: 'Goose'}, 201 | user = make('user', {info: startInfo, company: company1}); 202 | 203 | assert.deepEqual(user.savedTrackerValue('info'), JSON.stringify(startInfo), 'saveTrackerValue for info to start'); 204 | assert.deepEqual(user.savedTrackerValue('company'), {id: company1.id, type: 'company'}, 'saveTrackerValue for company to start'); 205 | 206 | run(() => user.setProperties({info: newInfo, company: company2})); 207 | 208 | user.saveTrackerChanges({except: ['company']}); 209 | 210 | assert.deepEqual(user.savedTrackerValue('info'), JSON.stringify(newInfo), 'saveTrackerValue for info is updated'); 211 | assert.deepEqual(user.savedTrackerValue('company'), {id: company1.id, type: 'company'}, 'saveTrackerValue for company does not change'); 212 | 213 | assert.ok(user.get('isDirty'), 'user isDirty => true'); 214 | }); 215 | 216 | test('with observer on model.isDirty', async function(assert) { 217 | let [company1, company2] = makeList('company', 2), 218 | [detail1, detail2] = makeList('detail', 2), 219 | project = make('project', {details: [detail1], company: company1}); 220 | 221 | project.startTrack(); 222 | 223 | EmberObject.extend({ 224 | record: null, 225 | init() { 226 | this._super(...arguments); 227 | this.isDirtyObserver(); 228 | }, 229 | isDirtyObserver: observer('record.isDirty', function() { 230 | this.get('record.isDirty'); 231 | }), 232 | }).create({record: project}); 233 | 234 | run(() => project.setProperties({title: 'Blob in Space', company: company2})); 235 | project.get('details').addObject(detail2); 236 | 237 | mockUpdate(project); 238 | 239 | await run(async () => project.save()); 240 | assert.equal(project.get('isDirty'), false); 241 | assert.equal(project.get('hasDirtyAttributes'), false); 242 | assert.equal(project.get('hasDirtyRelations'), false); 243 | }); 244 | }); 245 | 246 | module('#didChange', function() { 247 | test('when setting properties on newly created model', function(assert) { 248 | let company = make('company'), 249 | profile = make('profile'), 250 | projects = makeList('project', 1), 251 | pets = makeList('pet', 1), 252 | info = {dude: 1}; 253 | 254 | let params = {info, profile, company, projects, pets}; 255 | let user = run(() => FactoryGuy.store.createRecord('user', params)); 256 | 257 | assert.ok(user.didChange('info')); 258 | assert.ok(user.didChange('company')); 259 | assert.ok(user.didChange('profile')); 260 | assert.ok(user.didChange('projects')); 261 | assert.ok(user.didChange('pets')); 262 | }); 263 | 264 | test('when replacing properties in existing model', function(assert) { 265 | let company = make('small-company'), 266 | projects = makeList('project', 2), 267 | pets = makeList('pet', 2), 268 | info = {dude: 1}; 269 | 270 | let tests = [ 271 | ['info', undefined, null, true], 272 | ['info', undefined, info, true], 273 | ['company', null, null, false], 274 | ['company', null, company, true], 275 | ['projects', [], [], false], 276 | ['projects', [], projects, true], 277 | ['pets', [], [], false], 278 | ['pets', [], pets, true], 279 | ]; 280 | 281 | for (let test of tests) { 282 | let [key, firstValue, nextValue, expected] = test; 283 | let user = make('user', {[key]: firstValue}); 284 | user.saveChanges(); 285 | setModel(user, key, nextValue); 286 | assert.equal(user.didChange(key), expected); 287 | } 288 | }); 289 | }); 290 | 291 | test('#save method resets changed if auto tracking', async function(assert) { 292 | let company = make('company'), 293 | info = {dude: 1}, 294 | projects = makeList('project', 2), 295 | noPets = [], 296 | pets = makeList('pet', 2), 297 | user = make('user', {company, info, projects, noPets}); 298 | 299 | // change relationships and attribute 300 | run(() => { 301 | user.set('company', null); 302 | user.set('projects', []); 303 | user.set('pets', pets); 304 | }); 305 | 306 | info.dude = 2; 307 | 308 | mockUpdate(user); 309 | 310 | await run(async () => user.save()); 311 | assert.ok(!user.modelChanges().info, 'clears changed info after save'); 312 | assert.ok(!user.modelChanges().company, 'clears changed company after save'); 313 | assert.ok(!user.modelChanges().projects, 'clears changed projects after save'); 314 | assert.ok(!user.modelChanges().pets, 'clears changed pets after save'); 315 | }); 316 | 317 | module('#modelChanges', function() { 318 | 319 | test('modifying attribute of type undefined', function(assert) { 320 | let blob = {foo: 1}, 321 | company = make('company', {blob}); 322 | 323 | company.startTrack(); 324 | 325 | blob.foo = 2; 326 | 327 | let changed = company.modelChanges().blob; 328 | assert.ok(changed); 329 | }); 330 | 331 | test('modifying attribute of type that does not serialize to string', function(assert) { 332 | let blob = {foo: 1}, 333 | user = make('user', {blob}); 334 | 335 | blob.foo = 2; 336 | 337 | let changed = user.modelChanges().blob; 338 | assert.ok(changed); 339 | }); 340 | 341 | test('modifying attribute of type "object"', function(assert) { 342 | let info = {dude: 1}, 343 | user = make('user', {info}); 344 | 345 | info.dude = 3; 346 | 347 | let changed = (user.modelChanges().info); 348 | assert.ok(changed); 349 | }); 350 | 351 | test('replacing attributes or associations', function(assert) { 352 | let company = make('small-company'), 353 | projects = makeList('project', 2), 354 | pets = makeList('pet', 2), 355 | [cat, dog] = pets, 356 | info = {dude: 1}; 357 | 358 | let tests = [ 359 | ['info', undefined, null, true, 'undefined to null for an object attribute is not a change'], 360 | ['info', undefined, info, true, 'add item for an object attribute is a change'], 361 | ['info', info, null, true, 'remove value from object attribute is a change'], 362 | ['company', null, null, false, 'no item still no item in a belongsTo is not a change'], 363 | ['company', null, company, true, 'add item in a belongsTo is a change'], 364 | ['company', company, null, true, 'remove item in a belongsTo is a change'], 365 | ['company', company, company, false, 'same item in a belongsTo is not a change'], 366 | ['projects', [], [], false, 'empty staying empty in a hasMany is not a change'], 367 | ['projects', [], projects, true, 'adding many to a hasMany is a change'], 368 | ['projects', projects, [], true, 'removing all from a hasMany is a change'], 369 | ['projects', projects, projects, false, 'same items in a hasMany is not a change'], 370 | ['pets', [], [], false, 'from none to none in a polymorphic hasMany is not a change'], 371 | ['pets', [], [cat, dog], true, 'adding many to a polymorphic hasMany is a change'], 372 | ['pets', [cat, dog], [], true, 'removing all from a polymorphichasMany is a change'], 373 | ['pets', [cat, dog], [cat], true, 'removing one from a polymorphic hasMany is a change'], 374 | ['pets', [cat], [cat, dog], true, 'adding to a polymorphic hasMany is a change'], 375 | ['pets', [dog, cat], [cat, dog], true, 'change to the order of polymorphic hasMany is a change'], 376 | ]; 377 | 378 | for (let test of tests) { 379 | let [key, firstValue, nextValue, expected, message] = test; 380 | let user = make('user', {[key]: firstValue}); 381 | 382 | setModel(user, key, nextValue); 383 | assert.equal(!!user.modelChanges()[key], expected, message); 384 | } 385 | }); 386 | 387 | test('loading lazy belongsTo association via Related Resource Links', async function (assert) { 388 | let json = build('project', { 389 | links: { 390 | company: "/projects/1/company" 391 | } 392 | }); 393 | 394 | mockFindRecord('project').returns({json}); 395 | 396 | let project = await run(async () => FactoryGuy.store.find('project', json.get('id'))); 397 | assert.notOk(project.modelChanges().company, 'company has not been loaded, should not be considered to be changed'); 398 | 399 | project.startTrack(); // Start tracking before the full relationship is loaded 400 | 401 | let companyJson = build('company', {id: '14', type: 'company', name: 'foo'}); 402 | 403 | mock({ 404 | type: 'GET', 405 | url: '/projects/1/company', 406 | responseText: companyJson 407 | }); 408 | 409 | await project.get('company'); 410 | 411 | assert.notOk(project.modelChanges().company, 'company has been loaded but not modified, should not be considered to be changed'); 412 | 413 | let company = make('small-company'); 414 | setModel(project, 'company', company); 415 | 416 | assert.ok(project.modelChanges().company, 'company has been modified'); 417 | }); 418 | 419 | test('loading lazy hasMany association via Related Resource Links', async function (assert) { 420 | let json = build('project', { 421 | links: { 422 | details: "/projects/1/details" 423 | } 424 | }); 425 | 426 | mockFindRecord('project').returns({json}); 427 | 428 | let project = await run(async () => FactoryGuy.store.find('project', json.get('id'))); 429 | assert.notOk(project.modelChanges().details, 'details have not been loaded, should not be considered to be changed'); 430 | 431 | project.startTrack(); // Start tracking before the full relationship is loaded 432 | 433 | let detailsJson = buildList('detail', 2); 434 | 435 | mock({ 436 | type: 'GET', 437 | url: '/projects/1/details', 438 | responseText: detailsJson 439 | }); 440 | 441 | await project.get('details'); 442 | 443 | assert.notOk(project.modelChanges().details, 'details has been loaded but not modified, should not be considered to be changed'); 444 | 445 | let detail = make('detail'); 446 | setModel(project, 'details', [detail]); 447 | 448 | assert.ok(project.modelChanges().details, 'details has been modified'); 449 | }); 450 | }); 451 | 452 | module('when using keepOnlyChanged mixin', function() { 453 | 454 | test("touched but unchanged relationships should not serialize", async function(assert) { 455 | let json = build('project', { 456 | company: { 457 | data: {id: '1', type: 'company'} 458 | } 459 | }); 460 | 461 | delete json.included; // We don't want to sideload (create) the company 462 | 463 | mockFindRecord('project').returns({json}); 464 | 465 | let project = await run(async () => FactoryGuy.store.find('project', json.get('id'))); 466 | assert.equal(project.belongsTo('company').value(), null, 'relationship should not be loaded'); 467 | assert.equal(project.belongsTo('company').id(), '1', 'relationship record id should be set through linkage'); 468 | 469 | project.startTrack(); // Start tracking before the full relationship is loaded 470 | 471 | let companyJson = build('company', {id: '1', type: 'company', name: 'foo'}); 472 | run(() => FactoryGuy.store.pushPayload('company', companyJson)); 473 | 474 | mockFindRecord('company').returns({json}); 475 | 476 | let company = await run(async () => FactoryGuy.store.find('company', '1')); 477 | assert.equal(project.belongsTo('company').id(), company.get('id'), 'ids should match'); 478 | 479 | project.setProperties({company, title: 'test'}); 480 | 481 | assert.equal(project.belongsTo('company').id(), company.get('id'), 'ids should still match'); 482 | 483 | let {data} = project.serialize(); 484 | let relationships = data.relationships || {}; 485 | let attributes = data.attributes || {}; 486 | 487 | assert.ok(relationships.company == null, 'unchanged relationship should not be serialized'); 488 | assert.ok(attributes.title != null, 'changed attribute should be serialized'); 489 | }); 490 | 491 | test('when', function(assert) { 492 | let company = make('company'); 493 | let details = makeList('detail', 2); 494 | let blob = {dude: 1}; 495 | let project = make('project'); 496 | 497 | let tests = [ 498 | ['attributes', 'blob', null, true, 'undefined to null attribute is a change ( according to ember-data )'], 499 | ['attributes', 'blob', blob, true, 'replace attribute'], 500 | ['relationships', 'company', null, false, 'undefined to null belongsTo is NOT a change'], 501 | ['relationships', 'company', company, true, 'change belongsTo'], 502 | ['relationships', 'details', [], false, 'undefined to empty array hasMany is not a change'], 503 | ['relationships', 'details', details, true, 'change hasMany'] 504 | ]; 505 | 506 | for (let test of tests) { 507 | let [category, key, value, expected, message] = test; 508 | project.startTrack(); 509 | setModel(project, key, value); 510 | let attributes = project.serialize(); 511 | let data = attributes.data[category]; 512 | 513 | assert.equal(!!(data && data.hasOwnProperty(key)), expected, message); 514 | } 515 | }); 516 | }); 517 | 518 | module('#rollback', function() { 519 | 520 | test('from things to different things', function(assert) { 521 | run(() => { 522 | let info = {foo: 1}, 523 | blob = {dude: 'A'}, 524 | 525 | profile = make('profile'), 526 | profile2 = make('profile'), 527 | 528 | projects = makeList('project', 2), 529 | [project1] = projects, 530 | 531 | pets = makeList('cat', 4), 532 | [cat, cat2] = pets, 533 | 534 | company = make('big-company'), 535 | company2 = make('small-company'), 536 | 537 | list = [1, 2, 3, 4], 538 | location = build('location', {place: 'home'}).get(); 539 | 540 | let user = make('user', { 541 | info, 542 | blob, 543 | list, 544 | location, 545 | profile, 546 | company, 547 | pets, 548 | projects 549 | }); 550 | 551 | let savedUser = user.serialize(); 552 | // blob is speacial because it's serializer (json) does not stringify 553 | delete savedUser.data.attributes.blob; 554 | 555 | console.time('track'); 556 | 557 | user.startTrack(); 558 | 559 | user.setProperties({ 560 | 'info.foo': 3, 561 | 'blob.dude': 'B', 562 | 'location.place': 'zanzibar', 563 | company: company2, 564 | profile: profile2, 565 | projects: [project1], 566 | pets: [cat, cat2] 567 | }); 568 | 569 | user.get('list').addObject(5); 570 | 571 | user.rollback(); 572 | 573 | console.timeEnd('track'); 574 | 575 | let afterRollbackUser = user.serialize(); 576 | delete afterRollbackUser.data.attributes.blob; 577 | 578 | assert.equal(user.get('currentState.stateName'), 'root.loaded.saved'); 579 | assert.deepEqual(savedUser, afterRollbackUser); 580 | assert.deepEqual(user.get('blob'), {dude: 'A'}); 581 | }); 582 | }); 583 | 584 | test('hasMany to empty', function(assert) { 585 | 586 | let projects = makeList('project', 3), 587 | pets = makeList('cat', 4), 588 | user = make('user', 'empty'); 589 | 590 | console.time('track'); 591 | 592 | user.startTrack(); 593 | 594 | run(() => user.setProperties({projects, pets})); 595 | 596 | run(() => user.rollback()); 597 | 598 | console.timeEnd('track'); 599 | 600 | assert.equal(user.get('currentState.stateName'), 'root.loaded.saved'); 601 | assert.deepEqual(user.get('projects').mapBy('id'), []); 602 | assert.deepEqual(user.get('pets').mapBy('id'), []); 603 | }); 604 | 605 | test('hasMany when have at least one and add some more', function(assert) { 606 | let [project1, project2] = makeList('project', 2), 607 | [pet1, pet2] = makeList('cat', 2), 608 | 609 | user = make('user', 'empty', {pets: [pet1], projects: [project1]}); 610 | 611 | console.time('track'); 612 | 613 | user.startTrack(); 614 | 615 | user.get('projects').addObject(project2); 616 | user.get('pets').addObject(pet2); 617 | 618 | run(() => user.rollback()); 619 | 620 | console.timeEnd('track'); 621 | 622 | assert.equal(user.get('currentState.stateName'), 'root.loaded.saved'); 623 | assert.deepEqual(user.get('projects').mapBy('id'), [project1.id]); 624 | assert.deepEqual(user.get('pets').mapBy('id'), [pet1.id]); 625 | }); 626 | 627 | test('value for object / array type attribute', function(assert) { 628 | let blob = [1, 2, 3], 629 | company = make('company', {blob}); 630 | 631 | company.startTrack(); 632 | 633 | company.get('blob').push('4'); 634 | 635 | run(() => company.rollback()); 636 | 637 | assert.equal(company.get('currentState.stateName'), 'root.loaded.saved'); 638 | assert.deepEqual(company.get('blob'), [1, 2, 3]); 639 | }); 640 | }); 641 | 642 | module('#isDirty', function() { 643 | 644 | test('is available when the option enableIsDirty is true', function(assert) { 645 | let company = make('company'), 646 | user = make('user', 'empty'); 647 | 648 | assert.equal(get(user, 'isDirty'), false); 649 | assert.equal(get(company, 'isDirty'), undefined); 650 | }); 651 | 652 | module('with auto save model', function() { 653 | 654 | test('when changing standard attributes', function(assert) { 655 | let user = make('user', 'empty'); 656 | 657 | assert.equal(user.get('isDirty'), false); 658 | assert.equal(user.get('hasDirtyAttributes'), false); 659 | 660 | run(() => user.set('name', 'Michael')); 661 | 662 | assert.equal(user.get('isDirty'), true); 663 | assert.equal(user.get('hasDirtyAttributes'), true); 664 | }); 665 | 666 | test('when changing belongsTo relationship', function(assert) { 667 | let [profile1, profile2] = makeList('profile', 2), 668 | user = make('user', 'empty', {profile: profile1}); 669 | 670 | assert.equal(user.get('isDirty'), false); 671 | assert.equal(user.get('hasDirtyRelations'), false); 672 | 673 | run(() => user.set('profile', profile2)); 674 | 675 | assert.equal(user.get('isDirty'), true); 676 | assert.equal(user.get('hasDirtyRelations'), true); 677 | 678 | }); 679 | 680 | test('when removing belongsTo (async false) relationship', function(assert) { 681 | let [profile1] = makeList('profile', 1), 682 | user = make('user', 'empty', {profile: profile1}); 683 | 684 | assert.equal(user.get('isDirty'), false); 685 | assert.equal(user.get('hasDirtyRelations'), false); 686 | 687 | run(() => user.set('profile', null)); 688 | 689 | assert.equal(user.get('isDirty'), true); 690 | assert.equal(user.get('hasDirtyRelations'), true); 691 | }); 692 | 693 | test('when removing belongsTo (async true) relationship', function(assert) { 694 | let [company1] = makeList('company', 1), 695 | user = make('user', 'empty', {company: company1}); 696 | 697 | assert.equal(user.get('isDirty'), false); 698 | assert.equal(user.get('hasDirtyRelations'), false); 699 | 700 | run(() => user.set('company', null)); 701 | 702 | assert.equal(user.get('isDirty'), true); 703 | assert.equal(user.get('hasDirtyRelations'), true); 704 | }); 705 | 706 | test('when adding to hasMany relationship', function(assert) { 707 | let [project1, project2] = makeList('project', 2), 708 | user = make('user', 'empty', {projects: [project1]}); 709 | 710 | assert.equal(user.get('isDirty'), false); 711 | assert.equal(user.get('hasDirtyRelations'), false); 712 | 713 | user.get('projects').addObject(project2); 714 | 715 | assert.equal(user.get('isDirty'), true); 716 | assert.equal(user.get('hasDirtyRelations'), true); 717 | }); 718 | 719 | test('when removing from hasMany (async true) relationship', function(assert) { 720 | let [project1, project2] = makeList('project', 2); 721 | 722 | let user = make('user', 'empty', {projects: [project1, project2]}); 723 | 724 | assert.equal(user.get('isDirty'), false); 725 | assert.equal(user.get('hasDirtyRelations'), false); 726 | 727 | user.get('projects').removeObject(project2); 728 | 729 | assert.equal(user.get('isDirty'), true); 730 | assert.equal(user.get('hasDirtyRelations'), true); 731 | }); 732 | 733 | test('when removing from hasMany (async false) relationship', function(assert) { 734 | let [pet1, pet2] = makeList('pet', 2); 735 | 736 | let user = make('user', 'empty', {pets: [pet1, pet2]}); 737 | 738 | assert.equal(user.get('isDirty'), false); 739 | assert.equal(user.get('hasDirtyRelations'), false); 740 | 741 | run(() => user.get('pets').removeObject(pet2)); 742 | 743 | assert.equal(user.get('isDirty'), true); 744 | assert.equal(user.get('hasDirtyRelations'), true); 745 | }); 746 | 747 | test('resets on rollback', function(assert) { 748 | let [project1, project2] = makeList('project', 2), 749 | [profile1, profile2] = makeList('profile', 2), 750 | user = make('user', 'empty', {profile: profile1, projects: [project1]}); 751 | 752 | run(() => user.setProperties({name: 'Michael', profile: profile2})); 753 | user.get('projects').addObject(project2); 754 | 755 | user.rollback(); 756 | 757 | assert.equal(user.get('isDirty'), false); 758 | assert.equal(user.get('hasDirtyAttributes'), false); 759 | assert.equal(user.get('hasDirtyRelations'), false); 760 | }); 761 | 762 | test('resets on ajax update', async function(assert) { 763 | let [project1, project2] = makeList('project', 2), 764 | [profile1, profile2] = makeList('profile', 2), 765 | user = make('user', 'empty', {profile: profile1, projects: [project1]}); 766 | 767 | run(() => user.setProperties({name: 'Michael', profile: profile2})); 768 | user.get('projects').addObject(project2); 769 | 770 | assert.equal(user.get('isDirty'), true); 771 | 772 | mockUpdate(user); 773 | 774 | await run(async () => user.save()); 775 | assert.equal(user.get('isDirty'), false); 776 | assert.equal(user.get('hasDirtyAttributes'), false); 777 | assert.equal(user.get('hasDirtyRelations'), false); 778 | }); 779 | 780 | }); 781 | 782 | module('with NON auto save model', function() { 783 | 784 | test('when changing attributes or relationships', function(assert) { 785 | 786 | let [company1, company2] = makeList('company', 2), 787 | [detail1, detail2] = makeList('detail', 2), 788 | project = make('project', {details: [detail1], company: company1}); 789 | 790 | project.startTrack(); 791 | 792 | assert.equal(project.get('isDirty'), false); 793 | assert.equal(project.get('hasDirtyAttributes'), false); 794 | assert.equal(project.get('hasDirtyRelations'), false); 795 | 796 | run(() => project.setProperties({title: 'Blob in Space', company: company2})); 797 | project.get('details').addObject(detail2); 798 | 799 | assert.equal(project.get('isDirty'), true); 800 | assert.equal(project.get('hasDirtyAttributes'), true); 801 | assert.equal(project.get('hasDirtyRelations'), true); 802 | }); 803 | 804 | test('resets on rollback', function(assert) { 805 | let [company1, company2] = makeList('company', 2), 806 | [detail1, detail2] = makeList('detail', 2), 807 | project = make('project', {details: [detail1], company: company1}); 808 | 809 | project.startTrack(); 810 | 811 | run(() => project.setProperties({title: 'Blob in Space', company: company2})); 812 | project.get('details').addObject(detail2); 813 | 814 | run(() => project.rollback()); 815 | 816 | assert.equal(project.get('isDirty'), false); 817 | assert.equal(project.get('hasDirtyAttributes'), false); 818 | assert.equal(project.get('hasDirtyRelations'), false); 819 | }); 820 | 821 | test('resets on update', async function(assert) { 822 | let [company1, company2] = makeList('company', 2), 823 | [detail1, detail2] = makeList('detail', 2), 824 | project = make('project', {details: [detail1], company: company1}); 825 | 826 | project.startTrack(); 827 | 828 | run(() => project.setProperties({title: 'Blob in Space', company: company2})); 829 | project.get('details').addObject(detail2); 830 | 831 | mockUpdate(project); 832 | 833 | await run(async () => project.save()); 834 | assert.equal(project.get('isDirty'), false); 835 | assert.equal(project.get('hasDirtyAttributes'), false); 836 | assert.equal(project.get('hasDirtyRelations'), false); 837 | }); 838 | }); 839 | }); 840 | }); 841 | -------------------------------------------------------------------------------- /tests/unit/tracker-test.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import Tracker from 'ember-data-change-tracker/tracker'; 3 | import FactoryGuy, { make, makeList, manualSetup } from 'ember-data-factory-guy'; 4 | import { initializer as modelInitializer } from 'ember-data-change-tracker'; 5 | import { module, test } from 'qunit'; 6 | import { setupTest } from 'ember-qunit'; 7 | import sinon from 'sinon'; 8 | 9 | const {A} = Ember; 10 | const {w} = Ember.String; 11 | modelInitializer(); 12 | 13 | module('Unit | Tracker', function(hooks) { 14 | setupTest(hooks); 15 | 16 | hooks.beforeEach(function() { 17 | manualSetup(this); 18 | }); 19 | 20 | test('#envConfig returns the config for the application environment', function(assert) { 21 | let company = make('company'); 22 | assert.deepEqual(Tracker.envConfig(company), {}); 23 | }); 24 | 25 | module('#options ', function() { 26 | test('with valid options', function(assert) { 27 | let company = make('company'); 28 | let envConfig = sinon.stub(Tracker, 'envConfig'); 29 | let modelConfig = sinon.stub(Tracker, 'modelConfig'); 30 | 31 | let tests = [ 32 | [{}, {}, {trackHasMany: true, auto: false, enableIsDirty: false}], 33 | [{trackHasMany: false}, {}, {trackHasMany: false, auto: false, enableIsDirty: false}], 34 | [{trackHasMany: true}, {only: ['info']}, {auto: false, trackHasMany: true, only: ['info'], enableIsDirty: false}], 35 | [{}, {only: ['info']}, {only: ['info'], auto: false, trackHasMany: true, enableIsDirty: false}], 36 | [{}, {except: ['info']}, {except: ['info'], auto: false, trackHasMany: true, enableIsDirty: false}], 37 | [{enableIsDirty: true}, {except: ['info']}, {except: ['info'], auto: false, trackHasMany: true, enableIsDirty: true}], 38 | [{}, {enableIsDirty: true}, {auto: false, trackHasMany: true, enableIsDirty: true}], 39 | ]; 40 | 41 | for (let test of tests) { 42 | let [envOpts, modelOpts, expectedOptions] = test; 43 | envConfig.returns(envOpts); 44 | modelConfig.returns(modelOpts); 45 | assert.deepEqual(Tracker.options(company), expectedOptions); 46 | } 47 | 48 | Tracker.envConfig.restore(); 49 | Tracker.modelConfig.restore(); 50 | }); 51 | 52 | test('with invalid options', function(assert) { 53 | let company = make('company'); 54 | 55 | company.set('changeTracker', {dude: "where's my car"}); 56 | assert.throws(() => Tracker.options(company), `[ember-data-change-tracker] 57 | changeTracker options can have 'only', 'except' , 'auto', or 58 | 'trackHasMany' but you are declaring: dude`); 59 | }); 60 | }); 61 | 62 | test('#getTrackerKeys', function(assert) { 63 | let user = make('user'); 64 | let envConfig = sinon.stub(Tracker, 'envConfig'); 65 | let modelConfig = sinon.stub(Tracker, 'modelConfig'); 66 | 67 | let tests = [ 68 | [{}, {}, 'info blob company profile projects pets'], 69 | [{trackHasMany: false}, {}, 'info blob company profile'], 70 | [{trackHasMany: true}, {only: ['info']}, 'info'], 71 | [{}, {only: ['info']}, 'info'], 72 | [{}, {except: ['info']}, 'blob company profile projects pets'], 73 | [{auto: true}, {}, 'info blob company profile projects pets', true], 74 | [{enableIsDirty: true}, {}, 'info blob company profile projects pets', false, true], 75 | ]; 76 | 77 | for (let test of tests) { 78 | let [ 79 | envOpts, 80 | modelOpts, 81 | expectedKeys, 82 | expectedAutoSave = false, 83 | expectedEnableIsDirty = false 84 | ] = test; 85 | envConfig.returns(envOpts); 86 | modelConfig.returns(modelOpts); 87 | let info = Tracker.getTrackerInfo(user); 88 | assert.deepEqual(Object.keys(info.keyMeta), w(expectedKeys), 'has correct keys'); 89 | assert.equal(info.autoSave, expectedAutoSave, 'auto save setting'); 90 | assert.equal(info.enableIsDirty, expectedEnableIsDirty, 'enableIsDirty setting'); 91 | } 92 | 93 | Tracker.envConfig.restore(); 94 | Tracker.modelConfig.restore(); 95 | }); 96 | 97 | test('#serialize', function(assert) { 98 | let envConfig = sinon.stub(Tracker, 'envConfig'); 99 | envConfig.returns({trackHasMany: true, auto: true}); 100 | 101 | let company = make('company'); 102 | let profile = make('profile'); 103 | let projects = makeList('project', 2); 104 | let pets = makeList('pet', 2); 105 | 106 | let tests = [ 107 | ['info', null, "null"], 108 | ['info', {dude: 1}, '{"dude":1}'], 109 | ['profile', null, null], 110 | ['profile', profile, profile.id], 111 | ['company', null, null], 112 | ['company', company, {id: company.id, type: company.constructor.modelName}], 113 | ['projects', undefined, []], 114 | ['projects', projects, A(projects).mapBy('id')], 115 | ['pets', pets, pets.map((p) => ({id: p.id, type: p.constructor.modelName}))], 116 | ]; 117 | 118 | for (let test of tests) { 119 | let [key, value, expectedSerialized] = test; 120 | let user = make('user', {[key]: value}); 121 | 122 | let serializedValue = Tracker.serialize(user, key); 123 | assert.deepEqual(serializedValue, expectedSerialized); 124 | } 125 | Tracker.envConfig.restore(); 126 | }); 127 | 128 | test('#isKnown', function(assert) { 129 | let envConfig = sinon.stub(Tracker, 'envConfig'); 130 | envConfig.returns({trackHasMany: true, auto: true}); 131 | 132 | let company = make('company'); 133 | let profile = make('profile'); 134 | let projects = makeList('project', 2); 135 | let pets = makeList('pet', 2); 136 | 137 | let tests = [ 138 | ['info', null, true], 139 | ['info', {dude: 1}, true], 140 | ['profile', null, true], 141 | ['profile', profile, true], 142 | ['company', null, true], 143 | ['company', company, true], 144 | ['projects', undefined, true], 145 | ['projects', projects, true], 146 | ['pets', pets, true], 147 | ]; 148 | 149 | for (let test of tests) { 150 | let [key, value, expectedToBeKnown] = test; 151 | let user = make('user', {[key]: value}); 152 | 153 | let known = Tracker.isKnown(user, key); 154 | assert.equal(known, expectedToBeKnown); 155 | } 156 | Tracker.envConfig.restore(); 157 | }); 158 | }); 159 | 160 | module('Unit | performance', function(hooks){ 161 | setupTest(hooks); 162 | 163 | hooks.beforeEach(function() { 164 | manualSetup(this); 165 | }); 166 | 167 | const instanceCount = 200; 168 | const iterations = 10; 169 | 170 | // This approach has limitations. Real world use cases are exponentially slower, however this is of use for baselines. 171 | test('performance check', function(assert) { 172 | const untrackedTimes = [], 173 | trackedTimes = []; 174 | 175 | for (let i = 0; i < iterations; i++) { 176 | let t1 = performance.now(); 177 | makeList('perf-model-untracked', instanceCount); 178 | let t2 = performance.now(); 179 | makeList('perf-model-tracked', instanceCount); 180 | let t3 = performance.now(); 181 | 182 | untrackedTimes.push(Math.round(t2-t1)) 183 | trackedTimes.push(Math.round(t3-t2)) 184 | } 185 | const sum = arr => arr.reduce((a,b) => a+b , 0) 186 | 187 | assert.ok(true, `Average Untracked ${iterations} @ ${instanceCount} :: ${sum(untrackedTimes)/untrackedTimes.length} per iteration`) 188 | assert.ok(true, `Average Tracked ${iterations} @ ${instanceCount} :: ${sum(trackedTimes)/trackedTimes.length} per iteration`) 189 | }); 190 | }) 191 | 192 | module('Unit | previously unloaded model test', function(hooks) { 193 | setupTest(hooks); 194 | 195 | hooks.beforeEach(function() { 196 | manualSetup(this); 197 | }); 198 | //TODO: forgot why this is here .. try and remember 199 | test('transformFn when creating new record', function(assert) { 200 | Ember.run(() => FactoryGuy.store.createRecord('user')); 201 | assert.ok(true); 202 | }); 203 | }); 204 | 205 | -------------------------------------------------------------------------------- /tests/unit/utilities-test.js: -------------------------------------------------------------------------------- 1 | import {didModelChange, didModelsChange} from 'ember-data-change-tracker/utilities'; 2 | import {test, module} from 'ember-qunit'; 3 | 4 | module('Unit | Utilities'); 5 | 6 | test('#didModelChange', function(assert) { 7 | 8 | let tests = [ 9 | [null, null, false, false, 'two nulls'], 10 | [null, 1, false, true, 'null and 0'], 11 | [1, 10, false, true, 'different numbers'], 12 | [null, 1, false, true, 'null and 1'], 13 | [null, { id: 1, type: 'user' }, true, true, 'null and object'], 14 | [{ id: 1, type: 'user' }, null, true, true, 'object and null'], 15 | [{ id: 1, type: 'user' }, { id: 2, type: 'user' }, true, true, 'different model objects'], 16 | ]; 17 | 18 | for (let test of tests) { 19 | let [value1, value2, polymorphic, expected, message] = test; 20 | let result = didModelChange(value1, value2, polymorphic); 21 | assert.equal(result, expected, message); 22 | } 23 | }); 24 | 25 | test('#didModelsChange', function(assert) { 26 | 27 | let tests = [ 28 | [null, [], false, false, 'null and []'], 29 | [null, [0], false, true, 'null and [0]'], 30 | [[1], [10], false, true, 'different numbers'], 31 | [null, [1], false, true, 'null and [1]'], 32 | [null, [{ id: 1, type: 'user' }], true, true, 'null and [object]'], 33 | [[{ id: 1, type: 'user' }], null, true, true, '[object] and null'], 34 | [[{ id: 1, type: 'user' }], [{ id: 2, type: 'user' }], true, true, 'different model objects'], 35 | ]; 36 | 37 | for (let test of tests) { 38 | let [value1, value2, polymorphic, expected, message] = test; 39 | let result = didModelsChange(value1, value2, polymorphic); 40 | assert.equal(result, expected, message); 41 | } 42 | }); 43 | -------------------------------------------------------------------------------- /vendor/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielspaniel/ember-data-change-tracker/f7b1684efbf923f21509f3113030128361969b3d/vendor/.gitkeep --------------------------------------------------------------------------------