diff --git a/ghost/admin/app/components/gh-context-menu.js b/ghost/admin/app/components/gh-context-menu.js index 205e488bb1..8b3de5a54b 100644 --- a/ghost/admin/app/components/gh-context-menu.js +++ b/ghost/admin/app/components/gh-context-menu.js @@ -1,5 +1,5 @@ import Component from '@glimmer/component'; -import SelectionList from './posts-list/selection-list'; +import SelectionList from '../utils/selection-list'; import {action} from '@ember/object'; import {inject as service} from '@ember/service'; import {task} from 'ember-concurrency'; diff --git a/ghost/admin/app/components/posts-list/context-menu.js b/ghost/admin/app/components/posts-list/context-menu.js index 0e9b248661..f945cfda40 100644 --- a/ghost/admin/app/components/posts-list/context-menu.js +++ b/ghost/admin/app/components/posts-list/context-menu.js @@ -216,14 +216,11 @@ export default class PostsContextMenu extends Component { yield this.performBulkDestroy(); this.notifications.showNotification(this.#getToastMessage('deleted'), {type: 'success'}); - 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); - } - + 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); this.selectionList.clearSelection({force: true}); return true; } @@ -250,7 +247,9 @@ export default class PostsContextMenu extends Component { } } + // Remove posts that no longer match the filter this.updateFilteredPosts(); + return true; } @@ -283,17 +282,14 @@ export default class PostsContextMenu extends Component { ] }); - // 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); - } + 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); this.selectionList.clearUnavailableItems(); } @@ -390,10 +386,8 @@ 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 draft posts content - copied posts are always drafts - if (this.selectionList.infinityModel.draftPosts) { - this.selectionList.infinityModel.draftPosts.content.unshiftObject(model); - } + // Update infinity list + this.selectionList.infinityModel.content.unshiftObject(model); // Show notification this.notifications.showNotification(this.#getToastMessage('duplicated'), {type: 'success'}); diff --git a/ghost/admin/app/components/posts-list/list.hbs b/ghost/admin/app/components/posts-list/list.hbs index b1ab3acd99..4755c76d62 100644 --- a/ghost/admin/app/components/posts-list/list.hbs +++ b/ghost/admin/app/components/posts-list/list.hbs @@ -1,39 +1,14 @@ - {{!-- always order as scheduled, draft, remainder --}} - {{#if (or @model.scheduledPosts (or @model.draftPosts @model.publishedAndSentPosts))}} - {{#if @model.scheduledPosts}} - {{#each @model.scheduledPosts as |post|}} - - - - {{/each}} - {{/if}} - {{#if (and @model.draftPosts (or (not @model.scheduledPosts) (and @model.scheduledPosts @model.scheduledPosts.reachedInfinity)))}} - {{#each @model.draftPosts as |post|}} - - - - {{/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|}} - - - - {{/each}} - {{/if}} + {{#each @model as |post|}} + + + {{else}} {{yield}} - {{/if}} + {{/each}} {{!-- The currently selected item or items are passed to the context menu --}} diff --git a/ghost/admin/app/controllers/posts.js b/ghost/admin/app/controllers/posts.js index 1a8dd66e2a..014cad0f47 100644 --- a/ghost/admin/app/controllers/posts.js +++ b/ghost/admin/app/controllers/posts.js @@ -1,5 +1,5 @@ import Controller from '@ember/controller'; -import SelectionList from 'ghost-admin/components/posts-list/selection-list'; +import SelectionList from 'ghost-admin/utils/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'; diff --git a/ghost/admin/app/routes/posts.js b/ghost/admin/app/routes/posts.js index 1df44770ec..93e7d5d4ab 100644 --- a/ghost/admin/app/routes/posts.js +++ b/ghost/admin/app/routes/posts.js @@ -1,5 +1,4 @@ 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'; @@ -40,53 +39,43 @@ 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; - // otherwise we need to filter by author if present + // filterParams.status = 'draft'; } else if (params.author) { filterParams.authors = params.author; } - - let perPage = this.perPage; - - 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)); + + let filter = this._filterString(filterParams); + if (!isBlank(filter)) { + queryParams.filter = filter; } - return RSVP.hash(models); + 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); } // trigger a background load of all tags and authors for use in filter dropdowns @@ -131,12 +120,6 @@ 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]'; diff --git a/ghost/admin/app/templates/posts.hbs b/ghost/admin/app/templates/posts.hbs index 4187b57418..f0d0b6bbe8 100644 --- a/ghost/admin/app/templates/posts.hbs +++ b/ghost/admin/app/templates/posts.hbs @@ -30,7 +30,7 @@
  • @@ -43,7 +43,7 @@ {{else}}

    No posts match the current filter

    - + Show all posts {{/if}} @@ -51,26 +51,11 @@
  • - {{!-- only show one infinity loader wheel at a time - always order as scheduled, draft, remainder --}} - {{#if @model.scheduledPosts}} - {{/if}} - {{#if (and @model.draftPosts (or (not @model.scheduledPosts) (and @model.scheduledPosts @model.scheduledPosts.reachedInfinity)))}} - - {{/if}} - {{#if (and @model.publishedAndSentPosts (and (or (not @model.scheduledPosts) @model.scheduledPosts.reachedInfinity) (or (not @model.draftPosts) @model.draftPosts.reachedInfinity)))}} - - {{/if}} -
    + {{outlet}} diff --git a/ghost/admin/app/components/posts-list/selection-list.js b/ghost/admin/app/utils/selection-list.js similarity index 67% rename from ghost/admin/app/components/posts-list/selection-list.js rename to ghost/admin/app/utils/selection-list.js index c389cd9545..b409d6da4b 100644 --- a/ghost/admin/app/components/posts-list/selection-list.js +++ b/ghost/admin/app/utils/selection-list.js @@ -18,11 +18,7 @@ export default class SelectionList { #clearOnNextUnfreeze = false; constructor(infinityModel) { - this.infinityModel = infinityModel ?? { - draftPosts: { - content: [] - } - }; + this.infinityModel = infinityModel ?? {content: []}; } freeze() { @@ -45,12 +41,7 @@ export default class SelectionList { * Returns an NQL filter for all items, not the selection */ get allFilter() { - 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 ''; + return this.infinityModel.extraParams?.filter ?? ''; } /** @@ -90,13 +81,10 @@ 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 key in models) { - for (const item of models[key].content) { - if (this.isSelected(item.id)) { - arr.push(item); - } + for (const item of this.infinityModel.content) { + if (this.isSelected(item.id)) { + arr.push(item); } } return arr; @@ -114,13 +102,7 @@ export default class SelectionList { if (!this.inverted) { return this.selectedIds.size; } - - 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); + return Math.max((this.infinityModel.meta?.pagination?.total ?? 0) - this.selectedIds.size, 1); } isSelected(id) { @@ -165,12 +147,9 @@ export default class SelectionList { clearUnavailableItems() { const newSelection = new Set(); - 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); - } + for (const item of this.infinityModel.content) { + if (this.selectedIds.has(item.id)) { + newSelection.add(item.id); } } this.selectedIds = newSelection; @@ -202,40 +181,37 @@ export default class SelectionList { // todo let running = false; - 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; + for (const item of this.infinityModel.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); - } - } - break; + // 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); + } + } + break; } + } - if (running) { - this.lastShiftSelectionGroup.add(item.id); - if (this.inverted) { - this.selectedIds.delete(item.id); - } else { - this.selectedIds.add(item.id); - } + if (running) { + this.lastShiftSelectionGroup.add(item.id); + if (this.inverted) { + this.selectedIds.delete(item.id); + } else { + this.selectedIds.add(item.id); } } } diff --git a/ghost/admin/mirage/config/posts.js b/ghost/admin/mirage/config/posts.js index 2836e0613d..a12863bfe7 100644 --- a/ghost/admin/mirage/config/posts.js +++ b/ghost/admin/mirage/config/posts.js @@ -23,6 +23,7 @@ function extractTags(postAttrs, tags) { }); } +// TODO: handle authors filter export function getPosts({posts}, {queryParams}) { let {filter, page, limit} = queryParams; @@ -30,27 +31,15 @@ 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); } - if (!isEmpty(authorsFilter)) { - matchesAuthors = authorsFilter.includes(post.authors.models[0].slug); - } - - if (!isEmpty(visibilityFilter)) { - matchesVisibility = visibilityFilter.includes(post.visibility); - } - - return matchesStatus && matchesAuthors && matchesVisibility; + return matchesStatus; }); return paginateModelCollection('posts', collection, page, limit); @@ -70,6 +59,7 @@ export default function mockPosts(server) { return posts.create(attrs); }); + // TODO: handle authors filter server.get('/posts/', getPosts); server.get('/posts/:id/', function ({posts}, {params}) { @@ -110,13 +100,6 @@ 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; @@ -132,7 +115,7 @@ export default function mockPosts(server) { tags.create(tag); } }); - // TODO: update the actual posts in the mock db if wanting to write tests where we navigate around (refresh model) + // TODO: update the actual posts in the mock db // const postsToUpdate = posts.find(ids); // getting the posts is fine, but within this we CANNOT manipulate them (???) not even iterate with .forEach } diff --git a/ghost/admin/tests/acceptance/content-test.js b/ghost/admin/tests/acceptance/content-test.js index 0fa898abab..5fbbf3f743 100644 --- a/ghost/admin/tests/acceptance/content-test.js +++ b/ghost/admin/tests/acceptance/content-test.js @@ -17,15 +17,11 @@ 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'); }); @@ -36,70 +32,6 @@ 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; @@ -109,10 +41,11 @@ 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', visibility: 'paid'}); + publishedPost = this.server.create('post', {authors: [admin], status: 'published', title: 'Published Post'}); 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'}); + authorPost = this.server.create('post', {authors: [editor], status: 'published', title: 'Editor Published Post', visibiity: 'paid'}); // pages shouldn't appear in the list this.server.create('page', {authors: [admin], status: 'published', title: 'Published Page'}); @@ -128,17 +61,7 @@ describe('Acceptance: Content', function () { // displays all posts by default (all statuses) [no pages] expect(posts.length, 'all posts count').to.equal(4); - // 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]'); + // note: atm the mirage backend doesn't support ordering of the results set }); it('can filter by status', async function () { @@ -174,6 +97,13 @@ 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 () { @@ -184,31 +114,20 @@ describe('Acceptance: Content', function () { // API request is correct let [lastRequest] = this.server.pretender.handledRequests.slice(-1); - expect(lastRequest.queryParams.allFilter, '"editor" request status filter') + expect(lastRequest.queryParams.filter, '"editor" request status filter') .to.have.string('status:[draft,scheduled,published,sent]'); - expect(lastRequest.queryParams.allFilter, '"editor" request filter param') + expect(lastRequest.queryParams.filter, '"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.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); + expect(lastRequest.queryParams.filter, '"visibility" request filter param') + .to.have.string('visibility:[paid,tiers]+status:[draft,scheduled,published,sent]'); }); it('can filter by tag', async function () { @@ -231,13 +150,14 @@ 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.allFilter, '"tag" request filter param').to.have.string('tag:second'); + expect(lastRequest.queryParams.filter, 'request filter').to.have.string('tag:second'); }); }); describe('context menu actions', function () { describe('single post', function () { - it('can duplicate a post', async function () { + // has a duplicate option + it.skip('can duplicate a post', async function () { await visit('/posts'); // get the post @@ -245,11 +165,13 @@ 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