diff --git a/ghost/admin/app/components/gh-context-menu.js b/ghost/admin/app/components/gh-context-menu.js index 8b3de5a54b..205e488bb1 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 '../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'; diff --git a/ghost/admin/app/components/posts-list/context-menu.js b/ghost/admin/app/components/posts-list/context-menu.js index f945cfda40..0e9b248661 100644 --- a/ghost/admin/app/components/posts-list/context-menu.js +++ b/ghost/admin/app/components/posts-list/context-menu.js @@ -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'}); diff --git a/ghost/admin/app/components/posts-list/list.hbs b/ghost/admin/app/components/posts-list/list.hbs index 4755c76d62..b1ab3acd99 100644 --- a/ghost/admin/app/components/posts-list/list.hbs +++ b/ghost/admin/app/components/posts-list/list.hbs @@ -1,14 +1,39 @@ - {{#each @model as |post|}} - - - + {{!-- 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}} {{else}} {{yield}} - {{/each}} + {{/if}} {{!-- The currently selected item or items are passed to the context menu --}} diff --git a/ghost/admin/app/utils/selection-list.js b/ghost/admin/app/components/posts-list/selection-list.js similarity index 67% rename from ghost/admin/app/utils/selection-list.js rename to ghost/admin/app/components/posts-list/selection-list.js index b409d6da4b..c389cd9545 100644 --- a/ghost/admin/app/utils/selection-list.js +++ b/ghost/admin/app/components/posts-list/selection-list.js @@ -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); + } } } } diff --git a/ghost/admin/app/controllers/posts.js b/ghost/admin/app/controllers/posts.js index 014cad0f47..1a8dd66e2a 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/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'; diff --git a/ghost/admin/app/routes/posts.js b/ghost/admin/app/routes/posts.js index 93e7d5d4ab..1df44770ec 100644 --- a/ghost/admin/app/routes/posts.js +++ b/ghost/admin/app/routes/posts.js @@ -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]'; diff --git a/ghost/admin/app/templates/posts.hbs b/ghost/admin/app/templates/posts.hbs index f0d0b6bbe8..4187b57418 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,11 +51,26 @@
  • + {{!-- 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/mirage/config/posts.js b/ghost/admin/mirage/config/posts.js index a12863bfe7..2836e0613d 100644 --- a/ghost/admin/mirage/config/posts.js +++ b/ghost/admin/mirage/config/posts.js @@ -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 } diff --git a/ghost/admin/tests/acceptance/content-test.js b/ghost/admin/tests/acceptance/content-test.js index 5fbbf3f743..0fa898abab 100644 --- a/ghost/admin/tests/acceptance/content-test.js +++ b/ghost/admin/tests/acceptance/content-test.js @@ -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