🎨 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">
|
||||
|
||||
{{#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>
|
||||
<button class="mr2" type="button" {{on "click" this.unpublishPosts}}>
|
||||
<span>{{svg-jar "undo"}}Unpublish</span>
|
||||
</button>
|
||||
</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 this.canFeatureSelection}}
|
||||
{{#if this.shouldFeatureSelection }}
|
||||
|
@ -3,11 +3,12 @@ import Component from '@glimmer/component';
|
||||
import DeletePostsModal from './modals/delete-posts';
|
||||
import EditPostsAccessModal from './modals/edit-posts-access';
|
||||
import UnpublishPostsModal from './modals/unpublish-posts';
|
||||
import copyTextToClipboard from 'ghost-admin/utils/copy-text-to-clipboard';
|
||||
import nql from '@tryghost/nql';
|
||||
import {action} from '@ember/object';
|
||||
import {capitalizeFirstLetter} from 'ghost-admin/helpers/capitalize-first-letter';
|
||||
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)
|
||||
@ -43,6 +44,12 @@ const messages = {
|
||||
duplicated: {
|
||||
single: '{Type} 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)});
|
||||
}
|
||||
|
||||
@action
|
||||
async copyPostLink() {
|
||||
this.menu.performTask(this.copyPostLinkTask);
|
||||
}
|
||||
|
||||
@action
|
||||
async copyPreviewLink() {
|
||||
this.menu.performTask(this.copyPreviewLinkTask);
|
||||
}
|
||||
|
||||
@action
|
||||
async featurePosts() {
|
||||
this.menu.performTask(this.featurePostsTask);
|
||||
@ -403,6 +420,22 @@ export default class PostsContextMenu extends Component {
|
||||
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() {
|
||||
const filter = this.selectionList.filter;
|
||||
let bulkUpdateUrl = this.ghostPaths.url.api(this.type === 'post' ? 'posts' : 'pages') + `?filter=${encodeURIComponent(filter)}`;
|
||||
|
@ -1,4 +1,5 @@
|
||||
import {Factory} from 'miragejs';
|
||||
import {dasherize} from '@ember/string';
|
||||
import {isEmpty} from '@ember/utils';
|
||||
|
||||
export default Factory.extend({
|
||||
@ -27,6 +28,7 @@ export default Factory.extend({
|
||||
return statuses[i % statuses.length];
|
||||
},
|
||||
title(i) { return `Post ${i}`; },
|
||||
slug: null,
|
||||
twitterDescription: null,
|
||||
twitterImage: null,
|
||||
twitterTitle: null,
|
||||
@ -50,5 +52,10 @@ export default Factory.extend({
|
||||
post.authors = [user];
|
||||
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 sinon from 'sinon';
|
||||
import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support';
|
||||
import {beforeEach, describe, it} from 'mocha';
|
||||
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';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {NodeList} buttons
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {NodeList} buttons
|
||||
* @returns Node
|
||||
*/
|
||||
const findButton = (text, buttons) => {
|
||||
@ -27,10 +28,14 @@ describe('Acceptance: Posts / Pages', function () {
|
||||
this.server.loadFixtures('configs');
|
||||
});
|
||||
|
||||
this.afterEach(function () {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
describe('posts', function () {
|
||||
it('redirects to signin when not authenticated', async function () {
|
||||
await invalidateSession();
|
||||
|
||||
|
||||
await visit('/posts');
|
||||
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(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"]');
|
||||
@ -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[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');
|
||||
@ -152,10 +157,10 @@ describe('Acceptance: Posts / Pages', function () {
|
||||
// Displays draft post
|
||||
expect(findAll('[data-test-post-id]').length, 'drafts count').to.equal(1);
|
||||
expect(find(`[data-test-post-id="${draftPost.id}"]`), 'draft post').to.exist;
|
||||
|
||||
|
||||
// show published posts
|
||||
await selectChoose('[data-test-type-select]', 'Published posts');
|
||||
|
||||
|
||||
// API request is correct
|
||||
[lastRequest] = this.server.pretender.handledRequests.slice(-1);
|
||||
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(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;
|
||||
|
||||
|
||||
// show scheduled posts
|
||||
await selectChoose('[data-test-type-select]', 'Scheduled posts');
|
||||
|
||||
|
||||
// API request is correct
|
||||
[lastRequest] = this.server.pretender.handledRequests.slice(-1);
|
||||
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]');
|
||||
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')
|
||||
@ -250,21 +255,91 @@ describe('Acceptance: Posts / Pages', function () {
|
||||
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('Unpublish');
|
||||
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');
|
||||
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');
|
||||
|
||||
// duplicate the post
|
||||
await click(buttons[3]);
|
||||
await click(buttons[4]);
|
||||
|
||||
const posts = findAll('[data-test-post-id]');
|
||||
expect(posts.length, 'all posts count').to.equal(5);
|
||||
let [lastRequest] = this.server.pretender.handledRequests.slice(-1);
|
||||
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 () {
|
||||
@ -301,7 +376,7 @@ describe('Acceptance: Posts / Pages', function () {
|
||||
let [lastRequest] = this.server.pretender.handledRequests.slice(-1);
|
||||
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');
|
||||
|
||||
|
||||
// ensure ui shows these are now featured
|
||||
expect(postThreeContainer.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);
|
||||
expect(addTagButton, 'add tag button').to.exist;
|
||||
await click(addTagButton);
|
||||
|
||||
|
||||
const addTagsModal = find('[data-test-modal="add-tags"]');
|
||||
expect(addTagsModal, 'tag settings modal').to.exist;
|
||||
|
||||
@ -365,13 +440,13 @@ describe('Acceptance: Posts / Pages', function () {
|
||||
await fillIn(input, 'test-tag');
|
||||
await triggerKeyEvent(input, 'keydown', 13);
|
||||
await click('[data-test-button="confirm"]');
|
||||
|
||||
|
||||
// API request is correct - note, we don't mock the actual model updates
|
||||
let [lastRequest] = this.server.pretender.handledRequests.slice(-2);
|
||||
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');
|
||||
});
|
||||
|
||||
|
||||
// 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 () {
|
||||
await visit('/posts');
|
||||
@ -394,9 +469,9 @@ describe('Acceptance: Posts / Pages', function () {
|
||||
expect(contextMenu, 'context menu').to.exist;
|
||||
let buttons = contextMenu.querySelectorAll('button');
|
||||
let changeAccessButton = findButton('Change access', buttons);
|
||||
|
||||
|
||||
expect(changeAccessButton, 'change access button').not.to.exist;
|
||||
|
||||
|
||||
const settingsService = this.owner.lookup('service:settings');
|
||||
await settingsService.set('membersEnabled', true);
|
||||
|
||||
@ -497,7 +572,7 @@ describe('Acceptance: Posts / Pages', function () {
|
||||
const modal = find('[data-test-modal="delete-posts"]');
|
||||
expect(modal, 'delete modal').to.exist;
|
||||
await click('[data-test-button="confirm"]');
|
||||
|
||||
|
||||
// API request is correct - note, we don't mock the actual model updates
|
||||
let [lastRequest] = this.server.pretender.handledRequests.slice(-1);
|
||||
expect(lastRequest.queryParams.filter, 'delete request id').to.equal(`id:['${publishedPost.id}','${authorPost.id}']`);
|
||||
@ -666,4 +741,4 @@ describe('Acceptance: Posts / Pages', function () {
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user