mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-23 11:55:01 +03:00
🎨 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:
parent
dc7abe4712
commit
ae628d7520
@ -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 }}
|
||||||
|
@ -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)}`;
|
||||||
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -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 () {
|
||||||
|
Loading…
Reference in New Issue
Block a user