Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add afterInfinityModel #105

Merged
merged 1 commit into from
Nov 25, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 70 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,30 +115,39 @@ and ember-infinity will be set up to parse the total number of pages from a JSON
}
```

You may override `updateInfinityModel` to customize how the route's `model` should be updated with new objects. Let's say we only want to show objects with `isPublished === true`:
### Cursor-based pagination
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NICE


```js
updateInfinityModel(newObjects) {
let infinityModel = this.get(this.get('_modelPath'));

let content = newObjects.get('content');
let filtered = content.filter(obj => { return obj.get('isPublished'); });

return infinityModel.pushObjects(filtered);
}
```
If you are serving a continuously updating stream, it's helpful to keep track
of your place in the list while paginating, to avoid duplicates. This is known
as **cursor-based pagination** and is common in popular APIs like Twitter,
Facebook, and Instagram. Instead of relying on `page_number` to paginate,
you'll want to extract the `min_id` or `min_updated_at` from each page of
results, so that you can fetch the next page without risking duplicates if new
items are added to the top of the list by other users in between requests.

You may also invoke this method directly to manually push new objects into the model:
To do this, implement the `afterInfinityModel` hook as follows:

```js
actions: {
pushHughsRecordsIntoInfinityModel() [
var updatedInfinityModel = this.updateInfinityModel(Ember.A([
{ id: 1, name: "Hugh Francis Discography", isPublished: true }
]));
console.log(updatedInfinityModel);
export default Ember.Route.extend(InfinityRoute, {
_minId: undefined,
_minUpdatedAt: undefined,
_canLoadMore: true,

model() {
return this.infinityModel("post", {}, {
min_id: '_minId',
min_updated_at: '_minUpdatedAt'
});
},

afterInfinityModel(posts) {
loadedAny = posts.get('length') > 0;
this.set('_canLoadMore', loadedAny);

this.set('_minId', posts.get('lastObject.id'));
this.set('_minUpdatedAt', posts.get('lastObject.updated_at').toISOString());
}
}
});
```

### infinityModel
Expand All @@ -164,11 +173,11 @@ import InfinityRoute from 'ember-infinity/mixins/route';
export default Ember.Route.extend(InfinityRoute, {
...

prod: function () { return this.get('cat'); }.property('cat'),
prod: Ember.computed('cat', function () { return this.get('cat'); }),
country: '',
cat: 'shipped',

model: function () {
model() {
return this.infinityModel("product", { perPage: 12, startingPage: 1, make: "original" }, { country: "country", category: "prod" });
}
});
Expand All @@ -193,18 +202,53 @@ When you need to pass in bound parameters but no static parameters or custom pag

`modelPath` is optional parameter for situations when you are overriding `setupController`
or when your model is on different location than `controller.model`.

```js
model: function() {
model() {
return this.infinityModel("product", {
perPage: 12,
startingPage: 1,
modelPath: 'controller.products'
});
},
setupController: function(controller, model) {
setupController(controller, model) {
controller.set('products', model);
}
```

### afterInfinityModel

In some cases, a single call to your data store isn't enough. The afterInfinityModel
method is available for those cases when you need to chain together functions or
promises after fetching a model.

As a simple example, let's say you had a blog and just needed to set a property
on each Post model after fetching all of them:

```js
model() {
return this.infinityModel("post");
},

afterInfinityModel(posts) {
posts.setEach('author', 'Jane Smith');
}
```

As a more complex example, let's say you had a blog with Posts and Authors as separate
related models and you needed to extract an association from Posts. In that case,
return the collection you want from afterInfinityModel:

```js
model() {
return this.infinityModel("post");
},

afterInfinityModel(posts) {
return posts.mapBy('author').uniq();
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would need to return an RSVP.all for this code to actually work

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This example isn't great - it could just do

afterInfinityModel(posts) {
  return posts.mapBy('author');
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need for a promise at all

```

### Event Hooks

The route mixin also provides following event hooks:
Expand Down Expand Up @@ -237,15 +281,15 @@ import InfinityRoute from 'ember-infinity/mixins/route';
export default Ember.Route.extend(InfinityRoute, {
...

model: function () {
model() {
/* Load pages of the Product Model, starting from page 1, in groups of 12. */
return this.infinityModel("product", { perPage: 12, startingPage: 1 });
},

infinityModelUpdated: function(totalPages) {
infinityModelUpdated(totalPages) {
Ember.Logger.debug('updated with more items');
},
infinityModelLoaded: function(lastPageLoaded, totalPages, infinityModel) {
infinityModelLoaded(lastPageLoaded, totalPages, infinityModel) {
Ember.Logger.info('no more items to load');
}
}
Expand Down
46 changes: 43 additions & 3 deletions addon/mixins/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const keys = Object.keys || Ember.keys;
@module ember-infinity/mixins/route
@extends Ember.Mixin
*/
export default Ember.Mixin.create({
const RouteMixin = Ember.Mixin.create({

/**
@private
Expand Down Expand Up @@ -199,6 +199,26 @@ export default Ember.Mixin.create({
return this._loadNextPage();
},

/**
Call additional functions after finding the infinityModel in the Ember data store.
@private
@method _afterInfinityModel
@param {Function} infinityModelPromise The resolved result of the Ember store find method. Passed in automatically.
@return {Ember.RSVP.Promise}
*/
_afterInfinityModel(_this) {
return function(infinityModelPromiseResult) {
if (typeof _this.afterInfinityModel === 'function') {
let result = _this.afterInfinityModel(infinityModelPromiseResult);
if (result) {
return result;
}
}

return infinityModelPromiseResult;
};
},

/**
Trigger a load of the next page of results.

Expand Down Expand Up @@ -250,7 +270,8 @@ export default Ember.Mixin.create({
const nextPage = this.incrementProperty('currentPage');
const params = this._buildParams(nextPage);

return this.store[this._storeFindMethod](modelName, params);
return this.store[this._storeFindMethod](modelName, params).then(
this._afterInfinityModel(this));
},

/**
Expand Down Expand Up @@ -280,11 +301,16 @@ export default Ember.Mixin.create({
Update the infinity model with new objects
Only called on the second page and following

@deprecated
@method updateInfinityModel
@param {Ember.Enumerable} newObjects The new objects to add to the model
@return {Ember.Array} returns the new objects
*/
updateInfinityModel(newObjects) {
return this._doUpdate(newObjects);
},

_doUpdate(newObjects) {
let infinityModel = this._infinityModel();
return infinityModel.pushObjects(newObjects.get('content'));
},
Expand All @@ -303,7 +329,19 @@ export default Ember.Mixin.create({
let infinityModel = newObjects;

if (this.get('_firstPageLoaded')) {
infinityModel = this.updateInfinityModel(newObjects);
if (typeof this.updateInfinityModel === 'function' &&
(this.updateInfinityModel !==
Ember.Object.extend(RouteMixin).create().updateInfinityModel)) {
Ember.deprecate("EmberInfinity.updateInfinityModel is deprecated. "+
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

"Please use EmberInfinity.afterInfinityModel.",
false,
{id: 'ember-infinity.updateInfinityModel', until: '2.1'}
);

infinityModel = this.updateInfinityModel(newObjects);
} else {
infinityModel = this._doUpdate(newObjects);
}
}

this.set('_firstPageLoaded', true);
Expand Down Expand Up @@ -352,3 +390,5 @@ export default Ember.Mixin.create({
Ember.run.scheduleOnce('afterRender', this, 'infinityModelLoaded', { totalPages: totalPages });
}
});

export default RouteMixin;
85 changes: 60 additions & 25 deletions tests/unit/mixins/route-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ function createDummyStore(resolution, assertion) {
};
}

function createArrayStore(resolution) {
return {
query() {
return Ember.RSVP.resolve(
Ember.ArrayProxy.create({content: Ember.A(resolution)})
);
}
};
}

function captureModel(route) {
var model;
Ember.run(() => {
Expand Down Expand Up @@ -402,39 +412,64 @@ test('it allows overrides/manual invocations of updateInfinityModel', assert =>
});

test('It allows to set startingPage as 0', assert => {
var RouteObject = Ember.Route.extend(RouteMixin, {
model() {
return this.infinityModel('item', {startingPage: 0});
}
});
var route = RouteObject.create();
var route = createRoute('item', {
store: createDummyStore({
items: [{id: 1, name: 'Test'}],
meta: {
total_pages: 1
}
})
}, {startingPage: 0});

var dummyStore = {
query() {
return new Ember.RSVP.Promise(resolve => {
Ember.run(this, resolve, Ember.Object.create({
items: [{id: 1, name: 'Test'}],
meta: {
total_pages: 1
}
}));
});
}
captureModel(route);

assert.equal(0, route.get('currentPage'));
assert.equal(true, route.get('_canLoadMore'));
});

module('RouteMixin.afterInfinityModel', {
beforeEach() {
var item = { id: 1, title: 'The Great Gatsby' };
this.route = createRoute('item', {
store: createArrayStore([item])
});
}
});

test('it calls the afterInfinityModel method on objects fetched from the store', function (assert) {
this.route.afterInfinityModel = (items) => {
return items.setEach('author', 'F. Scott Fitzgerald');
};

route.set('store', dummyStore);
var model = captureModel(this.route);

var model;
Ember.run(() => {
route.model().then(result => {
model = result;
assert.equal(model.get('content.firstObject.author'), 'F. Scott Fitzgerald', 'updates made in afterInfinityModel should take effect');
});

test('it does not require a return value to work', function (assert) {
this.route.afterInfinityModel = (items) => {
items.setEach('author', 'F. Scott Fitzgerald');
};

var model = captureModel(this.route);

assert.equal(model.get('content.firstObject.author'), 'F. Scott Fitzgerald', 'updates made in afterInfinityModel should take effect');
});

test('it resolves a promise returned from afterInfinityModel', function (assert) {
this.route.afterInfinityModel = (items) => {
return new Ember.RSVP.Promise(function (resolve) {
resolve(items.setEach('author', 'F. Scott Fitzgerald'));
});
});
};

assert.equal(0, route.get('currentPage'));
assert.equal(true, route.get('_canLoadMore'));
var model = captureModel(this.route);

assert.equal(model.get('content.firstObject.author'), 'F. Scott Fitzgerald', 'updates made in afterInfinityModel should take effect');
});



/*
* Compatibility Tests
*/
Expand Down