add dropdown filters for post type, author, and tag (#554)

refs TryGhost/Ghost#7860
- removes post type filter links
- adds dropdown filters for post type, author, and tag
- replaces custom refresh on query params change with Ember's standard `refreshModel` config
This commit is contained in:
Kevin Ansfield 2017-03-02 18:35:09 +00:00 committed by Austin Burdine
parent 60dc827e59
commit ea8b4277b0
13 changed files with 306 additions and 91 deletions

View File

@ -0,0 +1,16 @@
import Controller from 'ember-controller';
import {readOnly} from 'ember-computed';
import injectController from 'ember-controller/inject';
export default Controller.extend({
postsController: injectController('posts'),
availableTypes: readOnly('postsController.availableTypes'),
selectedType: readOnly('postsController.selectedType'),
availableTags: readOnly('postsController.availableTags'),
selectedTag: readOnly('postsController.selectedTag'),
availableAuthors: readOnly('postsController.availableAuthors'),
selectedAuthor: readOnly('postsController.selectedAuthor')
});

View File

@ -1,23 +1,106 @@
import Controller from 'ember-controller';
import computed from 'ember-computed';
import injectService from 'ember-service/inject';
import get from 'ember-metal/get';
export default Controller.extend({
queryParams: ['type'],
type: null,
session: injectService(),
store: injectService(),
queryParams: ['type', 'author', 'tag'],
type: null,
author: null,
tag: null,
_hasLoadedTags: false,
_hasLoadedAuthors: false,
showDeletePostModal: false,
showingAll: computed('type', function () {
return this.get('type') === null;
availableTypes: [{
name: 'All posts',
value: null
}, {
name: 'Draft posts',
value: 'draft'
}, {
name: 'Published posts',
value: 'published'
}, {
name: 'Scheduled posts',
value: 'scheduled'
}, {
name: 'Pages',
value: 'page'
}],
showingAll: computed('type', 'author', 'tag', function () {
let {type, author, tag} = this.getProperties(['type', 'author', 'tag']);
return !type && !author && !tag;
}),
selectedType: computed('type', function () {
let types = this.get('availableTypes');
return types.findBy('value', this.get('type'));
}),
_availableTags: computed(function () {
return this.get('store').peekAll('tag');
}),
availableTags: computed('_availableTags.[]', function () {
let tags = this.get('_availableTags');
let options = tags.toArray();
options.unshiftObject({name: 'All tags', slug: null});
return options;
}),
selectedTag: computed('tag', '_availableTags.[]', function () {
let tag = this.get('tag');
let tags = this.get('availableTags');
return tags.findBy('slug', tag);
}),
_availableAuthors: computed(function () {
return this.get('store').peekAll('user');
}),
availableAuthors: computed('_availableAuthors.[]', function () {
let authors = this.get('_availableAuthors');
let options = authors.toArray();
options.unshiftObject({name: 'All authors', slug: null});
return options;
}),
selectedAuthor: computed('author', 'availableAuthors.[]', function () {
let author = this.get('author');
let authors = this.get('availableAuthors');
return authors.findBy('slug', author);
}),
actions: {
toggleDeletePostModal() {
this.toggleProperty('showDeletePostModal');
},
changeType(type) {
this.set('type', get(type, 'value'));
},
changeAuthor(author) {
this.set('author', get(author, 'slug'));
},
changeTag(tag) {
this.set('tag', get(tag, 'slug'));
}
}
});

View File

@ -1,8 +1,8 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import ShortcutsRoute from 'ghost-admin/mixins/shortcuts-route';
import InfinityRoute from 'ember-infinity/mixins/route';
import computed from 'ember-computed';
import {assign} from 'ember-platform';
import {isBlank} from 'ember-utils';
import $ from 'jquery';
export default AuthenticatedRoute.extend(InfinityRoute, ShortcutsRoute, {
@ -12,28 +12,49 @@ export default AuthenticatedRoute.extend(InfinityRoute, ShortcutsRoute, {
perPageParam: 'limit',
totalPagesParam: 'meta.pagination.pages',
queryParams: {
type: {
refreshModel: true,
replace: true
},
author: {
refreshModel: true,
replace: true
},
tag: {
refreshModel: true,
replace: true
}
},
_type: null,
_selectedPostIndex: null,
model(params) {
this.set('_type', params.type);
let filterSettings = this.get('filterSettings');
return this.get('session.user').then((user) => {
let queryParams = this._typeParams(params.type);
let filterParams = {tag: params.tag};
if (user.get('isAuthor')) {
filterSettings.filter = filterSettings.filter
? `${filterSettings.filter}+author:${user.get('slug')}` : `author:${user.get('slug')}`;
// authors can only view their own posts
filterParams.author = user.get('slug');
} else if (params.author) {
filterParams.author = params.author;
}
let filter = this._filterString(filterParams);
if (!isBlank(filter)) {
queryParams.filter = filter;
}
let perPage = this.get('perPage');
let paginationSettings = assign({perPage, startingPage: 1}, filterSettings);
let paginationSettings = assign({perPage, startingPage: 1}, queryParams);
return this.infinityModel('post', paginationSettings);
});
},
filterSettings: computed('_type', function () {
let type = this.get('_type');
_typeParams(type) {
let status = 'all';
let staticPages = 'all';
@ -59,7 +80,36 @@ export default AuthenticatedRoute.extend(InfinityRoute, ShortcutsRoute, {
status,
staticPages
};
}),
},
_filterString(filter) {
return Object.keys(filter).map((key) => {
let value = filter[key];
if (!isBlank(value)) {
return `${key}:${filter[key]}`;
}
}).compact().join('+');
},
// trigger a background load of all tags and authors for use in the filter dropdowns
setupController(controller) {
this._super(...arguments);
if (!controller._hasLoadedTags) {
this.get('store').query('tag', {limit: 'all'}).then(() => {
controller._hasLoadedTags = true;
});
}
this.get('session.user').then((user) => {
if (!user.get('isAuthor') && !controller._hasLoadedAuthors) {
this.get('store').query('user', {limit: 'all'}).then(() => {
controller._hasLoadedAuthors = true;
});
}
});
},
stepThroughPosts(step) {
let currentPost = this.get('controller.selectedPost');
@ -111,14 +161,10 @@ export default AuthenticatedRoute.extend(InfinityRoute, ShortcutsRoute, {
},
queryParamsDidChange() {
// on direct page load controller won't exist so we want to
// avoid a double transition
if (this.get('controller')) {
this.refresh();
}
// scroll back to the top
$('.content-list').scrollTop(0);
this._super(...arguments);
},
newPost() {

View File

@ -33,7 +33,7 @@
<li>
{{!-- clicking the Content link whilst on the content screen should reset the filter --}}
{{#if (eq routing.currentRouteName "posts.index")}}
{{#link-to "posts" (query-params type=null) classNames="gh-nav-main-content active"}}<i class="icon-content"></i>Stories{{/link-to}}
{{#link-to "posts" (query-params type=null author=null tag=null) classNames="gh-nav-main-content active"}}<i class="icon-content"></i>Stories{{/link-to}}
{{else}}
{{#link-to "posts" classNames="gh-nav-main-content"}}<i class="icon-content"></i>Stories{{/link-to}}
{{/if}}

View File

@ -7,21 +7,46 @@
</header>
<div class="gh-contentfilter">
{{#active-link}}
{{link-to "All" "posts.index" (query-params type=null) data-test-all-filter-link=true}}
{{/active-link}}
{{#active-link}}
{{link-to "Drafts" "posts.index" (query-params type="draft") data-test-drafts-filter-link=true}}
{{/active-link}}
{{#active-link}}
{{link-to "Published" "posts.index" (query-params type="published") data-test-published-filter-link=true}}
{{/active-link}}
{{#active-link}}
{{link-to "Scheduled" "posts.index" (query-params type="scheduled") data-test-scheduled-filter-link=true}}
{{/active-link}}
{{#active-link}}
{{link-to "Pages" "posts.index" (query-params type="page") data-test-pages-filter-link=true}}
{{/active-link}}
{{#power-select
placeholder="All posts"
selected=selectedType
options=availableTypes
searchField="name"
onchange=(action (mut k))
tagName="div"
data-test-type-select=true
as |type|
}}
{{type.name}}
{{/power-select}}
{{#unless session.user.isAuthor}}
{{#power-select
placeholder="All authors"
selected=selectedAuthor
options=availableAuthors
searchField="name"
onchange=(action (mut k))
tagName="div"
data-test-author-select=true
as |author|
}}
{{author.name}}
{{/power-select}}
{{/unless}}
{{#power-select
placeholder="All tags"
selected=selectedTag
options=availableTags
searchField="name"
onchange=(action (mut k))
tagName="div"
data-test-tag-select=true
as |tag|
}}
{{tag.name}}
{{/power-select}}
</div>
{{gh-loading-spinner}}

View File

@ -7,21 +7,43 @@
</header>
<div class="gh-contentfilter">
{{#active-link}}
{{link-to "All" "posts.index" (query-params type=null) data-test-all-filter-link=true}}
{{/active-link}}
{{#active-link}}
{{link-to "Drafts" "posts.index" (query-params type="draft") data-test-drafts-filter-link=true}}
{{/active-link}}
{{#active-link}}
{{link-to "Published" "posts.index" (query-params type="published") data-test-published-filter-link=true}}
{{/active-link}}
{{#active-link}}
{{link-to "Scheduled" "posts.index" (query-params type="scheduled") data-test-scheduled-filter-link=true}}
{{/active-link}}
{{#active-link}}
{{link-to "Pages" "posts.index" (query-params type="page") data-test-pages-filter-link=true}}
{{/active-link}}
{{#power-select
selected=selectedType
options=availableTypes
searchField="name"
onchange=(action "changeType")
tagName="div"
data-test-type-select=true
as |type|
}}
{{type.name}}
{{/power-select}}
{{#unless session.user.isAuthor}}
{{#power-select
selected=selectedAuthor
options=availableAuthors
searchField="name"
onchange=(action "changeAuthor")
tagName="div"
data-test-author-select=true
as |author|
}}
{{author.name}}
{{/power-select}}
{{/unless}}
{{#power-select
selected=selectedTag
options=availableTags
searchField="name"
onchange=(action "changeTag")
tagName="div"
data-test-tag-select=true
as |tag|
}}
{{tag.name}}
{{/power-select}}
</div>
<section class="content-list">
@ -31,7 +53,7 @@
post=post
active=(eq post selectedPost)
onDoubleClick="openEditor"
data-test-posts-list-item-id=post.id}}
data-test-post-id=post.id}}
{{else}}
<li class="no-posts-box">
<div class="no-posts">

View File

@ -1,3 +1,5 @@
import {Model} from 'ember-cli-mirage';
import {Model, belongsTo} from 'ember-cli-mirage';
export default Model.extend();
export default Model.extend({
author: belongsTo('user')
});

View File

@ -6,5 +6,5 @@ export default Model.extend({
postCount: false,
roles: hasMany(),
posts: hasMany()
posts: hasMany('post', {inverse: 'author'})
});

View File

@ -6,6 +6,12 @@ module.exports = {
globals: {
server: false,
expect: false,
fileUpload: false
fileUpload: false,
// ember-power-select test helpers
selectChoose: false,
selectSearch: false,
removeMultipleOption: false,
clearSelected: false
}
};

View File

@ -29,13 +29,14 @@ describe('Acceptance: Content', function() {
});
describe('as admin', function () {
let publishedPost, scheduledPost, draftPost, publishedPage, authorPost;
let admin, editor,
publishedPost, scheduledPost, draftPost, publishedPage, authorPost;
beforeEach(function () {
let adminRole = server.create('role', {name: 'Administrator'});
let admin = server.create('user', {roles: [adminRole]});
admin = server.create('user', {roles: [adminRole]});
let editorRole = server.create('role', {name: 'Editor'});
let editor = server.create('user', {roles: [editorRole]});
editor = server.create('user', {roles: [editorRole]});
publishedPost = server.create('post', {authorId: admin.id, status: 'published', title: 'Published Post'});
scheduledPost = server.create('post', {authorId: admin.id, status: 'scheduled', title: 'Scheduled Post'});
@ -50,71 +51,61 @@ describe('Acceptance: Content', function() {
visit('/');
andThen(() => {
// All filter is active by default
expect(find(testSelector('all-filter-link'))).to.have.class('active');
// Not checking request here as it won't be the last request made
// Displays all posts + pages
expect(find(testSelector('posts-list-item-id')).length, 'all posts count').to.equal(5);
expect(find(testSelector('post-id')).length, 'all posts count').to.equal(5);
});
click(testSelector('drafts-filter-link'));
selectChoose(testSelector('type-select'), 'Draft posts');
andThen(() => {
// Filter link is highlighted
expect(find(testSelector('drafts-filter-link'))).to.have.class('active');
// API request is correct
let [lastRequest] = server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.status, '"drafts" request status param').to.equal('draft');
expect(lastRequest.queryParams.staticPages, '"drafts" request staticPages param').to.equal('false');
// Displays draft post
expect(find(testSelector('posts-list-item-id')).length, 'drafts count').to.equal(1);
expect(find(testSelector('posts-list-item-id', draftPost.id)), 'draft post').to.exist;
expect(find(testSelector('post-id')).length, 'drafts count').to.equal(1);
expect(find(testSelector('post-id', draftPost.id)), 'draft post').to.exist;
});
click(testSelector('published-filter-link'));
selectChoose(testSelector('type-select'), 'Published posts');
andThen(() => {
// Filter link is highlighted
expect(find(testSelector('published-filter-link'))).to.have.class('active');
// API request is correct
let [lastRequest] = server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.status, '"published" request status param').to.equal('published');
expect(lastRequest.queryParams.staticPages, '"published" request staticPages param').to.equal('false');
// Displays three published posts + pages
expect(find(testSelector('posts-list-item-id')).length, 'published count').to.equal(2);
expect(find(testSelector('posts-list-item-id', publishedPost.id)), 'admin published post').to.exist;
expect(find(testSelector('posts-list-item-id', authorPost.id)), 'author published post').to.exist;
expect(find(testSelector('post-id')).length, 'published count').to.equal(2);
expect(find(testSelector('post-id', publishedPost.id)), 'admin published post').to.exist;
expect(find(testSelector('post-id', authorPost.id)), 'author published post').to.exist;
});
click(testSelector('scheduled-filter-link'));
selectChoose(testSelector('type-select'), 'Scheduled posts');
andThen(() => {
// Filter link is highlighted
expect(find(testSelector('scheduled-filter-link'))).to.have.class('active');
// API request is correct
let [lastRequest] = server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.status, '"scheduled" request status param').to.equal('scheduled');
expect(lastRequest.queryParams.staticPages, '"scheduled" request staticPages param').to.equal('false');
// Displays scheduled post
expect(find(testSelector('posts-list-item-id')).length, 'scheduled count').to.equal(1);
expect(find(testSelector('posts-list-item-id', scheduledPost.id)), 'scheduled post').to.exist;
expect(find(testSelector('post-id')).length, 'scheduled count').to.equal(1);
expect(find(testSelector('post-id', scheduledPost.id)), 'scheduled post').to.exist;
});
click(testSelector('pages-filter-link'));
selectChoose(testSelector('type-select'), 'Pages');
andThen(() => {
// Filter link is highlighted
expect(find(testSelector('pages-filter-link'))).to.have.class('active');
// API request is correct
let [lastRequest] = server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.status, '"pages" request status param').to.equal('all');
expect(lastRequest.queryParams.staticPages, '"pages" request staticPages param').to.equal('true');
// Displays page
expect(find(testSelector('posts-list-item-id')).length, 'pages count').to.equal(1);
expect(find(testSelector('posts-list-item-id', publishedPage.id)), 'page post').to.exist;
expect(find(testSelector('post-id')).length, 'pages count').to.equal(1);
expect(find(testSelector('post-id', publishedPage.id)), 'page post').to.exist;
});
click(testSelector('all-filter-link'));
selectChoose(testSelector('type-select'), 'All posts');
andThen(() => {
// API request is correct
@ -122,6 +113,23 @@ describe('Acceptance: Content', function() {
expect(lastRequest.queryParams.status, '"all" request status param').to.equal('all');
expect(lastRequest.queryParams.staticPages, '"all" request staticPages param').to.equal('all');
});
selectChoose(testSelector('author-select'), editor.name);
andThen(() => {
// API request is correct
let [lastRequest] = server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.status, '"all" request status param').to.equal('all');
expect(lastRequest.queryParams.staticPages, '"all" request staticPages param').to.equal('all');
expect(lastRequest.queryParams.filter, '"editor" request filter param')
.to.equal(`author:${editor.slug}`);
// Displays editor post
// TODO: implement "filter" param support and fix mirage post->author association
// expect(find(testSelector('post-id')).length, 'editor post count').to.equal(1);
// expect(find(testSelector('post-id', authorPost.id)), 'author post').to.exist;
});
// TODO: test tags dropdown
});
});
@ -144,7 +152,7 @@ describe('Acceptance: Content', function() {
it('only fetches the author\'s posts', function () {
visit('/');
// trigger a filter request so we can grab the posts API request easily
click(testSelector('published-filter-link'));
selectChoose(testSelector('type-select'), 'Published posts');
andThen(() => {
// API request includes author filter
@ -152,8 +160,8 @@ describe('Acceptance: Content', function() {
expect(lastRequest.queryParams.filter).to.equal(`author:${author.slug}`);
// only author's post is shown
expect(find(testSelector('posts-list-item-id')).length, 'post count').to.equal(1);
expect(find(testSelector('posts-list-item-id', authorPost.id)), 'author post').to.exist;
expect(find(testSelector('post-id')).length, 'post count').to.equal(1);
expect(find(testSelector('post-id', authorPost.id)), 'author post').to.exist;
});
});
});

View File

@ -25,6 +25,7 @@ describe('Acceptance: Editor', function() {
});
it('redirects to signin when not authenticated', function () {
server.create('user'); // necessray for post-author association
server.create('post');
invalidateSession(application);

View File

@ -55,10 +55,13 @@ describe('Acceptance: Version Mismatch', function() {
});
it('displays alert and aborts the transition when navigating', function () {
// mock the tags endpoint to return version mismatch
server.get('/tags/', versionMismatchResponse);
visit('/');
andThen(() => {
// mock the tags endpoint to return version mismatch
server.get('/tags/', versionMismatchResponse);
});
click('.gh-nav-settings-tags');
andThen(() => {

View File

@ -2,9 +2,12 @@ import {assign} from 'ember-platform';
import run from 'ember-runloop';
import Application from '../../app';
import config from '../../config/environment';
import registerPowerSelectHelpers from '../../tests/helpers/ember-power-select';
// eslint-disable-next-line no-unused-vars
import fileUpload from './file-upload';
registerPowerSelectHelpers();
export default function startApp(attrs) {
let application;