├── vendor
└── .gitkeep
├── tests
├── integration
│ └── .gitkeep
├── dummy
│ ├── app
│ │ ├── helpers
│ │ │ └── .gitkeep
│ │ ├── routes
│ │ │ └── .gitkeep
│ │ ├── styles
│ │ │ └── app.css
│ │ ├── components
│ │ │ └── .gitkeep
│ │ ├── controllers
│ │ │ └── .gitkeep
│ │ ├── templates
│ │ │ └── components
│ │ │ │ └── .gitkeep
│ │ ├── models
│ │ │ ├── cat.js
│ │ │ ├── dog.js
│ │ │ ├── big-company.js
│ │ │ ├── small-company.js
│ │ │ ├── location.js
│ │ │ ├── pet.js
│ │ │ ├── profile.js
│ │ │ ├── company.js
│ │ │ ├── detail.js
│ │ │ ├── project.js
│ │ │ ├── user.js
│ │ │ ├── perf-model-tracked.js
│ │ │ └── perf-model-untracked.js
│ │ ├── resolver.js
│ │ ├── serializers
│ │ │ ├── user.js
│ │ │ └── project.js
│ │ ├── tests
│ │ │ └── factories
│ │ │ │ ├── cat.js
│ │ │ │ ├── dog.js
│ │ │ │ ├── pet.js
│ │ │ │ ├── detail.js
│ │ │ │ ├── location.js
│ │ │ │ ├── perf-model-untracked.js
│ │ │ │ ├── big-company.js
│ │ │ │ ├── small-company.js
│ │ │ │ ├── profile.js
│ │ │ │ ├── project.js
│ │ │ │ ├── company.js
│ │ │ │ ├── user.js
│ │ │ │ └── perf-model-tracked.js
│ │ ├── adapters
│ │ │ └── application.js
│ │ ├── router.js
│ │ ├── app.js
│ │ └── index.html
│ ├── config
│ │ ├── optional-features.json
│ │ └── environment.js
│ └── public
│ │ ├── robots.txt
│ │ └── crossdomain.xml
├── .eslintrc.js
├── helpers
│ ├── destroy-app.js
│ ├── resolver.js
│ ├── start-app.js
│ └── module-for-acceptance.js
├── test-helper.js
├── index.html
└── unit
│ ├── utilities-test.js
│ ├── tracker-test.js
│ └── model-test.js
├── .watchmanconfig
├── app
├── transforms
│ ├── json.js
│ └── object.js
├── mixins
│ └── change-serializer.js
└── initializers
│ └── ember-data-change-tracker.js
├── index.js
├── addon
├── initializer.js
├── index.js
├── transforms
│ ├── object.js
│ └── json.js
├── mixins
│ └── keep-only-changed.js
├── utilities.js
├── model-ext.js
└── tracker.js
├── config
├── environment.js
└── ember-try.js
├── .npmignore
├── .eslintrc.js
├── .ember-cli
├── .gitignore
├── .editorconfig
├── testem.js
├── ember-cli-build.js
├── .travis.yml
├── LICENSE.md
├── package.json
└── README.md
/vendor/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/integration/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/dummy/app/helpers/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/dummy/app/routes/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/dummy/app/styles/app.css:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/dummy/app/components/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/dummy/app/controllers/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/dummy/app/templates/components/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.watchmanconfig:
--------------------------------------------------------------------------------
1 | {
2 | "ignore_dirs": ["tmp", "dist"]
3 | }
4 |
--------------------------------------------------------------------------------
/tests/dummy/config/optional-features.json:
--------------------------------------------------------------------------------
1 | {
2 | "jquery-integration": false
3 | }
4 |
--------------------------------------------------------------------------------
/tests/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | embertest: true
4 | }
5 | };
6 |
--------------------------------------------------------------------------------
/tests/dummy/public/robots.txt:
--------------------------------------------------------------------------------
1 | # http://www.robotstxt.org
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/app/transforms/json.js:
--------------------------------------------------------------------------------
1 | export { default } from 'ember-data-change-tracker/transforms/json';
2 |
--------------------------------------------------------------------------------
/tests/dummy/app/models/cat.js:
--------------------------------------------------------------------------------
1 | import Pet from './pet';
2 |
3 | export default Pet.extend();
4 |
--------------------------------------------------------------------------------
/tests/dummy/app/models/dog.js:
--------------------------------------------------------------------------------
1 | import Pet from './pet';
2 |
3 | export default Pet.extend();
4 |
--------------------------------------------------------------------------------
/app/transforms/object.js:
--------------------------------------------------------------------------------
1 | export { default } from 'ember-data-change-tracker/transforms/object';
2 |
--------------------------------------------------------------------------------
/tests/dummy/app/resolver.js:
--------------------------------------------------------------------------------
1 | import Resolver from 'ember-resolver';
2 |
3 | export default Resolver;
4 |
--------------------------------------------------------------------------------
/app/mixins/change-serializer.js:
--------------------------------------------------------------------------------
1 | export { default } from 'ember-data-change-tracker/mixins/keep-only-changed';
2 |
--------------------------------------------------------------------------------
/tests/dummy/app/models/big-company.js:
--------------------------------------------------------------------------------
1 | import Company from './company';
2 |
3 | export default Company.extend();
4 |
--------------------------------------------------------------------------------
/tests/dummy/app/models/small-company.js:
--------------------------------------------------------------------------------
1 | import Company from './company';
2 |
3 | export default Company.extend();
4 |
--------------------------------------------------------------------------------
/tests/dummy/app/serializers/user.js:
--------------------------------------------------------------------------------
1 | import DS from 'ember-data';
2 |
3 | export default DS.JSONAPISerializer.extend();
4 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | /* eslint-env node */
2 | 'use strict';
3 |
4 | module.exports = {
5 | name: 'ember-data-change-tracker'
6 | };
7 |
--------------------------------------------------------------------------------
/addon/initializer.js:
--------------------------------------------------------------------------------
1 | /* global require */
2 | export function initializer() {
3 | require('ember-data-change-tracker/model-ext');
4 | }
--------------------------------------------------------------------------------
/config/environment.js:
--------------------------------------------------------------------------------
1 | /*jshint node:true*/
2 | 'use strict';
3 |
4 | module.exports = function(/* environment, appConfig */) {
5 | return { };
6 | };
7 |
--------------------------------------------------------------------------------
/addon/index.js:
--------------------------------------------------------------------------------
1 | import {initializer} from './initializer';
2 | import keepOnlyChanged from './mixins/keep-only-changed';
3 |
4 | export {keepOnlyChanged, initializer};
--------------------------------------------------------------------------------
/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/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/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | });
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/tests/dummy/public/crossdomain.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
15 |
16 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | });
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ember-data-change-tracker
2 |
3 | [](http://travis-ci.org/danielspaniel/ember-data-change-tracker) [](http://emberobserver.com/addons/ember-data-change-tracker) [](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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------