Improved performance loading the posts list in admin (#20618)

ref https://github.com/TryGhost/Ghost/pull/20503
- undid the reversion for the performance improvements
- built upon new tests for the posts list functionality in admin,
including right click actions

This was originally reverted because the changes to improve loading
response times broke right click (bulk) actions in the posts list. This
was not caught because it turned out we had near-zero test coverage of
that part of the codebase. Test coverage has been expanded for the posts
list, and while not comprehensive, is a much better place for us to be
in.
This commit is contained in:
Steve Larson 2024-07-17 16:55:47 -05:00 committed by GitHub
parent 573dc9f3ee
commit cd17b94e9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 329 additions and 203 deletions

View File

@ -1,5 +1,5 @@
import Component from '@glimmer/component';
import SelectionList from '../utils/selection-list';
import SelectionList from './posts-list/selection-list';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';

View File

@ -216,11 +216,14 @@ export default class PostsContextMenu extends Component {
yield this.performBulkDestroy();
this.notifications.showNotification(this.#getToastMessage('deleted'), {type: 'success'});
const remainingModels = this.selectionList.infinityModel.content.filter((model) => {
return !deletedModels.includes(model);
});
// Deleteobjects method from infintiymodel is broken for all models except the first page, so we cannot use this
this.infinity.replace(this.selectionList.infinityModel, remainingModels);
for (const key in this.selectionList.infinityModel) {
const remainingModels = this.selectionList.infinityModel[key].content.filter((model) => {
return !deletedModels.includes(model);
});
// Deleteobjects method from infintiymodel is broken for all models except the first page, so we cannot use this
this.infinity.replace(this.selectionList.infinityModel[key], remainingModels);
}
this.selectionList.clearSelection({force: true});
return true;
}
@ -247,9 +250,7 @@ export default class PostsContextMenu extends Component {
}
}
// Remove posts that no longer match the filter
this.updateFilteredPosts();
return true;
}
@ -282,14 +283,17 @@ export default class PostsContextMenu extends Component {
]
});
const remainingModels = this.selectionList.infinityModel.content.filter((model) => {
if (!updatedModels.find(u => u.id === model.id)) {
return true;
}
return filterNql.queryJSON(model.serialize({includeId: true}));
});
// Deleteobjects method from infintiymodel is broken for all models except the first page, so we cannot use this
this.infinity.replace(this.selectionList.infinityModel, remainingModels);
// TODO: something is wrong in here
for (const key in this.selectionList.infinityModel) {
const remainingModels = this.selectionList.infinityModel[key].content.filter((model) => {
if (!updatedModels.find(u => u.id === model.id)) {
return true;
}
return filterNql.queryJSON(model.serialize({includeId: true}));
});
// Deleteobjects method from infintiymodel is broken for all models except the first page, so we cannot use this
this.infinity.replace(this.selectionList.infinityModel[key], remainingModels);
}
this.selectionList.clearUnavailableItems();
}
@ -386,8 +390,10 @@ export default class PostsContextMenu extends Component {
const data = result[this.type === 'post' ? 'posts' : 'pages'][0];
const model = this.store.peekRecord(this.type, data.id);
// Update infinity list
this.selectionList.infinityModel.content.unshiftObject(model);
// Update infinity draft posts content - copied posts are always drafts
if (this.selectionList.infinityModel.draftPosts) {
this.selectionList.infinityModel.draftPosts.content.unshiftObject(model);
}
// Show notification
this.notifications.showNotification(this.#getToastMessage('duplicated'), {type: 'success'});

View File

@ -1,14 +1,39 @@
<MultiList::List @model={{@list}} class="posts-list gh-list {{unless @model "no-posts"}} feature-memberAttribution" as |list| >
{{#each @model as |post|}}
<list.item @id={{post.id}} class="gh-posts-list-item-group">
<PostsList::ListItem
@post={{post}}
data-test-post-id={{post.id}}
/>
</list.item>
{{!-- always order as scheduled, draft, remainder --}}
{{#if (or @model.scheduledPosts (or @model.draftPosts @model.publishedAndSentPosts))}}
{{#if @model.scheduledPosts}}
{{#each @model.scheduledPosts as |post|}}
<list.item @id={{post.id}} class="gh-posts-list-item-group">
<PostsList::ListItem
@post={{post}}
data-test-post-id={{post.id}}
/>
</list.item>
{{/each}}
{{/if}}
{{#if (and @model.draftPosts (or (not @model.scheduledPosts) (and @model.scheduledPosts @model.scheduledPosts.reachedInfinity)))}}
{{#each @model.draftPosts as |post|}}
<list.item @id={{post.id}} class="gh-posts-list-item-group">
<PostsList::ListItem
@post={{post}}
data-test-post-id={{post.id}}
/>
</list.item>
{{/each}}
{{/if}}
{{#if (and @model.publishedAndSentPosts (and (or (not @model.scheduledPosts) @model.scheduledPosts.reachedInfinity) (or (not @model.draftPosts) @model.draftPosts.reachedInfinity)))}}
{{#each @model.publishedAndSentPosts as |post|}}
<list.item @id={{post.id}} class="gh-posts-list-item-group">
<PostsList::ListItem
@post={{post}}
data-test-post-id={{post.id}}
/>
</list.item>
{{/each}}
{{/if}}
{{else}}
{{yield}}
{{/each}}
{{/if}}
</MultiList::List>
{{!-- The currently selected item or items are passed to the context menu --}}

View File

@ -18,7 +18,11 @@ export default class SelectionList {
#clearOnNextUnfreeze = false;
constructor(infinityModel) {
this.infinityModel = infinityModel ?? {content: []};
this.infinityModel = infinityModel ?? {
draftPosts: {
content: []
}
};
}
freeze() {
@ -41,7 +45,12 @@ export default class SelectionList {
* Returns an NQL filter for all items, not the selection
*/
get allFilter() {
return this.infinityModel.extraParams?.filter ?? '';
const models = this.infinityModel;
// grab filter from the first key in the infinityModel object (they should all be identical)
for (const key in models) {
return models[key].extraParams?.allFilter ?? '';
}
return '';
}
/**
@ -81,10 +90,13 @@ export default class SelectionList {
* Keep in mind that when using CMD + A, we don't have all items in memory!
*/
get availableModels() {
const models = this.infinityModel;
const arr = [];
for (const item of this.infinityModel.content) {
if (this.isSelected(item.id)) {
arr.push(item);
for (const key in models) {
for (const item of models[key].content) {
if (this.isSelected(item.id)) {
arr.push(item);
}
}
}
return arr;
@ -102,7 +114,13 @@ export default class SelectionList {
if (!this.inverted) {
return this.selectedIds.size;
}
return Math.max((this.infinityModel.meta?.pagination?.total ?? 0) - this.selectedIds.size, 1);
const models = this.infinityModel;
let total;
for (const key in models) {
total += models[key].meta?.pagination?.total;
}
return Math.max((total ?? 0) - this.selectedIds.size, 1);
}
isSelected(id) {
@ -147,9 +165,12 @@ export default class SelectionList {
clearUnavailableItems() {
const newSelection = new Set();
for (const item of this.infinityModel.content) {
if (this.selectedIds.has(item.id)) {
newSelection.add(item.id);
const models = this.infinityModel;
for (const key in models) {
for (const item of models[key].content) {
if (this.selectedIds.has(item.id)) {
newSelection.add(item.id);
}
}
}
this.selectedIds = newSelection;
@ -181,37 +202,40 @@ export default class SelectionList {
// todo
let running = false;
for (const item of this.infinityModel.content) {
// Exlusing the last selected item
if (item.id === this.lastSelectedId || item.id === id) {
if (!running) {
running = true;
const models = this.infinityModel;
for (const key in models) {
for (const item of this.models[key].content) {
// Exlusing the last selected item
if (item.id === this.lastSelectedId || item.id === id) {
if (!running) {
running = true;
// Skip last selected on its own
if (item.id === this.lastSelectedId) {
continue;
}
} else {
// Still include id
if (item.id === id) {
this.lastShiftSelectionGroup.add(item.id);
if (this.inverted) {
this.selectedIds.delete(item.id);
} else {
this.selectedIds.add(item.id);
// Skip last selected on its own
if (item.id === this.lastSelectedId) {
continue;
}
}
break;
}
}
} else {
// Still include id
if (item.id === id) {
this.lastShiftSelectionGroup.add(item.id);
if (running) {
this.lastShiftSelectionGroup.add(item.id);
if (this.inverted) {
this.selectedIds.delete(item.id);
} else {
this.selectedIds.add(item.id);
if (this.inverted) {
this.selectedIds.delete(item.id);
} else {
this.selectedIds.add(item.id);
}
}
break;
}
}
if (running) {
this.lastShiftSelectionGroup.add(item.id);
if (this.inverted) {
this.selectedIds.delete(item.id);
} else {
this.selectedIds.add(item.id);
}
}
}
}

View File

@ -1,5 +1,5 @@
import Controller from '@ember/controller';
import SelectionList from 'ghost-admin/utils/selection-list';
import SelectionList from 'ghost-admin/components/posts-list/selection-list';
import {DEFAULT_QUERY_PARAMS} from 'ghost-admin/helpers/reset-query-params';
import {action} from '@ember/object';
import {inject} from 'ghost-admin/decorators/inject';

View File

@ -1,4 +1,5 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import RSVP from 'rsvp';
import {action} from '@ember/object';
import {assign} from '@ember/polyfills';
import {isBlank} from '@ember/utils';
@ -39,43 +40,53 @@ export default class PostsRoute extends AuthenticatedRoute {
model(params) {
const user = this.session.user;
let queryParams = {};
let filterParams = {tag: params.tag, visibility: params.visibility};
let paginationParams = {
perPageParam: 'limit',
totalPagesParam: 'meta.pagination.pages'
};
// type filters are actually mapping statuses
assign(filterParams, this._getTypeFilters(params.type));
if (params.type === 'featured') {
filterParams.featured = true;
}
// authors and contributors can only view their own posts
if (user.isAuthor) {
// authors can only view their own posts
filterParams.authors = user.slug;
} else if (user.isContributor) {
// Contributors can only view their own draft posts
filterParams.authors = user.slug;
// filterParams.status = 'draft';
// otherwise we need to filter by author if present
} else if (params.author) {
filterParams.authors = params.author;
}
let filter = this._filterString(filterParams);
if (!isBlank(filter)) {
queryParams.filter = filter;
}
if (!isBlank(params.order)) {
queryParams.order = params.order;
}
let perPage = this.perPage;
let paginationSettings = assign({perPage, startingPage: 1}, paginationParams, queryParams);
const filterStatuses = filterParams.status;
let queryParams = {allFilter: this._filterString({...filterParams})}; // pass along the parent filter so it's easier to apply the params filter to each infinity model
let models = {};
if (filterStatuses.includes('scheduled')) {
let scheduledPostsParams = {...queryParams, order: params.order || 'published_at desc', filter: this._filterString({...filterParams, status: 'scheduled'})};
models.scheduledPosts = this.infinity.model('post', assign({perPage, startingPage: 1}, paginationParams, scheduledPostsParams));
}
if (filterStatuses.includes('draft')) {
let draftPostsParams = {...queryParams, order: params.order || 'updated_at desc', filter: this._filterString({...filterParams, status: 'draft'})};
models.draftPosts = this.infinity.model('post', assign({perPage, startingPage: 1}, paginationParams, draftPostsParams));
}
if (filterStatuses.includes('published') || filterStatuses.includes('sent')) {
let publishedAndSentPostsParams;
if (filterStatuses.includes('published') && filterStatuses.includes('sent')) {
publishedAndSentPostsParams = {...queryParams, order: params.order || 'published_at desc', filter: this._filterString({...filterParams, status: '[published,sent]'})};
} else {
publishedAndSentPostsParams = {...queryParams, order: params.order || 'published_at desc', filter: this._filterString({...filterParams, status: filterStatuses.includes('published') ? 'published' : 'sent'})};
}
models.publishedAndSentPosts = this.infinity.model('post', assign({perPage, startingPage: 1}, paginationParams, publishedAndSentPostsParams));
}
return this.infinity.model(this.modelName, paginationSettings);
return RSVP.hash(models);
}
// trigger a background load of all tags and authors for use in filter dropdowns
@ -120,6 +131,12 @@ export default class PostsRoute extends AuthenticatedRoute {
};
}
/**
* Returns an object containing the status filter based on the given type.
*
* @param {string} type - The type of filter to generate (draft, published, scheduled, sent).
* @returns {Object} - An object containing the status filter.
*/
_getTypeFilters(type) {
let status = '[draft,scheduled,published,sent]';

View File

@ -30,7 +30,7 @@
<section class="view-container content-list">
<PostsList::List
@model={{this.postsInfinityModel}}
@model={{@model}}
@list={{this.selectionList}}
>
<li class="no-posts-box" data-test-no-posts-box>
@ -43,7 +43,7 @@
</LinkTo>
{{else}}
<h4>No posts match the current filter</h4>
<LinkTo @route="posts" @query={{hash type=null author=null tag=null}} class="gh-btn" data-test-link="show-all">
<LinkTo @route="posts" @query={{hash type=null author=null tag=null visibility=null}} class="gh-btn" data-test-link="show-all">
<span>Show all posts</span>
</LinkTo>
{{/if}}
@ -51,11 +51,26 @@
</li>
</PostsList::List>
{{!-- only show one infinity loader wheel at a time - always order as scheduled, draft, remainder --}}
{{#if @model.scheduledPosts}}
<GhInfinityLoader
@infinityModel={{this.postsInfinityModel}}
@infinityModel={{@model.scheduledPosts}}
@scrollable=".gh-main"
@triggerOffset={{1000}} />
</section>
{{/if}}
{{#if (and @model.draftPosts (or (not @model.scheduledPosts) (and @model.scheduledPosts @model.scheduledPosts.reachedInfinity)))}}
<GhInfinityLoader
@infinityModel={{@model.draftPosts}}
@scrollable=".gh-main"
@triggerOffset={{1000}} />
{{/if}}
{{#if (and @model.publishedAndSentPosts (and (or (not @model.scheduledPosts) @model.scheduledPosts.reachedInfinity) (or (not @model.draftPosts) @model.draftPosts.reachedInfinity)))}}
<GhInfinityLoader
@infinityModel={{@model.publishedAndSentPosts}}
@scrollable=".gh-main"
@triggerOffset={{1000}} />
{{/if}}
</section>
{{outlet}}
</section>

View File

@ -23,7 +23,6 @@ function extractTags(postAttrs, tags) {
});
}
// TODO: handle authors filter
export function getPosts({posts}, {queryParams}) {
let {filter, page, limit} = queryParams;
@ -31,15 +30,27 @@ export function getPosts({posts}, {queryParams}) {
limit = +limit || 15;
let statusFilter = extractFilterParam('status', filter);
let authorsFilter = extractFilterParam('authors', filter);
let visibilityFilter = extractFilterParam('visibility', filter);
let collection = posts.all().filter((post) => {
let matchesStatus = true;
let matchesAuthors = true;
let matchesVisibility = true;
if (!isEmpty(statusFilter)) {
matchesStatus = statusFilter.includes(post.status);
}
return matchesStatus;
if (!isEmpty(authorsFilter)) {
matchesAuthors = authorsFilter.includes(post.authors.models[0].slug);
}
if (!isEmpty(visibilityFilter)) {
matchesVisibility = visibilityFilter.includes(post.visibility);
}
return matchesStatus && matchesAuthors && matchesVisibility;
});
return paginateModelCollection('posts', collection, page, limit);
@ -59,7 +70,6 @@ export default function mockPosts(server) {
return posts.create(attrs);
});
// TODO: handle authors filter
server.get('/posts/', getPosts);
server.get('/posts/:id/', function ({posts}, {params}) {
@ -100,6 +110,13 @@ export default function mockPosts(server) {
posts.find(ids).destroy();
});
server.post('/posts/:id/copy/', function ({posts}, {params}) {
let post = posts.find(params.id);
let attrs = post.attrs;
return posts.create(attrs);
});
server.put('/posts/bulk/', function ({tags}, {requestBody}) {
const bulk = JSON.parse(requestBody).bulk;
const action = bulk.action;
@ -115,7 +132,7 @@ export default function mockPosts(server) {
tags.create(tag);
}
});
// TODO: update the actual posts in the mock db
// TODO: update the actual posts in the mock db if wanting to write tests where we navigate around (refresh model)
// const postsToUpdate = posts.find(ids);
// getting the posts is fine, but within this we CANNOT manipulate them (???) not even iterate with .forEach
}

View File

@ -17,11 +17,15 @@ const findButton = (text, buttons) => {
return Array.from(buttons).find(button => button.innerText.trim() === text);
};
// NOTE: With accommodations for faster loading of posts in the UI, the requests to fetch the posts have been split into separate requests based
// on the status of the post. This means that the tests for filtering by status will have multiple requests to check against.
describe('Acceptance: Content', function () {
let hooks = setupApplicationTest();
setupMirage(hooks);
beforeEach(async function () {
// console.log(`this.server`, this.server);
// console.log(`this.server.db`, this.server.db);
this.server.loadFixtures('configs');
});
@ -32,6 +36,70 @@ describe('Acceptance: Content', function () {
expect(currentURL()).to.equal('/signin');
});
describe('as contributor', function () {
beforeEach(async function () {
let contributorRole = this.server.create('role', {name: 'Contributor'});
this.server.create('user', {roles: [contributorRole]});
return await authenticateSession();
});
// NOTE: This test seems to fail if run AFTER the 'can change access' test in the 'as admin' section; router seems to fail, did not look into it further
it('shows posts list and allows post creation', async function () {
await visit('/posts');
// has an empty state
expect(findAll('[data-test-post-id]')).to.have.length(0);
expect(find('[data-test-no-posts-box]')).to.exist;
expect(find('[data-test-link="write-a-new-post"]')).to.exist;
await click('[data-test-link="write-a-new-post"]');
expect(currentURL()).to.equal('/editor/post');
await fillIn('[data-test-editor-title-input]', 'First contributor post');
await blur('[data-test-editor-title-input]');
expect(currentURL()).to.equal('/editor/post/1');
await click('[data-test-link="posts"]');
expect(findAll('[data-test-post-id]')).to.have.length(1);
expect(find('[data-test-no-posts-box]')).to.not.exist;
});
});
describe('as author', function () {
let author, authorPost;
beforeEach(async function () {
let authorRole = this.server.create('role', {name: 'Author'});
author = this.server.create('user', {roles: [authorRole]});
let adminRole = this.server.create('role', {name: 'Administrator'});
let admin = this.server.create('user', {roles: [adminRole]});
// create posts
authorPost = this.server.create('post', {authors: [author], status: 'published', title: 'Author Post'});
this.server.create('post', {authors: [admin], status: 'scheduled', title: 'Admin Post'});
return await authenticateSession();
});
it('only fetches the author\'s posts', async function () {
await visit('/posts');
// trigger a filter request so we can grab the posts API request easily
await selectChoose('[data-test-type-select]', 'Published posts');
// API request includes author filter
let [lastRequest] = this.server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.filter).to.have.string(`authors:${author.slug}`);
// only author's post is shown
expect(findAll('[data-test-post-id]').length, 'post count').to.equal(1);
expect(find(`[data-test-post-id="${authorPost.id}"]`), 'author post').to.exist;
});
});
describe('as admin', function () {
let admin, editor, publishedPost, scheduledPost, draftPost, authorPost;
@ -41,11 +109,10 @@ describe('Acceptance: Content', function () {
let editorRole = this.server.create('role', {name: 'Editor'});
editor = this.server.create('user', {roles: [editorRole]});
publishedPost = this.server.create('post', {authors: [admin], status: 'published', title: 'Published Post'});
publishedPost = this.server.create('post', {authors: [admin], status: 'published', title: 'Published Post', visibility: 'paid'});
scheduledPost = this.server.create('post', {authors: [admin], status: 'scheduled', title: 'Scheduled Post'});
// draftPost = this.server.create('post', {authors: [admin], status: 'draft', title: 'Draft Post', visibility: 'paid'});
draftPost = this.server.create('post', {authors: [admin], status: 'draft', title: 'Draft Post'});
authorPost = this.server.create('post', {authors: [editor], status: 'published', title: 'Editor Published Post', visibiity: 'paid'});
authorPost = this.server.create('post', {authors: [editor], status: 'published', title: 'Editor Published Post'});
// pages shouldn't appear in the list
this.server.create('page', {authors: [admin], status: 'published', title: 'Published Page'});
@ -61,7 +128,17 @@ describe('Acceptance: Content', function () {
// displays all posts by default (all statuses) [no pages]
expect(posts.length, 'all posts count').to.equal(4);
// note: atm the mirage backend doesn't support ordering of the results set
// make sure display is scheduled > draft > published/sent
expect(posts[0].querySelector('.gh-content-entry-title').textContent, 'post 1 title').to.contain('Scheduled Post');
expect(posts[1].querySelector('.gh-content-entry-title').textContent, 'post 2 title').to.contain('Draft Post');
expect(posts[2].querySelector('.gh-content-entry-title').textContent, 'post 3 title').to.contain('Published Post');
expect(posts[3].querySelector('.gh-content-entry-title').textContent, 'post 4 title').to.contain('Editor Published Post');
// check API requests
let lastRequests = this.server.pretender.handledRequests.filter(request => request.url.includes('/posts/'));
expect(lastRequests[0].queryParams.filter, 'scheduled request filter').to.have.string('status:scheduled');
expect(lastRequests[1].queryParams.filter, 'drafts request filter').to.have.string('status:draft');
expect(lastRequests[2].queryParams.filter, 'published request filter').to.have.string('status:[published,sent]');
});
it('can filter by status', async function () {
@ -97,13 +174,6 @@ describe('Acceptance: Content', function () {
// Displays scheduled post
expect(findAll('[data-test-post-id]').length, 'scheduled count').to.equal(1);
expect(find(`[data-test-post-id="${scheduledPost.id}"]`), 'scheduled post').to.exist;
// show all posts
await selectChoose('[data-test-type-select]', 'All posts');
// API request is correct
[lastRequest] = this.server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.filter, '"all" request status filter').to.have.string('status:[draft,scheduled,published,sent]');
});
it('can filter by author', async function () {
@ -114,20 +184,31 @@ describe('Acceptance: Content', function () {
// API request is correct
let [lastRequest] = this.server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.filter, '"editor" request status filter')
expect(lastRequest.queryParams.allFilter, '"editor" request status filter')
.to.have.string('status:[draft,scheduled,published,sent]');
expect(lastRequest.queryParams.filter, '"editor" request filter param')
expect(lastRequest.queryParams.allFilter, '"editor" request filter param')
.to.have.string(`authors:${editor.slug}`);
// Displays editor post
expect(findAll('[data-test-post-id]').length, 'editor count').to.equal(1);
});
it('can filter by visibility', async function () {
await visit('/posts');
await selectChoose('[data-test-visibility-select]', 'Paid members-only');
let [lastRequest] = this.server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.filter, '"visibility" request filter param')
.to.have.string('visibility:[paid,tiers]+status:[draft,scheduled,published,sent]');
expect(lastRequest.queryParams.allFilter, '"visibility" request filter param')
.to.have.string('visibility:[paid,tiers]');
let posts = findAll('[data-test-post-id]');
expect(posts.length, 'all posts count').to.equal(1);
await selectChoose('[data-test-visibility-select]', 'Public');
[lastRequest] = this.server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.allFilter, '"visibility" request filter param')
.to.have.string('visibility:public');
posts = findAll('[data-test-post-id]');
expect(posts.length, 'all posts count').to.equal(3);
});
it('can filter by tag', async function () {
@ -150,14 +231,13 @@ describe('Acceptance: Content', function () {
await selectChoose('[data-test-tag-select]', 'B - Second');
// affirm request
let [lastRequest] = this.server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.filter, 'request filter').to.have.string('tag:second');
expect(lastRequest.queryParams.allFilter, '"tag" request filter param').to.have.string('tag:second');
});
});
describe('context menu actions', function () {
describe('single post', function () {
// has a duplicate option
it.skip('can duplicate a post', async function () {
it('can duplicate a post', async function () {
await visit('/posts');
// get the post
@ -165,13 +245,11 @@ describe('Acceptance: Content', function () {
expect(post, 'post').to.exist;
await triggerEvent(post, 'contextmenu');
// await this.pauseTest();
let contextMenu = find('.gh-posts-context-menu'); // this is a <ul> element
let buttons = contextMenu.querySelectorAll('button');
// should have three options for a published post
expect(contextMenu, 'context menu').to.exist;
expect(buttons.length, 'context menu buttons').to.equal(5);
expect(buttons[0].innerText.trim(), 'context menu button 1').to.contain('Unpublish');
@ -183,19 +261,15 @@ describe('Acceptance: Content', function () {
// duplicate the post
await click(buttons[3]);
// API request is correct
// POST /ghost/api/admin/posts/{id}/copy/?formats=mobiledoc,lexical
// TODO: probably missing endpoint in mirage...
// let [lastRequest] = this.server.pretender.handledRequests.slice(-1);
// console.log(`lastRequest`, lastRequest);
// expect(lastRequest.url, 'request url').to.match(new RegExp(`/posts/${publishedPost.id}/copy/`));
const posts = findAll('[data-test-post-id]');
expect(posts.length, 'all posts count').to.equal(5);
let [lastRequest] = this.server.pretender.handledRequests.slice(-1);
expect(lastRequest.url, 'request url').to.match(new RegExp(`/posts/${publishedPost.id}/copy/`));
});
});
describe('multiple posts', function () {
it('can feature and unfeature posts', async function () {
it('can feature and unfeature', async function () {
await visit('/posts');
// get all posts
@ -226,7 +300,7 @@ describe('Acceptance: Content', function () {
// API request is correct - note, we don't mock the actual model updates
let [lastRequest] = this.server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.filter, 'feature request id').to.equal(`id:['3','4']`);
expect(lastRequest.queryParams.filter, 'feature request id').to.equal(`id:['${publishedPost.id}','${authorPost.id}']`);
expect(JSON.parse(lastRequest.requestBody).bulk.action, 'feature request action').to.equal('feature');
// ensure ui shows these are now featured
@ -247,7 +321,7 @@ describe('Acceptance: Content', function () {
// API request is correct - note, we don't mock the actual model updates
[lastRequest] = this.server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.filter, 'unfeature request id').to.equal(`id:['3','4']`);
expect(lastRequest.queryParams.filter, 'unfeature request id').to.equal(`id:['${publishedPost.id}','${authorPost.id}']`);
expect(JSON.parse(lastRequest.requestBody).bulk.action, 'unfeature request action').to.equal('unfeature');
// ensure ui shows these are now unfeatured
@ -255,7 +329,7 @@ describe('Acceptance: Content', function () {
expect(postFourContainer.querySelector('.gh-featured-post'), 'postFour featured').to.not.exist;
});
it('can add a tag to multiple posts', async function () {
it('can add a tag', async function () {
await visit('/posts');
// get all posts
@ -295,13 +369,12 @@ describe('Acceptance: Content', function () {
// API request is correct - note, we don't mock the actual model updates
let [lastRequest] = this.server.pretender.handledRequests.slice(-2);
expect(lastRequest.queryParams.filter, 'add tag request id').to.equal(`id:['3','4']`);
expect(lastRequest.queryParams.filter, 'add tag request id').to.equal(`id:['${publishedPost.id}','${authorPost.id}']`);
expect(JSON.parse(lastRequest.requestBody).bulk.action, 'add tag request action').to.equal('addTag');
});
// NOTE: we do not seem to be loading the settings properly into the membersutil service, such that the members
// service doesn't think members are enabled
it.skip('can change access to multiple posts', async function () {
// TODO: Skip for now. This causes the member creation test to fail ('New member' text doesn't show... ???).
it.skip('can change access', async function () {
await visit('/posts');
// get all posts
@ -317,26 +390,38 @@ describe('Acceptance: Content', function () {
expect(postFourContainer.getAttribute('data-selected'), 'postFour selected').to.exist;
expect(postThreeContainer.getAttribute('data-selected'), 'postThree selected').to.exist;
// NOTE: right clicks don't seem to work in these tests
// contextmenu is the event triggered - https://developer.mozilla.org/en-US/docs/Web/API/Element/contextmenu_event
await triggerEvent(postFourContainer, 'contextmenu');
let contextMenu = find('.gh-posts-context-menu'); // this is a <ul> element
expect(contextMenu, 'context menu').to.exist;
// TODO: the change access button is not showing; need to debug the UI to see what field it expects
// change access to the posts
let buttons = contextMenu.querySelectorAll('button');
let changeAccessButton = findButton('Change access', buttons);
expect(changeAccessButton, 'change access button').not.to.exist;
const settingsService = this.owner.lookup('service:settings');
await settingsService.set('membersEnabled', true);
await triggerEvent(postFourContainer, 'contextmenu');
contextMenu = find('.gh-posts-context-menu'); // this is a <ul> element
expect(contextMenu, 'context menu').to.exist;
buttons = contextMenu.querySelectorAll('button');
changeAccessButton = findButton('Change access', buttons);
expect(changeAccessButton, 'change access button').to.exist;
await click(changeAccessButton);
const changeAccessModal = find('[data-test-modal="edit-posts-access"]');
expect(changeAccessModal, 'change access modal').to.exist;
const selectElement = changeAccessModal.querySelector('select');
await fillIn(selectElement, 'members');
await click('[data-test-button="confirm"]');
// check API request
let [lastRequest] = this.server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.filter, 'change access request id').to.equal(`id:['${publishedPost.id}','${authorPost.id}']`);
expect(JSON.parse(lastRequest.requestBody).bulk.action, 'change access request action').to.equal('access');
});
it('can unpublish posts', async function () {
it('can unpublish', async function () {
await visit('/posts');
// get all posts
@ -372,7 +457,7 @@ describe('Acceptance: Content', function () {
// API request is correct - note, we don't mock the actual model updates
let [lastRequest] = this.server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.filter, 'unpublish request id').to.equal(`id:['3','4']`);
expect(lastRequest.queryParams.filter, 'unpublish request id').to.equal(`id:['${publishedPost.id}','${authorPost.id}']`);
expect(JSON.parse(lastRequest.requestBody).bulk.action, 'unpublish request action').to.equal('unpublish');
// ensure ui shows these are now unpublished
@ -380,7 +465,7 @@ describe('Acceptance: Content', function () {
expect(postFourContainer.querySelector('.gh-content-entry-status').textContent, 'postThree status').to.contain('Draft');
});
it('can delete posts', async function () {
it('can delete', async function () {
await visit('/posts');
// get all posts
@ -416,7 +501,7 @@ describe('Acceptance: Content', function () {
// API request is correct - note, we don't mock the actual model updates
let [lastRequest] = this.server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.filter, 'delete request id').to.equal(`id:['3','4']`);
expect(lastRequest.queryParams.filter, 'delete request id').to.equal(`id:['${publishedPost.id}','${authorPost.id}']`);
expect(lastRequest.method, 'delete request method').to.equal('DELETE');
// ensure ui shows these are now deleted
@ -508,67 +593,4 @@ describe('Acceptance: Content', function () {
expect(find('[data-test-screen-title]').innerText).to.match(/Scheduled/);
});
});
describe('as author', function () {
let author, authorPost;
beforeEach(async function () {
let authorRole = this.server.create('role', {name: 'Author'});
author = this.server.create('user', {roles: [authorRole]});
let adminRole = this.server.create('role', {name: 'Administrator'});
let admin = this.server.create('user', {roles: [adminRole]});
// create posts
authorPost = this.server.create('post', {authors: [author], status: 'published', title: 'Author Post'});
this.server.create('post', {authors: [admin], status: 'scheduled', title: 'Admin Post'});
return await authenticateSession();
});
it('only fetches the author\'s posts', async function () {
await visit('/posts');
// trigger a filter request so we can grab the posts API request easily
await selectChoose('[data-test-type-select]', 'Published posts');
// API request includes author filter
let [lastRequest] = this.server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.filter).to.have.string(`authors:${author.slug}`);
// only author's post is shown
expect(findAll('[data-test-post-id]').length, 'post count').to.equal(1);
expect(find(`[data-test-post-id="${authorPost.id}"]`), 'author post').to.exist;
});
});
describe('as contributor', function () {
beforeEach(async function () {
let contributorRole = this.server.create('role', {name: 'Contributor'});
this.server.create('user', {roles: [contributorRole]});
return await authenticateSession();
});
it('shows posts list and allows post creation', async function () {
await visit('/posts');
// has an empty state
expect(findAll('[data-test-post-id]')).to.have.length(0);
expect(find('[data-test-no-posts-box]')).to.exist;
expect(find('[data-test-link="write-a-new-post"]')).to.exist;
await click('[data-test-link="write-a-new-post"]');
expect(currentURL()).to.equal('/editor/post');
await fillIn('[data-test-editor-title-input]', 'First contributor post');
await blur('[data-test-editor-title-input]');
expect(currentURL()).to.equal('/editor/post/1');
await click('[data-test-link="posts"]');
expect(findAll('[data-test-post-id]')).to.have.length(1);
expect(find('[data-test-no-posts-box]')).to.not.exist;
});
});
});