FEATURE: make pin expiration mandatory

This commit is contained in:
Régis Hanol
2015-07-29 16:34:21 +02:00
parent 9e2632badd
commit faf4f44776
18 changed files with 192 additions and 97 deletions

View File

@ -4,7 +4,9 @@ const icons = {
'archived.enabled': 'folder', 'archived.enabled': 'folder',
'archived.disabled': 'folder-open', 'archived.disabled': 'folder-open',
'pinned.enabled': 'thumb-tack', 'pinned.enabled': 'thumb-tack',
'pinned.disabled': 'thumb-tack', 'pinned.disabled': 'thumb-tack unpinned',
'pinned_globally.enabled': 'thumb-tack',
'pinned_globally.disabled': 'thumb-tack unpinned',
'visible.enabled': 'eye', 'visible.enabled': 'eye',
'visible.disabled': 'eye-slash' 'visible.disabled': 'eye-slash'
}; };

View File

@ -10,15 +10,23 @@ export default ObjectController.extend(ModalFunctionality, {
pinnedGloballyCount: 0, pinnedGloballyCount: 0,
bannerCount: 0, bannerCount: 0,
reset: function() {
this.set("model.pinnedInCategoryUntil", null);
this.set("model.pinnedGloballyUntil", null);
},
categoryLink: function() { categoryLink: function() {
return categoryLinkHTML(this.get("model.category"), { allowUncategorized: true }); return categoryLinkHTML(this.get("model.category"), { allowUncategorized: true });
}.property("model.category"), }.property("model.category"),
unPinMessage: function() { unPinMessage: function() {
return this.get("model.pinned_globally") ? let name = "topic.feature_topic.unpin";
I18n.t("topic.feature_topic.unpin_globally") : if (this.get("model.pinned_globally")) name += "_globally";
I18n.t("topic.feature_topic.unpin", { categoryLink: this.get("categoryLink") }); if (moment(this.get("model.pinned_until")) > moment()) name += "_until";
}.property("categoryLink", "model.pinned_globally"), const until = moment(this.get("model.pinned_until")).format("LL");
return I18n.t(name, { categoryLink: this.get("categoryLink"), until: until });
}.property("categoryLink", "model.{pinned_globally,pinned_until}"),
pinMessage: function() { pinMessage: function() {
return I18n.t("topic.feature_topic.pin", { categoryLink: this.get("categoryLink") }); return I18n.t("topic.feature_topic.pin", { categoryLink: this.get("categoryLink") });
@ -28,6 +36,30 @@ export default ObjectController.extend(ModalFunctionality, {
return I18n.t("topic.feature_topic.already_pinned", { categoryLink: this.get("categoryLink"), count: this.get("pinnedInCategoryCount") }); return I18n.t("topic.feature_topic.already_pinned", { categoryLink: this.get("categoryLink"), count: this.get("pinnedInCategoryCount") });
}.property("categoryLink", "pinnedInCategoryCount"), }.property("categoryLink", "pinnedInCategoryCount"),
pinDisabled: function() {
return !this._isDateValid(this.get("parsedPinnedInCategoryUntil"));
}.property("parsedPinnedInCategoryUntil"),
pinGloballyDisabled: function() {
return !this._isDateValid(this.get("parsedPinnedGloballyUntil"));
}.property("pinnedGloballyUntil"),
parsedPinnedInCategoryUntil: function() {
return this._parseDate(this.get("model.pinnedInCategoryUntil"));
}.property("model.pinnedInCategoryUntil"),
parsedPinnedGloballyUntil: function() {
return this._parseDate(this.get("model.pinnedGloballyUntil"));
}.property("model.pinnedGloballyUntil"),
_parseDate(date) {
return moment(date, ["YYYY-MM-DD", "YYYY-MM-DD HH:mm"]);
},
_isDateValid(parsedDate) {
return parsedDate.isValid() && parsedDate > moment();
},
onShow() { onShow() {
this.set("loading", true); this.set("loading", true);

View File

@ -99,19 +99,6 @@ export default ObjectController.extend(SelectedPostsCount, BufferedContent, {
this.set('selectedReplies', []); this.set('selectedReplies', []);
}.on('init'), }.on('init'),
_togglePinnedStates(property) {
const value = this.get('model.pinned_at') ? false : true,
topic = this.get('content');
// optimistic update
topic.setProperties({
pinned_at: value,
pinned_globally: value
});
return topic.saveStatus(property, value);
},
actions: { actions: {
deleteTopic() { deleteTopic() {
this.deleteTopic(); this.deleteTopic();
@ -371,27 +358,31 @@ export default ObjectController.extend(SelectedPostsCount, BufferedContent, {
togglePinned() { togglePinned() {
const value = this.get('model.pinned_at') ? false : true, const value = this.get('model.pinned_at') ? false : true,
topic = this.get('content'); topic = this.get('content'),
until = this.get('model.pinnedInCategoryUntil');
// optimistic update // optimistic update
topic.setProperties({ topic.setProperties({
pinned_at: value ? moment() : null, pinned_at: value ? moment() : null,
pinned_globally: false pinned_globally: false,
pinned_until: value ? until : null
}); });
return topic.saveStatus("pinned", value); return topic.saveStatus("pinned", value, until);
}, },
pinGlobally() { pinGlobally() {
const topic = this.get('content'); const topic = this.get('content'),
until = this.get('model.pinnedGloballyUntil');
// optimistic update // optimistic update
topic.setProperties({ topic.setProperties({
pinned_at: moment(), pinned_at: moment(),
pinned_globally: true pinned_globally: true,
pinned_until: until
}); });
return topic.saveStatus("pinned_globally", true); return topic.saveStatus("pinned_globally", true, until);
}, },
toggleArchived() { toggleArchived() {

View File

@ -154,13 +154,17 @@ const Topic = RestModel.extend({
this.saveStatus(property, !!this.get(property)); this.saveStatus(property, !!this.get(property));
}, },
saveStatus(property, value) { saveStatus(property, value, until) {
if (property === 'closed' && value === true) { if (property === 'closed' && value === true) {
this.set('details.auto_close_at', null); this.set('details.auto_close_at', null);
} }
return Discourse.ajax(this.get('url') + "/status", { return Discourse.ajax(this.get('url') + "/status", {
type: 'PUT', type: 'PUT',
data: { status: property, enabled: !!value } data: {
status: property,
enabled: !!value,
until: until
}
}); });
}, },

View File

@ -61,6 +61,7 @@ const TopicRoute = Discourse.Route.extend(ShowFooter, {
showFeatureTopic() { showFeatureTopic() {
showModal('featureTopic', { model: this.modelFor('topic'), title: 'topic.feature_topic.title' }); showModal('featureTopic', { model: this.modelFor('topic'), title: 'topic.feature_topic.title' });
this.controllerFor('modal').set('modalClass', 'feature-topic-modal'); this.controllerFor('modal').set('modalClass', 'feature-topic-modal');
this.controllerFor('feature_topic').reset();
}, },
showInvite() { showInvite() {

View File

@ -1,79 +1,93 @@
<div class="modal-body feature-topic"> <div class="modal-body feature-topic">
{{#if model.pinned_at}} {{#if model.pinned_at}}
<div class="feature-section"> <div class="feature-section">
<div class="button">
{{d-button action="unpin" icon="thumb-tack" label="topic.feature.unpin" class="btn-primary"}}
</div>
<div class="desc"> <div class="desc">
<p>{{{unPinMessage}}}</p>
{{#if model.pinned_globally}} {{#if model.pinned_globally}}
<p>{{i18n "topic.feature_topic.global_pin_note"}}</p>
<p> <p>
{{#conditional-loading-spinner size="small" condition=loading}} {{#conditional-loading-spinner size="small" condition=loading}}
{{{i18n "topic.feature_topic.already_pinned_globally" count=pinnedGloballyCount}}} {{{i18n "topic.feature_topic.already_pinned_globally" count=pinnedGloballyCount}}}
{{/conditional-loading-spinner}} {{/conditional-loading-spinner}}
</p> </p>
<p>{{i18n "topic.feature_topic.global_pin_note"}}</p>
{{else}} {{else}}
<p>{{i18n "topic.feature_topic.pin_note"}}</p>
<p> <p>
{{#conditional-loading-spinner size="small" condition=loading}} {{#conditional-loading-spinner size="small" condition=loading}}
{{{alreadyPinnedMessage}}} {{{alreadyPinnedMessage}}}
{{/conditional-loading-spinner}} {{/conditional-loading-spinner}}
</p> </p>
<p>{{i18n "topic.feature_topic.pin_note"}}</p>
{{/if}} {{/if}}
<p>{{{unPinMessage}}}</p>
<p>{{d-button action="unpin" icon="thumb-tack" label="topic.feature.unpin" class="btn-primary"}}</p>
</div> </div>
</div> </div>
{{else}} {{else}}
<div class="feature-section"> <div class="feature-section">
<div class="button">
{{d-button action="pin" icon="thumb-tack" label="topic.feature.pin" class="btn-primary"}}
</div>
<div class="desc"> <div class="desc">
<p>{{{pinMessage}}}</p>
<p>{{i18n "topic.feature_topic.pin_note"}}</p>
<p> <p>
{{#conditional-loading-spinner size="small" condition=loading}} {{#conditional-loading-spinner size="small" condition=loading}}
{{{alreadyPinnedMessage}}} {{{alreadyPinnedMessage}}}
{{/conditional-loading-spinner}} {{/conditional-loading-spinner}}
</p> </p>
<p>
{{i18n "topic.feature_topic.pin_note"}}
</p>
<p>
{{{pinMessage}}}
{{fa-icon "clock-o"}}
{{input type="date" value=model.pinnedInCategoryUntil}}
</p>
<p>
{{d-button action="pin" icon="thumb-tack" label="topic.feature.pin" class="btn-primary" disabled=pinDisabled}}
</p>
</div> </div>
</div> </div>
<hr> <hr>
<div class="feature-section"> <div class="feature-section">
<div class="button">
{{d-button action="pinGlobally" icon="thumb-tack" label="topic.feature.pin_globally" class="btn-primary"}}
</div>
<div class="desc"> <div class="desc">
<p>{{i18n "topic.feature_topic.pin_globally"}}</p>
<p>{{i18n "topic.feature_topic.global_pin_note"}}</p>
<p> <p>
{{#conditional-loading-spinner size="small" condition=loading}} {{#conditional-loading-spinner size="small" condition=loading}}
{{{i18n "topic.feature_topic.already_pinned_globally" count=pinnedGloballyCount}}} {{{i18n "topic.feature_topic.already_pinned_globally" count=pinnedGloballyCount}}}
{{/conditional-loading-spinner}} {{/conditional-loading-spinner}}
</p> </p>
<p>
{{i18n "topic.feature_topic.global_pin_note"}}
</p>
<p>
{{i18n "topic.feature_topic.pin_globally"}}
{{fa-icon "clock-o"}}
{{input type="date" value=model.pinnedGloballyUntil}}
</p>
<p>
{{d-button action="pinGlobally" icon="thumb-tack" label="topic.feature.pin_globally" class="btn-primary" disabled=pinGloballyDisabled}}
</p>
</div> </div>
</div> </div>
{{/if}} {{/if}}
<hr> <hr>
<div class="feature-section"> <div class="feature-section">
<div class="button"> <div class="desc">
<p>
{{#conditional-loading-spinner size="small" condition=loading}}
{{{i18n "topic.feature_topic.already_banner" count=bannerCount}}}
{{/conditional-loading-spinner}}
</p>
<p>
{{i18n "topic.feature_topic.banner_note"}}
</p>
<p>
{{#if model.isBanner}}
{{i18n "topic.feature_topic.remove_banner"}}
{{else}}
{{i18n "topic.feature_topic.make_banner"}}
{{/if}}
</p>
<p>
{{#if model.isBanner}} {{#if model.isBanner}}
{{d-button action="removeBanner" icon="thumb-tack" label="topic.feature.remove_banner" class="btn-primary"}} {{d-button action="removeBanner" icon="thumb-tack" label="topic.feature.remove_banner" class="btn-primary"}}
{{else}} {{else}}
{{d-button action="makeBanner" icon="thumb-tack" label="topic.feature.make_banner" class="btn-primary"}} {{d-button action="makeBanner" icon="thumb-tack" label="topic.feature.make_banner" class="btn-primary"}}
{{/if}} {{/if}}
</div>
<div class="desc">
{{#if model.isBanner}}
<p>{{i18n "topic.feature_topic.remove_banner"}}</p>
{{else}}
<p>{{i18n "topic.feature_topic.make_banner"}}</p>
{{/if}}
<p>{{i18n "topic.feature_topic.banner_note"}}</p>
<p>
{{#conditional-loading-spinner size="small" condition=loading}}
{{{i18n "topic.feature_topic.already_banner" count=bannerCount}}}
{{/conditional-loading-spinner}}
</p> </p>
</div> </div>
</div> </div>

View File

@ -30,30 +30,36 @@
} }
} }
.modal-body.feature-topic .feature-section { .modal-body.feature-topic {
padding: 5px;
max-height: 500px;
hr {
margin: 10px 0;
}
.feature-section {
display: block; display: block;
.button { .badge-wrapper {
width: 33%; margin-right: 0;
display: inline-block; }
vertical-align: top; input[type="date"] {
margin-top: 15px; width: 120px;
margin: 0;
} }
.desc { .desc {
display: inline-block; display: inline-block;
vertical-align: middle; vertical-align: middle;
max-width: 60%;
margin-left: 10px; margin-left: 10px;
p:first-of-type {
margin: 0;
}
p { p {
margin: 10px 0; margin: 10px 0 0;
}
} }
} }
} }
.mobile-view .feature-topic .feature-section { .mobile-view .feature-topic .feature-section {
.button {
width: auto;
display: block;
margin: 0 10px;
}
.desc { .desc {
display: block; display: block;
clear: both; clear: both;

View File

@ -165,13 +165,16 @@ class TopicsController < ApplicationController
def status def status
params.require(:status) params.require(:status)
params.require(:enabled) params.require(:enabled)
status, topic_id = params[:status], params[:topic_id].to_i params.permit(:until)
enabled = (params[:enabled] == 'true')
status = params[:status]
topic_id = params[:topic_id].to_i
enabled = params[:enabled] == 'true'
check_for_status_presence(:status, status) check_for_status_presence(:status, status)
@topic = Topic.find_by(id: topic_id) @topic = Topic.find_by(id: topic_id)
guardian.ensure_can_moderate!(@topic) guardian.ensure_can_moderate!(@topic)
@topic.update_status(status, enabled, current_user) @topic.update_status(status, enabled, current_user, until: params[:until])
render nothing: true render nothing: true
end end

View File

@ -227,7 +227,8 @@ module Jobs
end end
def self.enqueue_at(datetime, job_name, opts={}) def self.enqueue_at(datetime, job_name, opts={})
enqueue_in( [(datetime - Time.zone.now).to_i, 0].max, job_name, opts ) secs = [(datetime - Time.zone.now).to_i, 0].max
enqueue_in(secs, job_name, opts)
end end
def self.cancel_scheduled_job(job_name, params={}) def self.cancel_scheduled_job(job_name, params={})

View File

@ -0,0 +1,16 @@
module Jobs
class UnpinTopic < Jobs::Base
def execute(args)
topic_id = args[:topic_id]
raise Discourse::InvalidParameters.new(:topic_id) unless topic_id.present?
topic = Topic.find_by(id: topic_id)
topic.update_pinned(false) if topic.present?
end
end
end

View File

@ -11,6 +11,7 @@ module Jobs
TopicFeaturedUsers.ensure_consistency! TopicFeaturedUsers.ensure_consistency!
PostRevision.ensure_consistency! PostRevision.ensure_consistency!
UserStat.update_view_counts(13.hours.ago) UserStat.update_view_counts(13.hours.ago)
Topic.ensure_consistency!
end end
end end
end end

View File

@ -465,7 +465,7 @@ SQL
# the threshold has been reached, we will close the topic waiting for intervention # the threshold has been reached, we will close the topic waiting for intervention
message = I18n.t("temporarily_closed_due_to_flags") message = I18n.t("temporarily_closed_due_to_flags")
topic.update_status("closed", true, Discourse.system_user, message) topic.update_status("closed", true, Discourse.system_user, message: message)
end end
def self.auto_hide_if_needed(acting_user, post, post_action_type) def self.auto_hide_if_needed(acting_user, post, post_action_type)

View File

@ -406,8 +406,8 @@ class Topic < ActiveRecord::Base
similar similar
end end
def update_status(status, enabled, user, message=nil) def update_status(status, enabled, user, opts={})
TopicStatusUpdate.new(self, user).update!(status, enabled, message) TopicStatusUpdate.new(self, user).update!(status, enabled, opts)
end end
# Atomically creates the next post number # Atomically creates the next post number
@ -726,9 +726,17 @@ class Topic < ActiveRecord::Base
TopicUser.change(user.id, id, cleared_pinned_at: nil) TopicUser.change(user.id, id, cleared_pinned_at: nil)
end end
def update_pinned(status, global=false) def update_pinned(status, global=false, pinned_until=nil)
update_column(:pinned_at, status ? Time.now : nil) pinned_until = Time.parse(pinned_until) rescue nil
update_column(:pinned_globally, global)
update_columns(
pinned_at: status ? Time.now : nil,
pinned_globally: global,
pinned_until: pinned_until
)
Jobs.cancel_scheduled_job(:unpin_topic, topic_id: self.id)
Jobs.enqueue_at(pinned_until, :unpin_topic, topic_id: self.id) if pinned_until
end end
def draft_key def draft_key
@ -745,6 +753,11 @@ class Topic < ActiveRecord::Base
end end
end end
def self.ensure_consistency!
# unpin topics that might have been missed
Topic.where("pinned_until < now()").update_all(pinned_at: nil, pinned_globally: false, pinned_until: nil)
end
def self.auto_close def self.auto_close
Topic.where("NOT closed AND auto_close_at < ? AND auto_close_user_id IS NOT NULL", 1.minute.ago).each do |t| Topic.where("NOT closed AND auto_close_at < ? AND auto_close_user_id IS NOT NULL", 1.minute.ago).each do |t|
t.auto_close t.auto_close

View File

@ -1,21 +1,20 @@
TopicStatusUpdate = Struct.new(:topic, :user) do TopicStatusUpdate = Struct.new(:topic, :user) do
def update!(status, enabled, message=nil) def update!(status, enabled, opts={})
status = Status.new(status, enabled) status = Status.new(status, enabled)
Topic.transaction do Topic.transaction do
change(status) change(status, opts)
highest_post_number = topic.highest_post_number highest_post_number = topic.highest_post_number
create_moderator_post_for(status, opts[:message])
create_moderator_post_for(status, message)
update_read_state_for(status, highest_post_number) update_read_state_for(status, highest_post_number)
end end
end end
private private
def change(status) def change(status, opts={})
if status.pinned? || status.pinned_globally? if status.pinned? || status.pinned_globally?
topic.update_pinned(status.enabled?, status.pinned_globally?) topic.update_pinned(status.enabled?, status.pinned_globally?, opts[:until])
elsif status.autoclosed? elsif status.autoclosed?
topic.update_column('closed', status.enabled?) topic.update_column('closed', status.enabled?)
else else

View File

@ -43,6 +43,7 @@ class TopicViewSerializer < ApplicationSerializer
:pinned_globally, :pinned_globally,
:pinned, # Is topic pinned and viewer hasn't cleared the pin? :pinned, # Is topic pinned and viewer hasn't cleared the pin?
:pinned_at, # Ignores clear pin :pinned_at, # Ignores clear pin
:pinned_until,
:details, :details,
:highest_post_number, :highest_post_number,
:last_read_post_number, :last_read_post_number,
@ -177,6 +178,10 @@ class TopicViewSerializer < ApplicationSerializer
object.topic.pinned_at object.topic.pinned_at
end end
def pinned_until
object.topic.pinned_until
end
def actions_summary def actions_summary
result = [] result = []
return [] unless post = object.posts.try(:first) return [] unless post = object.posts.try(:first)

View File

@ -130,11 +130,11 @@ en:
enabled: 'pinned this topic %{when}' enabled: 'pinned this topic %{when}'
disabled: 'unpinned this topic %{when}' disabled: 'unpinned this topic %{when}'
pinned_globally: pinned_globally:
enabled: 'pinned this topic %{when}' enabled: 'pinned globally this topic %{when}'
disabled: 'unpinned this topic %{when}' disabled: 'unpinned this topic %{when}'
visible: visible:
enabled: 'unlisted this topic %{when}' enabled: 'listed this topic %{when}'
disabled: 'listed this topic %{when}' disabled: 'unlisted this topic %{when}'
topic_admin_menu: "topic admin actions" topic_admin_menu: "topic admin actions"
@ -1131,17 +1131,19 @@ en:
feature_topic: feature_topic:
title: "Feature this topic" title: "Feature this topic"
pin: "Make this topic appear at the top of the {{categoryLink}} category." pin: "Make this topic appear at the top of the {{categoryLink}} category until"
confirm_pin: "You already have {{count}} pinned topics. Too many pinned topics may be a burden for new and anonymous users. Are you sure you want to pin another topic in this category?" confirm_pin: "You already have {{count}} pinned topics. Too many pinned topics may be a burden for new and anonymous users. Are you sure you want to pin another topic in this category?"
unpin: "Remove this topic from the top of the {{categoryLink}} category." unpin: "Remove this topic from the top of the {{categoryLink}} category."
unpin_until: "Remove this topic from the top of the {{categoryLink}} category or wait until <strong>%{until}</strong>."
pin_note: "Users can unpin the topic individually for themselves." pin_note: "Users can unpin the topic individually for themselves."
already_pinned: already_pinned:
zero: "There are no topics pinned in {{categoryLink}}." zero: "There are no topics pinned in {{categoryLink}}."
one: "Topics currently pinned in {{categoryLink}}: <strong class='badge badge-notification unread'>1</strong>." one: "Topics currently pinned in {{categoryLink}}: <strong class='badge badge-notification unread'>1</strong>."
other: "Topics currently pinned in {{categoryLink}}: <strong class='badge badge-notification unread'>{{count}}</strong>." other: "Topics currently pinned in {{categoryLink}}: <strong class='badge badge-notification unread'>{{count}}</strong>."
pin_globally: "Make this topic appear at the top of all topic lists, until a staff member unpins it." pin_globally: "Make this topic appear at the top of all topic lists until"
confirm_pin_globally: "You already have {{count}} globally pinned topics. Too many pinned topics may be a burden for new and anonymous users. Are you sure you want to pin another topic globally?" confirm_pin_globally: "You already have {{count}} globally pinned topics. Too many pinned topics may be a burden for new and anonymous users. Are you sure you want to pin another topic globally?"
unpin_globally: "Remove this topic from the top of all topic lists." unpin_globally: "Remove this topic from the top of all topic lists."
unpin_globally_until: "Remove this topic from the top of all topic lists or wait until <strong>%{until}</strong>."
global_pin_note: "Users can unpin the topic individually for themselves." global_pin_note: "Users can unpin the topic individually for themselves."
already_pinned_globally: already_pinned_globally:
zero: "There are no topics pinned globally." zero: "There are no topics pinned globally."

View File

@ -0,0 +1,5 @@
class AddPinnedUntilToTopics < ActiveRecord::Migration
def change
add_column :topics, :pinned_until, :datetime, null: true
end
end

View File

@ -324,12 +324,12 @@ describe TopicsController do
end end
it 'calls update_status on the forum topic with false' do it 'calls update_status on the forum topic with false' do
Topic.any_instance.expects(:update_status).with('closed', false, @user) Topic.any_instance.expects(:update_status).with('closed', false, @user, until: nil)
xhr :put, :status, topic_id: @topic.id, status: 'closed', enabled: 'false' xhr :put, :status, topic_id: @topic.id, status: 'closed', enabled: 'false'
end end
it 'calls update_status on the forum topic with true' do it 'calls update_status on the forum topic with true' do
Topic.any_instance.expects(:update_status).with('closed', true, @user) Topic.any_instance.expects(:update_status).with('closed', true, @user, until: nil)
xhr :put, :status, topic_id: @topic.id, status: 'closed', enabled: 'true' xhr :put, :status, topic_id: @topic.id, status: 'closed', enabled: 'true'
end end