mirror of
https://github.com/discourse/discourse.git
synced 2024-11-22 11:44:49 +08:00
FEATURE: Shared Drafts
This feature can be enabled by choosing a destination for the `shared drafts category` site setting. * Staff members can create shared drafts, choosing a destination category for the topic when it is published. * Shared Drafts can be viewed in their category, or above the topic list for the destination category where it will end up. * When the shared draft is ready, it can be published to the appropriate category by clicking a button on the topic view. * When published, Drafts change their timestamps to the current time, and any edits to the original post are removed.
This commit is contained in:
parent
dcbd9635f4
commit
b9abd7dc9e
|
@ -31,7 +31,7 @@ export default Ember.Controller.extend({
|
|||
if (item.get('setting').toLowerCase().indexOf(filter) > -1) return true;
|
||||
if (item.get('setting').toLowerCase().replace(/_/g, ' ').indexOf(filter) > -1) return true;
|
||||
if (item.get('description').toLowerCase().indexOf(filter) > -1) return true;
|
||||
if (item.get('value').toLowerCase().indexOf(filter) > -1) return true;
|
||||
if ((item.get('value') || '').toLowerCase().indexOf(filter) > -1) return true;
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
|
|
|
@ -1,7 +1,16 @@
|
|||
import computed from 'ember-addons/ember-computed-decorators';
|
||||
import { categoryLinkHTML } from 'discourse/helpers/category-link';
|
||||
|
||||
const CustomTypes = ['bool', 'enum', 'list', 'url_list', 'host_list', 'category_list', 'value_list'];
|
||||
const CUSTOM_TYPES = [
|
||||
'bool',
|
||||
'enum',
|
||||
'list',
|
||||
'url_list',
|
||||
'host_list',
|
||||
'category_list',
|
||||
'value_list',
|
||||
'category'
|
||||
];
|
||||
|
||||
export default Ember.Mixin.create({
|
||||
classNameBindings: [':row', ':setting', 'setting.overridden', 'typeClass'],
|
||||
|
@ -46,7 +55,7 @@ export default Ember.Mixin.create({
|
|||
|
||||
@computed("setting.type")
|
||||
componentType(type) {
|
||||
return CustomTypes.indexOf(type) !== -1 ? type : 'string';
|
||||
return CUSTOM_TYPES.indexOf(type) !== -1 ? type : 'string';
|
||||
},
|
||||
|
||||
@computed("typeClass")
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
{{category-chooser value=value allowUncategorized="true"}}
|
||||
{{setting-validation-message message=validationMessage}}
|
||||
<div class='desc'>{{{unbound setting.description}}}</div>
|
|
@ -36,6 +36,8 @@
|
|||
//= require ./discourse/models/result-set
|
||||
//= require ./discourse/models/store
|
||||
//= require ./discourse/models/action-summary
|
||||
//= require ./discourse/models/permission-type
|
||||
//= require ./discourse/models/category
|
||||
//= require ./discourse/models/topic
|
||||
//= require ./discourse/models/draft
|
||||
//= require ./discourse/models/composer
|
||||
|
@ -43,7 +45,6 @@
|
|||
//= require ./discourse/models/badge
|
||||
//= require ./discourse/models/permission-type
|
||||
//= require ./discourse/models/user-action-group
|
||||
//= require ./discourse/models/category
|
||||
//= require ./discourse/models/input-validation
|
||||
//= require ./discourse/lib/search
|
||||
//= require ./discourse/lib/user-search
|
||||
|
|
|
@ -1,7 +1,13 @@
|
|||
import { default as computed } from 'ember-addons/ember-computed-decorators';
|
||||
import { PRIVATE_MESSAGE, CREATE_TOPIC, REPLY, EDIT } from "discourse/models/composer";
|
||||
import { PRIVATE_MESSAGE, CREATE_TOPIC, CREATE_SHARED_DRAFT, REPLY, EDIT } from "discourse/models/composer";
|
||||
import { iconHTML } from 'discourse-common/lib/icon-library';
|
||||
|
||||
const TITLES = {
|
||||
[PRIVATE_MESSAGE]: 'topic.private_message',
|
||||
[CREATE_TOPIC]: 'topic.create_long',
|
||||
[CREATE_SHARED_DRAFT]: 'composer.create_shared_draft'
|
||||
}
|
||||
|
||||
export default Ember.Component.extend({
|
||||
classNames: ["composer-action-title"],
|
||||
options: Ember.computed.alias("model.replyOptions"),
|
||||
|
@ -10,11 +16,11 @@ export default Ember.Component.extend({
|
|||
|
||||
@computed("options", "action")
|
||||
actionTitle(opts, action) {
|
||||
if (TITLES[action]) {
|
||||
return I18n.t(TITLES[action]);
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case PRIVATE_MESSAGE:
|
||||
return I18n.t("topic.private_message");
|
||||
case CREATE_TOPIC:
|
||||
return I18n.t("topic.create_long");
|
||||
case REPLY:
|
||||
if (opts.userAvatar && opts.userLink) {
|
||||
return this._formatReplyToUserPost(opts.userAvatar, opts.userLink);
|
||||
|
|
|
@ -15,6 +15,7 @@ export default Ember.Component.extend(KeyEnterEscape, {
|
|||
'composer.createdPost:created-post',
|
||||
'composer.creatingTopic:topic',
|
||||
'composer.whisper:composing-whisper',
|
||||
'composer.sharedDraft:composing-shared-draft',
|
||||
'showPreview:show-preview:hide-preview',
|
||||
'currentUserPrimaryGroupClass'],
|
||||
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import computed from 'ember-addons/ember-computed-decorators';
|
||||
|
||||
export default Ember.Component.extend({
|
||||
tagName: '',
|
||||
publishing: false,
|
||||
|
||||
@computed('topic.destination_category_id')
|
||||
validCategory(destCatId) {
|
||||
return destCatId &&
|
||||
destCatId !== this.site.shared_drafts_category_id;
|
||||
},
|
||||
|
||||
actions: {
|
||||
publish() {
|
||||
|
||||
bootbox.confirm(I18n.t('shared_drafts.confirm_publish'), result => {
|
||||
if (result) {
|
||||
this.set('publishing', true);
|
||||
let destId = this.get('topic.destination_category_id');
|
||||
this.get('topic').publish().then(() => {
|
||||
this.set('topic.category_id', destId);
|
||||
}).finally(() => {
|
||||
this.set('publishing', false);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
|
@ -4,6 +4,7 @@ export default Ember.Component.extend({
|
|||
tagName: 'table',
|
||||
classNames: ['topic-list'],
|
||||
showTopicPostBadges: true,
|
||||
listTitle: 'topic.title',
|
||||
|
||||
// Overwrite this to perform client side filtering of topics, if desired
|
||||
filteredTopics: Ember.computed.alias('topics'),
|
||||
|
|
|
@ -46,6 +46,12 @@ export default Ember.Controller.extend(BufferedContent, {
|
|||
}
|
||||
},
|
||||
|
||||
@computed('model.postStream.loaded', 'model.category_id')
|
||||
showSharedDraftControls(loaded, categoryId) {
|
||||
let draftCat = this.site.shared_drafts_category_id;
|
||||
return loaded && draftCat && categoryId && draftCat === categoryId;
|
||||
},
|
||||
|
||||
@computed('site.mobileView', 'model.posts_count')
|
||||
showSelectedPostsAtBottom(mobileView, postsCount) {
|
||||
return mobileView && postsCount > 3;
|
||||
|
|
|
@ -9,6 +9,7 @@ import { escapeExpression, tinyAvatar } from 'discourse/lib/utilities';
|
|||
// The actions the composer can take
|
||||
export const
|
||||
CREATE_TOPIC = 'createTopic',
|
||||
CREATE_SHARED_DRAFT = 'createSharedDraft',
|
||||
PRIVATE_MESSAGE = 'privateMessage',
|
||||
NEW_PRIVATE_MESSAGE_KEY = 'new_private_message',
|
||||
REPLY = 'reply',
|
||||
|
@ -35,7 +36,8 @@ const CLOSED = 'closed',
|
|||
typing_duration_msecs: 'typingTime',
|
||||
composer_open_duration_msecs: 'composerTime',
|
||||
tags: 'tags',
|
||||
featured_link: 'featuredLink'
|
||||
featured_link: 'featuredLink',
|
||||
shared_draft: 'sharedDraft'
|
||||
},
|
||||
|
||||
_edit_topic_serializer = {
|
||||
|
@ -45,11 +47,21 @@ const CLOSED = 'closed',
|
|||
featuredLink: 'topic.featured_link'
|
||||
};
|
||||
|
||||
const _saveLabels = {};
|
||||
_saveLabels[EDIT] = 'composer.save_edit';
|
||||
_saveLabels[REPLY] = 'composer.reply';
|
||||
_saveLabels[CREATE_TOPIC] = 'composer.create_topic';
|
||||
_saveLabels[PRIVATE_MESSAGE] = 'composer.create_pm';
|
||||
const SAVE_LABELS = {
|
||||
[EDIT]: 'composer.save_edit',
|
||||
[REPLY]: 'composer.reply',
|
||||
[CREATE_TOPIC]: 'composer.create_topic',
|
||||
[PRIVATE_MESSAGE]: 'composer.create_pm',
|
||||
[CREATE_SHARED_DRAFT]: 'composer.create_shared_draft'
|
||||
};
|
||||
|
||||
const SAVE_ICONS = {
|
||||
[EDIT]: 'pencil',
|
||||
[REPLY]: 'reply',
|
||||
[CREATE_TOPIC]: 'plus',
|
||||
[PRIVATE_MESSAGE]: 'envelope',
|
||||
[CREATE_SHARED_DRAFT]: 'clipboard'
|
||||
};
|
||||
|
||||
const Composer = RestModel.extend({
|
||||
_categoryId: null,
|
||||
|
@ -59,6 +71,8 @@ const Composer = RestModel.extend({
|
|||
return this.site.get('archetypes');
|
||||
}.property(),
|
||||
|
||||
@computed('action')
|
||||
sharedDraft: action => action === CREATE_SHARED_DRAFT,
|
||||
|
||||
@computed
|
||||
categoryId: {
|
||||
|
@ -85,6 +99,7 @@ const Composer = RestModel.extend({
|
|||
},
|
||||
|
||||
creatingTopic: Em.computed.equal('action', CREATE_TOPIC),
|
||||
creatingSharedDraft: Em.computed.equal('action', CREATE_SHARED_DRAFT),
|
||||
creatingPrivateMessage: Em.computed.equal('action', PRIVATE_MESSAGE),
|
||||
notCreatingPrivateMessage: Em.computed.not('creatingPrivateMessage'),
|
||||
|
||||
|
@ -148,7 +163,14 @@ const Composer = RestModel.extend({
|
|||
}, 100, {leading: false, trailing: true}),
|
||||
|
||||
editingFirstPost: Em.computed.and('editingPost', 'post.firstPost'),
|
||||
canEditTitle: Em.computed.or('creatingTopic', 'creatingPrivateMessage', 'editingFirstPost'),
|
||||
|
||||
canEditTitle: Em.computed.or(
|
||||
'creatingTopic',
|
||||
'creatingPrivateMessage',
|
||||
'editingFirstPost',
|
||||
'creatingSharedDraft'
|
||||
),
|
||||
|
||||
canCategorize: Em.computed.and('canEditTitle', 'notCreatingPrivateMessage'),
|
||||
|
||||
@computed('canEditTitle', 'creatingPrivateMessage', 'categoryId')
|
||||
|
@ -250,17 +272,12 @@ const Composer = RestModel.extend({
|
|||
|
||||
@computed('action')
|
||||
saveIcon(action) {
|
||||
switch (action) {
|
||||
case EDIT: return 'pencil';
|
||||
case REPLY: return 'reply';
|
||||
case CREATE_TOPIC: 'plus';
|
||||
case PRIVATE_MESSAGE: 'envelope';
|
||||
}
|
||||
return SAVE_ICONS[action];
|
||||
},
|
||||
|
||||
@computed('action', 'whisper')
|
||||
saveLabel(action, whisper) {
|
||||
return whisper ? 'composer.create_whisper' : _saveLabels[action];
|
||||
return whisper ? 'composer.create_whisper' : SAVE_LABELS[action];
|
||||
},
|
||||
|
||||
hasMetaData: function() {
|
||||
|
|
|
@ -2,6 +2,23 @@ import { ajax } from 'discourse/lib/ajax';
|
|||
import RestModel from 'discourse/models/rest';
|
||||
import Model from 'discourse/models/model';
|
||||
|
||||
// Whether to show the category badge in topic lists
|
||||
function displayCategoryInList(site, category) {
|
||||
if (category) {
|
||||
if (category.get('has_children')) {
|
||||
return true;
|
||||
}
|
||||
let draftCategoryId = site.get('shared_drafts_category_id');
|
||||
if (draftCategoryId && category.get('id') === draftCategoryId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const TopicList = RestModel.extend({
|
||||
canLoadMore: Em.computed.notEmpty("more_topics_url"),
|
||||
|
||||
|
@ -108,16 +125,19 @@ const TopicList = RestModel.extend({
|
|||
});
|
||||
|
||||
TopicList.reopenClass({
|
||||
topicsFrom(store, result) {
|
||||
topicsFrom(store, result, opts) {
|
||||
if (!result) { return; }
|
||||
|
||||
opts = opts || {};
|
||||
let listKey = opts.listKey || 'topics';
|
||||
|
||||
// Stitch together our side loaded data
|
||||
|
||||
const categories = Discourse.Category.list(),
|
||||
users = Model.extractByKey(result.users, Discourse.User),
|
||||
groups = Model.extractByKey(result.primary_groups, Ember.Object);
|
||||
|
||||
return result.topic_list.topics.map(function (t) {
|
||||
return result.topic_list[listKey].map(function (t) {
|
||||
t.category = categories.findBy('id', t.category_id);
|
||||
t.posters.forEach(function(p) {
|
||||
p.user = users[p.user_id];
|
||||
|
@ -150,6 +170,10 @@ TopicList.reopenClass({
|
|||
json.per_page = json.topic_list.per_page;
|
||||
json.topics = this.topicsFrom(store, json);
|
||||
|
||||
if (json.topic_list.shared_drafts) {
|
||||
json.sharedDrafts = this.topicsFrom(store, json, { listKey: 'shared_drafts' });
|
||||
}
|
||||
|
||||
return json;
|
||||
},
|
||||
|
||||
|
@ -160,7 +184,7 @@ TopicList.reopenClass({
|
|||
|
||||
// hide the category when it has no children
|
||||
hideUniformCategory(list, category) {
|
||||
list.set('hideCategory', category && !category.get("has_children"));
|
||||
list.set('hideCategory', !displayCategoryInList(list.site, category));
|
||||
}
|
||||
|
||||
});
|
||||
|
|
|
@ -476,6 +476,15 @@ const Topic = RestModel.extend({
|
|||
return promise;
|
||||
},
|
||||
|
||||
publish() {
|
||||
return ajax(`/t/${this.get('id')}/publish`, {
|
||||
type: 'PUT',
|
||||
data: this.getProperties('destination_category_id')
|
||||
}).then(() => {
|
||||
this.set('destination_category_id', null);
|
||||
}).catch(popupAjaxError);
|
||||
},
|
||||
|
||||
convertTopic(type) {
|
||||
return ajax(`/t/${this.get('id')}/convert-topic/${type}`, {type: 'PUT'}).then(() => {
|
||||
window.location.reload();
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
<div class='shared-draft-controls'>
|
||||
{{#if publishing}}
|
||||
{{i18n "shared_drafts.publishing"}}
|
||||
{{else}}
|
||||
{{{i18n "shared_drafts.notice" category=topic.category.name}}}
|
||||
|
||||
<div class='publish-field'>
|
||||
<label>{{i18n "shared_drafts.destination_category"}}</label>
|
||||
{{category-chooser value=topic.destination_category_id}}
|
||||
</div>
|
||||
|
||||
<div class='publish-field'>
|
||||
{{#if validCategory}}
|
||||
{{d-button
|
||||
action=(action "publish")
|
||||
label="shared_drafts.publish"
|
||||
class="btn-primary publish-shared-draft"
|
||||
icon="clipboard"}}
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
|
@ -14,4 +14,5 @@
|
|||
{{topic-featured-link topic}}
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{plugin-outlet name="topic-category" args=(hash topic=topic category=topic.category)}}
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
order=order
|
||||
ascending=ascending
|
||||
sortable=sortable
|
||||
listTitle=listTitle
|
||||
bulkSelectEnabled=bulkSelectEnabled}}
|
||||
</thead>
|
||||
{{/unless}}
|
||||
|
|
|
@ -13,6 +13,17 @@
|
|||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if model.sharedDrafts}}
|
||||
{{topic-list
|
||||
class="shared-drafts"
|
||||
listTitle="shared_drafts.title"
|
||||
top=top
|
||||
hideCategory="true"
|
||||
category=category
|
||||
topics=model.sharedDrafts
|
||||
discoveryList=true}}
|
||||
{{/if}}
|
||||
|
||||
{{bulk-select-button selected=selected action="refresh" category=category}}
|
||||
|
||||
{{#discovery-topics-list model=model refresh="refresh" incomingCount=topicTrackingState.incomingCount}}
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
{{/if}}
|
||||
</th>
|
||||
{{/if}}
|
||||
{{raw "topic-list-header-column" order='default' name='topic.title' bulkSelectEnabled=bulkSelectEnabled showBulkToggle=toggleInTitle canBulkSelect=canBulkSelect}}
|
||||
{{raw "topic-list-header-column" order='default' name=listTitle bulkSelectEnabled=bulkSelectEnabled showBulkToggle=toggleInTitle canBulkSelect=canBulkSelect}}
|
||||
{{#unless hideCategory}}
|
||||
{{raw "topic-list-header-column" sortable=sortable order='category' name='category_title'}}
|
||||
{{/unless}}
|
||||
|
|
|
@ -6,6 +6,10 @@
|
|||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if showSharedDraftControls}}
|
||||
{{shared-draft-controls topic=model}}
|
||||
{{/if}}
|
||||
|
||||
{{plugin-outlet name="topic-above-post-stream" args=(hash model=model)}}
|
||||
|
||||
{{#if model.postStream.loaded}}
|
||||
|
@ -68,6 +72,7 @@
|
|||
{{/topic-title}}
|
||||
{{/if}}
|
||||
|
||||
|
||||
<div class="container posts">
|
||||
<div class='selected-posts {{unless multiSelect 'hidden'}}'>
|
||||
{{partial "selected-posts"}}
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
import DropdownSelectBoxComponent from "select-kit/components/dropdown-select-box";
|
||||
import computed from "ember-addons/ember-computed-decorators";
|
||||
import { default as Composer, PRIVATE_MESSAGE, CREATE_TOPIC, REPLY, EDIT } from "discourse/models/composer";
|
||||
import {
|
||||
PRIVATE_MESSAGE,
|
||||
CREATE_TOPIC,
|
||||
CREATE_SHARED_DRAFT,
|
||||
REPLY,
|
||||
EDIT,
|
||||
NEW_PRIVATE_MESSAGE_KEY
|
||||
} from "discourse/models/composer";
|
||||
|
||||
// Component can get destroyed and lose state
|
||||
let _topicSnapshot = null;
|
||||
|
@ -49,6 +56,9 @@ export default DropdownSelectBoxComponent.extend({
|
|||
case EDIT:
|
||||
content.icon = "pencil";
|
||||
break;
|
||||
case CREATE_SHARED_DRAFT:
|
||||
content.icon = 'clipboard';
|
||||
break;
|
||||
};
|
||||
|
||||
return content;
|
||||
|
@ -58,7 +68,7 @@ export default DropdownSelectBoxComponent.extend({
|
|||
content(options, canWhisper, action) {
|
||||
let items = [];
|
||||
|
||||
if (action !== CREATE_TOPIC) {
|
||||
if (action !== CREATE_TOPIC && action !== CREATE_SHARED_DRAFT) {
|
||||
items.push({
|
||||
name: I18n.t("composer.composer_actions.reply_as_new_topic.label"),
|
||||
description: I18n.t("composer.composer_actions.reply_as_new_topic.desc"),
|
||||
|
@ -107,10 +117,31 @@ export default DropdownSelectBoxComponent.extend({
|
|||
});
|
||||
}
|
||||
|
||||
// Edge case: If personal messages are disabled, it is possible to have
|
||||
// no items which stil renders a button that pops up nothing. In this
|
||||
// case, add an option for what you're currently doing.
|
||||
if (action === CREATE_TOPIC && items.length === 0) {
|
||||
let showCreateTopic = false;
|
||||
if (action === CREATE_SHARED_DRAFT) {
|
||||
showCreateTopic = true;
|
||||
}
|
||||
|
||||
if (action === CREATE_TOPIC) {
|
||||
if (this.site.shared_drafts_category_id) {
|
||||
// Shared Drafts Choice
|
||||
items.push({
|
||||
name: I18n.t("composer.composer_actions.shared_draft.label"),
|
||||
description: I18n.t("composer.composer_actions.shared_draft.desc"),
|
||||
icon: "clipboard",
|
||||
id: "shared_draft"
|
||||
});
|
||||
}
|
||||
|
||||
// Edge case: If personal messages are disabled, it is possible to have
|
||||
// no items which stil renders a button that pops up nothing. In this
|
||||
// case, add an option for what you're currently doing.
|
||||
if (items.length === 0) {
|
||||
showCreateTopic = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreateTopic) {
|
||||
items.push({
|
||||
name: I18n.t("composer.composer_actions.create_topic.label"),
|
||||
description: I18n.t("composer.composer_actions.reply_as_new_topic.desc"),
|
||||
|
@ -118,6 +149,7 @@ export default DropdownSelectBoxComponent.extend({
|
|||
id: "create_topic"
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
|
||||
|
@ -147,59 +179,77 @@ export default DropdownSelectBoxComponent.extend({
|
|||
});
|
||||
},
|
||||
|
||||
_openComposer(options) {
|
||||
this.get("composerController").close();
|
||||
this.get("composerController").open(options);
|
||||
},
|
||||
|
||||
toggleWhisperSelected(options, model) {
|
||||
model.toggleProperty('whisper');
|
||||
},
|
||||
|
||||
replyToTopicSelected(options) {
|
||||
options.action = REPLY;
|
||||
options.topic = _topicSnapshot;
|
||||
this._openComposer(options);
|
||||
},
|
||||
|
||||
replyToPostSelected(options) {
|
||||
options.action = REPLY;
|
||||
options.post = _postSnapshot;
|
||||
this._openComposer(options);
|
||||
},
|
||||
|
||||
replyAsNewTopicSelected(options) {
|
||||
options.action = CREATE_TOPIC;
|
||||
options.categoryId = this.get("composerModel.topic.category.id");
|
||||
this._replyFromExisting(options, _postSnapshot, _topicSnapshot);
|
||||
},
|
||||
|
||||
replyAsPrivateMessageSelected(options) {
|
||||
let usernames;
|
||||
|
||||
if (_postSnapshot && !_postSnapshot.get("yours")) {
|
||||
const postUsername = _postSnapshot.get("username");
|
||||
if (postUsername) {
|
||||
usernames = postUsername;
|
||||
}
|
||||
}
|
||||
|
||||
options.action = PRIVATE_MESSAGE;
|
||||
options.usernames = usernames;
|
||||
options.archetypeId = "private_message";
|
||||
options.draftKey = NEW_PRIVATE_MESSAGE_KEY;
|
||||
|
||||
this._replyFromExisting(options, _postSnapshot, _topicSnapshot);
|
||||
},
|
||||
|
||||
_switchCreate(options, action) {
|
||||
options.action = action;
|
||||
options.categoryId = this.get("composerModel.categoryId");
|
||||
options.topicTitle = this.get('composerModel.title');
|
||||
this._openComposer(options);
|
||||
},
|
||||
|
||||
createTopicSelected(options) {
|
||||
this._switchCreate(options, CREATE_TOPIC);
|
||||
},
|
||||
|
||||
sharedDraftSelected(options) {
|
||||
this._switchCreate(options, CREATE_SHARED_DRAFT);
|
||||
},
|
||||
|
||||
actions: {
|
||||
onSelect(value) {
|
||||
let options = {
|
||||
draftKey: this.get("composerModel.draftKey"),
|
||||
draftSequence: this.get("composerModel.draftSequence"),
|
||||
reply: this.get("composerModel.reply")
|
||||
};
|
||||
|
||||
switch(value) {
|
||||
case "toggle_whisper":
|
||||
this.set("composerModel.whisper", !this.get("composerModel.whisper"));
|
||||
break;
|
||||
|
||||
case "reply_to_post":
|
||||
options.action = Composer.REPLY;
|
||||
options.post = _postSnapshot;
|
||||
|
||||
this.get("composerController").close();
|
||||
this.get("composerController").open(options);
|
||||
break;
|
||||
|
||||
case "reply_to_topic":
|
||||
options.action = Composer.REPLY;
|
||||
options.topic = _topicSnapshot;
|
||||
|
||||
this.get("composerController").close();
|
||||
this.get("composerController").open(options);
|
||||
break;
|
||||
|
||||
case "reply_as_new_topic":
|
||||
options.action = Composer.CREATE_TOPIC;
|
||||
options.categoryId = this.get("composerModel.topic.category.id");
|
||||
|
||||
this._replyFromExisting(options, _postSnapshot, _topicSnapshot);
|
||||
break;
|
||||
|
||||
case "reply_as_private_message":
|
||||
let usernames;
|
||||
|
||||
if (_postSnapshot && !_postSnapshot.get("yours")) {
|
||||
const postUsername = _postSnapshot.get("username");
|
||||
if (postUsername) {
|
||||
usernames = postUsername;
|
||||
}
|
||||
}
|
||||
|
||||
options.action = Composer.PRIVATE_MESSAGE;
|
||||
options.usernames = usernames;
|
||||
options.archetypeId = "private_message";
|
||||
options.draftKey = Composer.NEW_PRIVATE_MESSAGE_KEY;
|
||||
|
||||
this._replyFromExisting(options, _postSnapshot, _topicSnapshot);
|
||||
break;
|
||||
let action = `${Ember.String.camelize(value)}Selected`;
|
||||
if (this[action]) {
|
||||
let model = this.get('composerModel');
|
||||
this[action](
|
||||
model.getProperties('draftKey', 'draftSequence', 'reply'),
|
||||
model
|
||||
);
|
||||
} else {
|
||||
Ember.Logger.error(`No method '${action}' found`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
.topic-list.shared-drafts {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
.show-more {
|
||||
width: 100%;
|
||||
z-index: z("base");
|
||||
|
|
10
app/assets/stylesheets/common/base/shared-drafts.scss
Normal file
10
app/assets/stylesheets/common/base/shared-drafts.scss
Normal file
|
@ -0,0 +1,10 @@
|
|||
.shared-draft-controls {
|
||||
background-color: $tertiary-low;
|
||||
padding: 1em;
|
||||
|
||||
.publish-field {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
clear: both;
|
||||
}
|
|
@ -71,6 +71,18 @@ class ListController < ApplicationController
|
|||
|
||||
list = TopicQuery.new(user, list_opts).public_send("list_#{filter}")
|
||||
|
||||
if @category.present? && guardian.can_create_shared_draft?
|
||||
shared_drafts = TopicQuery.new(
|
||||
user,
|
||||
category: SiteSetting.shared_drafts_category,
|
||||
destination_category_id: list_opts[:category]
|
||||
).list_latest
|
||||
|
||||
if shared_drafts.present? && shared_drafts.topics.present?
|
||||
list.shared_drafts = shared_drafts.topics
|
||||
end
|
||||
end
|
||||
|
||||
list.more_topics_url = construct_url_with(:next, list_opts)
|
||||
list.prev_topics_url = construct_url_with(:prev, list_opts)
|
||||
if Discourse.anonymous_filters.include?(filter)
|
||||
|
|
|
@ -641,6 +641,12 @@ class PostsController < ApplicationController
|
|||
result[:is_warning] = false
|
||||
end
|
||||
|
||||
if params[:shared_draft] == 'true'
|
||||
raise Discourse::InvalidParameters.new(:shared_draft) unless guardian.can_create_shared_draft?
|
||||
|
||||
result[:shared_draft] = true
|
||||
end
|
||||
|
||||
if current_user.staff? && SiteSetting.enable_whispers? && params[:whisper] == "true"
|
||||
result[:post_type] = Post.types[:whisper]
|
||||
end
|
||||
|
|
|
@ -4,6 +4,7 @@ require_dependency 'url_helper'
|
|||
require_dependency 'topics_bulk_action'
|
||||
require_dependency 'discourse_event'
|
||||
require_dependency 'rate_limiter'
|
||||
require_dependency 'topic_publisher'
|
||||
|
||||
class TopicsController < ApplicationController
|
||||
requires_login only: [
|
||||
|
@ -30,7 +31,8 @@ class TopicsController < ApplicationController
|
|||
:archive_message,
|
||||
:move_to_inbox,
|
||||
:convert_topic,
|
||||
:bookmark
|
||||
:bookmark,
|
||||
:publish
|
||||
]
|
||||
|
||||
before_action :consider_user_for_promotion, only: :show
|
||||
|
@ -131,6 +133,18 @@ class TopicsController < ApplicationController
|
|||
raise ex
|
||||
end
|
||||
|
||||
def publish
|
||||
params.permit(:id, :destination_category_id)
|
||||
|
||||
topic = Topic.find(params[:id])
|
||||
category = Category.find(params[:destination_category_id])
|
||||
|
||||
guardian.ensure_can_publish_topic!(topic, category)
|
||||
topic = TopicPublisher.new(topic, current_user, category.id).publish!
|
||||
|
||||
render_serialized(topic.reload, BasicTopicSerializer)
|
||||
end
|
||||
|
||||
def unsubscribe
|
||||
if current_user.blank?
|
||||
cookies[:destination_url] = request.fullpath
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
require_dependency 'topic_publisher'
|
||||
|
||||
module Jobs
|
||||
class PublishTopicToCategory < Jobs::Base
|
||||
def execute(args)
|
||||
|
@ -7,19 +9,9 @@ module Jobs
|
|||
topic = topic_timer.topic
|
||||
return if topic.blank?
|
||||
|
||||
TopicTimestampChanger.new(timestamp: Time.zone.now, topic: topic).change! do
|
||||
if topic.private_message?
|
||||
topic = TopicConverter.new(topic, Discourse.system_user)
|
||||
.convert_to_public_topic(topic_timer.category_id)
|
||||
else
|
||||
topic.change_category_to_id(topic_timer.category_id)
|
||||
end
|
||||
|
||||
topic.update_columns(visible: true)
|
||||
topic_timer.trash!(Discourse.system_user)
|
||||
TopicTimer.transaction do
|
||||
TopicPublisher.new(topic, Discourse.system_user, topic_timer.category_id).publish!
|
||||
end
|
||||
|
||||
MessageBus.publish("/topic/#{topic.id}", reload_topic: true, refresh_stream: true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -54,6 +54,7 @@ class Category < ActiveRecord::Base
|
|||
|
||||
after_destroy :reset_topic_ids_cache
|
||||
after_destroy :publish_category_deletion
|
||||
after_destroy :remove_site_settings
|
||||
|
||||
after_create :delete_category_permalink
|
||||
|
||||
|
@ -250,6 +251,15 @@ SQL
|
|||
MessageBus.publish('/categories', { categories: ActiveModel::ArraySerializer.new([self]).as_json }, group_ids: group_ids)
|
||||
end
|
||||
|
||||
def remove_site_settings
|
||||
SiteSetting.all_settings.each do |s|
|
||||
if s[:type] == 'category' && s[:value].to_i == self.id
|
||||
SiteSetting.send("#{s[:setting]}=", '')
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
def publish_category_deletion
|
||||
MessageBus.publish('/categories', deleted_categories: [self.id])
|
||||
end
|
||||
|
|
4
app/models/shared_draft.rb
Normal file
4
app/models/shared_draft.rb
Normal file
|
@ -0,0 +1,4 @@
|
|||
class SharedDraft < ActiveRecord::Base
|
||||
belongs_to :topic
|
||||
belongs_to :category
|
||||
end
|
|
@ -150,6 +150,11 @@ class SiteSetting < ActiveRecord::Base
|
|||
SiteSetting::Upload
|
||||
end
|
||||
|
||||
def self.shared_drafts_enabled?
|
||||
c = SiteSetting.shared_drafts_category
|
||||
c.present? && c.to_i != SiteSetting.uncategorized_category_id.to_i
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
|
|
|
@ -134,6 +134,8 @@ class Topic < ActiveRecord::Base
|
|||
has_many :tag_users, through: :tags
|
||||
|
||||
has_one :top_topic
|
||||
has_one :shared_draft, dependent: :destroy
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :last_poster, class_name: 'User', foreign_key: :last_post_user_id
|
||||
belongs_to :featured_user1, class_name: 'User', foreign_key: :featured_user1_id
|
||||
|
|
|
@ -36,7 +36,8 @@ class TopicList
|
|||
:per_page,
|
||||
:top_tags,
|
||||
:current_user,
|
||||
:tags
|
||||
:tags,
|
||||
:shared_drafts
|
||||
|
||||
def initialize(filter, current_user, topics, opts = nil)
|
||||
@filter = filter
|
||||
|
|
|
@ -16,105 +16,111 @@ class UserHistory < ActiveRecord::Base
|
|||
before_save :set_admin_only
|
||||
|
||||
def self.actions
|
||||
@actions ||= Enum.new(delete_user: 1,
|
||||
change_trust_level: 2,
|
||||
change_site_setting: 3,
|
||||
change_theme: 4,
|
||||
delete_theme: 5,
|
||||
checked_for_custom_avatar: 6, # not used anymore
|
||||
notified_about_avatar: 7,
|
||||
notified_about_sequential_replies: 8,
|
||||
notified_about_dominating_topic: 9,
|
||||
suspend_user: 10,
|
||||
unsuspend_user: 11,
|
||||
facebook_no_email: 12,
|
||||
grant_badge: 13,
|
||||
revoke_badge: 14,
|
||||
auto_trust_level_change: 15,
|
||||
check_email: 16,
|
||||
delete_post: 17,
|
||||
delete_topic: 18,
|
||||
impersonate: 19,
|
||||
roll_up: 20,
|
||||
change_username: 21,
|
||||
custom: 22,
|
||||
custom_staff: 23,
|
||||
anonymize_user: 24,
|
||||
reviewed_post: 25,
|
||||
change_category_settings: 26,
|
||||
delete_category: 27,
|
||||
create_category: 28,
|
||||
change_site_text: 29,
|
||||
silence_user: 30,
|
||||
unsilence_user: 31,
|
||||
grant_admin: 32,
|
||||
revoke_admin: 33,
|
||||
grant_moderation: 34,
|
||||
revoke_moderation: 35,
|
||||
backup_create: 36,
|
||||
rate_limited_like: 37, # not used anymore
|
||||
revoke_email: 38,
|
||||
deactivate_user: 39,
|
||||
wizard_step: 40,
|
||||
lock_trust_level: 41,
|
||||
unlock_trust_level: 42,
|
||||
activate_user: 43,
|
||||
change_readonly_mode: 44,
|
||||
backup_download: 45,
|
||||
backup_destroy: 46,
|
||||
notified_about_get_a_room: 47,
|
||||
change_name: 48,
|
||||
post_locked: 49,
|
||||
post_unlocked: 50,
|
||||
check_personal_message: 51,
|
||||
disabled_second_factor: 52,
|
||||
post_edit: 53)
|
||||
@actions ||= Enum.new(
|
||||
delete_user: 1,
|
||||
change_trust_level: 2,
|
||||
change_site_setting: 3,
|
||||
change_theme: 4,
|
||||
delete_theme: 5,
|
||||
checked_for_custom_avatar: 6, # not used anymore
|
||||
notified_about_avatar: 7,
|
||||
notified_about_sequential_replies: 8,
|
||||
notified_about_dominating_topic: 9,
|
||||
suspend_user: 10,
|
||||
unsuspend_user: 11,
|
||||
facebook_no_email: 12,
|
||||
grant_badge: 13,
|
||||
revoke_badge: 14,
|
||||
auto_trust_level_change: 15,
|
||||
check_email: 16,
|
||||
delete_post: 17,
|
||||
delete_topic: 18,
|
||||
impersonate: 19,
|
||||
roll_up: 20,
|
||||
change_username: 21,
|
||||
custom: 22,
|
||||
custom_staff: 23,
|
||||
anonymize_user: 24,
|
||||
reviewed_post: 25,
|
||||
change_category_settings: 26,
|
||||
delete_category: 27,
|
||||
create_category: 28,
|
||||
change_site_text: 29,
|
||||
silence_user: 30,
|
||||
unsilence_user: 31,
|
||||
grant_admin: 32,
|
||||
revoke_admin: 33,
|
||||
grant_moderation: 34,
|
||||
revoke_moderation: 35,
|
||||
backup_create: 36,
|
||||
rate_limited_like: 37, # not used anymore
|
||||
revoke_email: 38,
|
||||
deactivate_user: 39,
|
||||
wizard_step: 40,
|
||||
lock_trust_level: 41,
|
||||
unlock_trust_level: 42,
|
||||
activate_user: 43,
|
||||
change_readonly_mode: 44,
|
||||
backup_download: 45,
|
||||
backup_destroy: 46,
|
||||
notified_about_get_a_room: 47,
|
||||
change_name: 48,
|
||||
post_locked: 49,
|
||||
post_unlocked: 50,
|
||||
check_personal_message: 51,
|
||||
disabled_second_factor: 52,
|
||||
post_edit: 53,
|
||||
topic_published: 54
|
||||
)
|
||||
end
|
||||
|
||||
# Staff actions is a subset of all actions, used to audit actions taken by staff users.
|
||||
def self.staff_actions
|
||||
@staff_actions ||= [:delete_user,
|
||||
:change_trust_level,
|
||||
:change_site_setting,
|
||||
:change_theme,
|
||||
:delete_theme,
|
||||
:change_site_text,
|
||||
:suspend_user,
|
||||
:unsuspend_user,
|
||||
:grant_badge,
|
||||
:revoke_badge,
|
||||
:check_email,
|
||||
:delete_post,
|
||||
:delete_topic,
|
||||
:impersonate,
|
||||
:roll_up,
|
||||
:change_username,
|
||||
:custom_staff,
|
||||
:anonymize_user,
|
||||
:reviewed_post,
|
||||
:change_category_settings,
|
||||
:delete_category,
|
||||
:create_category,
|
||||
:silence_user,
|
||||
:unsilence_user,
|
||||
:grant_admin,
|
||||
:revoke_admin,
|
||||
:grant_moderation,
|
||||
:revoke_moderation,
|
||||
:backup_create,
|
||||
:revoke_email,
|
||||
:deactivate_user,
|
||||
:lock_trust_level,
|
||||
:unlock_trust_level,
|
||||
:activate_user,
|
||||
:change_readonly_mode,
|
||||
:backup_download,
|
||||
:backup_destroy,
|
||||
:post_locked,
|
||||
:post_unlocked,
|
||||
:check_personal_message,
|
||||
:disabled_second_factor,
|
||||
:post_edit]
|
||||
@staff_actions ||= [
|
||||
:delete_user,
|
||||
:change_trust_level,
|
||||
:change_site_setting,
|
||||
:change_theme,
|
||||
:delete_theme,
|
||||
:change_site_text,
|
||||
:suspend_user,
|
||||
:unsuspend_user,
|
||||
:grant_badge,
|
||||
:revoke_badge,
|
||||
:check_email,
|
||||
:delete_post,
|
||||
:delete_topic,
|
||||
:impersonate,
|
||||
:roll_up,
|
||||
:change_username,
|
||||
:custom_staff,
|
||||
:anonymize_user,
|
||||
:reviewed_post,
|
||||
:change_category_settings,
|
||||
:delete_category,
|
||||
:create_category,
|
||||
:silence_user,
|
||||
:unsilence_user,
|
||||
:grant_admin,
|
||||
:revoke_admin,
|
||||
:grant_moderation,
|
||||
:revoke_moderation,
|
||||
:backup_create,
|
||||
:revoke_email,
|
||||
:deactivate_user,
|
||||
:lock_trust_level,
|
||||
:unlock_trust_level,
|
||||
:activate_user,
|
||||
:change_readonly_mode,
|
||||
:backup_download,
|
||||
:backup_destroy,
|
||||
:post_locked,
|
||||
:post_unlocked,
|
||||
:check_personal_message,
|
||||
:disabled_second_factor,
|
||||
:post_edit,
|
||||
:topic_published
|
||||
]
|
||||
end
|
||||
|
||||
def self.staff_action_ids
|
||||
|
|
|
@ -4,30 +4,33 @@ require_dependency 'wizard/builder'
|
|||
|
||||
class SiteSerializer < ApplicationSerializer
|
||||
|
||||
attributes :default_archetype,
|
||||
:notification_types,
|
||||
:post_types,
|
||||
:groups,
|
||||
:filters,
|
||||
:periods,
|
||||
:top_menu_items,
|
||||
:anonymous_top_menu_items,
|
||||
:uncategorized_category_id, # this is hidden so putting it here
|
||||
:is_readonly,
|
||||
:disabled_plugins,
|
||||
:user_field_max_length,
|
||||
:suppressed_from_latest_category_ids,
|
||||
:post_action_types,
|
||||
:topic_flag_types,
|
||||
:can_create_tag,
|
||||
:can_tag_topics,
|
||||
:can_tag_pms,
|
||||
:tags_filter_regexp,
|
||||
:top_tags,
|
||||
:wizard_required,
|
||||
:topic_featured_link_allowed_category_ids,
|
||||
:user_themes,
|
||||
:censored_words
|
||||
attributes(
|
||||
:default_archetype,
|
||||
:notification_types,
|
||||
:post_types,
|
||||
:groups,
|
||||
:filters,
|
||||
:periods,
|
||||
:top_menu_items,
|
||||
:anonymous_top_menu_items,
|
||||
:uncategorized_category_id, # this is hidden so putting it here
|
||||
:is_readonly,
|
||||
:disabled_plugins,
|
||||
:user_field_max_length,
|
||||
:suppressed_from_latest_category_ids,
|
||||
:post_action_types,
|
||||
:topic_flag_types,
|
||||
:can_create_tag,
|
||||
:can_tag_topics,
|
||||
:can_tag_pms,
|
||||
:tags_filter_regexp,
|
||||
:top_tags,
|
||||
:wizard_required,
|
||||
:topic_featured_link_allowed_category_ids,
|
||||
:user_themes,
|
||||
:censored_words,
|
||||
:shared_drafts_category_id
|
||||
)
|
||||
|
||||
has_many :categories, serializer: BasicCategorySerializer, embed: :objects
|
||||
has_many :trust_levels, embed: :objects
|
||||
|
@ -153,4 +156,13 @@ class SiteSerializer < ApplicationSerializer
|
|||
def censored_words
|
||||
WordWatcher.words_for_action(:censor).join('|')
|
||||
end
|
||||
|
||||
def shared_drafts_category_id
|
||||
SiteSetting.shared_drafts_category.to_i
|
||||
end
|
||||
|
||||
def include_shared_drafts_category_id?
|
||||
scope.can_create_shared_draft?
|
||||
end
|
||||
|
||||
end
|
||||
|
|
|
@ -29,6 +29,15 @@ class TopicListItemSerializer < ListableTopicSerializer
|
|||
posters.find { |poster| poster.user.id == object.last_post_user_id }.try(:user).try(:username)
|
||||
end
|
||||
|
||||
def category_id
|
||||
# If it's a shared draft, show the destination topic instead
|
||||
if object.category_id == SiteSetting.shared_drafts_category.to_i && object.shared_draft
|
||||
return object.shared_draft.category_id
|
||||
end
|
||||
|
||||
object.category_id
|
||||
end
|
||||
|
||||
def participants
|
||||
object.participants_summary || []
|
||||
end
|
||||
|
|
|
@ -8,15 +8,21 @@ class TopicListSerializer < ApplicationSerializer
|
|||
:for_period,
|
||||
:per_page,
|
||||
:top_tags,
|
||||
:tags
|
||||
:tags,
|
||||
:shared_drafts
|
||||
|
||||
has_many :topics, serializer: TopicListItemSerializer, embed: :objects
|
||||
has_many :shared_drafts, serializer: TopicListItemSerializer, embed: :objects
|
||||
has_many :tags, serializer: TagSerializer, embed: :objects
|
||||
|
||||
def can_create_topic
|
||||
scope.can_create?(Topic)
|
||||
end
|
||||
|
||||
def include_shared_drafts?
|
||||
object.shared_drafts.present?
|
||||
end
|
||||
|
||||
def include_for_period?
|
||||
for_period.present?
|
||||
end
|
||||
|
|
|
@ -65,7 +65,8 @@ class TopicViewSerializer < ApplicationSerializer
|
|||
:private_topic_timer,
|
||||
:unicode_title,
|
||||
:message_bus_last_id,
|
||||
:participant_count
|
||||
:participant_count,
|
||||
:destination_category_id
|
||||
|
||||
# TODO: Split off into proper object / serializer
|
||||
def details
|
||||
|
@ -275,6 +276,16 @@ class TopicViewSerializer < ApplicationSerializer
|
|||
object.participant_count
|
||||
end
|
||||
|
||||
def destination_category_id
|
||||
object.topic.shared_draft.category_id
|
||||
end
|
||||
|
||||
def include_destination_category_id?
|
||||
scope.can_create_shared_draft? &&
|
||||
object.topic.category_id == SiteSetting.shared_drafts_category.to_i &&
|
||||
object.topic.shared_draft.present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def private_message?(topic)
|
||||
|
|
|
@ -95,6 +95,14 @@ class StaffActionLogger
|
|||
target_user_id: user.id))
|
||||
end
|
||||
|
||||
def log_topic_published(topic, opts = {})
|
||||
raise Discourse::InvalidParameters.new(:topic) unless topic && topic.is_a?(Topic)
|
||||
UserHistory.create!(params(opts).merge(
|
||||
action: UserHistory.actions[:topic_published],
|
||||
topic_id: topic.id)
|
||||
)
|
||||
end
|
||||
|
||||
def log_post_lock(post, opts = {})
|
||||
raise Discourse::InvalidParameters.new(:post) unless post && post.is_a?(Post)
|
||||
UserHistory.create!(params(opts).merge(
|
||||
|
|
|
@ -1243,6 +1243,14 @@ en:
|
|||
medium_dark_tone: Medium dark skin tone
|
||||
dark_tone: Dark skin tone
|
||||
|
||||
shared_drafts:
|
||||
title: "Shared Drafts"
|
||||
notice: "This topic is only visible to those who can see the <b>{{category}}</b> category."
|
||||
destination_category: "Destination Category"
|
||||
publish: "Publish Shared Draft"
|
||||
confirm_publish: "Are you sure you want to publish this draft?"
|
||||
publishing: "Publishing Topic..."
|
||||
|
||||
composer:
|
||||
emoji: "Emoji :)"
|
||||
more_emoji: "more..."
|
||||
|
@ -1287,6 +1295,7 @@ en:
|
|||
create_topic: "Create Topic"
|
||||
create_pm: "Message"
|
||||
create_whisper: "Whisper"
|
||||
create_shared_draft: "Create Shared Draft"
|
||||
title: "Or press Ctrl+Enter"
|
||||
|
||||
users_placeholder: "Add a user"
|
||||
|
@ -1359,6 +1368,9 @@ en:
|
|||
desc: Whispers are only visible to staff members
|
||||
create_topic:
|
||||
label: "New Topic"
|
||||
shared_draft:
|
||||
label: "Shared Draft"
|
||||
desc: "Draft a topic that will only be visible to staff"
|
||||
|
||||
notifications:
|
||||
tooltip:
|
||||
|
|
|
@ -1649,6 +1649,8 @@ en:
|
|||
company_full_name: "Company Name (full)"
|
||||
company_domain: "Company Domain"
|
||||
|
||||
shared_drafts_category: "Enable the Shared Drafts feature by designating a category for topic drafts."
|
||||
|
||||
errors:
|
||||
invalid_email: "Invalid email address."
|
||||
invalid_username: "There's no user with that username."
|
||||
|
|
|
@ -598,6 +598,7 @@ Discourse::Application.routes.draw do
|
|||
put "t/:id/archive-message" => "topics#archive_message"
|
||||
put "t/:id/move-to-inbox" => "topics#move_to_inbox"
|
||||
put "t/:id/convert-topic/:type" => "topics#convert_topic"
|
||||
put "t/:id/publish" => "topics#publish"
|
||||
put "topics/bulk"
|
||||
put "topics/reset-new" => 'topics#reset_new'
|
||||
post "topics/timings"
|
||||
|
|
|
@ -537,6 +537,9 @@ posting:
|
|||
editing_grace_period_max_diff: 100
|
||||
editing_grace_period_max_diff_high_trust: 400
|
||||
staff_edit_locks_post: false
|
||||
shared_drafts_category:
|
||||
type: category
|
||||
default: ''
|
||||
post_edit_time_limit:
|
||||
default: 86400
|
||||
max: 525600
|
||||
|
|
10
db/migrate/20180316165104_create_shared_drafts.rb
Normal file
10
db/migrate/20180316165104_create_shared_drafts.rb
Normal file
|
@ -0,0 +1,10 @@
|
|||
class CreateSharedDrafts < ActiveRecord::Migration[5.1]
|
||||
def change
|
||||
create_table :shared_drafts, id: false do |t|
|
||||
t.integer :topic_id, null: false
|
||||
t.integer :category_id, null: false
|
||||
t.timestamps
|
||||
end
|
||||
add_index :shared_drafts, :topic_id, unique: true
|
||||
end
|
||||
end
|
|
@ -10,6 +10,14 @@ module TopicGuardian
|
|||
)
|
||||
end
|
||||
|
||||
def can_create_shared_draft?
|
||||
is_staff? && SiteSetting.shared_drafts_enabled?
|
||||
end
|
||||
|
||||
def can_publish_topic?(topic, category)
|
||||
is_staff? && can_see?(topic) && can_create_topic?(category)
|
||||
end
|
||||
|
||||
# Creating Methods
|
||||
def can_create_topic?(parent)
|
||||
is_staff? ||
|
||||
|
|
|
@ -52,6 +52,7 @@ class PostCreator
|
|||
# created_at - Topic creation time (optional)
|
||||
# pinned_at - Topic pinned time (optional)
|
||||
# pinned_globally - Is the topic pinned globally (optional)
|
||||
# shared_draft - Is the topic meant to be a shared draft
|
||||
#
|
||||
def initialize(user, opts)
|
||||
# TODO: we should reload user in case it is tainted, should take in a user_id as opposed to user
|
||||
|
@ -375,20 +376,6 @@ class PostCreator
|
|||
|
||||
private
|
||||
|
||||
# TODO: merge the similar function in TopicCreator and fix parameter naming for `category`
|
||||
def find_category_id
|
||||
@opts.delete(:category) if @opts[:archetype].present? && @opts[:archetype] == Archetype.private_message
|
||||
|
||||
category =
|
||||
if (@opts[:category].is_a? Integer) || (@opts[:category] =~ /^\d+$/)
|
||||
Category.find_by(id: @opts[:category])
|
||||
else
|
||||
Category.find_by(name_lower: @opts[:category].try(:downcase))
|
||||
end
|
||||
|
||||
category&.id
|
||||
end
|
||||
|
||||
def create_topic
|
||||
return if @topic
|
||||
begin
|
||||
|
|
|
@ -13,21 +13,24 @@ class SiteSettings::TypeSupervisor
|
|||
SUPPORTED_TYPES = %i[email username list enum].freeze
|
||||
|
||||
def self.types
|
||||
@types ||= Enum.new(string: 1,
|
||||
time: 2,
|
||||
integer: 3,
|
||||
float: 4,
|
||||
bool: 5,
|
||||
null: 6,
|
||||
enum: 7,
|
||||
list: 8,
|
||||
url_list: 9,
|
||||
host_list: 10,
|
||||
category_list: 11,
|
||||
value_list: 12,
|
||||
regex: 13,
|
||||
email: 14,
|
||||
username: 15)
|
||||
@types ||= Enum.new(
|
||||
string: 1,
|
||||
time: 2,
|
||||
integer: 3,
|
||||
float: 4,
|
||||
bool: 5,
|
||||
null: 6,
|
||||
enum: 7,
|
||||
list: 8,
|
||||
url_list: 9,
|
||||
host_list: 10,
|
||||
category_list: 11,
|
||||
value_list: 12,
|
||||
regex: 13,
|
||||
email: 14,
|
||||
username: 15,
|
||||
category: 16
|
||||
)
|
||||
end
|
||||
|
||||
def self.parse_value_type(val)
|
||||
|
|
|
@ -48,12 +48,18 @@ class TopicCreator
|
|||
save_topic(topic)
|
||||
create_warning(topic)
|
||||
watch_topic(topic)
|
||||
create_shared_draft(topic)
|
||||
|
||||
topic
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_shared_draft(topic)
|
||||
return unless @opts[:shared_draft] && @opts[:category].present?
|
||||
SharedDraft.create(topic_id: topic.id, category_id: @opts[:category])
|
||||
end
|
||||
|
||||
def create_warning(topic)
|
||||
return unless @opts[:is_warning]
|
||||
|
||||
|
@ -138,6 +144,10 @@ class TopicCreator
|
|||
# PM can't have a category
|
||||
@opts.delete(:category) if @opts[:archetype].present? && @opts[:archetype] == Archetype.private_message
|
||||
|
||||
if @opts[:shared_draft]
|
||||
return Category.find(SiteSetting.shared_drafts_category)
|
||||
end
|
||||
|
||||
# Temporary fix to allow older clients to create topics.
|
||||
# When all clients are updated the category variable should
|
||||
# be set directly to the contents of the if statement.
|
||||
|
|
41
lib/topic_publisher.rb
Normal file
41
lib/topic_publisher.rb
Normal file
|
@ -0,0 +1,41 @@
|
|||
class TopicPublisher
|
||||
|
||||
def initialize(topic, published_by, category_id)
|
||||
@topic = topic
|
||||
@published_by = published_by
|
||||
@category_id = category_id
|
||||
end
|
||||
|
||||
def publish!
|
||||
TopicTimestampChanger.new(timestamp: Time.zone.now, topic: @topic).change! do
|
||||
if @topic.private_message?
|
||||
@topic = TopicConverter.new(@topic, @published_by)
|
||||
.convert_to_public_topic(@category_id)
|
||||
else
|
||||
@topic.change_category_to_id(@category_id)
|
||||
end
|
||||
|
||||
@topic.update_columns(visible: true)
|
||||
|
||||
StaffActionLogger.new(@published_by).log_topic_published(@topic)
|
||||
|
||||
# Clean up any publishing artifacts
|
||||
SharedDraft.where(topic: @topic).delete_all
|
||||
TopicTimer.where(topic: @topic).update_all(
|
||||
deleted_at: DateTime.now,
|
||||
deleted_by_id: @published_by.id
|
||||
)
|
||||
|
||||
op = @topic.first_post
|
||||
if op.present?
|
||||
op.revisions.delete_all
|
||||
op.update_column(:version, 1)
|
||||
end
|
||||
end
|
||||
|
||||
MessageBus.publish("/topic/#{@topic.id}", reload_topic: true, refresh_stream: true)
|
||||
|
||||
@topic
|
||||
end
|
||||
|
||||
end
|
|
@ -44,7 +44,8 @@ class TopicQuery
|
|||
per_page
|
||||
visible
|
||||
guardian
|
||||
no_definitions)
|
||||
no_definitions
|
||||
destination_category_id)
|
||||
end
|
||||
|
||||
# Maps `order` to a columns in `topics`
|
||||
|
@ -606,6 +607,11 @@ class TopicQuery
|
|||
result = apply_ordering(result, options)
|
||||
result = result.listable_topics.includes(:category)
|
||||
|
||||
if options[:destination_category_id]
|
||||
destination_category_id = get_category_id(options[:destination_category_id])
|
||||
result = result.includes(:shared_draft).where("shared_drafts.category_id" => destination_category_id)
|
||||
end
|
||||
|
||||
if options[:exclude_category_ids] && options[:exclude_category_ids].is_a?(Array) && options[:exclude_category_ids].size > 0
|
||||
result = result.where("categories.id NOT IN (?)", options[:exclude_category_ids]).references(:categories)
|
||||
end
|
||||
|
|
47
spec/components/topic_publisher_spec.rb
Normal file
47
spec/components/topic_publisher_spec.rb
Normal file
|
@ -0,0 +1,47 @@
|
|||
require 'topic_publisher'
|
||||
require 'rails_helper'
|
||||
|
||||
describe TopicPublisher do
|
||||
|
||||
context "shared drafts" do
|
||||
let(:shared_drafts_category) { Fabricate(:category) }
|
||||
let(:category) { Fabricate(:category) }
|
||||
|
||||
before do
|
||||
SiteSetting.shared_drafts_category = shared_drafts_category.id
|
||||
end
|
||||
|
||||
context "publishing" do
|
||||
let(:topic) { Fabricate(:topic, category: shared_drafts_category, visible: false) }
|
||||
let(:shared_draft) { Fabricate(:shared_draft, topic: topic, category: category) }
|
||||
let(:moderator) { Fabricate(:moderator) }
|
||||
let(:op) { Fabricate(:post, topic: topic) }
|
||||
|
||||
before do
|
||||
# Create a revision
|
||||
op.set_owner(Fabricate(:coding_horror), Discourse.system_user)
|
||||
op.reload
|
||||
end
|
||||
|
||||
it "will publish the topic properly" do
|
||||
TopicPublisher.new(topic, moderator, shared_draft.category_id).publish!
|
||||
|
||||
topic.reload
|
||||
expect(topic.category).to eq(category)
|
||||
expect(topic).to be_visible
|
||||
expect(topic.shared_draft).to be_blank
|
||||
expect(UserHistory.where(
|
||||
acting_user_id: moderator.id,
|
||||
action: UserHistory.actions[:topic_published]
|
||||
)).to be_present
|
||||
op.reload
|
||||
|
||||
# Should delete any edits on the OP
|
||||
expect(op.revisions.size).to eq(0)
|
||||
expect(op.version).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
4
spec/fabricators/shared_draft_fabricator.rb
Normal file
4
spec/fabricators/shared_draft_fabricator.rb
Normal file
|
@ -0,0 +1,4 @@
|
|||
Fabricator(:shared_draft) do
|
||||
topic
|
||||
category
|
||||
end
|
|
@ -383,12 +383,14 @@ describe Category do
|
|||
@category = Fabricate(:category)
|
||||
@category_id = @category.id
|
||||
@topic_id = @category.topic_id
|
||||
SiteSetting.shared_drafts_category = @category.id.to_s
|
||||
@category.destroy
|
||||
end
|
||||
|
||||
it 'is deleted correctly' do
|
||||
expect(Category.exists?(id: @category_id)).to be false
|
||||
expect(Topic.exists?(id: @topic_id)).to be false
|
||||
expect(SiteSetting.shared_drafts_category).to be_blank
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -119,7 +119,22 @@ describe SiteSetting do
|
|||
it "returns https when using ssl" do
|
||||
expect(SiteSetting.scheme).to eq("https")
|
||||
end
|
||||
end
|
||||
|
||||
context "shared_drafts_enabled?" do
|
||||
it "returns false by default" do
|
||||
expect(SiteSetting.shared_drafts_enabled?).to eq(false)
|
||||
end
|
||||
|
||||
it "returns false when the category is uncategorized" do
|
||||
SiteSetting.shared_drafts_category = SiteSetting.uncategorized_category_id
|
||||
expect(SiteSetting.shared_drafts_enabled?).to eq(false)
|
||||
end
|
||||
|
||||
it "returns true when the category is valid" do
|
||||
SiteSetting.shared_drafts_category = Fabricate(:category).id
|
||||
expect(SiteSetting.shared_drafts_enabled?).to eq(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'deprecated site settings' do
|
||||
|
|
|
@ -38,7 +38,6 @@ RSpec.describe PostsController do
|
|||
end
|
||||
|
||||
it 'can not create a post in a disallowed category' do
|
||||
|
||||
category.set_permissions(staff: :full)
|
||||
category.save!
|
||||
|
||||
|
@ -120,6 +119,59 @@ RSpec.describe PostsController do
|
|||
expect(new_topic.allowed_users).to contain_exactly(user, user_2, user_3)
|
||||
end
|
||||
|
||||
describe 'shared draft' do
|
||||
let(:destination_category) { Fabricate(:category) }
|
||||
|
||||
it "will raise an error for regular users" do
|
||||
post "/posts.json", params: {
|
||||
raw: 'this is the shared draft content',
|
||||
title: "this is the shared draft title",
|
||||
category: destination_category.id,
|
||||
shared_draft: 'true'
|
||||
}
|
||||
expect(response).not_to be_success
|
||||
end
|
||||
|
||||
describe "as a staff user" do
|
||||
before do
|
||||
sign_in(Fabricate(:moderator))
|
||||
end
|
||||
|
||||
it "will raise an error if there is no shared draft category" do
|
||||
post "/posts.json", params: {
|
||||
raw: 'this is the shared draft content',
|
||||
title: "this is the shared draft title",
|
||||
category: destination_category.id,
|
||||
shared_draft: 'true'
|
||||
}
|
||||
expect(response).not_to be_success
|
||||
end
|
||||
|
||||
context "with a shared category" do
|
||||
let(:shared_category) { Fabricate(:category) }
|
||||
before do
|
||||
SiteSetting.shared_drafts_category = shared_category.id
|
||||
end
|
||||
|
||||
it "will work if the shared draft category is present" do
|
||||
post "/posts.json", params: {
|
||||
raw: 'this is the shared draft content',
|
||||
title: "this is the shared draft title",
|
||||
category: destination_category.id,
|
||||
shared_draft: 'true'
|
||||
}
|
||||
expect(response).to be_success
|
||||
result = JSON.parse(response.body)
|
||||
topic = Topic.find(result['topic_id'])
|
||||
expect(topic.category_id).to eq(shared_category.id)
|
||||
expect(topic.shared_draft.category_id).to eq(destination_category.id)
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
describe 'warnings' do
|
||||
let(:user_2) { Fabricate(:user) }
|
||||
|
||||
|
|
|
@ -5,7 +5,6 @@ RSpec.describe TopicsController do
|
|||
let(:user) { Fabricate(:user) }
|
||||
|
||||
describe '#update' do
|
||||
|
||||
it "won't allow us to update a topic when we're not logged in" do
|
||||
put "/t/1.json", params: { slug: 'xyz' }
|
||||
expect(response.status).to eq(403)
|
||||
|
@ -457,4 +456,48 @@ RSpec.describe TopicsController do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'shared drafts' do
|
||||
let(:shared_drafts_category) { Fabricate(:category) }
|
||||
let(:category) { Fabricate(:category) }
|
||||
|
||||
before do
|
||||
SiteSetting.shared_drafts_category = shared_drafts_category.id
|
||||
end
|
||||
|
||||
describe "#publish" do
|
||||
let(:category) { Fabricate(:category) }
|
||||
let(:topic) { Fabricate(:topic, category: shared_drafts_category, visible: false) }
|
||||
let(:shared_draft) { Fabricate(:shared_draft, topic: topic, category: category) }
|
||||
let(:moderator) { Fabricate(:moderator) }
|
||||
|
||||
it "fails for anonymous users" do
|
||||
put "/t/#{topic.id}/publish.json", params: { category_id: category.id }
|
||||
expect(response).not_to be_success
|
||||
end
|
||||
|
||||
it "fails as a regular user" do
|
||||
sign_in(Fabricate(:user))
|
||||
put "/t/#{topic.id}/publish.json", params: { category_id: category.id }
|
||||
expect(response).not_to be_success
|
||||
end
|
||||
|
||||
context "as staff" do
|
||||
before do
|
||||
sign_in(moderator)
|
||||
end
|
||||
|
||||
it "will publish the topic" do
|
||||
put "/t/#{topic.id}/publish.json", params: { destination_category_id: category.id }
|
||||
expect(response).to be_success
|
||||
json = ::JSON.parse(response.body)['basic_topic']
|
||||
|
||||
result = Topic.find(json['id'])
|
||||
expect(result.category_id).to eq(category.id)
|
||||
expect(result.visible).to eq(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
|
|
@ -91,6 +91,22 @@ QUnit.test('replying to post - reply_as_new_topic', assert => {
|
|||
});
|
||||
});
|
||||
|
||||
QUnit.test('shared draft', assert => {
|
||||
let composerActions = selectKit('.composer-actions');
|
||||
|
||||
visit("/");
|
||||
click('#create-topic');
|
||||
andThen(() => {
|
||||
composerActions.expand().selectRowByValue('shared_draft');
|
||||
});
|
||||
andThen(() => {
|
||||
assert.equal(
|
||||
find('#reply-control .btn-primary.create .d-button-label').text(),
|
||||
I18n.t('composer.create_shared_draft')
|
||||
);
|
||||
assert.ok(find('#reply-control.composing-shared-draft').length === 1);
|
||||
});
|
||||
});
|
||||
|
||||
QUnit.test('interactions', assert => {
|
||||
const composerActions = selectKit('.composer-actions');
|
||||
|
@ -137,7 +153,7 @@ QUnit.test('interactions', assert => {
|
|||
assert.equal(composerActions.rowByIndex(0).value(), 'reply_to_post');
|
||||
assert.equal(composerActions.rowByIndex(1).value(), 'reply_as_private_message');
|
||||
assert.equal(composerActions.rowByIndex(2).value(), 'reply_to_topic');
|
||||
assert.equal(composerActions.rowByIndex(3).value(), undefined);
|
||||
assert.equal(composerActions.rowByIndex(3).value(), 'shared_draft');
|
||||
});
|
||||
|
||||
composerActions.selectRowByValue('reply_as_private_message').expand();
|
||||
|
|
19
test/javascripts/acceptance/shared-drafts-test.js.es6
Normal file
19
test/javascripts/acceptance/shared-drafts-test.js.es6
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { acceptance } from "helpers/qunit-helpers";
|
||||
|
||||
acceptance("Shared Drafts", { loggedIn: true });
|
||||
|
||||
QUnit.test('Viewing', assert => {
|
||||
visit("/t/some-topic/9");
|
||||
andThen(() => {
|
||||
assert.ok(find('.shared-draft-controls').length === 1);
|
||||
let categoryChooser = selectKit('.shared-draft-controls .category-chooser');
|
||||
assert.equal(categoryChooser.header().value(), '3');
|
||||
});
|
||||
|
||||
click('.publish-shared-draft');
|
||||
click('.bootbox .btn-primary');
|
||||
|
||||
andThen(() => {
|
||||
assert.ok(find('.shared-draft-controls').length === 0);
|
||||
});
|
||||
});
|
|
@ -6,6 +6,7 @@ QUnit.test("Enter a Topic", assert => {
|
|||
andThen(() => {
|
||||
assert.ok(exists("#topic"), "The topic was rendered");
|
||||
assert.ok(exists("#topic .cooked"), "The topic has cooked posts");
|
||||
assert.ok(find('.shared-draft-notice').length === 0, "no shared draft unless there's a dest category id");
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -38,4 +39,4 @@ QUnit.test("Enter with 500 errors", assert => {
|
|||
assert.ok(!exists("#topic"), "The topic was not rendered");
|
||||
assert.ok(exists(".topic-error"), "An error message is displayed");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -3,6 +3,7 @@ export default {
|
|||
"site":{
|
||||
"default_archetype":"regular",
|
||||
"disabled_plugins":[],
|
||||
"shared_drafts_category_id":24,
|
||||
"notification_types":{
|
||||
"mentioned":1,
|
||||
"replied":2,
|
||||
|
@ -171,17 +172,16 @@ export default {
|
|||
},
|
||||
{
|
||||
"id":24,
|
||||
"name":"sso",
|
||||
"name":"Shared Drafts",
|
||||
"color":"92278F",
|
||||
"text_color":"FFFFFF",
|
||||
"slug":"sso",
|
||||
"slug":"shared-drafts",
|
||||
"topic_count":13,
|
||||
"post_count":53,
|
||||
"description":"Only include actual maintained SSO (single sign on) implementations in this category. See the <a href=\"https://meta.discourse.org/t/official-single-sign-on-for-discourse/13045\">official documentation on Discourse's SSO support</a>.",
|
||||
"topic_url":"/t/about-the-sso-category/13110",
|
||||
"read_restricted":false,
|
||||
"description":"An area for staff members to post shared drafts",
|
||||
"topic_url":"/t/about-the-shared-drafts-category/13110",
|
||||
"read_restricted":true,
|
||||
"permission":1,
|
||||
"parent_category_id":5,
|
||||
"notification_level":null,
|
||||
"logo_url":null,
|
||||
"background_url":null
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -151,6 +151,7 @@ export default function() {
|
|||
|
||||
this.delete('/t/:id', success);
|
||||
this.put('/t/:id/recover', success);
|
||||
this.put('/t/:id/publish', success);
|
||||
|
||||
this.get("/404-body", () => {
|
||||
return [200, {"Content-Type": "text/html"}, "<div class='page-not-found'>not found</div>"];
|
||||
|
|
Loading…
Reference in New Issue
Block a user