mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-06 22:40:14 -05:00
parent
8d54e4bf7a
commit
2f02ec5566
9 changed files with 161 additions and 1 deletions
|
@ -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 }}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -218,6 +218,7 @@
|
|||
line-height: 1.15em;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.modal-header.icon-center {
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
13
ghost/post-events/src/PostsBulkUnscheduledEvent.ts
Normal file
13
ghost/post-events/src/PostsBulkUnscheduledEvent.ts
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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));
|
||||
|
|
Loading…
Reference in a new issue