diff --git a/app/assets/javascripts/discourse/components/composer-title.js.es6 b/app/assets/javascripts/discourse/components/composer-title.js.es6 index afc9a8ca652..459d1bd0a6e 100644 --- a/app/assets/javascripts/discourse/components/composer-title.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-title.js.es6 @@ -11,6 +11,7 @@ import afterTransition from "discourse/lib/after-transition"; export default Component.extend({ classNames: ["title-input"], watchForLink: Ember.computed.alias("composer.canEditTopicFeaturedLink"), + disabled: Ember.computed.or("composer.loading", "composer.disableTitleInput"), didInsertElement() { this._super(...arguments); diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index e2bdfadc042..cf839d8e8ac 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -187,6 +187,9 @@ export default Controller.extend({ ); }, + disableCategoryChooser: Ember.computed.not("model.topic.details.can_edit"), + disableTagsChooser: Ember.computed.not("model.topic.canEditTags"), + isStaffUser: Ember.computed.reads("currentUser.staff"), canUnlistTopic: Ember.computed.and("model.creatingTopic", "isStaffUser"), diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6 index 4517051df34..34b44985df0 100644 --- a/app/assets/javascripts/discourse/models/composer.js.es6 +++ b/app/assets/javascripts/discourse/models/composer.js.es6 @@ -141,6 +141,7 @@ const Composer = RestModel.extend({ creatingPrivateMessage: Ember.computed.equal("action", PRIVATE_MESSAGE), notCreatingPrivateMessage: Ember.computed.not("creatingPrivateMessage"), notPrivateMessage: Ember.computed.not("privateMessage"), + disableTitleInput: Ember.computed.not("topic.details.can_edit"), @computed("privateMessage", "archetype.hasOptions") showCategoryChooser(isPrivateMessage, hasOptions) { @@ -784,31 +785,31 @@ const Composer = RestModel.extend({ let promise = Ember.RSVP.resolve(); // Update the topic if we're editing the first post - if ( - this.title && - post.post_number === 1 && - this.get("topic.details.can_edit") - ) { - const topicProps = this.getProperties( - Object.keys(_edit_topic_serializer) - ); - // frontend should have featuredLink but backend needs featured_link - if (topicProps.featuredLink) { - topicProps.featured_link = topicProps.featuredLink; - delete topicProps.featuredLink; - } - + if (this.title && post.post_number === 1) { const topic = this.topic; - // If we're editing a shared draft, keep the original category - if (this.action === EDIT_SHARED_DRAFT) { - const destinationCategoryId = topicProps.categoryId; - promise = promise.then(() => - topic.updateDestinationCategory(destinationCategoryId) + if (topic.details.can_edit) { + const topicProps = this.getProperties( + Object.keys(_edit_topic_serializer) ); - topicProps.categoryId = topic.get("category.id"); + // frontend should have featuredLink but backend needs featured_link + if (topicProps.featuredLink) { + topicProps.featured_link = topicProps.featuredLink; + delete topicProps.featuredLink; + } + + // If we're editing a shared draft, keep the original category + if (this.action === EDIT_SHARED_DRAFT) { + const destinationCategoryId = topicProps.categoryId; + promise = promise.then(() => + topic.updateDestinationCategory(destinationCategoryId) + ); + topicProps.categoryId = topic.get("category.id"); + } + promise = promise.then(() => Topic.update(topic, topicProps)); + } else if (topic.details.can_edit_tags) { + promise = promise.then(() => topic.updateTags(this.tags)); } - promise = promise.then(() => Topic.update(topic, topicProps)); } const props = { diff --git a/app/assets/javascripts/discourse/models/topic.js.es6 b/app/assets/javascripts/discourse/models/topic.js.es6 index 1af3697106e..03345d77446 100644 --- a/app/assets/javascripts/discourse/models/topic.js.es6 +++ b/app/assets/javascripts/discourse/models/topic.js.es6 @@ -546,6 +546,7 @@ const Topic = RestModel.extend({ readLastPost: propertyEqual("last_read_post_number", "highest_post_number"), canClearPin: Ember.computed.and("pinned", "readLastPost"), + canEditTags: Ember.computed.or("details.can_edit", "details.can_edit_tags"), archiveMessage() { this.set("archiving", true); @@ -610,6 +611,17 @@ const Topic = RestModel.extend({ return ajax(`/t/${this.id}/reset-bump-date`, { type: "PUT" }).catch( popupAjaxError ); + }, + + updateTags(tags) { + if (!tags || tags.length === 0) { + tags = [""]; + } + + return ajax(`/t/${this.id}/tags`, { + type: "PUT", + data: { tags: tags } + }); } }); diff --git a/app/assets/javascripts/discourse/templates/components/composer-title.hbs b/app/assets/javascripts/discourse/templates/components/composer-title.hbs index 76f63dac1f0..a8483595de6 100644 --- a/app/assets/javascripts/discourse/templates/components/composer-title.hbs +++ b/app/assets/javascripts/discourse/templates/components/composer-title.hbs @@ -3,7 +3,7 @@ id="reply-title" maxLength=titleMaxLength placeholderKey=composer.titlePlaceholder - disabled=composer.loading + disabled=disabled autocomplete="discourse"}} {{popup-input-tip validation=validation}} diff --git a/app/assets/javascripts/discourse/templates/composer.hbs b/app/assets/javascripts/discourse/templates/composer.hbs index 43c3599ec8d..f738efc3625 100644 --- a/app/assets/javascripts/discourse/templates/composer.hbs +++ b/app/assets/javascripts/discourse/templates/composer.hbs @@ -76,12 +76,13 @@ fullWidthOnMobile=true value=model.categoryId scopedCategoryId=scopedCategoryId + isDisabled=disableCategoryChooser tabindex="3"}} {{popup-input-tip validation=categoryValidation}} {{/if}} {{#if canEditTags}} - {{mini-tag-chooser tags=model.tags tabindex="4" categoryId=model.categoryId minimum=model.minimumRequiredTags}} + {{mini-tag-chooser tags=model.tags tabindex="4" categoryId=model.categoryId minimum=model.minimumRequiredTags isDisabled=disableTagsChooser}} {{popup-input-tip validation=tagValidation}} {{/if}} diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 7e50ec7400d..2c29594e66b 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -374,6 +374,16 @@ class TopicsController < ApplicationController success ? render_serialized(topic, BasicTopicSerializer) : render_json_error(topic) end + def update_tags + params.require(:tags) + topic = Topic.find_by(id: params[:topic_id]) + guardian.ensure_can_edit_tags!(topic) + + success = PostRevisor.new(topic.first_post, topic).revise!(current_user, { tags: params[:tags] }, validate_post: false) + + success ? render_serialized(topic, BasicTopicSerializer) : render_json_error(topic) + end + def feature_stats params.require(:category_id) category_id = params[:category_id].to_i diff --git a/app/serializers/topic_view_details_serializer.rb b/app/serializers/topic_view_details_serializer.rb index c80ba6dece8..d21b1a778b0 100644 --- a/app/serializers/topic_view_details_serializer.rb +++ b/app/serializers/topic_view_details_serializer.rb @@ -14,7 +14,8 @@ class TopicViewDetailsSerializer < ApplicationSerializer :can_reply_as_new_topic, :can_flag_topic, :can_convert_topic, - :can_review_topic] + :can_review_topic, + :can_edit_tags] end attributes( @@ -128,6 +129,10 @@ class TopicViewDetailsSerializer < ApplicationSerializer scope.can_convert_topic?(object.topic) end + def include_can_edit_tags? + !scope.can_edit?(object.topic) && scope.can_edit_tags?(object.topic) + end + def allowed_users object.topic.allowed_users.reject { |user| object.group_allowed_user_ids.include?(user.id) } end diff --git a/config/routes.rb b/config/routes.rb index 55f2cd7a536..290282be38c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -754,6 +754,7 @@ Discourse::Application.routes.draw do delete "t/:topic_id/timings" => "topics#destroy_timings", constraints: { topic_id: /\d+/ } put "t/:topic_id/bookmark" => "topics#bookmark", constraints: { topic_id: /\d+/ } put "t/:topic_id/remove_bookmarks" => "topics#remove_bookmarks", constraints: { topic_id: /\d+/ } + put "t/:topic_id/tags" => "topics#update_tags", constraints: { topic_id: /\d+/ } post "t/:topic_id/notifications" => "topics#set_notifications" , constraints: { topic_id: /\d+/ } diff --git a/lib/guardian/topic_guardian.rb b/lib/guardian/topic_guardian.rb index 80cc2a3cf3e..b6d8a273068 100644 --- a/lib/guardian/topic_guardian.rb +++ b/lib/guardian/topic_guardian.rb @@ -184,4 +184,16 @@ module TopicGuardian def can_banner_topic?(topic) topic && authenticated? && !topic.private_message? && is_staff? end + + def can_edit_tags?(topic) + return false unless can_tag_topics? + return false if topic.private_message? && !can_tag_pms? + return true if can_edit_topic?(topic) + + if topic&.first_post&.wiki && (@user.trust_level >= SiteSetting.min_trust_to_edit_wiki_post.to_i) + return can_create_post?(topic) + end + + false + end end diff --git a/spec/requests/topics_controller_spec.rb b/spec/requests/topics_controller_spec.rb index 14200831541..357ce90fbbe 100644 --- a/spec/requests/topics_controller_spec.rb +++ b/spec/requests/topics_controller_spec.rb @@ -1027,6 +1027,31 @@ RSpec.describe TopicsController do expect(topic.tags.pluck(:id)).to contain_exactly(tag.id) end + it "can add a tag to wiki topic" do + SiteSetting.min_trust_to_edit_wiki_post = 2 + topic.first_post.update!(wiki: true) + user = Fabricate(:user) + sign_in(user) + + expect do + put "/t/#{topic.id}/tags.json", params: { + tags: [tag.name] + } + end.not_to change { topic.reload.first_post.revisions.count } + + expect(response.status).to eq(403) + user.update!(trust_level: 2) + + expect do + put "/t/#{topic.id}/tags.json", params: { + tags: [tag.name] + } + end.to change { topic.reload.first_post.revisions.count }.by(1) + + expect(response.status).to eq(200) + expect(topic.tags.pluck(:id)).to contain_exactly(tag.id) + end + it 'does not remove tag if no params is given' do topic.tags << tag diff --git a/spec/serializers/topic_view_serializer_spec.rb b/spec/serializers/topic_view_serializer_spec.rb index ccf8a647582..b0627ab05f5 100644 --- a/spec/serializers/topic_view_serializer_spec.rb +++ b/spec/serializers/topic_view_serializer_spec.rb @@ -257,6 +257,26 @@ describe TopicViewSerializer do expect(details[:allowed_users].find { |au| au[:id] == pm.user_id }).to be_present expect(details[:allowed_groups].find { |ag| ag[:id] == group.id }).to be_present end + + context "can_edit_tags" do + before do + SiteSetting.tagging_enabled = true + SiteSetting.min_trust_to_edit_wiki_post = 2 + end + + it "returns true when user can edit a wiki topic" do + post = Fabricate(:post, wiki: true) + topic = Fabricate(:topic, first_post: post) + + json = serialize_topic(topic, user) + expect(json[:details][:can_edit_tags]).to be_nil + + user.update!(trust_level: 2) + + json = serialize_topic(topic, user) + expect(json[:details][:can_edit_tags]).to eq(true) + end + end end end