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.disabled': 'folder-open',
'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.disabled': 'eye-slash'
};

View file

@ -10,15 +10,23 @@ export default ObjectController.extend(ModalFunctionality, {
pinnedGloballyCount: 0,
bannerCount: 0,
reset: function() {
this.set("model.pinnedInCategoryUntil", null);
this.set("model.pinnedGloballyUntil", null);
},
categoryLink: function() {
return categoryLinkHTML(this.get("model.category"), { allowUncategorized: true });
}.property("model.category"),
unPinMessage: function() {
return this.get("model.pinned_globally") ?
I18n.t("topic.feature_topic.unpin_globally") :
I18n.t("topic.feature_topic.unpin", { categoryLink: this.get("categoryLink") });
}.property("categoryLink", "model.pinned_globally"),
let name = "topic.feature_topic.unpin";
if (this.get("model.pinned_globally")) name += "_globally";
if (moment(this.get("model.pinned_until")) > moment()) name += "_until";
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() {
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") });
}.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() {
this.set("loading", true);

View file

@ -99,19 +99,6 @@ export default ObjectController.extend(SelectedPostsCount, BufferedContent, {
this.set('selectedReplies', []);
}.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: {
deleteTopic() {
this.deleteTopic();
@ -371,27 +358,31 @@ export default ObjectController.extend(SelectedPostsCount, BufferedContent, {
togglePinned() {
const value = this.get('model.pinned_at') ? false : true,
topic = this.get('content');
topic = this.get('content'),
until = this.get('model.pinnedInCategoryUntil');
// optimistic update
topic.setProperties({
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() {
const topic = this.get('content');
const topic = this.get('content'),
until = this.get('model.pinnedGloballyUntil');
// optimistic update
topic.setProperties({
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() {

View file

@ -154,13 +154,17 @@ const Topic = RestModel.extend({
this.saveStatus(property, !!this.get(property));
},
saveStatus(property, value) {
saveStatus(property, value, until) {
if (property === 'closed' && value === true) {
this.set('details.auto_close_at', null);
}
return Discourse.ajax(this.get('url') + "/status", {
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() {
showModal('featureTopic', { model: this.modelFor('topic'), title: 'topic.feature_topic.title' });
this.controllerFor('modal').set('modalClass', 'feature-topic-modal');
this.controllerFor('feature_topic').reset();
},
showInvite() {

View file

@ -1,80 +1,94 @@
<div class="modal-body feature-topic">
{{#if model.pinned_at}}
<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">
<p>{{{unPinMessage}}}</p>
{{#if model.pinned_globally}}
<p>{{i18n "topic.feature_topic.global_pin_note"}}</p>
<p>
{{#conditional-loading-spinner size="small" condition=loading}}
{{{i18n "topic.feature_topic.already_pinned_globally" count=pinnedGloballyCount}}}
{{/conditional-loading-spinner}}
</p>
<p>{{i18n "topic.feature_topic.global_pin_note"}}</p>
{{else}}
<p>{{i18n "topic.feature_topic.pin_note"}}</p>
<p>
{{#conditional-loading-spinner size="small" condition=loading}}
{{{alreadyPinnedMessage}}}
{{/conditional-loading-spinner}}
</p>
<p>{{i18n "topic.feature_topic.pin_note"}}</p>
{{/if}}
<p>{{{unPinMessage}}}</p>
<p>{{d-button action="unpin" icon="thumb-tack" label="topic.feature.unpin" class="btn-primary"}}</p>
</div>
</div>
{{else}}
<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">
<p>{{{pinMessage}}}</p>
<p>{{i18n "topic.feature_topic.pin_note"}}</p>
<p>
{{#conditional-loading-spinner size="small" condition=loading}}
{{{alreadyPinnedMessage}}}
{{/conditional-loading-spinner}}
</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>
<hr>
<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">
<p>{{i18n "topic.feature_topic.pin_globally"}}</p>
<p>{{i18n "topic.feature_topic.global_pin_note"}}</p>
<p>
{{#conditional-loading-spinner size="small" condition=loading}}
{{{i18n "topic.feature_topic.already_pinned_globally" count=pinnedGloballyCount}}}
{{/conditional-loading-spinner}}
</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>
{{/if}}
<hr>
<div class="feature-section">
<div class="button">
{{#if model.isBanner}}
{{d-button action="removeBanner" icon="thumb-tack" label="topic.feature.remove_banner" class="btn-primary"}}
{{else}}
{{d-button action="makeBanner" icon="thumb-tack" label="topic.feature.make_banner" class="btn-primary"}}
{{/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>
{{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}}
{{d-button action="removeBanner" icon="thumb-tack" label="topic.feature.remove_banner" class="btn-primary"}}
{{else}}
{{d-button action="makeBanner" icon="thumb-tack" label="topic.feature.make_banner" class="btn-primary"}}
{{/if}}
</p>
</div>
</div>
</div>

View file

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

View file

@ -165,13 +165,16 @@ class TopicsController < ApplicationController
def status
params.require(:status)
params.require(:enabled)
status, topic_id = params[:status], params[:topic_id].to_i
enabled = (params[:enabled] == 'true')
params.permit(:until)
status = params[:status]
topic_id = params[:topic_id].to_i
enabled = params[:enabled] == 'true'
check_for_status_presence(:status, status)
@topic = Topic.find_by(id: topic_id)
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
end

View file

@ -227,7 +227,8 @@ module Jobs
end
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
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!
PostRevision.ensure_consistency!
UserStat.update_view_counts(13.hours.ago)
Topic.ensure_consistency!
end
end
end

View file

@ -465,7 +465,7 @@ SQL
# the threshold has been reached, we will close the topic waiting for intervention
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
def self.auto_hide_if_needed(acting_user, post, post_action_type)

View file

@ -406,8 +406,8 @@ class Topic < ActiveRecord::Base
similar
end
def update_status(status, enabled, user, message=nil)
TopicStatusUpdate.new(self, user).update!(status, enabled, message)
def update_status(status, enabled, user, opts={})
TopicStatusUpdate.new(self, user).update!(status, enabled, opts)
end
# Atomically creates the next post number
@ -726,9 +726,17 @@ class Topic < ActiveRecord::Base
TopicUser.change(user.id, id, cleared_pinned_at: nil)
end
def update_pinned(status, global=false)
update_column(:pinned_at, status ? Time.now : nil)
update_column(:pinned_globally, global)
def update_pinned(status, global=false, pinned_until=nil)
pinned_until = Time.parse(pinned_until) rescue nil
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
def draft_key
@ -745,6 +753,11 @@ class Topic < ActiveRecord::Base
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
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

View file

@ -1,21 +1,20 @@
TopicStatusUpdate = Struct.new(:topic, :user) do
def update!(status, enabled, message=nil)
def update!(status, enabled, opts={})
status = Status.new(status, enabled)
Topic.transaction do
change(status)
change(status, opts)
highest_post_number = topic.highest_post_number
create_moderator_post_for(status, message)
create_moderator_post_for(status, opts[:message])
update_read_state_for(status, highest_post_number)
end
end
private
def change(status)
def change(status, opts={})
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?
topic.update_column('closed', status.enabled?)
else

View file

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

View file

@ -130,11 +130,11 @@ en:
enabled: 'pinned this topic %{when}'
disabled: 'unpinned this topic %{when}'
pinned_globally:
enabled: 'pinned this topic %{when}'
enabled: 'pinned globally this topic %{when}'
disabled: 'unpinned this topic %{when}'
visible:
enabled: 'unlisted this topic %{when}'
disabled: 'listed this topic %{when}'
enabled: 'listed this topic %{when}'
disabled: 'unlisted this topic %{when}'
topic_admin_menu: "topic admin actions"
@ -1131,17 +1131,19 @@ en:
feature_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?"
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."
already_pinned:
zero: "There are no topics pinned in {{categoryLink}}."
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>."
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?"
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."
already_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
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'
end
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'
end