From 92d9c11a7e48d4397d5df71d2b1147c7e1c7180a Mon Sep 17 00:00:00 2001 From: offirgolan Date: Mon, 12 Sep 2016 16:32:04 -0700 Subject: [PATCH 1/8] [FEATURE] Two-way sync between rows and model --- addon/-private/global-options.js | 14 +++++ addon/-private/sync-array-proxy.js | 89 +++++++++++++++++++++++++++ addon/classes/Table.js | 50 +++++++++++++-- package.json | 1 + tests/dummy/app/controllers/table.js | 15 +++-- tests/dummy/app/routes/table-route.js | 4 +- tests/unit/classes/table-test.js | 65 +++++++++++++++++++ 7 files changed, 226 insertions(+), 12 deletions(-) create mode 100644 addon/-private/global-options.js create mode 100644 addon/-private/sync-array-proxy.js diff --git a/addon/-private/global-options.js b/addon/-private/global-options.js new file mode 100644 index 00000000..1ac791a7 --- /dev/null +++ b/addon/-private/global-options.js @@ -0,0 +1,14 @@ +import Ember from 'ember'; +import config from 'ember-get-config'; + +const { + assign +} = Ember; + +const globalOptions = config['ember-light-table'] || {}; + +export default globalOptions; + +export function mergeOptionsWithGlobals(options) { + return assign(assign({}, globalOptions, options)); +} diff --git a/addon/-private/sync-array-proxy.js b/addon/-private/sync-array-proxy.js new file mode 100644 index 00000000..d2d7cb23 --- /dev/null +++ b/addon/-private/sync-array-proxy.js @@ -0,0 +1,89 @@ +import Ember from 'ember'; + +const { + assert, + isArray +} = Ember; + +export default Ember.ArrayProxy.extend({ + /** + * The model that will be synchronized to the content of this proxy + * @property syncArray + * @type {Array} + */ + syncArray: null, + + /** + * @property syncEnabled + * @type {Boolean} + */ + syncEnabled: true, + + init() { + this._super(...arguments); + + let syncArray = this.get('syncArray'); + + assert('[ember-light-table] enableSync requires the passed array to be an instance of Ember.A', isArray(syncArray) && typeof syncArray.addArrayObserver === 'function'); + + syncArray.addArrayObserver(this, { + willChange: 'syncArrayWillChange', + didChange: 'syncArrayDidChange' + }); + }, + + destroy() { + this.get('syncArray').removeArrayObserver(this, { + willChange: 'syncArrayWillChange', + didChange: 'syncArrayDidChange' + }); + + this.setProperties({ syncArray: null, content: null }); + }, + + /** + * Serialize objects before they are inserted into the content array + * @method serializeContentObjects + * @param {Array} objects + * @return {Array} + */ + serializeContentObjects(objects) { + return objects; + }, + + /** + * Serialize objects before they are inserted into the sync array + * @method serializeSyncArrayObjects + * @param {Array} objects + * @return {Array} + */ + serializeSyncArrayObjects(objects) { + return objects; + }, + + syncArrayWillChange() { /* Not needed */}, + + syncArrayDidChange(syncArray, start, removeCount, addCount) { + let content = this.get('content'); + + if(!this.get('syncEnabled')) { + return; + } + + if(addCount > 0) { + content.replace(start, 0, this.serializeContentObjects(syncArray.slice(start, start + addCount))); + } else if(removeCount > 0) { + content.replace(start, removeCount, []); + } + }, + + replaceContent(start, removeCount, objectsToAdd) { + let syncArray = this.get('syncArray'); + + if(!this.get('syncEnabled')) { + return this._super(...arguments); + } + + syncArray.replace(start, removeCount, this.serializeSyncArrayObjects(objectsToAdd)); + } +}); diff --git a/addon/classes/Table.js b/addon/classes/Table.js index ed950723..117d187f 100644 --- a/addon/classes/Table.js +++ b/addon/classes/Table.js @@ -1,14 +1,27 @@ import Ember from 'ember'; import Row from 'ember-light-table/classes/Row'; import Column from 'ember-light-table/classes/Column'; +import SyncArrayProxy from 'ember-light-table/-private/sync-array-proxy'; +import { mergeOptionsWithGlobals } from 'ember-light-table/-private/global-options'; const { + get, computed, isNone, isEmpty, A: emberArray } = Ember; +const RowSyncArrayProxy = SyncArrayProxy.extend({ + serializeContentObjects(objects) { + return Table.createRows(objects); + }, + + serializeSyncArrayObjects(objects) { + return objects.map(o => get(o, 'content')); + } +}); + /** * @module Table * @private @@ -129,13 +142,21 @@ export default class Table extends Ember.Object.extend({ * @constructor * @param {Array} columns * @param {Array} rows + * @param {Object} options + * */ - constructor(columns = [], rows = []) { + constructor(columns = [], rows = [], options = {}) { super(); - this.setProperties({ - rows: emberArray(Table.createRows(rows)), - columns: emberArray(Table.createColumns(columns)), - }); + + let _columns = emberArray(Table.createColumns(columns)); + let _rows = emberArray(Table.createRows(rows)); + let _options = mergeOptionsWithGlobals(options); + + if(_options.enableSync) { + _rows = RowSyncArrayProxy.create({ syncArray: rows, content: _rows }); + } + + this.setProperties({ columns: _columns, rows: _rows }); } // Rows @@ -237,6 +258,16 @@ export default class Table extends Ember.Object.extend({ rows.forEach(r => this.removeRow(r)); } + + /** + * Remove a row at the specified index + * @method removeRowAt + * @param {Number} index + */ + removeRowAt(index) { + this.get('rows').removeAt(index); + } + // Columns /** @@ -322,6 +353,15 @@ export default class Table extends Ember.Object.extend({ return this.get('columns').removeObjects(columns); } + /** + * Remove a column at the specified index + * @method removeColumnAt + * @param {Number} index + */ + removeColumnAt(index) { + this.get('columns').removeAt(index); + } + /** * Create a Row object with the given content * @method createRow diff --git a/package.json b/package.json index 0a74e5ba..53451802 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "dependencies": { "ember-cli-babel": "^5.1.6", "ember-cli-htmlbars": "^1.0.11", + "ember-get-config": "0.1.7", "ember-in-viewport": "2.1.0", "ember-scrollable": "0.3.2", "ember-truth-helpers": "1.2.0", diff --git a/tests/dummy/app/controllers/table.js b/tests/dummy/app/controllers/table.js index 92ea57ec..5f3a2f7b 100644 --- a/tests/dummy/app/controllers/table.js +++ b/tests/dummy/app/controllers/table.js @@ -2,28 +2,33 @@ import Ember from 'ember'; import Table from 'ember-light-table'; const { - isEmpty + isEmpty, + computed } = Ember; export default Ember.Controller.extend({ columns: null, - table: null, sort: null, page: 1, limit: 10, dir: 'asc', isLoading: false, canLoadMore: true, + model: null, init() { this._super(...arguments); - this.set('table', new Table(this.get('columns'))); }, + table: computed('model', function() { + return new Table(this.get('columns'), this.get('model'), { enableSync: true }); + }), + fetchRecords() { this.set('isLoading', true); this.store.query('user', this.getProperties(['page', 'limit', 'sort', 'dir'])).then(records => { - this.get('table').addRows(records); + // this.get('table').addRows(records); + this.get('model').pushObjects(records.toArray()); this.set('isLoading', false); this.set('canLoadMore', !isEmpty(records)); }); @@ -44,7 +49,7 @@ export default Ember.Controller.extend({ sort: column.get('valuePath'), page: 1 }); - this.get('table').setRows([]); + this.get('model').setObjects([]); this.fetchRecords(); } } diff --git a/tests/dummy/app/routes/table-route.js b/tests/dummy/app/routes/table-route.js index be336d1b..d7de659a 100644 --- a/tests/dummy/app/routes/table-route.js +++ b/tests/dummy/app/routes/table-route.js @@ -2,11 +2,11 @@ import Ember from 'ember'; export default Ember.Route.extend({ model() { - return this.store.query('user', {page: 1, limit: 10}); + return this.store.query('user', { page: 1, limit: 10 }); }, setupController(controller, model) { - controller.get('table').setRows(model.toArray()); + controller.set('model', model.toArray()); }, resetController: function(controller, isExiting) { diff --git a/tests/unit/classes/table-test.js b/tests/unit/classes/table-test.js index 00e1d993..2cf39102 100644 --- a/tests/unit/classes/table-test.js +++ b/tests/unit/classes/table-test.js @@ -1,6 +1,11 @@ +import Ember from 'ember'; import { Table, Column, Row } from 'ember-light-table'; import { module, test } from 'qunit'; +const { + A: emberArray +} = Ember; + module('Unit | Classes | Table'); test('create table - default options', function(assert) { @@ -408,3 +413,63 @@ test('static table method - createColumns', function(assert) { assert.equal(cols.length, 2); assert.ok(cols[0] instanceof Column); }); + +test('table modifications with sync enabled - simple', function(assert) { + let rows = emberArray([]); + let table = new Table([], rows, { enableSync: true }); + + table.addRow({ firstName: 'Offir' }); + + assert.equal(table.get('rows.length'), 1); + assert.equal(table.get('rows.length'), rows.get('length')); + + rows.pushObject({ firstName: 'Taras' }); + + assert.equal(rows.get('length'), 2); + assert.equal(table.get('rows.length'), rows.get('length')); + + assert.deepEqual(table.get('rows').getEach('firstName'), rows.getEach('firstName')); + + table.get('rows').clear(); + + assert.equal(table.get('rows.length'), 0); + assert.equal(table.get('rows.length'), rows.get('length')); +}); + +test('table modifications with sync enabled - stress', function(assert) { + let rows = emberArray([]); + let table = new Table([], rows, { enableSync: true }); + + for(let i = 0; i < 100; i++) { + table.addRow({ position: i }); + } + + assert.equal(table.get('rows.length'), rows.get('length')); + assert.deepEqual(table.get('rows').getEach('position'), rows.getEach('position')); + + for(let i = 100; i < 200; i++) { + rows.pushObject({ position: i }); + } + + assert.equal(table.get('rows.length'), rows.get('length')); + assert.deepEqual(table.get('rows').getEach('position'), rows.getEach('position')); + + table.removeRowAt(5); + table.removeRowAt(10); + table.removeRowAt(125); + + assert.equal(table.get('rows.length'), rows.get('length')); + assert.deepEqual(table.get('rows').getEach('position'), rows.getEach('position')); + + rows.removeAt(10); + rows.removeAt(20); + rows.removeAt(150); + + assert.equal(table.get('rows.length'), rows.get('length')); + assert.deepEqual(table.get('rows').getEach('position'), rows.getEach('position')); + + table.get('rows').clear(); + + assert.equal(table.get('rows.length'), 0); + assert.equal(table.get('rows.length'), rows.get('length')); +}); From 9d751d3ee771d1f1df0bb893596eb01277fc99f0 Mon Sep 17 00:00:00 2001 From: offirgolan Date: Mon, 12 Sep 2016 16:38:35 -0700 Subject: [PATCH 2/8] Fix assign usage in global options --- addon/-private/global-options.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addon/-private/global-options.js b/addon/-private/global-options.js index 1ac791a7..6cfb5d8f 100644 --- a/addon/-private/global-options.js +++ b/addon/-private/global-options.js @@ -10,5 +10,5 @@ const globalOptions = config['ember-light-table'] || {}; export default globalOptions; export function mergeOptionsWithGlobals(options) { - return assign(assign({}, globalOptions, options)); + return assign({}, globalOptions, options); } From 7c181e55946dc763af9ed9d57009549492aeba8b Mon Sep 17 00:00:00 2001 From: offirgolan Date: Mon, 12 Sep 2016 16:45:34 -0700 Subject: [PATCH 3/8] Fallback to Ember.merge if assign is not present --- addon/-private/global-options.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/addon/-private/global-options.js b/addon/-private/global-options.js index 6cfb5d8f..0e371f5d 100644 --- a/addon/-private/global-options.js +++ b/addon/-private/global-options.js @@ -1,10 +1,7 @@ import Ember from 'ember'; import config from 'ember-get-config'; -const { - assign -} = Ember; - +const assign = Ember.assign || Ember.merge; const globalOptions = config['ember-light-table'] || {}; export default globalOptions; From ea3b527bea33966fba0a21e28d007fdb95502d12 Mon Sep 17 00:00:00 2001 From: offirgolan Date: Tue, 13 Sep 2016 10:26:52 -0700 Subject: [PATCH 4/8] Ember.merge only takes 2 parameters --- addon/-private/global-options.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addon/-private/global-options.js b/addon/-private/global-options.js index 0e371f5d..49c5c96b 100644 --- a/addon/-private/global-options.js +++ b/addon/-private/global-options.js @@ -7,5 +7,5 @@ const globalOptions = config['ember-light-table'] || {}; export default globalOptions; export function mergeOptionsWithGlobals(options) { - return assign({}, globalOptions, options); + return assign(assign({}, globalOptions), options); } From 523c4fc9f5c5efca1b839ccf3e19de187654999c Mon Sep 17 00:00:00 2001 From: offirgolan Date: Tue, 13 Sep 2016 10:55:14 -0700 Subject: [PATCH 5/8] Destroy sync proxy --- addon/classes/Table.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/addon/classes/Table.js b/addon/classes/Table.js index 117d187f..71eff0ad 100644 --- a/addon/classes/Table.js +++ b/addon/classes/Table.js @@ -159,6 +159,16 @@ export default class Table extends Ember.Object.extend({ this.setProperties({ columns: _columns, rows: _rows }); } + destroy() { + this._super(...arguments); + + let rows = this.get('rows'); + + if(rows instanceof RowSyncArrayProxy) { + rows.destroy(); + } + } + // Rows /** From 9bf19dd4d8b9cefe9127845356431843d6372c35 Mon Sep 17 00:00:00 2001 From: offirgolan Date: Tue, 13 Sep 2016 11:37:36 -0700 Subject: [PATCH 6/8] Replace all baseURL with rootURL --- tests/dummy/app/adapters/application.js | 2 +- tests/dummy/config/environment.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/dummy/app/adapters/application.js b/tests/dummy/app/adapters/application.js index 79c1c74c..eda70d3c 100644 --- a/tests/dummy/app/adapters/application.js +++ b/tests/dummy/app/adapters/application.js @@ -2,5 +2,5 @@ import DS from 'ember-data'; import ENV from '../config/environment'; export default DS.RESTAdapter.extend({ - namespace: ENV.baseURL + 'api' + namespace: ENV.rootURL + 'api' }); diff --git a/tests/dummy/config/environment.js b/tests/dummy/config/environment.js index 98103ff2..44b4c7c1 100644 --- a/tests/dummy/config/environment.js +++ b/tests/dummy/config/environment.js @@ -29,7 +29,7 @@ module.exports = function(environment) { if (environment === 'test') { // Testem prefers this... - ENV.baseURL = '/'; + ENV.rootURL = '/'; ENV.locationType = 'none'; // keep test console output quieter @@ -41,7 +41,7 @@ module.exports = function(environment) { if (environment === 'production') { ENV.locationType = 'hash'; - ENV.baseURL = '/ember-light-table/'; + ENV.rootURL = '/ember-light-table/'; ENV['ember-cli-mirage'] = { enabled: true }; From a54d989be5d72d072f9a62941fb06150be7b4f4e Mon Sep 17 00:00:00 2001 From: offirgolan Date: Tue, 13 Sep 2016 13:13:52 -0700 Subject: [PATCH 7/8] Add rootURL to tested script --- tests/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/index.html b/tests/index.html index eafd0ef3..e1ba4580 100644 --- a/tests/index.html +++ b/tests/index.html @@ -22,7 +22,7 @@ {{content-for "body"}} {{content-for "test-body"}} - + From dd9dd45efd17493906c9108bd13886ceddbc0580 Mon Sep 17 00:00:00 2001 From: offirgolan Date: Tue, 13 Sep 2016 13:28:25 -0700 Subject: [PATCH 8/8] Fix travis yaml --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index f1a34981..b27730f1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -36,10 +36,10 @@ before_install: - export DISPLAY=:99.0 - sh -e /etc/init.d/xvfb start - npm config set spin false - - npm install -g bower - - bower --version - npm install -g npm@^3 + - npm install -g bower - npm install -g codeclimate-test-reporter + - bower --version install: - npm install