🎨 Added “Copy post link” to posts list context menu (#20760)

REF DES-321
- Added a "Copy post link" button to the context menu to copy the post URL for published posts, and a "Copy preview link" for draft and scheduled posts.

---------

Co-authored-by: Kevin Ansfield <kevin@lookingsideways.co.uk>
This commit is contained in:
Sanne de Vries 2024-08-15 16:09:48 +02:00 committed by GitHub
parent dc7abe4712
commit ae628d7520
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 160 additions and 30 deletions

View File

@ -1,11 +1,26 @@
<ul class="gh-posts-context-menu dropdown-menu dropdown-triangle-top-left"> <ul class="gh-posts-context-menu dropdown-menu dropdown-triangle-top-left">
{{#if this.canUnpublishSelection}} {{#if this.canUnpublishSelection}}
{{#if this.canCopySelection}}
<li>
<button class="mr2" type="button" {{on "click" this.copyPostLink}}>
<span>{{svg-jar "link"}}Copy link to post</span>
</button>
</li>
{{/if}}
<li> <li>
<button class="mr2" type="button" {{on "click" this.unpublishPosts}}> <button class="mr2" type="button" {{on "click" this.unpublishPosts}}>
<span>{{svg-jar "undo"}}Unpublish</span> <span>{{svg-jar "undo"}}Unpublish</span>
</button> </button>
</li> </li>
{{else}}
{{#if this.canCopySelection}}
<li>
<button class="mr2" type="button" {{on "click" this.copyPreviewLink}}>
<span>{{svg-jar "link"}}Copy preview link</span>
</button>
</li>
{{/if}}
{{/if}} {{/if}}
{{#if this.canFeatureSelection}} {{#if this.canFeatureSelection}}
{{#if this.shouldFeatureSelection }} {{#if this.shouldFeatureSelection }}

View File

@ -3,11 +3,12 @@ import Component from '@glimmer/component';
import DeletePostsModal from './modals/delete-posts'; import DeletePostsModal from './modals/delete-posts';
import EditPostsAccessModal from './modals/edit-posts-access'; import EditPostsAccessModal from './modals/edit-posts-access';
import UnpublishPostsModal from './modals/unpublish-posts'; import UnpublishPostsModal from './modals/unpublish-posts';
import copyTextToClipboard from 'ghost-admin/utils/copy-text-to-clipboard';
import nql from '@tryghost/nql'; import nql from '@tryghost/nql';
import {action} from '@ember/object'; import {action} from '@ember/object';
import {capitalizeFirstLetter} from 'ghost-admin/helpers/capitalize-first-letter'; import {capitalizeFirstLetter} from 'ghost-admin/helpers/capitalize-first-letter';
import {inject as service} from '@ember/service'; import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency'; import {task, timeout} from 'ember-concurrency';
/** /**
* @tryghost/tpl doesn't work in admin yet (Safari) * @tryghost/tpl doesn't work in admin yet (Safari)
@ -43,6 +44,12 @@ const messages = {
duplicated: { duplicated: {
single: '{Type} duplicated', single: '{Type} duplicated',
multiple: '{count} {type}s duplicated' multiple: '{count} {type}s duplicated'
},
copiedPostUrl: {
single: 'Post link copied'
},
copiedPreviewUrl: {
single: 'Preview link copied'
} }
}; };
@ -74,6 +81,16 @@ export default class PostsContextMenu extends Component {
return tpl(messages[type].multiple, {count: this.selectionList.count, type: this.type, Type: capitalizeFirstLetter(this.type)}); return tpl(messages[type].multiple, {count: this.selectionList.count, type: this.type, Type: capitalizeFirstLetter(this.type)});
} }
@action
async copyPostLink() {
this.menu.performTask(this.copyPostLinkTask);
}
@action
async copyPreviewLink() {
this.menu.performTask(this.copyPreviewLinkTask);
}
@action @action
async featurePosts() { async featurePosts() {
this.menu.performTask(this.featurePostsTask); this.menu.performTask(this.featurePostsTask);
@ -403,6 +420,22 @@ export default class PostsContextMenu extends Component {
return true; return true;
} }
@task
*copyPostLinkTask() {
copyTextToClipboard(this.selectionList.availableModels[0].url);
this.notifications.showNotification(this.#getToastMessage('copiedPostUrl'), {type: 'success'});
yield timeout(1000);
return true;
}
@task
*copyPreviewLinkTask() {
copyTextToClipboard(this.selectionList.availableModels[0].url);
this.notifications.showNotification(this.#getToastMessage('copiedPreviewUrl'), {type: 'success'});
yield timeout(1000);
return true;
}
async performBulkDestroy() { async performBulkDestroy() {
const filter = this.selectionList.filter; const filter = this.selectionList.filter;
let bulkUpdateUrl = this.ghostPaths.url.api(this.type === 'post' ? 'posts' : 'pages') + `?filter=${encodeURIComponent(filter)}`; let bulkUpdateUrl = this.ghostPaths.url.api(this.type === 'post' ? 'posts' : 'pages') + `?filter=${encodeURIComponent(filter)}`;

View File

@ -1,4 +1,5 @@
import {Factory} from 'miragejs'; import {Factory} from 'miragejs';
import {dasherize} from '@ember/string';
import {isEmpty} from '@ember/utils'; import {isEmpty} from '@ember/utils';
export default Factory.extend({ export default Factory.extend({
@ -27,6 +28,7 @@ export default Factory.extend({
return statuses[i % statuses.length]; return statuses[i % statuses.length];
}, },
title(i) { return `Post ${i}`; }, title(i) { return `Post ${i}`; },
slug: null,
twitterDescription: null, twitterDescription: null,
twitterImage: null, twitterImage: null,
twitterTitle: null, twitterTitle: null,
@ -50,5 +52,10 @@ export default Factory.extend({
post.authors = [user]; post.authors = [user];
post.save(); post.save();
} }
if (isEmpty(post.slug)) {
post.slug = dasherize(post.title);
post.save();
}
} }
}); });

View File

@ -1,4 +1,5 @@
import ctrlOrCmd from 'ghost-admin/utils/ctrl-or-cmd'; import ctrlOrCmd from 'ghost-admin/utils/ctrl-or-cmd';
import sinon from 'sinon';
import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support'; import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support';
import {beforeEach, describe, it} from 'mocha'; import {beforeEach, describe, it} from 'mocha';
import {blur, click, currentURL, fillIn, find, findAll, triggerEvent, triggerKeyEvent, visit} from '@ember/test-helpers'; import {blur, click, currentURL, fillIn, find, findAll, triggerEvent, triggerKeyEvent, visit} from '@ember/test-helpers';
@ -8,9 +9,9 @@ import {setupApplicationTest} from 'ember-mocha';
import {setupMirage} from 'ember-cli-mirage/test-support'; import {setupMirage} from 'ember-cli-mirage/test-support';
/** /**
* *
* @param {string} text * @param {string} text
* @param {NodeList} buttons * @param {NodeList} buttons
* @returns Node * @returns Node
*/ */
const findButton = (text, buttons) => { const findButton = (text, buttons) => {
@ -27,10 +28,14 @@ describe('Acceptance: Posts / Pages', function () {
this.server.loadFixtures('configs'); this.server.loadFixtures('configs');
}); });
this.afterEach(function () {
sinon.restore();
});
describe('posts', function () { describe('posts', function () {
it('redirects to signin when not authenticated', async function () { it('redirects to signin when not authenticated', async function () {
await invalidateSession(); await invalidateSession();
await visit('/posts'); await visit('/posts');
expect(currentURL()).to.equal('/signin'); expect(currentURL()).to.equal('/signin');
}); });
@ -51,14 +56,14 @@ describe('Acceptance: Posts / Pages', function () {
expect(findAll('[data-test-post-id]')).to.have.length(0); expect(findAll('[data-test-post-id]')).to.have.length(0);
expect(find('[data-test-no-posts-box]')).to.exist; expect(find('[data-test-no-posts-box]')).to.exist;
expect(find('[data-test-link="write-a-new-post"]')).to.exist; expect(find('[data-test-link="write-a-new-post"]')).to.exist;
await click('[data-test-link="write-a-new-post"]'); await click('[data-test-link="write-a-new-post"]');
expect(currentURL()).to.equal('/editor/post'); expect(currentURL()).to.equal('/editor/post');
await fillIn('[data-test-editor-title-input]', 'First contributor post'); await fillIn('[data-test-editor-title-input]', 'First contributor post');
await blur('[data-test-editor-title-input]'); await blur('[data-test-editor-title-input]');
expect(currentURL()).to.equal('/editor/post/1'); expect(currentURL()).to.equal('/editor/post/1');
await click('[data-test-link="posts"]'); await click('[data-test-link="posts"]');
@ -132,7 +137,7 @@ describe('Acceptance: Posts / Pages', function () {
expect(posts[1].querySelector('.gh-content-entry-title').textContent, 'post 2 title').to.contain('Draft 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[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'); expect(posts[3].querySelector('.gh-content-entry-title').textContent, 'post 4 title').to.contain('Editor Published Post');
// check API requests // check API requests
let lastRequests = this.server.pretender.handledRequests.filter(request => request.url.includes('/posts/')); 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[0].queryParams.filter, 'scheduled request filter').to.have.string('status:scheduled');
@ -152,10 +157,10 @@ describe('Acceptance: Posts / Pages', function () {
// Displays draft post // Displays draft post
expect(findAll('[data-test-post-id]').length, 'drafts count').to.equal(1); expect(findAll('[data-test-post-id]').length, 'drafts count').to.equal(1);
expect(find(`[data-test-post-id="${draftPost.id}"]`), 'draft post').to.exist; expect(find(`[data-test-post-id="${draftPost.id}"]`), 'draft post').to.exist;
// show published posts // show published posts
await selectChoose('[data-test-type-select]', 'Published posts'); await selectChoose('[data-test-type-select]', 'Published posts');
// API request is correct // API request is correct
[lastRequest] = this.server.pretender.handledRequests.slice(-1); [lastRequest] = this.server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.filter, '"published" request status filter').to.have.string('status:published'); expect(lastRequest.queryParams.filter, '"published" request status filter').to.have.string('status:published');
@ -163,10 +168,10 @@ describe('Acceptance: Posts / Pages', function () {
expect(findAll('[data-test-post-id]').length, 'published count').to.equal(2); expect(findAll('[data-test-post-id]').length, 'published count').to.equal(2);
expect(find(`[data-test-post-id="${publishedPost.id}"]`), 'admin published post').to.exist; expect(find(`[data-test-post-id="${publishedPost.id}"]`), 'admin published post').to.exist;
expect(find(`[data-test-post-id="${authorPost.id}"]`), 'author published post').to.exist; expect(find(`[data-test-post-id="${authorPost.id}"]`), 'author published post').to.exist;
// show scheduled posts // show scheduled posts
await selectChoose('[data-test-type-select]', 'Scheduled posts'); await selectChoose('[data-test-type-select]', 'Scheduled posts');
// API request is correct // API request is correct
[lastRequest] = this.server.pretender.handledRequests.slice(-1); [lastRequest] = this.server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.filter, '"scheduled" request status filter').to.have.string('status:scheduled'); expect(lastRequest.queryParams.filter, '"scheduled" request status filter').to.have.string('status:scheduled');
@ -201,7 +206,7 @@ describe('Acceptance: Posts / Pages', function () {
.to.have.string('visibility:[paid,tiers]'); .to.have.string('visibility:[paid,tiers]');
let posts = findAll('[data-test-post-id]'); let posts = findAll('[data-test-post-id]');
expect(posts.length, 'all posts count').to.equal(1); expect(posts.length, 'all posts count').to.equal(1);
await selectChoose('[data-test-visibility-select]', 'Public'); await selectChoose('[data-test-visibility-select]', 'Public');
[lastRequest] = this.server.pretender.handledRequests.slice(-1); [lastRequest] = this.server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.allFilter, '"visibility" request filter param') expect(lastRequest.queryParams.allFilter, '"visibility" request filter param')
@ -250,21 +255,91 @@ describe('Acceptance: Posts / Pages', function () {
let buttons = contextMenu.querySelectorAll('button'); let buttons = contextMenu.querySelectorAll('button');
expect(contextMenu, 'context menu').to.exist; expect(contextMenu, 'context menu').to.exist;
expect(buttons.length, 'context menu buttons').to.equal(5); expect(buttons.length, 'context menu buttons').to.equal(6);
expect(buttons[0].innerText.trim(), 'context menu button 1').to.contain('Unpublish'); expect(buttons[0].innerText.trim(), 'context menu button 1').to.contain('Copy link to post');
expect(buttons[1].innerText.trim(), 'context menu button 2').to.contain('Feature'); // or Unfeature expect(buttons[1].innerText.trim(), 'context menu button 1').to.contain('Unpublish');
expect(buttons[2].innerText.trim(), 'context menu button 3').to.contain('Add a tag'); expect(buttons[2].innerText.trim(), 'context menu button 2').to.contain('Feature'); // or Unfeature
expect(buttons[3].innerText.trim(), 'context menu button 4').to.contain('Duplicate'); expect(buttons[3].innerText.trim(), 'context menu button 3').to.contain('Add a tag');
expect(buttons[4].innerText.trim(), 'context menu button 5').to.contain('Delete'); expect(buttons[4].innerText.trim(), 'context menu button 4').to.contain('Duplicate');
expect(buttons[5].innerText.trim(), 'context menu button 5').to.contain('Delete');
// duplicate the post // duplicate the post
await click(buttons[3]); await click(buttons[4]);
const posts = findAll('[data-test-post-id]'); const posts = findAll('[data-test-post-id]');
expect(posts.length, 'all posts count').to.equal(5); expect(posts.length, 'all posts count').to.equal(5);
let [lastRequest] = this.server.pretender.handledRequests.slice(-1); let [lastRequest] = this.server.pretender.handledRequests.slice(-1);
expect(lastRequest.url, 'request url').to.match(new RegExp(`/posts/${publishedPost.id}/copy/`)); expect(lastRequest.url, 'request url').to.match(new RegExp(`/posts/${publishedPost.id}/copy/`));
}); });
it('can copy a post link', async function () {
sinon.stub(navigator.clipboard, 'writeText').resolves();
await visit('/posts');
// get the post
const post = find(`[data-test-post-id="${publishedPost.id}"]`);
expect(post, 'post').to.exist;
await triggerEvent(post, 'contextmenu');
let contextMenu = find('.gh-posts-context-menu'); // this is a <ul> element
let buttons = contextMenu.querySelectorAll('button');
expect(contextMenu, 'context menu').to.exist;
expect(buttons.length, 'context menu buttons').to.equal(6);
expect(buttons[0].innerText.trim(), 'context menu button 1').to.contain('Copy link to post');
expect(buttons[1].innerText.trim(), 'context menu button 1').to.contain('Unpublish');
expect(buttons[2].innerText.trim(), 'context menu button 2').to.contain('Feature'); // or Unfeature
expect(buttons[3].innerText.trim(), 'context menu button 3').to.contain('Add a tag');
expect(buttons[4].innerText.trim(), 'context menu button 4').to.contain('Duplicate');
expect(buttons[5].innerText.trim(), 'context menu button 5').to.contain('Delete');
// Copy the post link
await click(buttons[0]);
// Check that the notification is displayed
expect(find('[data-test-text="notification-content"]')).to.contain.text('Post link copied');
// Check that the clipboard contains the right content
expect(navigator.clipboard.writeText.calledOnce).to.be.true;
expect(navigator.clipboard.writeText.firstCall.args[0]).to.equal(`http://localhost:4200/${publishedPost.slug}/`);
});
it('can copy a preview link', async function () {
sinon.stub(navigator.clipboard, 'writeText').resolves();
await visit('/posts');
// get the post
const post = find(`[data-test-post-id="${draftPost.id}"]`);
expect(post, 'post').to.exist;
await triggerEvent(post, 'contextmenu');
let contextMenu = find('.gh-posts-context-menu'); // this is a <ul> element
let buttons = contextMenu.querySelectorAll('button');
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('Copy preview link');
expect(buttons[1].innerText.trim(), 'context menu button 2').to.contain('Feature'); // or Unfeature
expect(buttons[2].innerText.trim(), 'context menu button 3').to.contain('Add a tag');
expect(buttons[3].innerText.trim(), 'context menu button 4').to.contain('Duplicate');
expect(buttons[4].innerText.trim(), 'context menu button 5').to.contain('Delete');
// Copy the preview link
await click(buttons[0]);
// Check that the notification is displayed
expect(find('[data-test-text="notification-content"]')).to.contain.text('Preview link copied');
// Check that the clipboard contains the right content
expect(navigator.clipboard.writeText.calledOnce).to.be.true;
expect(navigator.clipboard.writeText.firstCall.args[0]).to.equal(`http://localhost:4200/p/${draftPost.uuid}/`);
});
}); });
describe('multiple posts', function () { describe('multiple posts', function () {
@ -301,7 +376,7 @@ describe('Acceptance: Posts / Pages', function () {
let [lastRequest] = this.server.pretender.handledRequests.slice(-1); let [lastRequest] = this.server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.filter, 'feature request id').to.equal(`id:['${publishedPost.id}','${authorPost.id}']`); 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'); expect(JSON.parse(lastRequest.requestBody).bulk.action, 'feature request action').to.equal('feature');
// ensure ui shows these are now featured // ensure ui shows these are now featured
expect(postThreeContainer.querySelector('.gh-featured-post'), 'postFour featured').to.exist; expect(postThreeContainer.querySelector('.gh-featured-post'), 'postFour featured').to.exist;
expect(postFourContainer.querySelector('.gh-featured-post'), 'postFour featured').to.exist; expect(postFourContainer.querySelector('.gh-featured-post'), 'postFour featured').to.exist;
@ -356,7 +431,7 @@ describe('Acceptance: Posts / Pages', function () {
let addTagButton = findButton('Add a tag', buttons); let addTagButton = findButton('Add a tag', buttons);
expect(addTagButton, 'add tag button').to.exist; expect(addTagButton, 'add tag button').to.exist;
await click(addTagButton); await click(addTagButton);
const addTagsModal = find('[data-test-modal="add-tags"]'); const addTagsModal = find('[data-test-modal="add-tags"]');
expect(addTagsModal, 'tag settings modal').to.exist; expect(addTagsModal, 'tag settings modal').to.exist;
@ -365,13 +440,13 @@ describe('Acceptance: Posts / Pages', function () {
await fillIn(input, 'test-tag'); await fillIn(input, 'test-tag');
await triggerKeyEvent(input, 'keydown', 13); await triggerKeyEvent(input, 'keydown', 13);
await click('[data-test-button="confirm"]'); await click('[data-test-button="confirm"]');
// API request is correct - note, we don't mock the actual model updates // API request is correct - note, we don't mock the actual model updates
let [lastRequest] = this.server.pretender.handledRequests.slice(-2); let [lastRequest] = this.server.pretender.handledRequests.slice(-2);
expect(lastRequest.queryParams.filter, 'add tag request id').to.equal(`id:['${publishedPost.id}','${authorPost.id}']`); 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'); expect(JSON.parse(lastRequest.requestBody).bulk.action, 'add tag request action').to.equal('addTag');
}); });
// TODO: Skip for now. This causes the member creation test to fail ('New member' text doesn't show... ???). // 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 () { it.skip('can change access', async function () {
await visit('/posts'); await visit('/posts');
@ -394,9 +469,9 @@ describe('Acceptance: Posts / Pages', function () {
expect(contextMenu, 'context menu').to.exist; expect(contextMenu, 'context menu').to.exist;
let buttons = contextMenu.querySelectorAll('button'); let buttons = contextMenu.querySelectorAll('button');
let changeAccessButton = findButton('Change access', buttons); let changeAccessButton = findButton('Change access', buttons);
expect(changeAccessButton, 'change access button').not.to.exist; expect(changeAccessButton, 'change access button').not.to.exist;
const settingsService = this.owner.lookup('service:settings'); const settingsService = this.owner.lookup('service:settings');
await settingsService.set('membersEnabled', true); await settingsService.set('membersEnabled', true);
@ -497,7 +572,7 @@ describe('Acceptance: Posts / Pages', function () {
const modal = find('[data-test-modal="delete-posts"]'); const modal = find('[data-test-modal="delete-posts"]');
expect(modal, 'delete modal').to.exist; expect(modal, 'delete modal').to.exist;
await click('[data-test-button="confirm"]'); await click('[data-test-button="confirm"]');
// API request is correct - note, we don't mock the actual model updates // API request is correct - note, we don't mock the actual model updates
let [lastRequest] = this.server.pretender.handledRequests.slice(-1); let [lastRequest] = this.server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.filter, 'delete request id').to.equal(`id:['${publishedPost.id}','${authorPost.id}']`); expect(lastRequest.queryParams.filter, 'delete request id').to.equal(`id:['${publishedPost.id}','${authorPost.id}']`);
@ -666,4 +741,4 @@ describe('Acceptance: Posts / Pages', function () {
}); });
}); });
}); });
}); });