✨ 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:
parent
573dc9f3ee
commit
cd17b94e9c
@ -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';
|
||||
|
@ -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'});
|
||||
|
@ -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 --}}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
@ -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]';
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user