FEATURE: Allow admins to schedule a topic to be published in the future.
This commit is contained in:
parent
dc5a6e7cda
commit
f4758a4c4d
|
@ -1,9 +1,12 @@
|
||||||
import computed from "ember-addons/ember-computed-decorators";
|
import { default as computed, observes } from "ember-addons/ember-computed-decorators";
|
||||||
import { observes } from "ember-addons/ember-computed-decorators";
|
|
||||||
|
|
||||||
export default Ember.Component.extend({
|
export default Ember.Component.extend({
|
||||||
limited: false,
|
limited: false,
|
||||||
inputValid: false,
|
|
||||||
|
didInsertElement() {
|
||||||
|
this._super();
|
||||||
|
this._updateInputValid();
|
||||||
|
},
|
||||||
|
|
||||||
@computed("limited")
|
@computed("limited")
|
||||||
inputUnitsKey(limited) {
|
inputUnitsKey(limited) {
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { categoryBadgeHTML } from 'discourse/helpers/category-link';
|
||||||
import computed from 'ember-addons/ember-computed-decorators';
|
import computed from 'ember-addons/ember-computed-decorators';
|
||||||
import { observes, on } from 'ember-addons/ember-computed-decorators';
|
import { observes, on } from 'ember-addons/ember-computed-decorators';
|
||||||
import PermissionType from 'discourse/models/permission-type';
|
import PermissionType from 'discourse/models/permission-type';
|
||||||
|
import Category from 'discourse/models/category';
|
||||||
|
|
||||||
export default ComboboxView.extend({
|
export default ComboboxView.extend({
|
||||||
classNames: ['combobox category-combobox'],
|
classNames: ['combobox category-combobox'],
|
||||||
|
@ -14,13 +15,16 @@ export default ComboboxView.extend({
|
||||||
content(scopedCategoryId, categories) {
|
content(scopedCategoryId, categories) {
|
||||||
// Always scope to the parent of a category, if present
|
// Always scope to the parent of a category, if present
|
||||||
if (scopedCategoryId) {
|
if (scopedCategoryId) {
|
||||||
const scopedCat = Discourse.Category.findById(scopedCategoryId);
|
const scopedCat = Category.findById(scopedCategoryId);
|
||||||
scopedCategoryId = scopedCat.get('parent_category_id') || scopedCat.get('id');
|
scopedCategoryId = scopedCat.get('parent_category_id') || scopedCat.get('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const excludeCategoryId = this.get('excludeCategoryId');
|
||||||
|
|
||||||
return categories.filter(c => {
|
return categories.filter(c => {
|
||||||
if (scopedCategoryId && c.get('id') !== scopedCategoryId && c.get('parent_category_id') !== scopedCategoryId) { return false; }
|
const categoryId = c.get('id');
|
||||||
if (c.get('isUncategorizedCategory')) { return false; }
|
if (scopedCategoryId && categoryId !== scopedCategoryId && c.get('parent_category_id') !== scopedCategoryId) { return false; }
|
||||||
|
if (c.get('isUncategorizedCategory') || excludeCategoryId === categoryId) { return false; }
|
||||||
return c.get('permission') === PermissionType.FULL;
|
return c.get('permission') === PermissionType.FULL;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@ -30,8 +34,8 @@ export default ComboboxView.extend({
|
||||||
_updateCategories() {
|
_updateCategories() {
|
||||||
if (!this.get('categories')) {
|
if (!this.get('categories')) {
|
||||||
const categories = Discourse.SiteSettings.fixed_category_positions_on_create ?
|
const categories = Discourse.SiteSettings.fixed_category_positions_on_create ?
|
||||||
Discourse.Category.list() :
|
Category.list() :
|
||||||
Discourse.Category.listByActivity();
|
Category.listByActivity();
|
||||||
this.set('categories', categories);
|
this.set('categories', categories);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -42,7 +46,7 @@ export default ComboboxView.extend({
|
||||||
if (rootNone) {
|
if (rootNone) {
|
||||||
return "category.none";
|
return "category.none";
|
||||||
} else {
|
} else {
|
||||||
return Discourse.Category.findUncategorized();
|
return Category.findUncategorized();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return 'category.choose';
|
return 'category.choose';
|
||||||
|
@ -54,12 +58,12 @@ export default ComboboxView.extend({
|
||||||
|
|
||||||
// If we have no id, but text with the uncategorized name, we can use that badge.
|
// If we have no id, but text with the uncategorized name, we can use that badge.
|
||||||
if (Ember.isEmpty(item.id)) {
|
if (Ember.isEmpty(item.id)) {
|
||||||
const uncat = Discourse.Category.findUncategorized();
|
const uncat = Category.findUncategorized();
|
||||||
if (uncat && uncat.get('name') === item.text) {
|
if (uncat && uncat.get('name') === item.text) {
|
||||||
category = uncat;
|
category = uncat;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
category = Discourse.Category.findById(parseInt(item.id,10));
|
category = Category.findById(parseInt(item.id,10));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!category) return item.text;
|
if (!category) return item.text;
|
||||||
|
@ -67,7 +71,7 @@ export default ComboboxView.extend({
|
||||||
const parentCategoryId = category.get('parent_category_id');
|
const parentCategoryId = category.get('parent_category_id');
|
||||||
|
|
||||||
if (parentCategoryId) {
|
if (parentCategoryId) {
|
||||||
result = categoryBadgeHTML(Discourse.Category.findById(parentCategoryId), {link: false}) + " " + result;
|
result = categoryBadgeHTML(Category.findById(parentCategoryId), {link: false}) + " " + result;
|
||||||
}
|
}
|
||||||
|
|
||||||
result += ` <span class='topic-count'>× ${category.get('topic_count')}</span>`;
|
result += ` <span class='topic-count'>× ${category.get('topic_count')}</span>`;
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { bufferedRender } from 'discourse-common/lib/buffered-render';
|
import { bufferedRender } from 'discourse-common/lib/buffered-render';
|
||||||
|
import Category from 'discourse/models/category';
|
||||||
|
|
||||||
export default Ember.Component.extend(bufferedRender({
|
export default Ember.Component.extend(bufferedRender({
|
||||||
elementId: 'topic-status-info',
|
elementId: 'topic-status-info',
|
||||||
|
@ -8,7 +9,8 @@ export default Ember.Component.extend(bufferedRender({
|
||||||
'topic.topic_status_update',
|
'topic.topic_status_update',
|
||||||
'topic.topic_status_update.execute_at',
|
'topic.topic_status_update.execute_at',
|
||||||
'topic.topic_status_update.based_on_last_post',
|
'topic.topic_status_update.based_on_last_post',
|
||||||
'topic.topic_status_update.duration'
|
'topic.topic_status_update.duration',
|
||||||
|
'topic.topic_status_update.category_id',
|
||||||
],
|
],
|
||||||
|
|
||||||
buildBuffer(buffer) {
|
buildBuffer(buffer) {
|
||||||
|
@ -35,11 +37,23 @@ export default Ember.Component.extend(bufferedRender({
|
||||||
|
|
||||||
buffer.push('<h3><i class="fa fa-clock-o"></i> ');
|
buffer.push('<h3><i class="fa fa-clock-o"></i> ');
|
||||||
|
|
||||||
buffer.push(I18n.t(this._noticeKey(), {
|
let options = {
|
||||||
timeLeft: duration.humanize(true),
|
timeLeft: duration.humanize(true),
|
||||||
duration: moment.duration(autoCloseHours, "hours").humanize()
|
duration: moment.duration(autoCloseHours, "hours").humanize(),
|
||||||
}));
|
};
|
||||||
|
|
||||||
|
const categoryId = this.get('topic.topic_status_update.category_id');
|
||||||
|
|
||||||
|
if (categoryId) {
|
||||||
|
const category = Category.findById(categoryId);
|
||||||
|
|
||||||
|
options = _.assign({
|
||||||
|
categoryName: category.get('slug'),
|
||||||
|
categoryUrl: category.get('url')
|
||||||
|
}, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.push(I18n.t(this._noticeKey(), options));
|
||||||
buffer.push('</h3>');
|
buffer.push('</h3>');
|
||||||
|
|
||||||
// TODO Sam: concerned this can cause a heavy rerender loop
|
// TODO Sam: concerned this can cause a heavy rerender loop
|
||||||
|
|
|
@ -5,18 +5,36 @@ import { popupAjaxError } from 'discourse/lib/ajax-error';
|
||||||
|
|
||||||
const CLOSE_STATUS_TYPE = 'close';
|
const CLOSE_STATUS_TYPE = 'close';
|
||||||
const OPEN_STATUS_TYPE = 'open';
|
const OPEN_STATUS_TYPE = 'open';
|
||||||
|
const PUBLISH_TO_CATEGORY_STATUS_TYPE = 'publish_to_category';
|
||||||
|
|
||||||
export default Ember.Controller.extend(ModalFunctionality, {
|
export default Ember.Controller.extend(ModalFunctionality, {
|
||||||
|
closeStatusType: CLOSE_STATUS_TYPE,
|
||||||
|
openStatusType: OPEN_STATUS_TYPE,
|
||||||
|
publishToCategoryStatusType: PUBLISH_TO_CATEGORY_STATUS_TYPE,
|
||||||
updateTimeValid: null,
|
updateTimeValid: null,
|
||||||
updateTimeInvalid: Em.computed.not('updateTimeValid'),
|
updateTimeInvalid: Em.computed.not('updateTimeValid'),
|
||||||
loading: false,
|
loading: false,
|
||||||
updateTime: null,
|
updateTime: null,
|
||||||
topicStatusUpdate: Ember.computed.alias("model.topic_status_update"),
|
topicStatusUpdate: Ember.computed.alias("model.topic_status_update"),
|
||||||
selection: Ember.computed.alias('model.topic_status_update.status_type'),
|
selection: Ember.computed.alias('model.topic_status_update.status_type'),
|
||||||
autoReopen: Ember.computed.equal('selection', OPEN_STATUS_TYPE),
|
autoOpen: Ember.computed.equal('selection', OPEN_STATUS_TYPE),
|
||||||
autoClose: Ember.computed.equal('selection', CLOSE_STATUS_TYPE),
|
autoClose: Ember.computed.equal('selection', CLOSE_STATUS_TYPE),
|
||||||
disableAutoReopen: Ember.computed.and('autoClose', 'updateTime'),
|
publishToCategory: Ember.computed.equal('selection', PUBLISH_TO_CATEGORY_STATUS_TYPE),
|
||||||
disableAutoClose: Ember.computed.and('autoReopen', 'updateTime'),
|
|
||||||
|
@computed('autoClose', 'updateTime')
|
||||||
|
disableAutoClose(autoClose, updateTime) {
|
||||||
|
return updateTime && !autoClose;
|
||||||
|
},
|
||||||
|
|
||||||
|
@computed('autoOpen', 'updateTime')
|
||||||
|
disableAutoOpen(autoOpen, updateTime) {
|
||||||
|
return updateTime && !autoOpen;
|
||||||
|
},
|
||||||
|
|
||||||
|
@computed('publishToCatgory', 'updateTime')
|
||||||
|
disablePublishToCategory(publishToCatgory, updateTime) {
|
||||||
|
return updateTime && !publishToCatgory;
|
||||||
|
},
|
||||||
|
|
||||||
@computed('topicStatusUpdate.based_on_last_post', 'updateTime', 'model.last_posted_at')
|
@computed('topicStatusUpdate.based_on_last_post', 'updateTime', 'model.last_posted_at')
|
||||||
willCloseImmediately(basedOnLastPost, updateTime, lastPostedAt) {
|
willCloseImmediately(basedOnLastPost, updateTime, lastPostedAt) {
|
||||||
|
@ -42,7 +60,7 @@ export default Ember.Controller.extend(ModalFunctionality, {
|
||||||
},
|
},
|
||||||
|
|
||||||
@observes("topicStatusUpdate.execute_at", "topicStatusUpdate.duration")
|
@observes("topicStatusUpdate.execute_at", "topicStatusUpdate.duration")
|
||||||
setAutoCloseTime() {
|
_setUpdateTime() {
|
||||||
let time = null;
|
let time = null;
|
||||||
|
|
||||||
if (this.get("topicStatusUpdate.based_on_last_post")) {
|
if (this.get("topicStatusUpdate.based_on_last_post")) {
|
||||||
|
@ -65,12 +83,18 @@ export default Ember.Controller.extend(ModalFunctionality, {
|
||||||
this.get('model.id'),
|
this.get('model.id'),
|
||||||
time,
|
time,
|
||||||
this.get('topicStatusUpdate.based_on_last_post'),
|
this.get('topicStatusUpdate.based_on_last_post'),
|
||||||
status_type
|
status_type,
|
||||||
|
this.get('categoryId')
|
||||||
).then(result => {
|
).then(result => {
|
||||||
if (time) {
|
if (time) {
|
||||||
this.send('closeModal');
|
this.send('closeModal');
|
||||||
this.set('topicStatusUpdate.execute_at', result.execute_at);
|
|
||||||
this.set('topicStatusUpdate.duration', result.duration);
|
this.get("topicStatusUpdate").setProperties({
|
||||||
|
execute_at: result.execute_at,
|
||||||
|
duration: result.duration,
|
||||||
|
category_id: result.category_id
|
||||||
|
});
|
||||||
|
|
||||||
this.set('model.closed', result.closed);
|
this.set('model.closed', result.closed);
|
||||||
} else {
|
} else {
|
||||||
this.set('topicStatusUpdate', Ember.Object.create({}));
|
this.set('topicStatusUpdate', Ember.Object.create({}));
|
||||||
|
|
|
@ -4,7 +4,7 @@ import RestModel from 'discourse/models/rest';
|
||||||
const TopicStatusUpdate = RestModel.extend({});
|
const TopicStatusUpdate = RestModel.extend({});
|
||||||
|
|
||||||
TopicStatusUpdate.reopenClass({
|
TopicStatusUpdate.reopenClass({
|
||||||
updateStatus(topicId, time, basedOnLastPost, statusType) {
|
updateStatus(topicId, time, basedOnLastPost, statusType, categoryId) {
|
||||||
let data = {
|
let data = {
|
||||||
time: time,
|
time: time,
|
||||||
timezone_offset: (new Date().getTimezoneOffset()),
|
timezone_offset: (new Date().getTimezoneOffset()),
|
||||||
|
@ -12,6 +12,7 @@ TopicStatusUpdate.reopenClass({
|
||||||
};
|
};
|
||||||
|
|
||||||
if (basedOnLastPost) data.based_on_last_post = basedOnLastPost;
|
if (basedOnLastPost) data.based_on_last_post = basedOnLastPost;
|
||||||
|
if (categoryId) data.category_id = categoryId;
|
||||||
|
|
||||||
return ajax({
|
return ajax({
|
||||||
url: `/t/${topicId}/status_update`,
|
url: `/t/${topicId}/status_update`,
|
||||||
|
|
|
@ -52,7 +52,7 @@ const TopicRoute = Discourse.Route.extend({
|
||||||
|
|
||||||
showTopicStatusUpdate() {
|
showTopicStatusUpdate() {
|
||||||
const model = this.modelFor('topic');
|
const model = this.modelFor('topic');
|
||||||
if (!model.get('topic_status_update')) model.set('topic_status_update', Ember.Object.create());
|
model.set('topic_status_update', Ember.Object.create(model.get('topic_status_update')));
|
||||||
showModal('edit-topic-status-update', { model });
|
showModal('edit-topic-status-update', { model });
|
||||||
this.controllerFor('modal').set('modalClass', 'topic-close-modal');
|
this.controllerFor('modal').set('modalClass', 'topic-close-modal');
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,21 +1,20 @@
|
||||||
<div class="auto-update-input">
|
<div class="auto-update-input">
|
||||||
<div>
|
<div class="control-group">
|
||||||
<label>
|
<label>
|
||||||
{{i18n inputLabelKey}}
|
{{i18n inputLabelKey}}
|
||||||
{{text-field value=input}}
|
{{text-field value=input}}
|
||||||
{{i18n inputUnitsKey}}
|
{{i18n inputUnitsKey}}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{{#if inputExamplesKey}}
|
||||||
|
<div class="examples">
|
||||||
|
{{i18n inputExamplesKey}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{#if inputExamplesKey}}
|
|
||||||
<div class="examples">
|
|
||||||
{{i18n inputExamplesKey}}
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
|
|
||||||
{{#unless hideBasedOnLastPost}}
|
{{#unless hideBasedOnLastPost}}
|
||||||
<div>
|
<div class="control-group">
|
||||||
<label>
|
<label>
|
||||||
{{input type="checkbox" checked=basedOnLastPost}}
|
{{input type="checkbox" checked=basedOnLastPost}}
|
||||||
{{i18n 'topic.auto_close.based_on_last_post'}}
|
{{i18n 'topic.auto_close.based_on_last_post'}}
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
disabled=disableAutoClose
|
disabled=disableAutoClose
|
||||||
name="auto-close"
|
name="auto-close"
|
||||||
id="auto-close"
|
id="auto-close"
|
||||||
value="close"
|
value=closeStatusType
|
||||||
selection=selection}}
|
selection=selection}}
|
||||||
|
|
||||||
<label class="radio" for="auto-close">
|
<label class="radio" for="auto-close">
|
||||||
|
@ -19,46 +19,70 @@
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{{radio-button
|
{{radio-button
|
||||||
disabled=disableAutoReopen
|
disabled=disableAutoOpen
|
||||||
name="auto-reopen"
|
name="auto-reopen"
|
||||||
id="auto-reopen"
|
id="auto-reopen"
|
||||||
value="open"
|
value=openStatusType
|
||||||
selection=selection}}
|
selection=selection}}
|
||||||
|
|
||||||
<label class="radio" for="auto-reopen">
|
<label class="radio" for="auto-reopen">
|
||||||
{{fa-icon "clock-o"}} {{fa-icon "unlock"}}
|
{{fa-icon "clock-o"}} {{fa-icon "unlock"}}
|
||||||
|
|
||||||
|
|
||||||
{{#if model.closed}}
|
{{#if model.closed}}
|
||||||
{{i18n 'topic.auto_reopen.title'}}
|
{{i18n 'topic.auto_reopen.title'}}
|
||||||
{{else}}
|
{{else}}
|
||||||
{{i18n 'topic.temp_close.title'}}
|
{{i18n 'topic.temp_close.title'}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{{radio-button
|
||||||
|
disabled=disablePublishToCategory
|
||||||
|
name="publish-to-category"
|
||||||
|
id="publish-to-category"
|
||||||
|
value=publishToCategoryStatusType
|
||||||
|
selection=selection}}
|
||||||
|
|
||||||
|
<label class="radio" for="publish-to-category">
|
||||||
|
{{fa-icon "clock-o"}} {{i18n 'topic.publish_to_category.title'}}
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{#if autoReopen}}
|
<div>
|
||||||
{{auto-update-input
|
{{#if autoOpen}}
|
||||||
inputLabelKey='topic.topic_status_update.time'
|
{{auto-update-input
|
||||||
input=updateTime
|
inputLabelKey='topic.topic_status_update.time'
|
||||||
inputValid=updateTimeValid
|
input=updateTime
|
||||||
hideBasedOnLastPost=true
|
inputValid=updateTimeValid
|
||||||
basedOnLastPost=false}}
|
hideBasedOnLastPost=true
|
||||||
{{else if autoClose}}
|
basedOnLastPost=false}}
|
||||||
{{auto-update-input
|
{{else if publishToCategory}}
|
||||||
inputLabelKey='topic.topic_status_update.time'
|
<div class="control-group">
|
||||||
input=updateTime
|
<label>{{i18n 'topic.topic_status_update.publish_to'}}</label>
|
||||||
inputValid=updateTimeValid
|
{{category-chooser valueAttribute="id" value=categoryId excludeCategoryId=model.category_id}}
|
||||||
limited=topicStatusUpdate.based_on_last_post
|
|
||||||
basedOnLastPost=topicStatusUpdate.based_on_last_post}}
|
|
||||||
|
|
||||||
{{#if willCloseImmediately}}
|
|
||||||
<div class="warning">
|
|
||||||
{{fa-icon "warning"}}
|
|
||||||
{{willCloseI18n}}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{auto-update-input
|
||||||
|
inputLabelKey='topic.topic_status_update.time'
|
||||||
|
input=updateTime
|
||||||
|
inputValid=updateTimeValid
|
||||||
|
hideBasedOnLastPost=true
|
||||||
|
basedOnLastPost=false}}
|
||||||
|
{{else if autoClose}}
|
||||||
|
{{auto-update-input
|
||||||
|
inputLabelKey='topic.topic_status_update.time'
|
||||||
|
input=updateTime
|
||||||
|
inputValid=updateTimeValid
|
||||||
|
limited=topicStatusUpdate.based_on_last_post
|
||||||
|
basedOnLastPost=topicStatusUpdate.based_on_last_post}}
|
||||||
|
|
||||||
|
{{#if willCloseImmediately}}
|
||||||
|
<div class="warning">
|
||||||
|
{{fa-icon "warning"}}
|
||||||
|
{{willCloseI18n}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{/if}}
|
</div>
|
||||||
{{/d-modal-body}}
|
{{/d-modal-body}}
|
||||||
|
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
|
|
@ -134,12 +134,6 @@ div.ac-wrap {
|
||||||
}
|
}
|
||||||
|
|
||||||
.auto-update-input {
|
.auto-update-input {
|
||||||
div:not(:first-child) {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
label {
|
|
||||||
font-size: 1em;
|
|
||||||
}
|
|
||||||
.examples {
|
.examples {
|
||||||
color: lighten($primary, 40%);
|
color: lighten($primary, 40%);
|
||||||
}
|
}
|
||||||
|
|
|
@ -295,38 +295,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-close-modal {
|
|
||||||
.radios {
|
|
||||||
padding-bottom: 20px;
|
|
||||||
display: inline-block;
|
|
||||||
|
|
||||||
input[type='radio'] {
|
|
||||||
vertical-align: middle;
|
|
||||||
margin: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
padding: 0 10px 0px 5px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.pull-right {
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
form {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.auto-update-input {
|
|
||||||
i.fa-clock-o {
|
|
||||||
font-size: 1.143em;
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-category-modal {
|
.edit-category-modal {
|
||||||
.auto-update-input, .num-featured-topics-fields, .position-fields {
|
.auto-update-input, .num-featured-topics-fields, .position-fields {
|
||||||
input[type=text] {
|
input[type=text] {
|
||||||
|
@ -334,10 +302,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.auto-update-input label {
|
|
||||||
font-size: .929em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subcategory-list-style-field {
|
.subcategory-list-style-field {
|
||||||
margin-left: 16px;
|
margin-left: 16px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
.topic-close-modal {
|
||||||
|
label {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radios {
|
||||||
|
padding-bottom: 20px;
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
input[type='radio'] {
|
||||||
|
vertical-align: middle;
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
padding: 0 10px 0px 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.pull-right {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-update-input {
|
||||||
|
input {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -290,7 +290,7 @@ class TopicsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def status_update
|
def status_update
|
||||||
params.permit(:time, :timezone_offset, :based_on_last_post)
|
params.permit(:time, :timezone_offset, :based_on_last_post, :category_id)
|
||||||
params.require(:status_type)
|
params.require(:status_type)
|
||||||
|
|
||||||
status_type =
|
status_type =
|
||||||
|
@ -303,12 +303,18 @@ class TopicsController < ApplicationController
|
||||||
topic = Topic.find_by(id: params[:topic_id])
|
topic = Topic.find_by(id: params[:topic_id])
|
||||||
guardian.ensure_can_moderate!(topic)
|
guardian.ensure_can_moderate!(topic)
|
||||||
|
|
||||||
topic_status_update = topic.set_or_create_status_update(
|
options = {
|
||||||
status_type,
|
|
||||||
params[:time],
|
|
||||||
by_user: current_user,
|
by_user: current_user,
|
||||||
timezone_offset: params[:timezone_offset]&.to_i,
|
timezone_offset: params[:timezone_offset]&.to_i,
|
||||||
based_on_last_post: params[:based_on_last_post]
|
based_on_last_post: params[:based_on_last_post]
|
||||||
|
}
|
||||||
|
|
||||||
|
options.merge!(category_id: params[:category_id]) if !params[:category_id].blank?
|
||||||
|
|
||||||
|
topic_status_update = topic.set_or_create_status_update(
|
||||||
|
status_type,
|
||||||
|
params[:time],
|
||||||
|
options
|
||||||
)
|
)
|
||||||
|
|
||||||
if topic.save
|
if topic.save
|
||||||
|
@ -316,7 +322,8 @@ class TopicsController < ApplicationController
|
||||||
execute_at: topic_status_update&.execute_at,
|
execute_at: topic_status_update&.execute_at,
|
||||||
duration: topic_status_update&.duration,
|
duration: topic_status_update&.duration,
|
||||||
based_on_last_post: topic_status_update&.based_on_last_post,
|
based_on_last_post: topic_status_update&.based_on_last_post,
|
||||||
closed: topic.closed
|
closed: topic.closed,
|
||||||
|
category_id: topic_status_update&.category_id
|
||||||
})
|
})
|
||||||
else
|
else
|
||||||
render_json_error(topic)
|
render_json_error(topic)
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
module Jobs
|
||||||
|
class PublishTopicToCategory < Jobs::Base
|
||||||
|
def execute(args)
|
||||||
|
topic_status_update = TopicStatusUpdate.find_by(id: args[:topic_status_update_id])
|
||||||
|
raise Discourse::InvalidParameters.new(:topic_status_update_id) if topic_status_update.blank?
|
||||||
|
|
||||||
|
topic = topic_status_update.topic
|
||||||
|
return if topic.blank?
|
||||||
|
|
||||||
|
PostTimestampChanger.new(timestamp: Time.zone.now, topic: topic).change! do
|
||||||
|
topic.change_category_to_id(topic_status_update.category_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -951,8 +951,10 @@ SQL
|
||||||
# * `nil` to delete the topic's status update.
|
# * `nil` to delete the topic's status update.
|
||||||
# Options:
|
# Options:
|
||||||
# * by_user: User who is setting the topic's status update.
|
# * by_user: User who is setting the topic's status update.
|
||||||
# * timezone_offset: (Integer) offset from UTC in minutes of the given argument. Default 0.
|
# * timezone_offset: (Integer) offset from UTC in minutes of the given argument.
|
||||||
def set_or_create_status_update(status_type, time, by_user: nil, timezone_offset: 0, based_on_last_post: false)
|
# * based_on_last_post: True if time should be based on timestamp of the last post.
|
||||||
|
# * category_id: Category that the update will apply to.
|
||||||
|
def set_or_create_status_update(status_type, time, by_user: nil, timezone_offset: 0, based_on_last_post: false, category_id: SiteSetting.uncategorized_category_id)
|
||||||
topic_status_update = TopicStatusUpdate.find_or_initialize_by(
|
topic_status_update = TopicStatusUpdate.find_or_initialize_by(
|
||||||
status_type: status_type,
|
status_type: status_type,
|
||||||
topic: self
|
topic: self
|
||||||
|
@ -966,6 +968,10 @@ SQL
|
||||||
time_now = Time.zone.now
|
time_now = Time.zone.now
|
||||||
topic_status_update.based_on_last_post = !based_on_last_post.blank?
|
topic_status_update.based_on_last_post = !based_on_last_post.blank?
|
||||||
|
|
||||||
|
if status_type == TopicStatusUpdate.types[:publish_to_category]
|
||||||
|
topic_status_update.category_id = category_id
|
||||||
|
end
|
||||||
|
|
||||||
if topic_status_update.based_on_last_post
|
if topic_status_update.based_on_last_post
|
||||||
num_hours = time.to_f
|
num_hours = time.to_f
|
||||||
|
|
||||||
|
@ -1191,51 +1197,56 @@ end
|
||||||
#
|
#
|
||||||
# Table name: topics
|
# Table name: topics
|
||||||
#
|
#
|
||||||
# id :integer not null, primary key
|
# id :integer not null, primary key
|
||||||
# title :string not null
|
# title :string not null
|
||||||
# last_posted_at :datetime
|
# last_posted_at :datetime
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
# views :integer default(0), not null
|
# views :integer default(0), not null
|
||||||
# posts_count :integer default(0), not null
|
# posts_count :integer default(0), not null
|
||||||
# user_id :integer
|
# user_id :integer
|
||||||
# last_post_user_id :integer not null
|
# last_post_user_id :integer not null
|
||||||
# reply_count :integer default(0), not null
|
# reply_count :integer default(0), not null
|
||||||
# featured_user1_id :integer
|
# featured_user1_id :integer
|
||||||
# featured_user2_id :integer
|
# featured_user2_id :integer
|
||||||
# featured_user3_id :integer
|
# featured_user3_id :integer
|
||||||
# avg_time :integer
|
# avg_time :integer
|
||||||
# deleted_at :datetime
|
# deleted_at :datetime
|
||||||
# highest_post_number :integer default(0), not null
|
# highest_post_number :integer default(0), not null
|
||||||
# image_url :string
|
# image_url :string
|
||||||
# like_count :integer default(0), not null
|
# like_count :integer default(0), not null
|
||||||
# incoming_link_count :integer default(0), not null
|
# incoming_link_count :integer default(0), not null
|
||||||
# category_id :integer
|
# category_id :integer
|
||||||
# visible :boolean default(TRUE), not null
|
# visible :boolean default(TRUE), not null
|
||||||
# moderator_posts_count :integer default(0), not null
|
# moderator_posts_count :integer default(0), not null
|
||||||
# closed :boolean default(FALSE), not null
|
# closed :boolean default(FALSE), not null
|
||||||
# archived :boolean default(FALSE), not null
|
# archived :boolean default(FALSE), not null
|
||||||
# bumped_at :datetime not null
|
# bumped_at :datetime not null
|
||||||
# has_summary :boolean default(FALSE), not null
|
# has_summary :boolean default(FALSE), not null
|
||||||
# vote_count :integer default(0), not null
|
# vote_count :integer default(0), not null
|
||||||
# archetype :string default("regular"), not null
|
# archetype :string default("regular"), not null
|
||||||
# featured_user4_id :integer
|
# featured_user4_id :integer
|
||||||
# notify_moderators_count :integer default(0), not null
|
# notify_moderators_count :integer default(0), not null
|
||||||
# spam_count :integer default(0), not null
|
# spam_count :integer default(0), not null
|
||||||
# pinned_at :datetime
|
# pinned_at :datetime
|
||||||
# score :float
|
# score :float
|
||||||
# percent_rank :float default(1.0), not null
|
# percent_rank :float default(1.0), not null
|
||||||
# subtype :string
|
# subtype :string
|
||||||
# slug :string
|
# slug :string
|
||||||
# deleted_by_id :integer
|
# auto_close_at :datetime
|
||||||
# participant_count :integer default(1)
|
# auto_close_user_id :integer
|
||||||
# word_count :integer
|
# auto_close_started_at :datetime
|
||||||
# excerpt :string(1000)
|
# deleted_by_id :integer
|
||||||
# pinned_globally :boolean default(FALSE), not null
|
# participant_count :integer default(1)
|
||||||
# pinned_until :datetime
|
# word_count :integer
|
||||||
# fancy_title :string(400)
|
# excerpt :string(1000)
|
||||||
# highest_staff_post_number :integer default(0), not null
|
# pinned_globally :boolean default(FALSE), not null
|
||||||
# featured_link :string
|
# auto_close_based_on_last_post :boolean default(FALSE)
|
||||||
|
# auto_close_hours :float
|
||||||
|
# pinned_until :datetime
|
||||||
|
# fancy_title :string(400)
|
||||||
|
# highest_staff_post_number :integer default(0), not null
|
||||||
|
# featured_link :string
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
|
|
|
@ -8,9 +8,7 @@ class TopicStatusUpdate < ActiveRecord::Base
|
||||||
validates :topic_id, presence: true
|
validates :topic_id, presence: true
|
||||||
validates :execute_at, presence: true
|
validates :execute_at, presence: true
|
||||||
validates :status_type, presence: true
|
validates :status_type, presence: true
|
||||||
|
|
||||||
validates :status_type, uniqueness: { scope: [:topic_id, :deleted_at] }
|
validates :status_type, uniqueness: { scope: [:topic_id, :deleted_at] }
|
||||||
|
|
||||||
validate :ensure_update_will_happen
|
validate :ensure_update_will_happen
|
||||||
|
|
||||||
before_save do
|
before_save do
|
||||||
|
@ -22,7 +20,7 @@ class TopicStatusUpdate < ActiveRecord::Base
|
||||||
end
|
end
|
||||||
|
|
||||||
after_save do
|
after_save do
|
||||||
if execute_at_changed? || user_id_changed?
|
if (execute_at_changed? || user_id_changed?) && topic
|
||||||
now = Time.zone.now
|
now = Time.zone.now
|
||||||
time = execute_at < now ? now : execute_at
|
time = execute_at < now ? now : execute_at
|
||||||
|
|
||||||
|
@ -33,7 +31,8 @@ class TopicStatusUpdate < ActiveRecord::Base
|
||||||
def self.types
|
def self.types
|
||||||
@types ||= Enum.new(
|
@types ||= Enum.new(
|
||||||
close: 1,
|
close: 1,
|
||||||
open: 2
|
open: 2,
|
||||||
|
publish_to_category: 3
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -69,26 +68,30 @@ class TopicStatusUpdate < ActiveRecord::Base
|
||||||
end
|
end
|
||||||
alias_method :cancel_auto_open_job, :cancel_auto_close_job
|
alias_method :cancel_auto_open_job, :cancel_auto_close_job
|
||||||
|
|
||||||
def schedule_auto_open_job(time)
|
def cancel_auto_publish_to_category_job
|
||||||
if topic
|
Jobs.cancel_scheduled_job(:publish_topic_to_category, topic_status_update_id: id)
|
||||||
topic.update_status('closed', true, user) if !topic.closed
|
end
|
||||||
|
|
||||||
Jobs.enqueue_at(time, :toggle_topic_closed,
|
def schedule_auto_open_job(time)
|
||||||
topic_status_update_id: id,
|
topic.update_status('closed', true, user) if !topic.closed
|
||||||
state: false
|
|
||||||
)
|
Jobs.enqueue_at(time, :toggle_topic_closed,
|
||||||
end
|
topic_status_update_id: id,
|
||||||
|
state: false
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def schedule_auto_close_job(time)
|
def schedule_auto_close_job(time)
|
||||||
if topic
|
topic.update_status('closed', false, user) if topic.closed
|
||||||
topic.update_status('closed', false, user) if topic.closed
|
|
||||||
|
|
||||||
Jobs.enqueue_at(time, :toggle_topic_closed,
|
Jobs.enqueue_at(time, :toggle_topic_closed,
|
||||||
topic_status_update_id: id,
|
topic_status_update_id: id,
|
||||||
state: true
|
state: true
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def schedule_auto_publish_to_category_job(time)
|
||||||
|
Jobs.enqueue_at(time, :publish_topic_to_category, topic_status_update_id: id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -106,6 +109,7 @@ end
|
||||||
# deleted_by_id :integer
|
# deleted_by_id :integer
|
||||||
# created_at :datetime
|
# created_at :datetime
|
||||||
# updated_at :datetime
|
# updated_at :datetime
|
||||||
|
# category_id :integer
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
|
|
|
@ -3,7 +3,8 @@ class TopicStatusUpdateSerializer < ApplicationSerializer
|
||||||
:execute_at,
|
:execute_at,
|
||||||
:duration,
|
:duration,
|
||||||
:based_on_last_post,
|
:based_on_last_post,
|
||||||
:status_type
|
:status_type,
|
||||||
|
:category_id
|
||||||
|
|
||||||
def status_type
|
def status_type
|
||||||
TopicStatusUpdate.types[object.status_type]
|
TopicStatusUpdate.types[object.status_type]
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
class PostTimestampChanger
|
class PostTimestampChanger
|
||||||
def initialize(params)
|
def initialize(params)
|
||||||
@topic = Topic.with_deleted.find(params[:topic_id])
|
@topic = params[:topic] || Topic.with_deleted.find(params[:topic_id])
|
||||||
@posts = @topic.posts
|
@posts = @topic.posts
|
||||||
@timestamp = Time.at(params[:timestamp])
|
@timestamp = Time.at(params[:timestamp])
|
||||||
@time_difference = calculate_time_difference
|
@time_difference = calculate_time_difference
|
||||||
|
@ -21,6 +21,8 @@ class PostTimestampChanger
|
||||||
end
|
end
|
||||||
|
|
||||||
update_topic(last_posted_at)
|
update_topic(last_posted_at)
|
||||||
|
|
||||||
|
yield(@topic) if block_given?
|
||||||
end
|
end
|
||||||
|
|
||||||
# Burst the cache for stats
|
# Burst the cache for stats
|
||||||
|
|
|
@ -1474,6 +1474,7 @@ en:
|
||||||
save: "Set Timer"
|
save: "Set Timer"
|
||||||
time: "Time:"
|
time: "Time:"
|
||||||
remove: "Remove Timer"
|
remove: "Remove Timer"
|
||||||
|
publish_to: "Publish To:"
|
||||||
auto_update_input:
|
auto_update_input:
|
||||||
limited:
|
limited:
|
||||||
units: "(# of hours)"
|
units: "(# of hours)"
|
||||||
|
@ -1481,6 +1482,8 @@ en:
|
||||||
all:
|
all:
|
||||||
units: ""
|
units: ""
|
||||||
examples: 'Enter number of hours (24), absolute time (17:30) or timestamp (2013-11-22 14:00).'
|
examples: 'Enter number of hours (24), absolute time (17:30) or timestamp (2013-11-22 14:00).'
|
||||||
|
publish_to_category:
|
||||||
|
title: "Schedule Publishing"
|
||||||
temp_open:
|
temp_open:
|
||||||
title: "Open Temporarily"
|
title: "Open Temporarily"
|
||||||
auto_reopen:
|
auto_reopen:
|
||||||
|
@ -1496,7 +1499,7 @@ en:
|
||||||
status_update_notice:
|
status_update_notice:
|
||||||
auto_open: "This topic will automatically open %{timeLeft}."
|
auto_open: "This topic will automatically open %{timeLeft}."
|
||||||
auto_close: "This topic will automatically close %{timeLeft}."
|
auto_close: "This topic will automatically close %{timeLeft}."
|
||||||
auto_open_based_on_last_post: "This topic will open %{duration} after the last reply."
|
auto_publish_to_category: "This topic will be published to <a href=%{categoryUrl}>#%{categoryName}</a> %{timeLeft}."
|
||||||
auto_close_based_on_last_post: "This topic will close %{duration} after the last reply."
|
auto_close_based_on_last_post: "This topic will close %{duration} after the last reply."
|
||||||
auto_close_title: 'Auto-Close Settings'
|
auto_close_title: 'Auto-Close Settings'
|
||||||
auto_close_immediate:
|
auto_close_immediate:
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
class AddCategoryIdToTopicStatusUpdates < ActiveRecord::Migration
|
||||||
|
def change
|
||||||
|
add_column :topic_status_updates, :category_id, :integer
|
||||||
|
end
|
||||||
|
end
|
|
@ -260,6 +260,9 @@ describe PostCreator do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "topic's auto close" do
|
describe "topic's auto close" do
|
||||||
|
before do
|
||||||
|
SiteSetting.queue_jobs = true
|
||||||
|
end
|
||||||
|
|
||||||
it "doesn't update topic's auto close when it's not based on last post" do
|
it "doesn't update topic's auto close when it's not based on last post" do
|
||||||
Timecop.freeze do
|
Timecop.freeze do
|
||||||
|
@ -275,8 +278,6 @@ describe PostCreator do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "updates topic's auto close date when it's based on last post" do
|
it "updates topic's auto close date when it's based on last post" do
|
||||||
SiteSetting.queue_jobs = true
|
|
||||||
|
|
||||||
Timecop.freeze do
|
Timecop.freeze do
|
||||||
topic = Fabricate(:topic,
|
topic = Fabricate(:topic,
|
||||||
topic_status_updates: [Fabricate(:topic_status_update,
|
topic_status_updates: [Fabricate(:topic_status_update,
|
||||||
|
|
|
@ -76,6 +76,31 @@ RSpec.describe "Managing a topic's status update", type: :request do
|
||||||
expect(json['closed']).to eq(topic.closed)
|
expect(json['closed']).to eq(topic.closed)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'publishing topic to category in the future' do
|
||||||
|
it 'should be able to create the topic status update' do
|
||||||
|
post "/t/#{topic.id}/status_update.json",
|
||||||
|
time: 24,
|
||||||
|
status_type: TopicStatusUpdate.types[3],
|
||||||
|
category_id: topic.category_id
|
||||||
|
|
||||||
|
expect(response).to be_success
|
||||||
|
|
||||||
|
topic_status_update = TopicStatusUpdate.last
|
||||||
|
|
||||||
|
expect(topic_status_update.topic).to eq(topic)
|
||||||
|
|
||||||
|
expect(topic_status_update.execute_at)
|
||||||
|
.to be_within(1.second).of(24.hours.from_now)
|
||||||
|
|
||||||
|
expect(topic_status_update.status_type)
|
||||||
|
.to eq(TopicStatusUpdate.types[:publish_to_category])
|
||||||
|
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
|
||||||
|
expect(json['category_id']).to eq(topic.category_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe 'invalid status type' do
|
describe 'invalid status type' do
|
||||||
it 'should raise the right error' do
|
it 'should raise the right error' do
|
||||||
expect do
|
expect do
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Jobs::PublishTopicToCategory do
|
||||||
|
let(:category) { Fabricate(:category) }
|
||||||
|
let(:another_category) { Fabricate(:category) }
|
||||||
|
|
||||||
|
let(:topic) do
|
||||||
|
Fabricate(:topic, category: category, topic_status_updates: [
|
||||||
|
Fabricate(:topic_status_update,
|
||||||
|
status_type: TopicStatusUpdate.types[:publish_to_category],
|
||||||
|
category_id: another_category.id
|
||||||
|
)
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
SiteSetting.queue_jobs = true
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'when topic_status_update_id is invalid' do
|
||||||
|
it 'should raise the right error' do
|
||||||
|
expect { described_class.new.execute(topic_status_update_id: -1) }
|
||||||
|
.to raise_error(Discourse::InvalidParameters)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'when topic has been deleted' do
|
||||||
|
it 'should not publish the topic to the new category' do
|
||||||
|
Timecop.travel(1.hour.ago) { topic }
|
||||||
|
topic.trash!
|
||||||
|
|
||||||
|
described_class.new.execute(topic_status_update_id: topic.topic_status_update.id)
|
||||||
|
|
||||||
|
topic.reload
|
||||||
|
expect(topic.category).to eq(category)
|
||||||
|
expect(topic.created_at).to be_within(1.second).of(Time.zone.now - 1.hour)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'should publish the topic to the new category correctly' do
|
||||||
|
Timecop.travel(1.hour.ago) { topic }
|
||||||
|
|
||||||
|
described_class.new.execute(topic_status_update_id: topic.topic_status_update.id)
|
||||||
|
|
||||||
|
topic.reload
|
||||||
|
expect(topic.category).to eq(another_category)
|
||||||
|
|
||||||
|
%w{created_at bumped_at updated_at last_posted_at}.each do |attribute|
|
||||||
|
expect(topic.public_send(attribute)).to be_within(1.second).of(Time.zone.now)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -1258,7 +1258,7 @@ describe Topic do
|
||||||
|
|
||||||
expect(topic.reload.closed).to eq(false)
|
expect(topic.reload.closed).to eq(false)
|
||||||
|
|
||||||
Timecop.freeze(3.hours.from_now) do
|
Timecop.travel(3.hours.from_now) do
|
||||||
TopicStatusUpdate.ensure_consistency!
|
TopicStatusUpdate.ensure_consistency!
|
||||||
expect(topic.reload.closed).to eq(true)
|
expect(topic.reload.closed).to eq(true)
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in New Issue