🎨 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';
@ -27,6 +28,10 @@ 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();
@ -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 () {