Improved performance in Admin Posts view (#20503)
ref https://linear.app/tryghost/issue/ONC-111 - changed posts fetching/display behavior to be client-side instead of server-side - admin will issue (potentially multiple) requests based on the desired status(es) - updated admin acceptance test for missing coverage I've pulled the sort from the database query as this triple sort performs very poorly at scale (taking ~4s+ past ~20k posts sometimes). Instead, we now split up the fetch to grab only one status at a time and use the front-end logic to handle displaying scheduled, then drafts, then published. This should result in a much more responsive view. We will separately change the default sort on the Admin API as that was the ultimate intent for this change.
This commit is contained in:
parent
7f963e9c2a
commit
3d9d552271
@ -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 --}}
|
||||
|
@ -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';
|
||||
@ -46,36 +47,46 @@ export default class PostsRoute extends AuthenticatedRoute {
|
||||
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);
|
||||
|
||||
return this.infinity.model(this.modelName, paginationSettings);
|
||||
const filterStatuses = filterParams.status;
|
||||
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 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>
|
||||
@ -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>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support';
|
||||
import {beforeEach, describe, it} from 'mocha';
|
||||
import {blur, click, currentURL, fillIn, find, findAll, settled, visit} from '@ember/test-helpers';
|
||||
import {blur, click, currentURL, fillIn, find, findAll, visit} from '@ember/test-helpers';
|
||||
import {clickTrigger, selectChoose} from 'ember-power-select/test-support/helpers';
|
||||
import {expect} from 'chai';
|
||||
import {setupApplicationTest} from 'ember-mocha';
|
||||
@ -41,7 +41,7 @@ describe('Acceptance: Content', function () {
|
||||
return await authenticateSession();
|
||||
});
|
||||
|
||||
it.skip('displays and filters posts', async function () {
|
||||
it('displays and filters posts', async function () {
|
||||
await visit('/posts');
|
||||
// Not checking request here as it won't be the last request made
|
||||
// Displays all posts + pages
|
||||
@ -81,38 +81,29 @@ describe('Acceptance: Content', function () {
|
||||
// show all posts
|
||||
await selectChoose('[data-test-type-select]', 'All posts');
|
||||
|
||||
// API request is correct
|
||||
// Posts are ordered scheduled -> draft -> published/sent
|
||||
// check API request is correct - we submit one request for scheduled, one for drafts, and one for published+sent
|
||||
[lastRequest] = this.server.pretender.handledRequests.slice(-3);
|
||||
expect(lastRequest.queryParams.filter, '"all" request status filter').to.have.string('status:scheduled');
|
||||
[lastRequest] = this.server.pretender.handledRequests.slice(-2);
|
||||
expect(lastRequest.queryParams.filter, '"all" request status filter').to.have.string('status:draft');
|
||||
[lastRequest] = this.server.pretender.handledRequests.slice(-1);
|
||||
expect(lastRequest.queryParams.filter, '"all" request status filter').to.have.string('status:[draft,scheduled,published]');
|
||||
expect(lastRequest.queryParams.filter, '"all" request status filter').to.have.string('status:[published,sent]');
|
||||
|
||||
// check order display is correct
|
||||
let postIds = findAll('[data-test-post-id]').map(el => el.getAttribute('data-test-post-id'));
|
||||
expect(postIds, 'post order').to.deep.equal([scheduledPost.id, draftPost.id, publishedPost.id, authorPost.id]);
|
||||
|
||||
// show all posts by editor
|
||||
await selectChoose('[data-test-type-select]', 'Published posts');
|
||||
await selectChoose('[data-test-author-select]', editor.name);
|
||||
|
||||
// API request is correct
|
||||
[lastRequest] = this.server.pretender.handledRequests.slice(-1);
|
||||
expect(lastRequest.queryParams.filter, '"editor" request status filter')
|
||||
.to.have.string('status:[draft,scheduled,published]');
|
||||
.to.have.string('status:published');
|
||||
expect(lastRequest.queryParams.filter, '"editor" request filter param')
|
||||
.to.have.string(`authors:${editor.slug}`);
|
||||
|
||||
// Post status is only visible when members is enabled
|
||||
expect(find('[data-test-visibility-select]'), 'access dropdown before members enabled').to.not.exist;
|
||||
let featureService = this.owner.lookup('service:feature');
|
||||
featureService.set('members', true);
|
||||
await settled();
|
||||
expect(find('[data-test-visibility-select]'), 'access dropdown after members enabled').to.exist;
|
||||
|
||||
await selectChoose('[data-test-visibility-select]', 'Paid members-only');
|
||||
[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]');
|
||||
|
||||
// Displays editor post
|
||||
// TODO: implement "filter" param support and fix mirage post->author association
|
||||
// expect(find('[data-test-post-id]').length, 'editor post count').to.equal(1);
|
||||
// expect(find(`[data-test-post-id="${authorPost.id}"]`), 'author post').to.exist;
|
||||
|
||||
// TODO: test tags dropdown
|
||||
});
|
||||
|
||||
// TODO: skipped due to consistently random failures on Travis
|
||||
|
Loading…
Reference in New Issue
Block a user