FEATURE: Add button to delete unused tags (#6587)

This is particularly useful if you have uploaded a CSV file, and wish
to bulk-delete all of the tags that you uploaded.
This commit is contained in:
David Taylor 2018-11-12 16:24:34 +00:00 committed by GitHub
parent 182b34243d
commit d89ffbeffd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 125 additions and 1 deletions

View File

@ -22,6 +22,12 @@ export default DropdownSelectBoxComponent.extend({
name: I18n.t("tagging.upload"), name: I18n.t("tagging.upload"),
description: I18n.t("tagging.upload_description"), description: I18n.t("tagging.upload_description"),
icon: "upload" icon: "upload"
},
{
id: "deleteUnusedTags",
name: I18n.t("tagging.delete_unused"),
description: I18n.t("tagging.delete_unused_description"),
icon: "trash"
} }
]; ];
@ -30,7 +36,8 @@ export default DropdownSelectBoxComponent.extend({
actionNames: { actionNames: {
manageGroups: "showTagGroups", manageGroups: "showTagGroups",
uploadTags: "showUploader" uploadTags: "showUploader",
deleteUnusedTags: "deleteUnused"
}, },
mutateValue(id) { mutateValue(id) {

View File

@ -1,5 +1,7 @@
import computed from "ember-addons/ember-computed-decorators"; import computed from "ember-addons/ember-computed-decorators";
import showModal from "discourse/lib/show-modal"; import showModal from "discourse/lib/show-modal";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
export default Ember.Controller.extend({ export default Ember.Controller.extend({
sortProperties: ["totalCount:desc", "id"], sortProperties: ["totalCount:desc", "id"],
@ -38,6 +40,41 @@ export default Ember.Controller.extend({
showUploader() { showUploader() {
showModal("tag-upload"); showModal("tag-upload");
},
deleteUnused() {
ajax("/tags/unused", { type: "GET" })
.then(result => {
const displayN = 20;
const tags = result["tags"];
const tagString = tags.slice(0, displayN).join(", ");
var more = Math.max(0, tags.length - displayN);
const string =
more === 0
? I18n.t("tagging.delete_unused_confirmation", {
count: tags.length,
tags: tagString
})
: I18n.t("tagging.delete_unused_confirmation_more", {
total: tags.length,
tags: tagString,
count: more
});
bootbox.confirm(
string,
I18n.t("tagging.cancel_delete_unused"),
I18n.t("tagging.delete_unused"),
proceed => {
if (proceed) {
ajax("/tags/unused", { type: "DELETE" })
.then(() => this.send("refresh"))
.catch(popupAjaxError);
}
}
);
})
.catch(popupAjaxError);
} }
} }
}); });

View File

@ -146,6 +146,19 @@ class TagsController < ::ApplicationController
end end
end end
def list_unused
guardian.ensure_can_admin_tags!
render json: { tags: Tag.unused.pluck(:name) }
end
def destroy_unused
guardian.ensure_can_admin_tags!
tags = Tag.unused
StaffActionLogger.new(current_user).log_custom('deleted_unused_tags', tags: tags.pluck(:name))
tags.destroy_all
render json: success_json
end
def destroy def destroy
guardian.ensure_can_admin_tags! guardian.ensure_can_admin_tags!
tag_name = params[:tag_id] tag_name = params[:tag_id]

View File

@ -9,6 +9,8 @@ class Tag < ActiveRecord::Base
where("lower(name) IN (?)", name) where("lower(name) IN (?)", name)
end end
scope :unused, -> { where(topic_count: 0, pm_topic_count: 0) }
has_many :tag_users # notification settings has_many :tag_users # notification settings
has_many :topic_tags, dependent: :destroy has_many :topic_tags, dependent: :destroy

View File

@ -2710,6 +2710,15 @@ en:
upload_description: "Upload a text file to create tags in bulk" upload_description: "Upload a text file to create tags in bulk"
upload_instructions: "One per line, optionally with a tag group in the format 'tag_name,tag_group'." upload_instructions: "One per line, optionally with a tag group in the format 'tag_name,tag_group'."
upload_successful: "Tags uploaded successfully" upload_successful: "Tags uploaded successfully"
delete_unused_confirmation:
one: "1 tag will be deleted: %{tags}"
other: "{{count}} tags will be deleted: %{tags}"
delete_unused_confirmation_more:
one: "{{total}} tags will be deleted: %{tags} and one more"
other: "{{total}} tags will be deleted: %{tags} and %{count} more"
delete_unused: "Delete Unused Tags"
delete_unused_description: "Delete all tags which are not attached to any topics or personal messages"
cancel_delete_unused: "Cancel"
filters: filters:
without_category: "%{filter} %{tag} topics" without_category: "%{filter} %{tag} topics"
with_category: "%{filter} %{tag} topics in %{category}" with_category: "%{filter} %{tag} topics in %{category}"
@ -3534,6 +3543,7 @@ en:
revoke_moderation: "revoke moderation" revoke_moderation: "revoke moderation"
backup_create: "create backup" backup_create: "create backup"
deleted_tag: "deleted tag" deleted_tag: "deleted tag"
deleted_unused_tags: "deleted unused tags"
renamed_tag: "renamed tag" renamed_tag: "renamed tag"
revoke_email: "revoke email" revoke_email: "revoke email"
lock_trust_level: "lock trust level" lock_trust_level: "lock trust level"

View File

@ -776,6 +776,8 @@ Discourse::Application.routes.draw do
get '/check' => 'tags#check_hashtag' get '/check' => 'tags#check_hashtag'
get '/personal_messages/:username' => 'tags#personal_messages' get '/personal_messages/:username' => 'tags#personal_messages'
post '/upload' => 'tags#upload' post '/upload' => 'tags#upload'
get '/unused' => 'tags#list_unused'
delete '/unused' => 'tags#destroy_unused'
constraints(tag_id: /[^\/]+?/, format: /json|rss/) do constraints(tag_id: /[^\/]+?/, format: /json|rss/) do
get '/:tag_id.rss' => 'tags#tag_feed' get '/:tag_id.rss' => 'tags#tag_feed'
get '/:tag_id' => 'tags#show', as: 'tag_show' get '/:tag_id' => 'tags#show', as: 'tag_show'

View File

@ -172,4 +172,18 @@ describe Tag do
expect(tag.topic_count).to eq(1) expect(tag.topic_count).to eq(1)
end end
end end
context "unused tags scope" do
let!(:tags) do
[ Fabricate(:tag, name: "used_publically", topic_count: 2, pm_topic_count: 0),
Fabricate(:tag, name: "used_privately", topic_count: 0, pm_topic_count: 3),
Fabricate(:tag, name: "used_everywhere", topic_count: 0, pm_topic_count: 3),
Fabricate(:tag, name: "unused1", topic_count: 0, pm_topic_count: 0),
Fabricate(:tag, name: "unused2", topic_count: 0, pm_topic_count: 0)]
end
it "returns the correct tags" do
expect(Tag.unused.pluck(:name)).to contain_exactly("unused1", "unused2")
end
end
end end

View File

@ -389,6 +389,45 @@ describe TagsController do
end end
end end
describe '#unused' do
it "fails if you can't manage tags" do
sign_in(Fabricate(:user))
get "/tags/unused.json"
expect(response.status).to eq(403)
delete "/tags/unused.json"
expect(response.status).to eq(403)
end
context 'logged in' do
before do
sign_in(Fabricate(:admin))
end
context 'with some tags' do
let!(:tags) { [
Fabricate(:tag, name: "used_publically", topic_count: 2, pm_topic_count: 0),
Fabricate(:tag, name: "used_privately", topic_count: 0, pm_topic_count: 3),
Fabricate(:tag, name: "used_everywhere", topic_count: 0, pm_topic_count: 3),
Fabricate(:tag, name: "unused1", topic_count: 0, pm_topic_count: 0),
Fabricate(:tag, name: "unused2", topic_count: 0, pm_topic_count: 0)
]}
it 'returns the correct unused tags' do
get "/tags/unused.json"
expect(response.status).to eq(200)
json = ::JSON.parse(response.body)
expect(json["tags"]).to contain_exactly("unused1", "unused2")
end
it 'deletes the correct tags' do
expect { delete "/tags/unused.json" }.to change { Tag.count }.by(-2) & change { UserHistory.count }.by(1)
expect(Tag.pluck(:name)).to contain_exactly("used_publically", "used_privately", "used_everywhere")
end
end
end
end
context '#upload_csv' do context '#upload_csv' do
it 'requires you to be logged in' do it 'requires you to be logged in' do
post "/tags/upload.json" post "/tags/upload.json"