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:
Robin Ward 2018-03-13 15:59:12 -04:00
parent dcbd9635f4
commit b9abd7dc9e
59 changed files with 851 additions and 260 deletions

View File

@ -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;

View File

@ -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")

View File

@ -0,0 +1,3 @@
{{category-chooser value=value allowUncategorized="true"}}
{{setting-validation-message message=validationMessage}}
<div class='desc'>{{{unbound setting.description}}}</div>

View File

@ -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

View File

@ -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);

View File

@ -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'],

View File

@ -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);
});
}
});
}
}
});

View File

@ -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'),

View File

@ -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;

View File

@ -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() {

View File

@ -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));
}
});

View File

@ -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();

View File

@ -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>

View File

@ -14,4 +14,5 @@
{{topic-featured-link topic}}
{{/if}}
</div>
{{plugin-outlet name="topic-category" args=(hash topic=topic category=topic.category)}}

View File

@ -11,6 +11,7 @@
order=order
ascending=ascending
sortable=sortable
listTitle=listTitle
bulkSelectEnabled=bulkSelectEnabled}}
</thead>
{{/unless}}

View File

@ -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}}

View File

@ -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}}

View File

@ -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"}}

View File

@ -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`);
}
}
}

View File

@ -1,3 +1,7 @@
.topic-list.shared-drafts {
margin-bottom: 1.5em;
}
.show-more {
width: 100%;
z-index: z("base");

View File

@ -0,0 +1,10 @@
.shared-draft-controls {
background-color: $tertiary-low;
padding: 1em;
.publish-field {
margin-top: 1em;
}
clear: both;
}

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,4 @@
class SharedDraft < ActiveRecord::Base
belongs_to :topic
belongs_to :category
end

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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(

View File

@ -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:

View File

@ -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."

View File

@ -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"

View File

@ -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

View 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

View File

@ -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? ||

View File

@ -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

View File

@ -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)

View File

@ -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
View 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

View File

@ -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

View 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

View File

@ -0,0 +1,4 @@
Fabricator(:shared_draft) do
topic
category
end

View File

@ -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

View File

@ -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

View File

@ -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) }

View File

@ -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

View File

@ -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();

View 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);
});
});

View File

@ -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");
});
});
});

View File

@ -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

View File

@ -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>"];