Merge pull request #3337 from morficus/issue-3222

Pagination on the Users Management screen
This commit is contained in:
Hannah Wolfe 2014-07-21 20:34:53 +01:00
commit 90880469f6
16 changed files with 408 additions and 118 deletions

View File

@ -1,6 +1,6 @@
import { getRequestErrorMessage } from 'ghost/utils/ajax';
import PaginationControllerMixin from 'ghost/mixins/pagination-controller';
var PostsController = Ember.ArrayController.extend({
var PostsController = Ember.ArrayController.extend(PaginationControllerMixin, {
// this will cause the list to re-sort when any of these properties change on any of the models
sortProperties: ['status', 'published_at', 'updated_at'],
@ -66,64 +66,11 @@ var PostsController = Ember.ArrayController.extend({
return statusResult;
},
// set from PostsRoute
paginationSettings: null,
// holds the next page to load during infinite scroll
nextPage: null,
// indicates whether we're currently loading the next page
isLoading: null,
init: function () {
this._super();
//let the PaginationControllerMixin know what type of model we will be paginating
//this is necesariy because we do not have access to the model inside the Controller::init method
this._super({'modelType': 'post'});
var metadata = this.store.metadataFor('post');
this.set('nextPage', metadata.pagination.next);
},
/**
* Takes an ajax response, concatenates any error messages, then generates an error notification.
* @param {jqXHR} response The jQuery ajax reponse object.
* @return
*/
reportLoadError: function (response) {
var message = 'A problem was encountered while loading more posts';
if (response) {
// Get message from response
message += ': ' + getRequestErrorMessage(response, true);
} else {
message += '.';
}
this.notifications.showError(message);
},
actions: {
/**
* Loads the next paginated page of posts into the ember-data store. Will cause the posts list UI to update.
* @return
*/
loadNextPage: function () {
var self = this,
store = this.get('store'),
nextPage = this.get('nextPage'),
paginationSettings = this.get('paginationSettings');
if (nextPage) {
this.set('isLoading', true);
this.set('paginationSettings.page', nextPage);
store.find('post', paginationSettings).then(function () {
var metadata = store.metadataFor('post');
self.set('nextPage', metadata.pagination.next);
self.set('isLoading', false);
}, function (response) {
self.reportLoadError(response);
});
}
}
}
});

View File

@ -1,4 +1,12 @@
var UsersIndexController = Ember.ArrayController.extend({
import PaginationControllerMixin from 'ghost/mixins/pagination-controller';
var UsersIndexController = Ember.ArrayController.extend(PaginationControllerMixin, {
init: function () {
//let the PaginationControllerMixin know what type of model we will be paginating
//this is necesariy because we do not have access to the model inside the Controller::init method
this._super({'modelType': 'user'});
},
users: Ember.computed.alias('model'),
activeUsers: Ember.computed.filterBy('users', 'active', true).property('users'),

View File

@ -0,0 +1,76 @@
import { getRequestErrorMessage } from 'ghost/utils/ajax';
var PaginationControllerMixin = Ember.Mixin.create({
// set from PaginationRouteMixin
paginationSettings: null,
// holds the next page to load during infinite scroll
nextPage: null,
// indicates whether we're currently loading the next page
isLoading: null,
/**
*
* @param options: {
* modelType: <String> name of the model that will be paginated
* }
*/
init: function (options) {
this._super();
var metadata = this.store.metadataFor(options.modelType);
this.set('nextPage', metadata.pagination.next);
},
/**
* Takes an ajax response, concatenates any error messages, then generates an error notification.
* @param {jqXHR} response The jQuery ajax reponse object.
* @return
*/
reportLoadError: function (response) {
var message = 'A problem was encountered while loading more records';
if (response) {
// Get message from response
message += ': ' + getRequestErrorMessage(response, true);
} else {
message += '.';
}
this.notifications.showError(message);
},
actions: {
/**
* Loads the next paginated page of posts into the ember-data store. Will cause the posts list UI to update.
* @return
*/
loadNextPage: function () {
var self = this,
store = this.get('store'),
recordType = this.get('model').get('type'),
nextPage = this.get('nextPage'),
paginationSettings = this.get('paginationSettings');
if (nextPage) {
this.set('isLoading', true);
this.set('paginationSettings.page', nextPage);
store.find(recordType, paginationSettings).then(function () {
var metadata = store.metadataFor(recordType);
self.set('nextPage', metadata.pagination.next);
self.set('isLoading', false);
}, function (response) {
self.reportLoadError(response);
});
}
}
}
});
export default PaginationControllerMixin;

View File

@ -0,0 +1,23 @@
var defaultPaginationSettings = {
page: 1,
limit: 15
};
var PaginationRoute = Ember.Mixin.create({
/**
* Sets up pagination details
* @param {settings}: object that specifies additional pagination details
*/
setupPagination: function (settings) {
settings = settings || {};
settings = _.defaults(settings, defaultPaginationSettings);
this.set('paginationSettings', settings);
this.controller.set('paginationSettings', settings);
}
});
export default PaginationRoute;

View File

@ -0,0 +1,39 @@
var PaginationViewInfiniteScrollMixin = Ember.Mixin.create({
/**
* Determines if we are past a scroll point where we need to fetch the next page
* @param event The scroll event
*/
checkScroll: function (event) {
var element = event.target,
triggerPoint = 100,
controller = this.get('controller'),
isLoading = controller.get('isLoading');
// If we haven't passed our threshold or we are already fetching content, exit
if (isLoading || (element.scrollTop + element.clientHeight + triggerPoint <= element.scrollHeight)) {
return;
}
controller.send('loadNextPage');
},
/**
* Bind to the scroll event once the element is in the DOM
*/
didInsertElement: function () {
var el = this.$();
el.on('scroll', Ember.run.bind(this, this.checkScroll));
},
/**
* Unbind from the scroll event when the element is no longer in the DOM
*/
willDestroyElement: function () {
var el = this.$();
el.off('scroll');
}
});
export default PaginationViewInfiniteScrollMixin;

View File

@ -1,6 +1,7 @@
import styleBody from 'ghost/mixins/style-body';
import ShortcutsRoute from 'ghost/mixins/shortcuts-route';
import loadingIndicator from 'ghost/mixins/loading-indicator';
import PaginationRouteMixin from 'ghost/mixins/pagination-route';
var paginationSettings = {
status: 'all',
@ -9,7 +10,7 @@ var paginationSettings = {
page: 1
};
var PostsRoute = Ember.Route.extend(Ember.SimpleAuth.AuthenticatedRouteMixin, ShortcutsRoute, styleBody, loadingIndicator, {
var PostsRoute = Ember.Route.extend(Ember.SimpleAuth.AuthenticatedRouteMixin, ShortcutsRoute, styleBody, loadingIndicator, PaginationRouteMixin, {
classNames: ['manage'],
model: function () {
@ -22,7 +23,7 @@ var PostsRoute = Ember.Route.extend(Ember.SimpleAuth.AuthenticatedRouteMixin, Sh
setupController: function (controller, model) {
this._super(controller, model);
controller.set('paginationSettings', paginationSettings);
this.setupPagination(paginationSettings);
},
shortcuts: {

View File

@ -1,7 +1,34 @@
var UsersIndexRoute = Ember.Route.extend(Ember.SimpleAuth.AuthenticatedRouteMixin, {
import PaginationRouteMixin from 'ghost/mixins/pagination-route';
var activeUsersPaginationSettings = {
include: 'roles',
page: 1,
limit: 20
};
var invitedUsersPaginationSettings = {
include: 'roles',
limit: 'all',
status: 'invited'
};
var UsersIndexRoute = Ember.Route.extend(Ember.SimpleAuth.AuthenticatedRouteMixin, PaginationRouteMixin, {
setupController: function (controller, model) {
this._super(controller, model.active);
this.setupPagination(activeUsersPaginationSettings);
},
model: function () {
return this.store.find('user');
// using `.filter` allows the template to auto-update when new models are pulled in from the server.
// we just need to 'return true' to allow all models by default.
return Ember.RSVP.hash({
inactive: this.store.filter('user', invitedUsersPaginationSettings, function () {
return true;
}),
active: this.store.filter('user', activeUsersPaginationSettings, function () {
return true;
})
});
}
});

View File

@ -6,7 +6,7 @@
</section>
</header>
<section class="content settings-users">
{{#view "settings/users/users-list-view" tagName="section" class="content settings-users" }}
{{#if invitedUsers}}
<section class="object-list invited-users">
@ -62,4 +62,4 @@
{{/each}}
</section>
</section>
{{/view}}

View File

@ -1,34 +1,17 @@
import setScrollClassName from 'ghost/utils/set-scroll-classname';
import PaginationViewMixin from 'ghost/mixins/pagination-view-infinite-scroll';
var PostsListView = Ember.View.extend({
var PostsListView = Ember.View.extend(PaginationViewMixin, {
classNames: ['content-list-content'],
checkScroll: function (event) {
var element = event.target,
triggerPoint = 100,
controller = this.get('controller'),
isLoading = controller.get('isLoading');
// If we haven't passed our threshold, exit
if (isLoading || (element.scrollTop + element.clientHeight + triggerPoint <= element.scrollHeight)) {
return;
}
controller.send('loadNextPage');
},
didInsertElement: function () {
this._super();
var el = this.$();
el.on('scroll', Ember.run.bind(this, this.checkScroll));
el.on('scroll', Ember.run.bind(el, setScrollClassName, {
target: el.closest('.content-list'),
offset: 10
}));
},
willDestroyElement: function () {
var el = this.$();
el.off('scroll');
}
});

View File

@ -0,0 +1,8 @@
//import setScrollClassName from 'ghost/utils/set-scroll-classname';
import PaginationViewMixin from 'ghost/mixins/pagination-view-infinite-scroll';
var UsersListView = Ember.View.extend(PaginationViewMixin, {
classNames: ['settings-users']
});
export default UsersListView;

View File

@ -42,9 +42,7 @@ users = {
if (options.include) {
options.include = prepareInclude(options.include);
}
return dataProvider.User.findAll(options).then(function (result) {
return { users: result.toJSON() };
});
return dataProvider.User.findPage(options);
}, function () {
return when.reject(new errors.NoPermissionError('You do not have permission to browse users.'));
});

View File

@ -109,7 +109,8 @@ User = ghostBookshelf.Model.extend({
findOne: ['withRelated'],
findAll: ['withRelated'],
setup: ['id'],
edit: ['withRelated', 'id']
edit: ['withRelated', 'id'],
findPage: ['page', 'limit', 'status']
};
if (validOptions[methodName]) {
@ -131,6 +132,144 @@ User = ghostBookshelf.Model.extend({
return ghostBookshelf.Model.findAll.call(this, options);
},
/**
* #### findPage
* Find results by page - returns an object containing the
* information about the request (page, limit), along with the
* info needed for pagination (pages, total).
*
* **response:**
*
* {
* users: [
* {...}, {...}, {...}
* ],
* meta: {
* page: __,
* limit: __,
* pages: __,
* total: __
* }
* }
*
* @params {Object} options
*/
findPage: function (options) {
options = options || {};
var userCollection = Users.forge(),
userQuery;
if (options.limit && options.limit !== 'all') {
options.limit = parseInt(options.limit) || 15;
}
options = this.filterOptions(options, 'findPage');
// Set default settings for options
options = _.extend({
page: 1, // pagination page
limit: 15,
status: 'active',
where: {}
}, options);
//TODO: there are multiple statuses that make a user "active" or "invited" - we a way to translate/map them:
//TODO (cont'd from above): * valid "active" statuses: active, warn-1, warn-2, warn-3, warn-4, locked
//TODO (cont'd from above): * valid "invited" statuses" invited, invited-pending
// the status provided.
if (options.status) {
// make sure that status is valid
//TODO: need a better way of getting a list of statuses other than hard-coding them...
options.status = _.indexOf(['active', 'warn-1', 'warn-2', 'warn-3', 'locked', 'invited'], options.status) !== -1 ? options.status : 'active';
options.where.status = options.status;
}
// If there are where conditionals specified, add those
// to the query.
if (options.where) {
userCollection.query('where', options.where);
}
// Add related objects
options.withRelated = _.union([ 'roles' ], options.include);
//only include a limit-query if a numeric limit is provided
if (_.isNumber(options.limit)) {
userCollection
.query('limit', options.limit)
.query('offset', options.limit * (options.page - 1));
}
userQuery = userCollection
.query('orderBy', 'last_login', 'DESC')
.query('orderBy', 'name', 'ASC')
.query('orderBy', 'created_at', 'DESC')
.fetch(_.omit(options, 'page', 'limit'));
return when(userQuery)
// Fetch pagination information
.then(function () {
var qb,
tableName = _.result(userCollection, 'tableName'),
idAttribute = _.result(userCollection, 'idAttribute');
// After we're done, we need to figure out what
// the limits are for the pagination values.
qb = ghostBookshelf.knex(tableName);
if (options.where) {
qb.where(options.where);
}
return qb.count(tableName + '.' + idAttribute + ' as aggregate');
})
// Format response of data
.then(function (resp) {
var totalUsers = parseInt(resp[0].aggregate, 10),
calcPages = Math.ceil(totalUsers / options.limit),
pagination = {},
meta = {},
data = {};
pagination.page = parseInt(options.page, 10);
pagination.limit = options.limit;
pagination.pages = calcPages === 0 ? 1 : calcPages;
pagination.total = totalUsers;
pagination.next = null;
pagination.prev = null;
// Pass include to each model so that toJSON works correctly
if (options.include) {
_.each(userCollection.models, function (item) {
item.include = options.include;
});
}
data.users = userCollection.toJSON();
data.meta = meta;
meta.pagination = pagination;
if (pagination.pages > 1) {
if (pagination.page === 1) {
pagination.next = pagination.page + 1;
} else if (pagination.page === pagination.pages) {
pagination.prev = pagination.page - 1;
} else {
pagination.next = pagination.page + 1;
pagination.prev = pagination.page - 1;
}
}
return data;
})
.catch(errors.logAndThrowError);
},
/**
* ### Find One
* @extends ghostBookshelf.Model.findOne to include roles

View File

@ -117,7 +117,7 @@ describe('User API', function () {
should.not.exist(res.headers['x-cache-invalidate']);
var jsonResponse = res.body;
jsonResponse.users.should.exist;
testUtils.API.checkResponse(jsonResponse, 'users');
should.not.exist(jsonResponse.meta);
jsonResponse.users.should.have.length(1);
testUtils.API.checkResponse(jsonResponse.users[0], 'user', ['roles']);
@ -138,7 +138,7 @@ describe('User API', function () {
should.not.exist(res.headers['x-cache-invalidate']);
var jsonResponse = res.body;
jsonResponse.users.should.exist;
testUtils.API.checkResponse(jsonResponse, 'users');
should.not.exist(jsonResponse.meta);
jsonResponse.users.should.have.length(1);
testUtils.API.checkResponse(jsonResponse.users[0], 'user', ['roles']);
@ -159,7 +159,7 @@ describe('User API', function () {
should.not.exist(res.headers['x-cache-invalidate']);
var jsonResponse = res.body;
jsonResponse.users.should.exist;
testUtils.API.checkResponse(jsonResponse, 'users');
should.not.exist(jsonResponse.meta);
jsonResponse.users.should.have.length(1);
testUtils.API.checkResponse(jsonResponse.users[0], 'user', ['roles']);
@ -180,7 +180,7 @@ describe('User API', function () {
should.not.exist(res.headers['x-cache-invalidate']);
var jsonResponse = res.body;
jsonResponse.users.should.exist;
testUtils.API.checkResponse(jsonResponse, 'users');
should.not.exist(jsonResponse.meta);
jsonResponse.users.should.have.length(1);
testUtils.API.checkResponse(jsonResponse.users[0], 'user', ['roles']);
@ -201,7 +201,7 @@ describe('User API', function () {
should.not.exist(res.headers['x-cache-invalidate']);
var jsonResponse = res.body;
jsonResponse.users.should.exist;
testUtils.API.checkResponse(jsonResponse, 'users');
should.not.exist(jsonResponse.meta);
jsonResponse.users.should.have.length(1);
testUtils.API.checkResponse(jsonResponse.users[0], 'user', ['roles']);
@ -223,7 +223,7 @@ describe('User API', function () {
should.not.exist(res.headers['x-cache-invalidate']);
var jsonResponse = res.body;
jsonResponse.users.should.exist;
testUtils.API.checkResponse(jsonResponse, 'users');
should.not.exist(jsonResponse.meta);
jsonResponse.users.should.have.length(1);
testUtils.API.checkResponse(jsonResponse.users[0], 'user', ['roles']);
@ -247,7 +247,7 @@ describe('User API', function () {
should.not.exist(res.headers['x-cache-invalidate']);
var jsonResponse = res.body;
jsonResponse.users.should.exist;
testUtils.API.checkResponse(jsonResponse, 'users');
should.not.exist(jsonResponse.meta);
jsonResponse.users.should.have.length(1);
testUtils.API.checkResponse(jsonResponse.users[0], 'user', ['roles']);
@ -366,4 +366,4 @@ describe('User API', function () {
});
});
});
});
});

View File

@ -58,8 +58,8 @@ describe('Authentication API', function () {
AuthAPI.setup({ setup: [setupData] }).then(function (result) {
should.exist(result);
should.exist(result.users);
should.not.exist(result.meta);
result.users.should.have.length(1);
testUtils.API.checkResponse(result, 'users');
testUtils.API.checkResponse(result.users[0], 'user', ['roles']);
var newUser = result.users[0];

View File

@ -1,19 +1,53 @@
/*globals describe, before, beforeEach, afterEach, it */
/*jshint expr:true*/
var testUtils = require('../../utils'),
should = require('should'),
var testUtils = require('../../utils'),
should = require('should'),
permissions = require('../../../server/permissions'),
UserModel = require('../../../server/models').User;
// Stuff we are testing
permissions = require('../../../server/permissions'),
UserModel = require('../../../server/models').User,
UsersAPI = require('../../../server/api/users');
UsersAPI = require('../../../server/api/users');
AuthAPI = require('../../../server/api/authentication');
describe('Users API', function () {
// Keep the DB clean
before(testUtils.teardown);
afterEach(testUtils.teardown);
should.exist(UsersAPI);
before(function (done) {
testUtils.clearData().then(function () {
done();
}).catch(done);
});
afterEach(function (done) {
testUtils.clearData().then(function () {
done();
}).catch(done);
});
describe('No User', function () {
beforeEach(function (done) {
testUtils.initData().then(function () {
return permissions.init();
}).then(function () {
done();
}).catch(done);
});
it('can add with internal user', function (done) {
AuthAPI.setup({ setup: [{
'name': 'Hello World',
'email': 'hello@world.com',
'password': 'password'
}]}).then(function (results) {
should.exist(results);
should.not.exist(results.meta);
should.exist(results.users);
results.users.should.have.length(1);
testUtils.API.checkResponse(results.users[0], 'user', ['roles']);
results.users[0].name.should.equal('Hello World');
done();
}).catch(done);
});
});
describe('With Users', function () {
beforeEach(function (done) {
@ -95,7 +129,8 @@ describe('Users API', function () {
it('admin can read', function (done) {
UsersAPI.read({id: 1, context: {user: 1}}).then(function (results) {
should.exist(results);
testUtils.API.checkResponse(results, 'users');
should.not.exist(results.meta);
should.exist(results.users);
results.users[0].id.should.eql(1);
testUtils.API.checkResponse(results.users[0], 'user', ['roles']);
@ -108,7 +143,8 @@ describe('Users API', function () {
it('editor can read', function (done) {
UsersAPI.read({id: 1, context: {user: 2}}).then(function (results) {
should.exist(results);
testUtils.API.checkResponse(results, 'users');
should.not.exist(results.meta);
should.exist(results.users);
results.users[0].id.should.eql(1);
testUtils.API.checkResponse(results.users[0], 'user', ['roles']);
done();
@ -118,7 +154,8 @@ describe('Users API', function () {
it('author can read', function (done) {
UsersAPI.read({id: 1, context: {user: 3}}).then(function (results) {
should.exist(results);
testUtils.API.checkResponse(results, 'users');
should.not.exist(results.meta);
should.exist(results.users);
results.users[0].id.should.eql(1);
testUtils.API.checkResponse(results.users[0], 'user', ['roles']);
done();
@ -128,7 +165,8 @@ describe('Users API', function () {
it('no-auth can read', function (done) {
UsersAPI.read({id: 1}).then(function (results) {
should.exist(results);
testUtils.API.checkResponse(results, 'users');
should.not.exist(results.meta);
should.exist(results.users);
results.users[0].id.should.eql(1);
testUtils.API.checkResponse(results.users[0], 'user', ['roles']);
done();
@ -138,7 +176,8 @@ describe('Users API', function () {
it('admin can edit', function (done) {
UsersAPI.edit({users: [{name: 'Joe Blogger'}]}, {id: 1, context: {user: 1}}).then(function (response) {
should.exist(response);
testUtils.API.checkResponse(response, 'users');
should.not.exist(response.meta);
should.exist(response.users);
response.users.should.have.length(1);
testUtils.API.checkResponse(response.users[0], 'user', ['roles']);
response.users[0].name.should.equal('Joe Blogger');
@ -150,7 +189,8 @@ describe('Users API', function () {
it('editor can edit', function (done) {
UsersAPI.edit({users: [{name: 'Joe Blogger'}]}, {id: 1, context: {user: 2}}).then(function (response) {
should.exist(response);
testUtils.API.checkResponse(response, 'users');
should.not.exist(response.meta);
should.exist(response.users);
response.users.should.have.length(1);
testUtils.API.checkResponse(response.users[0], 'user', ['roles']);
response.users[0].name.should.eql('Joe Blogger');
@ -170,7 +210,8 @@ describe('Users API', function () {
return UsersAPI.edit({users: [{name: 'Timothy Bogendath'}]}, {id: 3, context: {user: 3}})
.then(function (response) {
should.exist(response);
testUtils.API.checkResponse(response, 'users');
should.not.exist(response.meta);
should.exist(response.users);
response.users.should.have.length(1);
testUtils.API.checkResponse(response.users[0], 'user', ['roles']);
response.users[0].name.should.eql('Timothy Bogendath');
@ -179,4 +220,4 @@ describe('Users API', function () {
});
});
});
});
});

View File

@ -6,7 +6,7 @@ var url = require('url'),
schema = 'http://',
expectedProperties = {
posts: ['posts', 'meta'],
users: ['users'],
users: ['users', 'meta'],
pagination: ['page', 'limit', 'pages', 'total', 'next', 'prev'],
post: ['id', 'uuid', 'title', 'slug', 'markdown', 'html', 'meta_title', 'meta_description',
'featured', 'image', 'status', 'language', 'created_at', 'created_by', 'updated_at',