0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-06 22:40:14 -05:00

Added unschedule bulk action to posts (#20945)

ref DES-675
This commit is contained in:
Sodbileg Gansukh 2024-09-24 15:32:45 +08:00 committed by GitHub
parent 8d54e4bf7a
commit 2f02ec5566
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 161 additions and 1 deletions

View file

@ -21,6 +21,13 @@
</button>
</li>
{{/if}}
{{#if this.canUnscheduleSelection}}
<li>
<button class="mr2" type="button" {{on "click" this.unschedulePosts}}>
<span>{{svg-jar "undo"}}Unschedule</span>
</button>
</li>
{{/if}}
{{/if}}
{{#if this.canFeatureSelection}}
{{#if this.shouldFeatureSelection }}

View file

@ -3,6 +3,7 @@ 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 UnschedulePostsModal from './modals/unschedule-posts';
import copyTextToClipboard from 'ghost-admin/utils/copy-text-to-clipboard';
import nql from '@tryghost/nql';
import {action} from '@ember/object';
@ -29,6 +30,10 @@ const messages = {
single: '{Type} reverted to a draft',
multiple: '{count} {type}s reverted to drafts'
},
unscheduled: {
single: '{Type} unscheduled',
multiple: '{count} {type}s unscheduled'
},
accessUpdated: {
single: '{Type} access updated',
multiple: '{Type} access updated for {count} {type}s'
@ -128,6 +133,15 @@ export default class PostsContextMenu extends Component {
});
}
@action
async unschedulePosts() {
await this.menu.openModal(UnschedulePostsModal, {
type: this.type,
selectionList: this.selectionList,
confirm: this.unschedulePostsTask
});
}
@action
async editPostsAccess() {
this.menu.openModal(EditPostsAccessModal, {
@ -240,7 +254,7 @@ export default class PostsContextMenu extends Component {
// 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;
}
@ -271,6 +285,33 @@ export default class PostsContextMenu extends Component {
return true;
}
@task
*unschedulePostsTask() {
const updatedModels = this.selectionList.availableModels;
yield this.performBulkEdit('unschedule');
this.notifications.showNotification(this.#getToastMessage('unscheduled'), {type: 'success'});
// Update the models on the client side
for (const post of updatedModels) {
if (post.status === 'scheduled') {
// We need to do it this way to prevent marking the model as dirty
this.store.push({
data: {
id: post.id,
type: this.type,
attributes: {
status: 'draft',
published_at: null
}
}
});
}
}
this.updateFilteredPosts();
return true;
}
updateFilteredPosts() {
const updatedModels = this.selectionList.availableModels;
const filter = this.selectionList.allFilter;
@ -489,6 +530,15 @@ export default class PostsContextMenu extends Component {
return false;
}
get canUnscheduleSelection() {
for (const m of this.selectionList.availableModels) {
if (m.status === 'scheduled') {
return true;
}
}
return false;
}
get canCopySelection() {
return this.selectionList.availableModels.length === 1;
}

View file

@ -0,0 +1,27 @@
<div class="modal-content" data-test-modal="unschedule-posts">
<header class="modal-header">
<h1>Are you sure you want to unschedule {{if @data.selectionList.isSingle (concat 'this ' @data.type) (concat 'these ' @data.type 's')}}?</h1>
</header>
<button type="button" class="close" title="Close" {{on "click" (fn @close false)}} data-test-button="close">{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body mb9">
<p>
You're about to revert <strong>{{if @data.selectionList.isSingle (concat '"' @data.selectionList.first.title '"') (concat @data.selectionList.count ' ' @data.type 's')}}</strong> to a private draft.
</p>
</div>
<div class="modal-footer">
<button class="gh-btn" data-test-button="cancel" type="button" {{on "click" (fn @close false)}}><span>Cancel</span></button>
<GhTaskButton
@buttonText="Unschedule"
@runningText="Unscheduling"
@showSuccess={{false}}
@task={{@data.confirm}}
@taskArgs={{@close}}
@class="gh-btn gh-btn-red gh-btn-icon"
data-test-button="confirm"
/>
</div>
</div>

View file

@ -218,6 +218,7 @@
line-height: 1.15em;
font-weight: 600;
letter-spacing: -0.025em;
text-wrap: pretty;
}
.modal-header.icon-center {

View file

@ -539,6 +539,46 @@ describe('Acceptance: Posts / Pages', function () {
expect(postFourContainer.querySelector('.gh-content-entry-status').textContent, 'postThree status').to.contain('Draft');
});
it('can unschedule', async function () {
await visit('/posts');
// get all posts
const posts = findAll('[data-test-post-id]');
expect(posts.length, 'all posts count').to.equal(4);
const postOneContainer = posts[0].parentElement; // scheduled post
await click(postOneContainer, {metaKey: ctrlOrCmd === 'command', ctrlKey: ctrlOrCmd === 'ctrl'});
expect(postOneContainer.getAttribute('data-selected'), 'postOne selected').to.exist;
// NOTE: right clicks don't seem to work in these tests
// contextmenu is the event triggered - https://developer.mozilla.org/en-US/docs/Web/API/Element/contextmenu_event
await triggerEvent(postOneContainer, 'contextmenu');
let contextMenu = find('.gh-posts-context-menu'); // this is a <ul> element
expect(contextMenu, 'context menu').to.exist;
// unschedule the post
let buttons = contextMenu.querySelectorAll('button');
let unscheduleButton = findButton('Unschedule', buttons);
expect(unscheduleButton, 'unschedule button').to.exist;
await click(unscheduleButton);
// handle modal
const modal = find('[data-test-modal="unschedule-posts"]');
expect(modal, 'unschedule 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, 'unschedule request id').to.equal(`id:['${scheduledPost.id}']`);
expect(JSON.parse(lastRequest.requestBody).bulk.action, 'unschedule request action').to.equal('unschedule');
// ensure ui shows these are now unpublished
expect(postOneContainer.querySelector('.gh-content-entry-status').textContent, 'postOne status').to.contain('Draft');
});
it('can delete', async function () {
await visit('/posts');

View file

@ -0,0 +1,13 @@
export class PostsBulkUnscheduledEvent {
data: string[];
timestamp: Date;
constructor(data: string[], timestamp: Date) {
this.data = data;
this.timestamp = timestamp;
}
static create(data: string[], timestamp = new Date()) {
return new PostsBulkUnscheduledEvent(data, timestamp);
}
}

View file

@ -2,6 +2,7 @@ export * from './PostDeletedEvent';
export * from './PostsBulkDestroyedEvent';
export * from './PostsBulkUnpublishedEvent';
export * from './PostsBulkUnscheduledEvent';
export * from './PostsBulkFeaturedEvent';
export * from './PostsBulkUnfeaturedEvent';
export * from './PostsBulkAddTagsEvent';

View file

@ -3,6 +3,7 @@ import {
PostDeletedEvent,
PostsBulkDestroyedEvent,
PostsBulkUnpublishedEvent,
PostsBulkUnscheduledEvent,
PostsBulkFeaturedEvent,
PostsBulkUnfeaturedEvent,
PostsBulkAddTagsEvent
@ -27,6 +28,12 @@ describe('Post Events', function () {
assert.equal(event.data.length, 3);
});
it('Can instantiate PostsBulkUnscheduledEvent', function () {
const event = PostsBulkUnscheduledEvent.create(['1', '2', '3']);
assert.ok(event);
assert.equal(event.data.length, 3);
});
it('Can instantiate PostsBulkFeaturedEvent', function () {
const event = PostsBulkFeaturedEvent.create(['1', '2', '3']);
assert.ok(event);

View file

@ -8,6 +8,7 @@ const DomainEvents = require('@tryghost/domain-events');
const {
PostsBulkDestroyedEvent,
PostsBulkUnpublishedEvent,
PostsBulkUnscheduledEvent,
PostsBulkFeaturedEvent,
PostsBulkUnfeaturedEvent,
PostsBulkAddTagsEvent
@ -246,6 +247,19 @@ class PostsService {
return updateResult;
}
if (data.action === 'unschedule') {
const updateResult = await this.#updatePosts({status: 'draft', published_at: null}, {filter: this.#mergeFilters('status:scheduled', options.filter), context: options.context, actionName: 'unscheduled'});
// makes sure `email_only` value is reverted for the unscheduled posts
await this.models.Post.bulkEdit(updateResult.editIds, 'posts_meta', {
data: {email_only: false},
column: 'post_id',
transacting: options.transacting,
throwErrors: true
});
DomainEvents.dispatch(PostsBulkUnscheduledEvent.create(updateResult.editIds));
return updateResult;
}
if (data.action === 'feature') {
const updateResult = await this.#updatePosts({featured: true}, {filter: options.filter, context: options.context, actionName: 'featured'});
DomainEvents.dispatch(PostsBulkFeaturedEvent.create(updateResult.editIds));