Use token input to allow selection of multiple authors in PSM

requires https://github.com/TryGhost/Ghost/pull/9426
- fixed default token component display in {{gh-token-input}}
    - if no `tokenComponent` is passed to `{{gh-token-input}}` then it should default to the ember-drag-drop `draggable-object` component but instead it didn't output anything
    - put `draggable-object` in quotes because `{{component}}` needs a component name rather than an object
    - rename `option` attribute to `content` to match the default `{{draggable-object}}` interface
- add embedded `authors` attr to the Post model
    - ensure authors is populated when starting new post
    - add validation for empty authors list
- swap author dropdown for a token input in PSM
- show all post authors in posts list
- update tests for `authors`
  - always provide through an authors array
  - fix mirage serialisation for paginated responses (embedded records were not being serialised)
- unify tags and author inputs design
  - remove highlight of primary tags
  - highlight internal tags
  - remove unnecessary/redundant title attributes on tags
  - use SVG icon for "remove option" button in token inputs
This commit is contained in:
Kevin Ansfield 2018-03-13 11:17:29 +00:00
parent d361aa4b36
commit 38b138d759
29 changed files with 263 additions and 196 deletions

View File

@ -22,9 +22,7 @@ export default Component.extend(SettingsMenuMixin, {
settings: service(),
ui: service(),
authors: null,
post: null,
selectedAuthor: null,
_showSettingsMenu: false,
_showThrobbers: false,
@ -92,24 +90,9 @@ export default Component.extend(SettingsMenuMixin, {
return seoURL;
}),
init() {
this._super(...arguments);
this.authors = this.authors || [];
},
didReceiveAttrs() {
this._super(...arguments);
this.get('store').query('user', {limit: 'all'}).then((users) => {
if (!this.get('isDestroyed')) {
this.set('authors', users.sortBy('name'));
}
});
this.get('post.author').then((author) => {
this.set('selectedAuthor', author);
});
// HACK: ugly method of working around the CSS animations so that we
// can add throbbers only when the animation has finished
// TODO: use liquid-fire to handle PSM slide-in and replace tabs manager
@ -485,25 +468,24 @@ export default Component.extend(SettingsMenuMixin, {
});
},
changeAuthor(newAuthor) {
let author = this.get('post.author');
changeAuthors(newAuthors) {
let post = this.get('post');
// return if nothing changed
if (newAuthor.get('id') === author.get('id')) {
if (newAuthors.mapBy('id').join() === post.get('authors').mapBy('id').join()) {
return;
}
post.set('author', newAuthor);
post.set('authors', newAuthors);
post.validate({property: 'authors'});
// if this is a new post (never been saved before), don't try to save it
if (this.get('post.isNew')) {
if (post.get('isNew')) {
return;
}
this.get('savePost').perform().catch((error) => {
this.showError(error);
this.set('selectedAuthor', author);
post.rollbackAttributes();
});
},

View File

@ -1,14 +1,10 @@
import $ from 'jquery';
import Component from '@ember/component';
import Ember from 'ember';
import {alias, equal} from '@ember/object/computed';
import {computed} from '@ember/object';
import {htmlSafe} from '@ember/string';
import {isBlank} from '@ember/utils';
import {inject as service} from '@ember/service';
const {Handlebars} = Ember;
export default Component.extend({
ghostPaths: service(),
@ -29,19 +25,10 @@ export default Component.extend({
isPublished: equal('post.status', 'published'),
isScheduled: equal('post.status', 'scheduled'),
authorName: computed('post.author.{name,email}', function () {
return this.get('post.author.name') || this.get('post.author.email');
}),
authorNames: computed('post.authors.[]', function () {
let authors = this.get('post.authors');
authorAvatar: computed('post.author.profileImage', function () {
let defaultImage = '/img/user-image.png';
return this.get('post.author.profileImage') || `${this.get('ghostPaths.assetRoot')}${defaultImage}`;
}),
authorAvatarBackground: computed('authorAvatar', function () {
let authorAvatar = this.get('authorAvatar');
let safeUrl = Handlebars.Utils.escapeExpression(authorAvatar);
return htmlSafe(`background-image: url(${safeUrl})`);
return authors.map(author => author.get('name') || author.get('email')).join(', ');
}),
// HACK: this is intentionally awful due to time constraints

View File

@ -0,0 +1,32 @@
import Component from '@ember/component';
import {computed} from '@ember/object';
import {inject as service} from '@ember/service';
export default Component.extend({
store: service(),
// public attrs
selectedAuthors: null,
tagName: '',
triggerId: '',
// closure actions
updateAuthors() {},
// live-query of all users for author input autocomplete
availableAuthors: computed(function () {
return this.get('store').filter('user', {limit: 'all'}, () => true);
}),
availableAuthorNames: computed('availableAuthors.@each.name', function () {
return this.get('availableAuthors').map(author => author.get('name').toLowerCase());
}),
actions: {
updateAuthors(newAuthors) {
this.updateAuthors(newAuthors);
}
}
});

View File

@ -1,7 +1,7 @@
/* global key */
import Component from '@ember/component';
import Ember from 'ember';
import {A} from '@ember/array';
import {A, isArray} from '@ember/array';
import {
advanceSelectableOption,
defaultMatcher,
@ -21,6 +21,7 @@ const TAB = 9;
export default Component.extend({
// public attrs
allowCreation: true,
closeOnSelect: false,
labelField: 'name',
matcher: defaultMatcher,
@ -85,6 +86,10 @@ export default Component.extend({
}),
shouldShowCreateOption(term, options) {
if (!this.get('allowCreation')) {
return false;
}
if (this.get('showCreateWhen')) {
return this.get('showCreateWhen')(term, options);
} else {
@ -140,6 +145,11 @@ export default Component.extend({
return;
}
// guard against return being pressed when nothing is selected
if (!isArray(selection)) {
return;
}
let suggestion = selection.find(option => option.__isSuggestion__);
if (suggestion) {

View File

@ -7,29 +7,19 @@ export default DraggableObject.extend({
attributeBindings: ['title'],
classNames: ['tag-token'],
classNameBindings: [
'primary:tag-token--primary',
'internal:tag-token--internal'
],
content: readOnly('option'),
internal: readOnly('option.isInternal'),
internal: readOnly('content.isInternal'),
primary: computed('idx', 'internal', function () {
return !this.get('internal') && this.get('idx') === 0;
}),
title: computed('option.name', 'primary', 'internal', function () {
let name = this.get('option.name');
title: computed('internal', function () {
if (this.get('internal')) {
return `${name} (internal)`;
return `Internal tag`;
}
if (this.get('primary')) {
return `${name} (primary tag)`;
}
return name;
})
});

View File

@ -7,6 +7,10 @@ import {isBlank} from '@ember/utils';
export default EmberPowerSelectMultipleTrigger.extend({
actions: {
chooseOption(option) {
this.get('select').actions.choose(option);
},
handleOptionMouseDown(event) {
let action = this.get('extra.optionMouseDown');
if (action) {

View File

@ -77,7 +77,6 @@ export default Model.extend(Comparable, ValidationEngine, {
validationType: 'post',
authorId: attr('string'),
createdAtUTC: attr('moment-utc'),
customExcerpt: attr('string'),
featured: attr('boolean', {defaultValue: false}),
@ -107,7 +106,10 @@ export default Model.extend(Comparable, ValidationEngine, {
url: attr('string'),
uuid: attr('string'),
author: belongsTo('user', {async: true}),
authors: hasMany('user', {
embedded: 'always',
async: false
}),
createdBy: belongsTo('user', {async: true}),
publishedBy: belongsTo('user', {async: true}),
tags: hasMany('tag', {
@ -115,6 +117,10 @@ export default Model.extend(Comparable, ValidationEngine, {
async: false
}),
primaryAuthor: computed('authors.[]', function () {
return this.get('authors.firstObject');
}),
init() {
// HACK: we can't use the defaultValue property on the mobiledoc attr
// because it won't have access to `this` for the feature check so we do
@ -282,7 +288,7 @@ export default Model.extend(Comparable, ValidationEngine, {
},
isAuthoredByUser(user) {
return user.get('id') === this.get('authorId');
return this.get('authors').includes(user);
},
// a custom sort function is needed in order to sort the posts list the same way the server would:

View File

@ -3,7 +3,7 @@ import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
export default AuthenticatedRoute.extend({
model() {
return this.get('session.user').then(user => (
this.store.createRecord('post', {author: user})
this.store.createRecord('post', {authors: [user]})
));
},

View File

@ -6,23 +6,13 @@ import {pluralize} from 'ember-inflector';
export default ApplicationSerializer.extend(EmbeddedRecordsMixin, {
// settings for the EmbeddedRecordsMixin.
attrs: {
authors: {embedded: 'always'},
tags: {embedded: 'always'},
publishedAtUTC: {key: 'published_at'},
createdAtUTC: {key: 'created_at'},
updatedAtUTC: {key: 'updated_at'}
},
normalize(model, hash, prop) {
// this is to enable us to still access the raw authorId
// without requiring an extra get request (since it is an
// async relationship).
if ((prop === 'post' || prop === 'posts') && hash.author !== undefined) {
hash.author_id = hash.author;
}
return this._super(...arguments);
},
normalizeSingleResponse(store, primaryModelClass, payload) {
let root = this.keyForAttribute(primaryModelClass.modelName);
let pluralizedRoot = pluralize(primaryModelClass.modelName);
@ -62,6 +52,8 @@ export default ApplicationSerializer.extend(EmbeddedRecordsMixin, {
delete data.author_id;
// Read-only virtual property.
delete data.url;
// Deprecated property (replaced with data.authors)
delete data.author;
hash[root] = [data];
}

View File

@ -165,11 +165,26 @@
.ember-power-select-multiple-option {
margin: 2px;
padding: 2px 4px;
padding: 2px 6px;
border: 1px solid #3fb0ef;
border-radius: 3px;
border: 0;
background: #3eb0ef;
font-size: 0.93em;
color: white;
background: #3fb0ef;
}
.ember-power-select-multiple-remove-btn:not(:hover) {
opacity: 0.75;
}
.ember-power-select-multiple-remove-btn svg {
width: 0.6em;
height: auto;
}
.ember-power-select-multiple-remove-btn svg path {
stroke: white;
fill: white;
}
.ember-power-select-trigger-multiple-input {
@ -187,6 +202,13 @@
}
/* Tag input */
.tag-token--primary {
background: color(#3eb0ef lightness(-20%));
.tag-token--internal {
background: #ebf7fd;
color: #3fb0ef;
}
.tag-token--internal svg path {
stroke: #3fb0ef;
fill: #3fb0ef;
}

View File

@ -148,6 +148,7 @@ select {
.gh-input.error,
.error .gh-input,
.error .ember-power-select-multiple-trigger,
.gh-select.error,
select.error {
border-color: var(--red);

View File

@ -88,24 +88,11 @@
{{/gh-form-group}}
{{#unless session.user.isAuthorOrContributor}}
<div class="form-group for-select">
<label for="author-list">Author</label>
<span class="gh-input-icon gh-icon-user">
{{svg-jar "user-circle"}}
<span class="gh-select" tabindex="0">
{{one-way-select
selectedAuthor
id="author-list"
name="post-setting-author"
options=authors
optionValuePath="id"
optionLabelPath="name"
update=(action "changeAuthor")
}}
{{svg-jar "arrow-down-small"}}
</span>
</span>
</div>
{{#gh-form-group class="for-select" errors=post.errors hasValidated=post.hasValidated property="authors" data-test-input="authors"}}
<label for="author-list">Authors</label>
{{gh-psm-authors-input selectedAuthors=post.authors updateAuthors=(action "changeAuthors") triggerId="author-list"}}
{{gh-error-message errors=post.errors property="authors" data-test-error="authors"}}
{{/gh-form-group}}
{{/unless}}
<ul class="nav-list nav-list-block">

View File

@ -22,7 +22,7 @@
<span class="gh-content-status-published">Published</span>
{{/if}}
by <span class="gh-content-entry-author">{{authorName}}</span> &mdash;
by <span class="gh-content-entry-author">{{authorNames}}</span> &mdash;
{{#if isPublished}}
{{gh-format-post-time post.publishedAtUTC published=true}}

View File

@ -0,0 +1,8 @@
{{gh-token-input
options=availableAuthors
selected=selectedAuthors
onchange=(action "updateAuthors")
allowCreation=false
renderInPlace=true
triggerId=triggerId
}}

View File

@ -7,11 +7,11 @@
sortEndAction=(action "reorderItems")
}}
{{#each select.selected as |opt idx|}}
{{#component (or extra.tokenComponent draggable-object)
{{#component (or extra.tokenComponent "draggable-object")
tagName="li"
class="ember-power-select-multiple-option"
select=select
option=(readonly opt)
content=(readonly opt)
idx=idx
isSortable=true
mouseDown=(action "handleOptionMouseDown")
@ -26,8 +26,10 @@
<span role="button"
aria-label="remove element"
class="ember-power-select-multiple-remove-btn"
data-selected-index={{idx}}>
&times;
data-selected-index={{idx}}
onmousedown={{action "chooseOption" opt}}
>
{{svg-jar "close" data-selected-index=idx}}
</span>
{{/unless}}
{{/component}}

View File

@ -6,6 +6,7 @@ import {isBlank, isEmpty, isPresent} from '@ember/utils';
export default BaseValidator.create({
properties: [
'title',
'authors',
'customExcerpt',
'codeinjectionHead',
'codeinjectionFoot',
@ -33,6 +34,15 @@ export default BaseValidator.create({
}
},
authors(model) {
let authors = model.get('authors');
if (isEmpty(authors)) {
model.get('errors').add('authors', 'At least one author is required.');
this.invalidate();
}
},
customExcerpt(model) {
let customExcerpt = model.get('customExcerpt');

View File

@ -1,15 +1,21 @@
import {Response} from 'ember-cli-mirage';
import {dasherize} from '@ember/string';
import {isBlank} from '@ember/utils';
import {paginateModelArray} from '../utils';
import {paginateModelCollection} from '../utils';
export default function mockPosts(server) {
server.post('/posts', function ({posts}) {
server.post('/posts', function ({posts, users}) {
let attrs = this.normalizedRequestAttrs();
let authors = [];
// mirage expects `author` to be a reference but we only have an ID
attrs.authorId = attrs.author;
delete attrs.author;
// NOTE: this is necessary so that ember-cli-mirage has a valid user
// schema object rather than a plain object
// TODO: should ember-cli-mirage be handling this automatically?
attrs.authors.forEach((author) => {
authors.push(users.find(author.id));
});
attrs.authors = authors;
if (isBlank(attrs.slug) && !isBlank(attrs.title)) {
attrs.slug = dasherize(attrs.title);
@ -24,7 +30,6 @@ export default function mockPosts(server) {
let limit = +queryParams.limit || 15;
let {status, staticPages} = queryParams;
let query = {};
let models;
if (status && status !== 'all') {
query.status = status;
@ -38,9 +43,9 @@ export default function mockPosts(server) {
query.page = true;
}
models = posts.where(query).models;
let collection = posts.where(query);
return paginateModelArray('posts', models, page, limit);
return paginateModelCollection('posts', collection, page, limit);
});
server.get('/posts/:id/', function ({posts}, {params}) {
@ -55,17 +60,21 @@ export default function mockPosts(server) {
});
});
// Handle embedded author in post
server.put('/posts/:id/', function ({posts}, request) {
let post = this.normalizedRequestAttrs();
let {author} = post;
delete post.author;
server.put('/posts/:id/', function ({posts, users}, {params}) {
let attrs = this.normalizedRequestAttrs();
let post = posts.find(params.id);
let authors = [];
let savedPost = posts.find(request.params.id).update(post);
savedPost.authorId = author;
savedPost.save();
// NOTE: this is necessary so that ember-cli-mirage has a valid user
// schema object rather than a plain object
// TODO: should ember-cli-mirage be handling this automatically?
attrs.authors.forEach((author) => {
authors.push(users.find(author.id));
});
return savedPost;
attrs.authors = authors;
return post.update(attrs);
});
server.del('/posts/:id/');

View File

@ -1,5 +1,5 @@
import {Response} from 'ember-cli-mirage';
import {paginateModelArray} from '../utils';
import {paginateModelCollection} from '../utils';
export default function mockUsers(server) {
// /users/me = Always return the user with ID=1
@ -20,7 +20,7 @@ export default function mockUsers(server) {
// NOTE: this is naive and only set up to work with queries that are
// actually used - if you use a different filter in the app, add it here!
let {models} = users.where(function (user) {
let collection = users.where(function (user) {
let statusMatch = true;
if (queryParams.filter === 'status:-inactive') {
@ -34,7 +34,7 @@ export default function mockUsers(server) {
return statusMatch;
});
return paginateModelArray('users', models, page, queryParams.limit);
return paginateModelCollection('users', collection, page, queryParams.limit);
});
server.get('/users/slug/:slug/', function ({users}, {params, queryParams}) {

View File

@ -1,4 +1,5 @@
import {Factory, faker} from 'ember-cli-mirage';
import {isEmpty} from '@ember/utils';
export default Factory.extend({
codeinjectionFoot: null,
@ -21,14 +22,29 @@ export default Factory.extend({
plaintext(i) { return `Plaintext for post ${i}.`; },
publishedAt: '2015-12-19T16:25:07.000Z',
publishedBy: 1,
slug(i) { return `post-${i}`; },
status(i) { return faker.list.cycle('draft', 'published', 'scheduled')(i); },
tags() { return []; },
title(i) { return `Post ${i}`; },
twitterDescription: null,
twitterImage: null,
twitterTitle: null,
updatedAt: '2015-10-19T16:25:07.756Z',
updatedBy: 1,
uuid(i) { return `post-${i}`; }
uuid(i) { return `post-${i}`; },
authors() { return []; },
tags() { return []; },
afterCreate(post, server) {
if (isEmpty(post.authors)) {
let user = server.schema.users.find(1);
if (!user) {
let role = server.schema.roles.find({name: 'Administrator'}) || server.create('role', {name: 'Administrator'});
user = server.create('user', {roles: [role]});
}
post.authors = [user];
post.save();
}
}
});

View File

@ -19,6 +19,7 @@ export default Factory.extend({
updatedAt: '2015-11-02T16:12:05.000Z',
updatedBy: '1',
website: 'http://example.com',
posts() { return []; },
roles() { return []; }
});

View File

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

View File

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

View File

@ -1,28 +1,19 @@
import BaseSerializer from './application';
import {RestSerializer} from 'ember-cli-mirage';
export default BaseSerializer.extend({
embed: true,
include(request) {
let includes = [];
if (request.queryParams.include && request.queryParams.include.indexOf('tags') >= 0) {
return ['tags'];
includes.push('tags');
}
return [];
},
serialize(object, request) {
if (this.isCollection(object)) {
return BaseSerializer.prototype.serialize.apply(this, arguments);
if (request.queryParams.include && request.queryParams.include.indexOf('authors') >= 0) {
includes.push('authors');
}
let {post} = RestSerializer.prototype.serialize.call(this, object, request);
if (object.author) {
post.author = object.author.id;
}
return {posts: [post]};
return includes;
}
});

View File

@ -14,7 +14,7 @@ export default BaseSerializer.extend({
serialize(object, request) {
if (this.isCollection(object)) {
return BaseSerializer.prototype.serialize.apply(this, arguments);
return BaseSerializer.prototype.serialize.call(this, object, request);
}
let {user} = RestSerializer.prototype.serialize.call(this, object, request);

View File

@ -5,13 +5,13 @@ export function paginatedResponse(modelName) {
return function (schema, request) {
let page = +request.queryParams.page || 1;
let limit = +request.queryParams.limit || 15;
let allModels = this.serialize(schema[modelName].all())[modelName];
let collection = schema[modelName].all();
return paginateModelArray(modelName, allModels, page, limit);
return paginateModelCollection(modelName, collection, page, limit);
};
}
export function paginateModelArray(modelName, allModels, page, limit) {
export function paginateModelCollection(modelName, collection, page, limit) {
let pages, next, prev, models;
if (limit === 'all') {
@ -22,31 +22,34 @@ export function paginateModelArray(modelName, allModels, page, limit) {
let start = (page - 1) * limit;
let end = start + limit;
pages = Math.ceil(allModels.length / limit);
models = allModels.slice(start, end);
pages = Math.ceil(collection.models.length / limit);
models = collection.models.slice(start, end);
if (start > 0) {
prev = page - 1;
}
if (end < allModels.length) {
if (end < collection.models.length) {
next = page + 1;
}
}
return {
meta: {
pagination: {
page,
limit,
pages,
total: allModels.length,
next: next || null,
prev: prev || null
}
},
[modelName]: models || allModels
collection.meta = {
pagination: {
page,
limit,
pages,
total: collection.models.length,
next: next || null,
prev: prev || null
}
};
if (models) {
collection.models = models;
}
return collection;
}
export function maintenanceResponse() {

View File

@ -35,11 +35,11 @@ describe('Acceptance: Content', function () {
let editorRole = server.create('role', {name: 'Editor'});
editor = server.create('user', {roles: [editorRole]});
publishedPost = server.create('post', {author: admin, status: 'published', title: 'Published Post'});
scheduledPost = server.create('post', {author: admin, status: 'scheduled', title: 'Scheduled Post'});
draftPost = server.create('post', {author: admin, status: 'draft', title: 'Draft Post'});
publishedPage = server.create('post', {author: admin, status: 'published', page: true, title: 'Published Page'});
authorPost = server.create('post', {author: editor, status: 'published', title: 'Editor Published Post'});
publishedPost = server.create('post', {authors: [admin], status: 'published', title: 'Published Post'});
scheduledPost = server.create('post', {authors: [admin], status: 'scheduled', title: 'Scheduled Post'});
draftPost = server.create('post', {authors: [admin], status: 'draft', title: 'Draft Post'});
publishedPage = server.create('post', {authors: [admin], status: 'published', page: true, title: 'Published Page'});
authorPost = server.create('post', {authors: [editor], status: 'published', title: 'Editor Published Post'});
return authenticateSession(application);
});
@ -138,8 +138,8 @@ describe('Acceptance: Content', function () {
let admin = server.create('user', {roles: [adminRole]});
// create posts
authorPost = server.create('post', {authorId: author.id, status: 'published', title: 'Author Post'});
server.create('post', {authorId: admin.id, status: 'scheduled', title: 'Admin Post'});
authorPost = server.create('post', {authors: [author], status: 'published', title: 'Author Post'});
server.create('post', {authors: [admin], status: 'scheduled', title: 'Admin Post'});
return authenticateSession(application);
});
@ -169,9 +169,9 @@ describe('Acceptance: Content', function () {
let admin = server.create('user', {roles: [adminRole]});
// Create posts
contributorPost = server.create('post', {authorId: contributor.id, status: 'draft', title: 'Contributor Post Draft'});
server.create('post', {authorId: contributor.id, status: 'published', title: 'Contributor Published Post'});
server.create('post', {authorId: admin.id, status: 'scheduled', title: 'Admin Post'});
contributorPost = server.create('post', {authors: [contributor], status: 'draft', title: 'Contributor Post Draft'});
server.create('post', {authors: [contributor], status: 'published', title: 'Contributor Published Post'});
server.create('post', {authors: [admin], status: 'scheduled', title: 'Admin Post'});
return authenticateSession(application);
});

View File

@ -6,6 +6,7 @@ import startApp from '../helpers/start-app';
import {afterEach, beforeEach, describe, it} from 'mocha';
import {authenticateSession, invalidateSession} from 'ghost-admin/tests/helpers/ember-simple-auth';
import {expect} from 'chai';
// import {selectChoose} from 'ember-power-select/test-support';
describe('Acceptance: Editor', function () {
let application;
@ -20,7 +21,7 @@ describe('Acceptance: Editor', function () {
it('redirects to signin when not authenticated', async function () {
let author = server.create('user'); // necesary for post-author association
server.create('post', {author});
server.create('post', {authors: [author]});
invalidateSession(application);
await visit('/editor/1');
@ -31,7 +32,7 @@ describe('Acceptance: Editor', function () {
it('does not redirect to team page when authenticated as contributor', async function () {
let role = server.create('role', {name: 'Contributor'});
let author = server.create('user', {roles: [role], slug: 'test-user'});
server.create('post', {author});
server.create('post', {authors: [author]});
authenticateSession(application);
await visit('/editor/1');
@ -42,7 +43,7 @@ describe('Acceptance: Editor', function () {
it('does not redirect to team page when authenticated as author', async function () {
let role = server.create('role', {name: 'Author'});
let author = server.create('user', {roles: [role], slug: 'test-user'});
server.create('post', {author});
server.create('post', {authors: [author]});
authenticateSession(application);
await visit('/editor/1');
@ -53,7 +54,7 @@ describe('Acceptance: Editor', function () {
it('does not redirect to team page when authenticated as editor', async function () {
let role = server.create('role', {name: 'Editor'});
let author = server.create('user', {roles: [role], slug: 'test-user'});
server.create('post', {author});
server.create('post', {authors: [author]});
authenticateSession(application);
await visit('/editor/1');
@ -75,7 +76,7 @@ describe('Acceptance: Editor', function () {
it('when logged in as a contributor, renders a save button instead of a publish menu & hides tags input', async function () {
let role = server.create('role', {name: 'Contributor'});
let author = server.create('user', {roles: [role]});
server.createList('post', 2, {author});
server.createList('post', 2, {authors: [author]});
server.loadFixtures('settings');
authenticateSession(application);
@ -117,7 +118,7 @@ describe('Acceptance: Editor', function () {
});
it('renders the editor correctly, PSM Publish Date and Save Button', async function () {
let [post1] = server.createList('post', 2, {author});
let [post1] = server.createList('post', 2, {authors: [author]});
let futureTime = moment().tz('Etc/UTC').add(10, 'minutes');
// post id 1 is a draft, checking for draft behaviour now
@ -433,7 +434,7 @@ describe('Acceptance: Editor', function () {
});
});
let post = server.create('post', 1, {author});
let post = server.create('post', 1, {authors: [author]});
let plusTenMin = moment().utc().add(10, 'minutes');
await visit(`/editor/${post.id}`);
@ -457,7 +458,7 @@ describe('Acceptance: Editor', function () {
});
it('handles title validation errors correctly', async function () {
server.create('post', {author});
server.create('post', {authors: [author]});
// post id 1 is a draft, checking for draft behaviour now
await visit('/editor/1');
@ -524,7 +525,7 @@ describe('Acceptance: Editor', function () {
let compareDate = moment().tz('Etc/UTC').add(4, 'minutes');
let compareDateString = compareDate.format('MM/DD/YYYY');
let compareTimeString = compareDate.format('HH:mm');
server.create('post', {publishedAt: moment.utc().add(4, 'minutes'), status: 'scheduled', author});
server.create('post', {publishedAt: moment.utc().add(4, 'minutes'), status: 'scheduled', authors: [author]});
server.create('setting', {activeTimezone: 'Europe/Dublin'});
clock.restore();
@ -544,10 +545,12 @@ describe('Acceptance: Editor', function () {
.to.contain('Post will be published in');
});
it('shows author list and allows switching of author in PSM', async function () {
let role = server.create('role', {name: 'Author'});
let otherAuthor = server.create('user', {name: 'Waldo', roles: [role]});
server.create('post', {author});
it('shows author token input and allows changing of authors in PSM', async function () {
let adminRole = server.create('role', {name: 'Adminstrator'});
let authorRole = server.create('role', {name: 'Author'});
let user1 = server.create('user', {name: 'Primary', roles: [adminRole]});
server.create('user', {name: 'Waldo', roles: [authorRole]});
server.create('post', {authors: [user1]});
await visit('/editor/1');
@ -556,13 +559,18 @@ describe('Acceptance: Editor', function () {
await click('button.post-settings');
expect(find('select[name="post-setting-author"]').val()).to.equal('1');
expect(find('select[name="post-setting-author"] option[value="2"]')).to.be.ok;
let tokens = find('[data-test-input="authors"] .ember-power-select-multiple-option');
await fillIn('select[name="post-setting-author"]', '2');
expect(tokens.length).to.equal(1);
expect(tokens[0].textContent.trim()).to.have.string('Primary');
expect(find('select[name="post-setting-author"]').val()).to.equal('2');
expect(server.db.posts[0].authorId).to.equal(otherAuthor.id);
await selectChoose('[data-test-input="authors"]', 'Waldo');
let savedAuthors = server.schema.posts.find('1').authors.models;
expect(savedAuthors.length).to.equal(2);
expect(savedAuthors[0].name).to.equal('Primary');
expect(savedAuthors[1].name).to.equal('Waldo');
});
it('autosaves when title loses focus', async function () {
@ -594,7 +602,7 @@ describe('Acceptance: Editor', function () {
});
it('saves post settings fields', async function () {
let post = server.create('post', {author});
let post = server.create('post', {authors: [author]});
await visit(`/editor/${post.id}`);
@ -810,7 +818,7 @@ describe('Acceptance: Editor', function () {
});
it('has unsplash icon when server doesn\'t return unsplash settings key', async function () {
server.createList('post', 1, {author});
server.createList('post', 1, {authors: [author]});
await visit('/editor/1');

View File

@ -409,7 +409,12 @@ describe('Acceptance: Team', function () {
it('can delete users', async function () {
let user1 = server.create('user');
let user2 = server.create('user');
server.create('post', {author: user2});
let post = server.create('post', {authors: [user2]});
// we don't have a full many-to-many relationship in mirage so we
// need to add the inverse manually
user2.posts = [post];
user2.save();
await visit('/team');
await click(`[data-test-user-id="${user1.id}"]`);

View File

@ -1,4 +1,3 @@
import EmberObject from '@ember/object';
import {describe, it} from 'mocha';
import {run} from '@ember/runloop';
import {setupModelTest} from 'ember-mocha';
@ -54,17 +53,19 @@ describe('Unit: Model: post', function () {
});
it('isAuthoredByUser is correct', function () {
let model = this.subject({
authorId: 'abcd1234'
});
let user = EmberObject.create({id: 'abcd1234'});
let user1 = this.store().createRecord('user', {id: 'abcd1234'});
let user2 = this.store().createRecord('user', {id: 'wxyz9876'});
expect(model.isAuthoredByUser(user)).to.be.ok;
let model = this.subject({
authors: [user1]
});
expect(model.isAuthoredByUser(user1)).to.be.ok;
run(function () {
model.set('authorId', 'wxyz9876');
model.set('authors', [user2]);
expect(model.isAuthoredByUser(user)).to.not.be.ok;
expect(model.isAuthoredByUser(user1)).to.not.be.ok;
});
});