0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-06 22:40:14 -05:00

Adds tag pages

fixes #2111

- modified Post model to support a tag query
  param that will filter the desired post collection
  to only include posts that contain the requested tag

- in the updated Post model it includes the Tag model
  under a nested object called 'aspects'

- added tests for updated Post model, updating
  test utils to add more posts_tags relations

- adds two new routes to frontend,
  one for initial tag page,
  another to page that tag page

- for tag pages the array of posts
  is exposed to the view similarly
  to the homepeage

- on the tag view page the information
  for the tag is also accessible
  for further theme usage

- the tag view page supports a hierarchy of
  views, it'll first attempt to use a tag.hbs
  file if it exists, otherwise fall back
  to the default index.hbs file

- modified pageUrl and pagination helper
  to have it be compatible with tag paging

- added unit tests for frontend controller

- added unit tests for handlebar helper modifications

- add functional tests for new tag routes
This commit is contained in:
Harry Wolff 2014-02-12 22:26:56 -05:00
parent 679f65c50a
commit 9ab4b7d4d5
11 changed files with 493 additions and 96 deletions

View file

@ -20,53 +20,121 @@ var moment = require('moment'),
// Cache static post permalink regex
staticPostPermalink = new Route(null, '/:slug/:edit?');
function getPostPage(options) {
return api.settings.read('postsPerPage').then(function (postPP) {
var postsPerPage = parseInt(postPP.value, 10);
// No negative posts per page, must be number
if (!isNaN(postsPerPage) && postsPerPage > 0) {
options.limit = postsPerPage;
}
return api.posts.browse(options);
}).then(function (page) {
// A bit of a hack for situations with no content.
if (page.pages === 0) {
page.pages = 1;
}
return page;
});
}
function formatPageResponse(posts, page) {
return {
posts: posts,
pagination: {
page: page.page,
prev: page.prev,
next: page.next,
limit: page.limit,
total: page.total,
pages: page.pages
}
};
}
function handleError(next) {
return function (err) {
var e = new Error(err.message);
e.status = err.errorCode;
return next(e);
};
}
frontendControllers = {
'homepage': function (req, res, next) {
// Parse the page number
var pageParam = req.params.page !== undefined ? parseInt(req.params.page, 10) : 1,
postsPerPage,
options = {};
options = {
page: pageParam
};
// No negative pages, or page 1
if (isNaN(pageParam) || pageParam < 1 || (pageParam === 1 && req.route.path === '/page/:page/')) {
return res.redirect(config().paths.subdir + '/');
}
return api.settings.read('postsPerPage').then(function (postPP) {
postsPerPage = parseInt(postPP.value, 10);
options.page = pageParam;
// No negative posts per page, must be number
if (!isNaN(postsPerPage) && postsPerPage > 0) {
options.limit = postsPerPage;
}
return;
}).then(function () {
return api.posts.browse(options);
}).then(function (page) {
var maxPage = page.pages;
// A bit of a hack for situations with no content.
if (maxPage === 0) {
maxPage = 1;
page.pages = 1;
}
return getPostPage(options).then(function (page) {
// If page is greater than number of pages we have, redirect to last page
if (pageParam > maxPage) {
return res.redirect(maxPage === 1 ? config().paths.subdir + '/' : (config().paths.subdir + '/page/' + maxPage + '/'));
if (pageParam > page.pages) {
return res.redirect(page.pages === 1 ? config().paths.subdir + '/' : (config().paths.subdir + '/page/' + page.pages + '/'));
}
// Render the page of posts
filters.doFilter('prePostsRender', page.posts).then(function (posts) {
res.render('index', {posts: posts, pagination: {page: page.page, prev: page.prev, next: page.next, limit: page.limit, total: page.total, pages: page.pages}});
res.render('index', formatPageResponse(posts, page));
});
}).otherwise(function (err) {
var e = new Error(err.message);
e.status = err.errorCode;
return next(e);
});
}).otherwise(handleError(next));
},
'tag': function (req, res, next) {
// Parse the page number
var pageParam = req.params.page !== undefined ? parseInt(req.params.page, 10) : 1,
options = {
page: pageParam,
tag: req.params.slug
};
// Get url for tag page
function tagUrl(tag, page) {
var url = config().paths.subdir + '/tag/' + tag + '/';
if (page && page > 1) {
url += 'page/' + page + '/';
}
return url;
}
// No negative pages, or page 1
if (isNaN(pageParam) || pageParam < 1 || (req.params.page !== undefined && pageParam === 1)) {
return res.redirect(tagUrl(options.tag));
}
return getPostPage(options).then(function (page) {
// If page is greater than number of pages we have, redirect to last page
if (pageParam > page.pages) {
return res.redirect(tagUrl(options.tag, page.pages));
}
// Render the page of posts
filters.doFilter('prePostsRender', page.posts).then(function (posts) {
api.settings.read('activeTheme').then(function (activeTheme) {
var paths = config().paths.availableThemes[activeTheme.value],
view = paths.hasOwnProperty('tag') ? 'tag' : 'index',
// Format data for template
response = _.extend(formatPageResponse(posts, page), {
tag: page.aspect.tag
});
res.render(view, response);
});
});
}).otherwise(handleError(next));
},
'single': function (req, res, next) {
var path = req.path,
@ -177,15 +245,9 @@ frontendControllers = {
return next();
}
var e = new Error(err.message);
e.status = err.errorCode;
return next(e);
return handleError(next)(err);
});
},
'edit': function (req, res, next) {
req.params[2] = 'edit';
return frontendControllers.single(req, res, next);
},
'rss': function (req, res, next) {
// Initialize RSS
var pageParam = req.params.page !== undefined ? parseInt(req.params.page, 10) : 1,
@ -271,11 +333,7 @@ frontendControllers = {
res.send(feed.xml());
});
});
}).otherwise(function (err) {
var e = new Error(err.message);
e.status = err.errorCode;
return next(e);
});
}).otherwise(handleError(next));
}
};

View file

@ -92,7 +92,19 @@ coreHelpers.encode = function (context, str) {
//
coreHelpers.pageUrl = function (context, block) {
/*jslint unparam:true*/
return config().paths.subdir + (context === 1 ? '/' : ('/page/' + context + '/'));
var url = config().paths.subdir;
if (this.tagSlug !== undefined) {
url += '/tag/' + this.tagSlug;
}
if (context > 1) {
url += '/page/' + context;
}
url += '/';
return url;
};
// ### URL helper
@ -296,7 +308,7 @@ coreHelpers.body_class = function (options) {
tags = this.post && this.post.tags ? this.post.tags : this.tags || [],
page = this.post && this.post.page ? this.post.page : this.page || false;
if (_.isString(this.relativeUrl) && this.relativeUrl.match(/\/page/)) {
if (_.isString(this.relativeUrl) && this.relativeUrl.match(/\/(page|tag)/)) {
classes.push('archive-template');
} else if (!this.relativeUrl || this.relativeUrl === '/' || this.relativeUrl === '') {
classes.push('home-template');
@ -542,7 +554,13 @@ coreHelpers.pagination = function (options) {
errors.logAndThrowError('Invalid value, check page, pages, limit and total are numbers');
return;
}
return template.execute('pagination', this.pagination);
var context = _.merge({}, this.pagination);
if (this.tag !== undefined) {
context.tagSlug = this.tag.slug;
}
return template.execute('pagination', context);
};
coreHelpers.helperMissing = function (arg) {

View file

@ -254,31 +254,24 @@ Post = ghostBookshelf.Model.extend({
* @params opts
*/
findPage: function (opts) {
var postCollection,
var postCollection = Posts.forge(),
tagInstance = opts.tag !== undefined ? Tag.forge({slug: opts.tag}) : false,
permittedOptions = ['page', 'limit', 'status', 'staticPages'];
// sanitize opts
// sanitize opts so we are not automatically passing through any and all
// query strings to Bookshelf / Knex. Although the API requires auth, we
// should prevent this until such time as we can design the API properly and safely.
opts = _.pick(opts, permittedOptions);
// Allow findPage(n)
if (_.isString(opts) || _.isNumber(opts)) {
opts = {page: opts};
}
// Without this we are automatically passing through any and all query strings
// to Bookshelf / Knex. Although the API requires auth, we should prevent this
// until such time as we can design the API properly and safely.
opts.where = {};
// Set default settings for options
opts = _.extend({
page: 1, // pagination page
limit: 15,
staticPages: false, // include static pages
status: 'published'
status: 'published',
where: {}
}, opts);
postCollection = Posts.forge();
if (opts.staticPages !== 'all') {
// convert string true/false to boolean
if (!_.isBoolean(opts.staticPages)) {
@ -301,53 +294,96 @@ Post = ghostBookshelf.Model.extend({
postCollection.query('where', opts.where);
}
// Fetch related models
opts.withRelated = [ 'author', 'user', 'tags' ];
// Set the limit & offset for the query, fetching
// with the opts (to specify any eager relations, etc.)
// Omitting the `page`, `limit`, `where` just to be sure
// aren't used for other purposes.
return postCollection
.query('limit', opts.limit)
.query('offset', opts.limit * (opts.page - 1))
.query('orderBy', 'status', 'ASC')
.query('orderBy', 'published_at', 'DESC')
.query('orderBy', 'updated_at', 'DESC')
.fetch(_.omit(opts, 'page', 'limit'))
.then(function (collection) {
var qb;
// If a query param for a tag is attached
// we need to fetch the tag model to find its id
function fetchTagQuery() {
if (tagInstance) {
return tagInstance.fetch();
}
return false;
}
return when(fetchTagQuery())
// Set the limit & offset for the query, fetching
// with the opts (to specify any eager relations, etc.)
// Omitting the `page`, `limit`, `where` just to be sure
// aren't used for other purposes.
.then(function () {
// If we have a tag instance we need to modify our query.
// We need to ensure we only select posts that contain
// the tag given in the query param.
if (tagInstance) {
postCollection
.query('join', 'posts_tags', 'posts_tags.post_id', '=', 'posts.id')
.query('where', 'posts_tags.tag_id', '=', tagInstance.id);
}
return postCollection
.query('limit', opts.limit)
.query('offset', opts.limit * (opts.page - 1))
.query('orderBy', 'status', 'ASC')
.query('orderBy', 'published_at', 'DESC')
.query('orderBy', 'updated_at', 'DESC')
.fetch(_.omit(opts, 'page', 'limit'));
})
// Fetch pagination information
.then(function () {
var qb,
tableName = _.result(postCollection, 'tableName'),
idAttribute = _.result(postCollection, 'idAttribute');
// After we're done, we need to figure out what
// the limits are for the pagination values.
qb = ghostBookshelf.knex(_.result(collection, 'tableName'));
qb = ghostBookshelf.knex(tableName);
if (opts.where) {
qb.where(opts.where);
}
return qb.count(_.result(collection, 'idAttribute') + ' as aggregate').then(function (resp) {
var totalPosts = parseInt(resp[0].aggregate, 10),
data = {
posts: collection.toJSON(),
page: parseInt(opts.page, 10),
limit: opts.limit,
pages: Math.ceil(totalPosts / opts.limit),
total: totalPosts
};
if (tagInstance) {
qb.join('posts_tags', 'posts_tags.post_id', '=', 'posts.id');
qb.where('posts_tags.tag_id', '=', tagInstance.id);
}
if (data.pages > 1) {
if (data.page === 1) {
data.next = data.page + 1;
} else if (data.page === data.pages) {
data.prev = data.page - 1;
} else {
data.next = data.page + 1;
data.prev = data.page - 1;
}
return qb.count(tableName + '.' + idAttribute + ' as aggregate');
})
// Format response of data
.then(function (resp) {
var totalPosts = parseInt(resp[0].aggregate, 10),
data = {
posts: postCollection.toJSON(),
page: parseInt(opts.page, 10),
limit: opts.limit,
pages: Math.ceil(totalPosts / opts.limit),
total: totalPosts
};
if (data.pages > 1) {
if (data.page === 1) {
data.next = data.page + 1;
} else if (data.page === data.pages) {
data.prev = data.page - 1;
} else {
data.next = data.page + 1;
data.prev = data.page - 1;
}
return data;
}, errors.logAndThrowError);
}, errors.logAndThrowError);
}
if (tagInstance) {
data.aspect = {
tag: tagInstance.toJSON()
};
}
return data;
})
.catch(errors.logAndThrowError);
},
permissable: function (postModelOrId, userId, action_type, userPermissions) {

View file

@ -6,6 +6,8 @@ module.exports = function (server) {
// ### Frontend routes
server.get('/rss/', frontend.rss);
server.get('/rss/:page/', frontend.rss);
server.get('/tag/:slug/page/:page/', frontend.tag);
server.get('/tag/:slug/', frontend.tag);
server.get('/page/:page/', frontend.homepage);
server.get('/', frontend.homepage);
server.get('*', frontend.single);

View file

@ -47,7 +47,7 @@ describe('Tag API', function () {
response.should.be.json;
var jsonResponse = JSON.parse(body);
jsonResponse.should.exist;
jsonResponse.should.have.length(5);
jsonResponse.should.have.length(6);
testUtils.API.checkResponse(jsonResponse[0], 'tag');
done();
});

View file

@ -290,6 +290,68 @@ describe('Frontend Routing', function () {
// });
});
describe('Tag pages', function () {
// Add enough posts to trigger tag pages
before(function (done) {
testUtils.clearData().then(function () {
// we initialise data, but not a user. No user should be required for navigating the frontend
return testUtils.initData();
}).then(function () {
return testUtils.insertPosts();
}).then(function () {
return testUtils.insertMorePosts(22);
}).then(function() {
return testUtils.insertMorePostsTags(22);
}).then(function () {
done();
}).then(null, done);
});
it('should redirect without slash', function (done) {
request.get('/tag/injection/page/2')
.expect('Location', '/tag/injection/page/2/')
.expect('Cache-Control', cacheRules.year)
.expect(301)
.end(doEnd(done));
});
it('should respond with html', function (done) {
request.get('/tag/injection/page/2/')
.expect('Content-Type', /html/)
.expect('Cache-Control', cacheRules['public'])
.expect(200)
.end(doEnd(done));
});
it('should redirect page 1', function (done) {
request.get('/tag/injection/page/1/')
.expect('Location', '/tag/injection/')
.expect('Cache-Control', cacheRules['public'])
// TODO: This should probably be a 301?
.expect(302)
.end(doEnd(done));
});
it('should redirect to last page is page too high', function (done) {
request.get('/tag/injection/page/4/')
.expect('Location', '/tag/injection/page/2/')
.expect('Cache-Control', cacheRules['public'])
.expect(302)
.end(doEnd(done));
});
it('should redirect to first page is page too low', function (done) {
request.get('/tag/injection/page/0/')
.expect('Location', '/tag/injection/')
.expect('Cache-Control', cacheRules['public'])
.expect(302)
.end(doEnd(done));
});
});
// ### The rest of the tests switch to date permalinks
// describe('Date permalinks', function () {

View file

@ -353,6 +353,8 @@ describe('Post Model', function () {
it('can fetch a paginated set, with various options', function (done) {
testUtils.insertMorePosts().then(function () {
return testUtils.insertMorePostsTags();
}).then(function () {
return PostModel.findPage({page: 2});
}).then(function (paginationResult) {
paginationResult.page.should.equal(2);
@ -385,6 +387,43 @@ describe('Post Model', function () {
}).then(function (paginationResult) {
paginationResult.pages.should.equal(11);
// Test tag filter
return PostModel.findPage({page: 1, tag: 'bacon'});
}).then(function (paginationResult) {
paginationResult.page.should.equal(1);
paginationResult.limit.should.equal(15);
paginationResult.posts.length.should.equal(2);
paginationResult.pages.should.equal(1);
paginationResult.aspect.tag.name.should.equal('bacon');
paginationResult.aspect.tag.slug.should.equal('bacon');
return PostModel.findPage({page: 1, tag: 'kitchen-sink'});
}).then(function (paginationResult) {
paginationResult.page.should.equal(1);
paginationResult.limit.should.equal(15);
paginationResult.posts.length.should.equal(2);
paginationResult.pages.should.equal(1);
paginationResult.aspect.tag.name.should.equal('kitchen sink');
paginationResult.aspect.tag.slug.should.equal('kitchen-sink');
return PostModel.findPage({page: 1, tag: 'injection'});
}).then(function (paginationResult) {
paginationResult.page.should.equal(1);
paginationResult.limit.should.equal(15);
paginationResult.posts.length.should.equal(15);
paginationResult.pages.should.equal(2);
paginationResult.aspect.tag.name.should.equal('injection');
paginationResult.aspect.tag.slug.should.equal('injection');
return PostModel.findPage({page: 2, tag: 'injection'});
}).then(function (paginationResult) {
paginationResult.page.should.equal(2);
paginationResult.limit.should.equal(15);
paginationResult.posts.length.should.equal(9);
paginationResult.pages.should.equal(2);
paginationResult.aspect.tag.name.should.equal('injection');
paginationResult.aspect.tag.slug.should.equal('injection');
done();
}).then(null, done);
});

View file

@ -143,6 +143,120 @@ describe('Frontend Controller', function () {
});
});
describe('tag redirects', function () {
var res;
beforeEach(function () {
res = {
redirect: sandbox.spy(),
render: sandbox.spy()
};
sandbox.stub(api.posts, 'browse', function () {
return when({posts: {}, pages: 3});
});
apiSettingsStub = sandbox.stub(api.settings, 'read');
apiSettingsStub.withArgs('postsPerPage').returns(when({
'key': 'postsPerPage',
'value': 6
}));
});
it('Redirects to base tag page if page number is -1', function () {
var req = {params: {page: -1, slug: 'pumpkin'}};
frontend.tag(req, res, null);
res.redirect.called.should.be.true;
res.redirect.calledWith('/tag/pumpkin/').should.be.true;
res.render.called.should.be.false;
});
it('Redirects to base tag page if page number is 0', function () {
var req = {params: {page: 0, slug: 'pumpkin'}};
frontend.tag(req, res, null);
res.redirect.called.should.be.true;
res.redirect.calledWith('/tag/pumpkin/').should.be.true;
res.render.called.should.be.false;
});
it('Redirects to base tag page if page number is 1', function () {
var req = {params: {page: 1, slug: 'pumpkin'}};
frontend.tag(req, res, null);
res.redirect.called.should.be.true;
res.redirect.calledWith('/tag/pumpkin/').should.be.true;
res.render.called.should.be.false;
});
it('Redirects to base tag page if page number is 0 with subdirectory', function () {
frontend.__set__('config', function() {
return {
paths: {subdir: '/blog'}
};
});
var req = {params: {page: 0, slug: 'pumpkin'}};
frontend.tag(req, res, null);
res.redirect.called.should.be.true;
res.redirect.calledWith('/blog/tag/pumpkin/').should.be.true;
res.render.called.should.be.false;
});
it('Redirects to base tag page if page number is 1 with subdirectory', function () {
frontend.__set__('config', function() {
return {
paths: {subdir: '/blog'}
};
});
var req = {params: {page: 1, slug: 'pumpkin'}};
frontend.tag(req, res, null);
res.redirect.called.should.be.true;
res.redirect.calledWith('/blog/tag/pumpkin/').should.be.true;
res.render.called.should.be.false;
});
it('Redirects to last page if page number too big', function (done) {
var req = {params: {page: 4, slug: 'pumpkin'}};
frontend.tag(req, res, done).then(function () {
res.redirect.called.should.be.true;
res.redirect.calledWith('/tag/pumpkin/page/3/').should.be.true;
res.render.called.should.be.false;
done();
});
});
it('Redirects to last page if page number too big with subdirectory', function (done) {
frontend.__set__('config', function() {
return {
paths: {subdir: '/blog'}
};
});
var req = {params: {page: 4, slug: 'pumpkin'}};
frontend.tag(req, res, done).then(function () {
res.redirect.calledOnce.should.be.true;
res.redirect.calledWith('/blog/tag/pumpkin/page/3/').should.be.true;
res.render.called.should.be.false;
done();
});
});
});
describe('single', function () {
var mockStaticPost = {
'status': 'published',

View file

@ -486,6 +486,29 @@ describe('Core Helpers', function () {
helpers.pageUrl(2).should.equal('/blog/page/2/');
helpers.pageUrl(50).should.equal('/blog/page/50/');
});
it('can return a valid url for tag pages', function () {
var tagContext = {
tagSlug: 'pumpkin'
};
helpers.pageUrl.call(tagContext, 1).should.equal('/tag/pumpkin/');
helpers.pageUrl.call(tagContext, 2).should.equal('/tag/pumpkin/page/2/');
helpers.pageUrl.call(tagContext, 50).should.equal('/tag/pumpkin/page/50/');
});
it('can return a valid url for tag pages with subdirectory', function () {
helpers.__set__('config', function() {
return {
paths: {'subdir': '/blog'}
};
});
var tagContext = {
tagSlug: 'pumpkin'
};
helpers.pageUrl.call(tagContext, 1).should.equal('/blog/tag/pumpkin/');
helpers.pageUrl.call(tagContext, 2).should.equal('/blog/tag/pumpkin/page/2/');
helpers.pageUrl.call(tagContext, 50).should.equal('/blog/tag/pumpkin/page/50/');
});
});
describe("Pagination helper", function () {

View file

@ -62,6 +62,10 @@ DataGenerator.Content = {
{
name: "pollo",
slug: "pollo"
},
{
name: "injection",
slug: "injection"
}
],
@ -174,6 +178,13 @@ DataGenerator.forKnex = (function () {
};
}
function createPostsTags(postId, tagId) {
return {
post_id: postId,
tag_id: tagId
};
}
posts = [
createPost(DataGenerator.Content.posts[0]),
createPost(DataGenerator.Content.posts[1]),
@ -188,12 +199,14 @@ DataGenerator.forKnex = (function () {
createTag(DataGenerator.Content.tags[0]),
createTag(DataGenerator.Content.tags[1]),
createTag(DataGenerator.Content.tags[2]),
createTag(DataGenerator.Content.tags[3])
createTag(DataGenerator.Content.tags[3]),
createTag(DataGenerator.Content.tags[4])
];
posts_tags = [
{ post_id: 2, tag_id: 2 },
{ post_id: 2, tag_id: 3 },
{ post_id: 3, tag_id: 2 },
{ post_id: 3, tag_id: 3 },
{ post_id: 4, tag_id: 4 },
{ post_id: 5, tag_id: 5 }
@ -206,6 +219,7 @@ DataGenerator.forKnex = (function () {
createUser: createUser,
createGenericUser: createGenericUser,
createUserRole: createUserRole,
createPostsTags: createPostsTags,
posts: posts,
tags: tags,

View file

@ -1,6 +1,7 @@
var knex = require('../../server/models/base').knex,
when = require('when'),
nodefn = require('when/node/function'),
_ = require('lodash'),
fs = require('fs-extra'),
path = require('path'),
migration = require("../../server/data/migration/"),
@ -50,6 +51,35 @@ function insertMorePosts(max) {
return when.all(promises);
}
function insertMorePostsTags(max) {
max = max || 50;
return when.all([
knex('posts').select('id'),
knex('tags').select('id', 'name')
]).then(function (results) {
var posts = _.pluck(results[0], 'id'),
injectionTagId = _.chain(results[1])
.where({name: 'injection'})
.pluck('id')
.value()[0],
promises = [],
i;
if (max > posts.length) {
throw new Error('Trying to add more posts_tags than the number of posts.');
}
for (i = 0; i < max; i += 1) {
promises.push(DataGenerator.forKnex.createPostsTags(posts[i], injectionTagId));
}
promises.push(knex('posts_tags').insert(promises));
return when.all(promises);
});
}
function insertDefaultUser() {
var users = [],
userRoles = [];
@ -90,6 +120,7 @@ module.exports = {
insertDefaultFixtures: insertDefaultFixtures,
insertPosts: insertPosts,
insertMorePosts: insertMorePosts,
insertMorePostsTags: insertMorePostsTags,
insertDefaultUser: insertDefaultUser,
loadExportFixture: loadExportFixture,