From ea8b4277b0092bf55cbb4b51b85c1f5000524991 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Thu, 2 Mar 2017 18:35:09 +0000 Subject: [PATCH] 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 --- ghost/admin/app/controllers/posts-loading.js | 16 ++++ ghost/admin/app/controllers/posts.js | 93 ++++++++++++++++++- ghost/admin/app/routes/posts.js | 78 ++++++++++++---- .../app/templates/components/gh-nav-menu.hbs | 2 +- ghost/admin/app/templates/posts-loading.hbs | 55 ++++++++--- ghost/admin/app/templates/posts.hbs | 54 +++++++---- ghost/admin/mirage/models/post.js | 6 +- ghost/admin/mirage/models/user.js | 2 +- ghost/admin/tests/.eslintrc.js | 8 +- ghost/admin/tests/acceptance/content-test.js | 70 +++++++------- ghost/admin/tests/acceptance/editor-test.js | 1 + .../tests/acceptance/version-mismatch-test.js | 9 +- ghost/admin/tests/helpers/start-app.js | 3 + 13 files changed, 306 insertions(+), 91 deletions(-) create mode 100644 ghost/admin/app/controllers/posts-loading.js diff --git a/ghost/admin/app/controllers/posts-loading.js b/ghost/admin/app/controllers/posts-loading.js new file mode 100644 index 0000000000..116cf9a237 --- /dev/null +++ b/ghost/admin/app/controllers/posts-loading.js @@ -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') + +}); diff --git a/ghost/admin/app/controllers/posts.js b/ghost/admin/app/controllers/posts.js index baa83acde7..91386ec094 100644 --- a/ghost/admin/app/controllers/posts.js +++ b/ghost/admin/app/controllers/posts.js @@ -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')); } } }); diff --git a/ghost/admin/app/routes/posts.js b/ghost/admin/app/routes/posts.js index 6e48e20c3f..39114a48f9 100644 --- a/ghost/admin/app/routes/posts.js +++ b/ghost/admin/app/routes/posts.js @@ -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() { diff --git a/ghost/admin/app/templates/components/gh-nav-menu.hbs b/ghost/admin/app/templates/components/gh-nav-menu.hbs index f264af5daa..ce91b6c134 100644 --- a/ghost/admin/app/templates/components/gh-nav-menu.hbs +++ b/ghost/admin/app/templates/components/gh-nav-menu.hbs @@ -33,7 +33,7 @@
  • {{!-- 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"}}Stories{{/link-to}} + {{#link-to "posts" (query-params type=null author=null tag=null) classNames="gh-nav-main-content active"}}Stories{{/link-to}} {{else}} {{#link-to "posts" classNames="gh-nav-main-content"}}Stories{{/link-to}} {{/if}} diff --git a/ghost/admin/app/templates/posts-loading.hbs b/ghost/admin/app/templates/posts-loading.hbs index 36eaf40d9b..5d5bb97165 100644 --- a/ghost/admin/app/templates/posts-loading.hbs +++ b/ghost/admin/app/templates/posts-loading.hbs @@ -7,21 +7,46 @@
    - {{#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}}
    {{gh-loading-spinner}} diff --git a/ghost/admin/app/templates/posts.hbs b/ghost/admin/app/templates/posts.hbs index 848832a465..0bef6df4c5 100644 --- a/ghost/admin/app/templates/posts.hbs +++ b/ghost/admin/app/templates/posts.hbs @@ -7,21 +7,43 @@
    - {{#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}}
    @@ -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}}
  • diff --git a/ghost/admin/mirage/models/post.js b/ghost/admin/mirage/models/post.js index 17ecbeaab9..315616b465 100644 --- a/ghost/admin/mirage/models/post.js +++ b/ghost/admin/mirage/models/post.js @@ -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') +}); diff --git a/ghost/admin/mirage/models/user.js b/ghost/admin/mirage/models/user.js index 12bb724134..66617ece5b 100644 --- a/ghost/admin/mirage/models/user.js +++ b/ghost/admin/mirage/models/user.js @@ -6,5 +6,5 @@ export default Model.extend({ postCount: false, roles: hasMany(), - posts: hasMany() + posts: hasMany('post', {inverse: 'author'}) }); diff --git a/ghost/admin/tests/.eslintrc.js b/ghost/admin/tests/.eslintrc.js index 7f4676cc75..c92ca105de 100644 --- a/ghost/admin/tests/.eslintrc.js +++ b/ghost/admin/tests/.eslintrc.js @@ -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 } }; diff --git a/ghost/admin/tests/acceptance/content-test.js b/ghost/admin/tests/acceptance/content-test.js index 04f6f2ba0b..f2b029acae 100644 --- a/ghost/admin/tests/acceptance/content-test.js +++ b/ghost/admin/tests/acceptance/content-test.js @@ -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; }); }); }); diff --git a/ghost/admin/tests/acceptance/editor-test.js b/ghost/admin/tests/acceptance/editor-test.js index 5baac5e774..ecf0a226e0 100644 --- a/ghost/admin/tests/acceptance/editor-test.js +++ b/ghost/admin/tests/acceptance/editor-test.js @@ -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); diff --git a/ghost/admin/tests/acceptance/version-mismatch-test.js b/ghost/admin/tests/acceptance/version-mismatch-test.js index ca5edf8cd8..021959969c 100644 --- a/ghost/admin/tests/acceptance/version-mismatch-test.js +++ b/ghost/admin/tests/acceptance/version-mismatch-test.js @@ -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(() => { diff --git a/ghost/admin/tests/helpers/start-app.js b/ghost/admin/tests/helpers/start-app.js index 944548d3a1..8196a61b1a 100644 --- a/ghost/admin/tests/helpers/start-app.js +++ b/ghost/admin/tests/helpers/start-app.js @@ -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;