├── Gemfile ├── lang ├── en.yml └── ru.yml ├── config ├── configuration-travis.yml ├── routes.rb ├── database-postgresql-travis.yml ├── database-mysql-travis.yml └── locales │ ├── ja.yml │ ├── nl.yml │ ├── ru.yml │ ├── zh.yml │ └── en.yml ├── doc ├── tagging_1.PNG ├── tagging_2.PNG ├── tagging_3.PNG ├── tagging_4.PNG ├── tagging_5.PNG ├── tagging_6.PNG ├── tagging_7.PNG ├── tagging_8.PNG └── tagging_9.PNG ├── app ├── views │ ├── issue_tags │ │ ├── _form.erb │ │ └── edit.erb │ ├── tagging │ │ ├── _taglinks.erb │ │ ├── _tagcloud.erb │ │ ├── _tagcloud_search.erb │ │ ├── _issue_tagcloud.erb │ │ ├── _tagtab.erb │ │ └── _settings.html.erb │ ├── reports │ │ └── _simple_tags.html.erb │ └── issues │ │ ├── index_with_tags.api.rsb │ │ └── show_with_tags.api.rsb ├── models │ ├── wiki_page_tag.rb │ └── issue_tag.rb ├── controllers │ └── issue_tags_controller.rb └── helpers │ └── tagging_helper.rb ├── lib ├── tagging_plugin │ ├── context_helper.rb │ ├── tags_helper.rb │ ├── api_template_handler_patch.rb │ ├── tagging_patches.rb │ └── tagging_hooks.rb ├── tasks │ ├── test.rake │ └── reconfigure.rake ├── redmine_tagging.rb └── redmine_tagging │ └── patches │ ├── project_patch.rb │ ├── queries_helper_patch.rb │ ├── application_controller_patch.rb │ ├── wiki_page_patch.rb │ ├── query_patch.rb │ └── issue_patch.rb ├── db └── migrate │ ├── 20161031000008_fix_tags_with_backslash.rb │ ├── 20161031000004_fix_tagging_contexts.rb │ ├── 20161031000001_create_views.rb │ ├── 20161031000002_scrub_body.rb │ └── 20161031000006_fix_broken_issue_and_wiki_contexts.rb ├── assets ├── stylesheets │ └── tagging.css └── javascripts │ ├── jquery.tagcloud.js │ ├── toggle_tags.js │ └── tag.js ├── test ├── functional │ ├── gantts_controller_test.rb │ ├── calendars_controller_test.rb │ ├── issue_tags_controller_test.rb │ └── issues_controller_test.rb ├── test_helper.rb ├── unit │ └── redmine_tagging │ │ └── project_test.rb └── integration │ └── tagging_test.rb ├── .travis.yml ├── init.rb └── README.md /Gemfile: -------------------------------------------------------------------------------- 1 | gem 'acts-as-taggable-on', '~> 4.0' 2 | -------------------------------------------------------------------------------- /lang/en.yml: -------------------------------------------------------------------------------- 1 | # English strings go here 2 | my_label: "My label" 3 | -------------------------------------------------------------------------------- /lang/ru.yml: -------------------------------------------------------------------------------- 1 | # Russian strings go here 2 | my_label: "Моя метка" 3 | -------------------------------------------------------------------------------- /config/configuration-travis.yml: -------------------------------------------------------------------------------- 1 | default: 2 | email_delivery: 3 | delivery_method: :test -------------------------------------------------------------------------------- /doc/tagging_1.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Restream/redmine_tagging/HEAD/doc/tagging_1.PNG -------------------------------------------------------------------------------- /doc/tagging_2.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Restream/redmine_tagging/HEAD/doc/tagging_2.PNG -------------------------------------------------------------------------------- /doc/tagging_3.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Restream/redmine_tagging/HEAD/doc/tagging_3.PNG -------------------------------------------------------------------------------- /doc/tagging_4.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Restream/redmine_tagging/HEAD/doc/tagging_4.PNG -------------------------------------------------------------------------------- /doc/tagging_5.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Restream/redmine_tagging/HEAD/doc/tagging_5.PNG -------------------------------------------------------------------------------- /doc/tagging_6.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Restream/redmine_tagging/HEAD/doc/tagging_6.PNG -------------------------------------------------------------------------------- /doc/tagging_7.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Restream/redmine_tagging/HEAD/doc/tagging_7.PNG -------------------------------------------------------------------------------- /doc/tagging_8.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Restream/redmine_tagging/HEAD/doc/tagging_8.PNG -------------------------------------------------------------------------------- /doc/tagging_9.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Restream/redmine_tagging/HEAD/doc/tagging_9.PNG -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | RedmineApp::Application.routes.draw do 2 | resources :issue_tags, only: [:destroy] 3 | end 4 | -------------------------------------------------------------------------------- /app/views/issue_tags/_form.erb: -------------------------------------------------------------------------------- 1 | <%= error_messages_for 'tags' %> 2 | 3 |
4 |

<%= f.text_field :title, size: 30, required: true %>

5 |
6 | -------------------------------------------------------------------------------- /config/database-postgresql-travis.yml: -------------------------------------------------------------------------------- 1 | # http://about.travis-ci.org/docs/user/database-setup/#PostgreSQL 2 | test: 3 | adapter: postgresql 4 | database: redmine 5 | username: postgres 6 | -------------------------------------------------------------------------------- /config/database-mysql-travis.yml: -------------------------------------------------------------------------------- 1 | # http://about.travis-ci.org/docs/user/database-setup/#MySQL 2 | test: 3 | adapter: mysql2 4 | database: redmine 5 | username: travis 6 | encoding: utf8 7 | -------------------------------------------------------------------------------- /app/views/tagging/_taglinks.erb: -------------------------------------------------------------------------------- 1 | 2 | <%= l(:field_tags) %>: 3 | 4 | <% tags.each do |tag| %> 5 | <%= link_to_project_tag_filter(@project, tag) %> 6 | <% end %> 7 | 8 | 9 | -------------------------------------------------------------------------------- /lib/tagging_plugin/context_helper.rb: -------------------------------------------------------------------------------- 1 | module TaggingPlugin 2 | module ContextHelper 3 | class << self 4 | def context_for(project) 5 | "context_" + project.identifier.gsub('-', '_') 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/views/issue_tags/edit.erb: -------------------------------------------------------------------------------- 1 |

<%=l(:label_issue_category)%>

2 | 3 | <% labelled_tabular_form_for :issue_tag, @tag, url: { action: 'update', id: @tag } do |f| %> 4 | <%= render partial: 'issue_tags/form', locals: { f: f } %> 5 | <%= submit_tag l(:button_save) %> 6 | <% end %> 7 | -------------------------------------------------------------------------------- /config/locales/ja.yml: -------------------------------------------------------------------------------- 1 | # Japanese strings go here for Rails i18n 2 | ja: 3 | field_tags: "タグ" 4 | tagging_wiki_pages_inline: "Wikiページのタグをインラインで編集する" 5 | tagging_issues_inline: "チケットのタグをインラインで編集する" 6 | tagging_sidebar_tagcloud: "チケットのサイドバーでタグクラウドを表示する" 7 | tagging_dynamic_font_size: "タグの使用頻度に基づいてタグクラウドのフォントサイズを変更する" 8 | -------------------------------------------------------------------------------- /db/migrate/20161031000008_fix_tags_with_backslash.rb: -------------------------------------------------------------------------------- 1 | class FixTagsWithBackslash < ActiveRecord::Migration 2 | def up 3 | ActsAsTaggableOn::Tag.where("name like ?", '%\\\%').find_each do |tag| 4 | tag.name = tag.name.gsub("\\", '/') 5 | tag.save 6 | end 7 | end 8 | 9 | def down 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/views/tagging/_tagcloud.erb: -------------------------------------------------------------------------------- 1 |

<%= l(:field_tags) %>

2 | 3 |
4 | <% 5 | tag_cloud_in_project(@project) do |tag, factor| 6 | options = {:style => "font-size: #{10 * factor + 9}pt" } 7 | %> 8 | <%= link_to_project_tag_filter(@project, tag, {}, options) %> 9 | <% end %> 10 |
11 | -------------------------------------------------------------------------------- /lib/tasks/test.rake: -------------------------------------------------------------------------------- 1 | namespace :redmine_tagging do 2 | 3 | desc 'Runs the plugins tests.' 4 | desc 'Runs the redmine_tagging plugin tests' 5 | Rake::TestTask.new :test do |t| 6 | t.libs << 'test' 7 | t.verbose = true 8 | t.warning = false 9 | t.pattern = 'plugins/redmine_tagging/test/**/*_test.rb' 10 | end 11 | 12 | end -------------------------------------------------------------------------------- /config/locales/nl.yml: -------------------------------------------------------------------------------- 1 | # English strings go here for Rails i18n 2 | nl: 3 | field_tags: "Tags" 4 | tagging_wiki_pages_inline: "Tag bewerking in text van wiki paginas" 5 | tagging_issues_inline: "Tag bewerking in text van issues" 6 | tagging_sidebar_tagcloud: "Show tagcloud in issues sidebar" 7 | tagging_dynamic_font_size: "Font size in tag cloud based on tag use" 8 | -------------------------------------------------------------------------------- /app/models/wiki_page_tag.rb: -------------------------------------------------------------------------------- 1 | class WikiPageTag < ActiveRecord::Base 2 | self.table_name = :wiki_page_tags 3 | self.primary_key = :id 4 | 5 | belongs_to :wiki_page 6 | 7 | def readonly? 8 | return true 9 | end 10 | 11 | # Prevent objects from being destroyed 12 | def before_destroy 13 | raise ActiveRecord::ReadOnlyRecord 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /config/locales/ru.yml: -------------------------------------------------------------------------------- 1 | # Russian strings go here for Rails i18n 2 | ru: 3 | field_tags: "Теги" 4 | tagging_wiki_pages_inline: "Использовать теги из текста страниц вики" 5 | tagging_issues_inline: "Использовать теги из описаний задач" 6 | detach: "Отделить" 7 | notice_successful_detached: "Тег успешно отвязан от всех тикетов проекта" 8 | tags_in_project: "Теги в проекте" 9 | -------------------------------------------------------------------------------- /lib/redmine_tagging.rb: -------------------------------------------------------------------------------- 1 | module RedmineTagging 2 | end 3 | 4 | require 'redmine_tagging/patches/issue_patch' 5 | require 'redmine_tagging/patches/project_patch' 6 | require 'redmine_tagging/patches/query_patch' 7 | require 'redmine_tagging/patches/wiki_page_patch' 8 | require 'redmine_tagging/patches/queries_helper_patch' 9 | require 'redmine_tagging/patches/application_controller_patch' 10 | -------------------------------------------------------------------------------- /app/views/tagging/_tagcloud_search.erb: -------------------------------------------------------------------------------- 1 |

<%= l(:field_tags) %>

2 | 3 |
4 | <% 5 | tag_cloud_in_project(@project) do |tag, factor| 6 | options = {:style => "font-size: #{10 * factor + 9}pt" } 7 | %> 8 | <%= link_to(tag, {:controller => "search", :action => "index", :id => @project, :q => "\"#{tag_without_sharp(tag)}\"", :wiki_pages => true }, options) %> 9 | <% end %> 10 |
11 | -------------------------------------------------------------------------------- /app/views/tagging/_issue_tagcloud.erb: -------------------------------------------------------------------------------- 1 | <%= t(:tags_in_project) %> 2 | — 3 | <% 4 | tag_cloud_in_project(@project) do |tag, factor| 5 | style = "font-size: #{10 * factor + 9}pt" 6 | %> 7 | 10 | <%= tag %> 11 | 12 | <% end %> 13 | 14 | -------------------------------------------------------------------------------- /lib/tagging_plugin/tags_helper.rb: -------------------------------------------------------------------------------- 1 | module TaggingPlugin 2 | module TagsHelper 3 | class << self 4 | def from_string(tags_string) 5 | tags_string.split(/[#"'\s,\\]+/) \ 6 | .select { |tag| tag.length > 0 } \ 7 | .map { |tag| "##{tag}" } \ 8 | .join(', ') 9 | end 10 | 11 | def to_string(tags) 12 | tags.sort_by { |t| t.downcase }.map{ |tag| tag.gsub(/^#/, '') }.join(' ') 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /config/locales/zh.yml: -------------------------------------------------------------------------------- 1 | # Simplified Chinese strings go here for Rails i18n 2 | # Author: Steven.W 3 | # Based on file: en.yml 4 | 5 | zh: 6 | field_tags: "标签" 7 | tagging_wiki_pages_inline: "在WIKI页中,内嵌标签编辑" 8 | tagging_issues_inline: "在问题中,内嵌标签编辑" 9 | tagging_sidebar_tagcloud: "在问题侧边栏内显示标签云" 10 | tagging_dynamic_font_size: "根据标签的使用情况,自动调整标签云中的标签字体大小" 11 | notice_successful_detached: "标签已拆分" 12 | tagging_tab_label: "标签" 13 | label_tagname: "标签名称" 14 | field_tag: "标签主题" 15 | 16 | tags: "标签" 17 | append_tags: "追加标签,而非重写标签" 18 | detach: "拆分" -------------------------------------------------------------------------------- /db/migrate/20161031000004_fix_tagging_contexts.rb: -------------------------------------------------------------------------------- 1 | class FixTaggingContexts < ActiveRecord::Migration 2 | 3 | PREFIX = "context_" 4 | 5 | def up 6 | ActsAsTaggableOn::Tagging.where("context not like ?", PREFIX + "%").find_each do |tagging| 7 | tagging.context = PREFIX + tagging.context 8 | tagging.save 9 | end 10 | end 11 | 12 | def down 13 | ActsAsTaggableOn::Tagging.where("context like ?", PREFIX + "%").find_each do |tagging| 14 | tagging.context = tagging.context[PREFIX.length..-1] 15 | tagging.save 16 | end 17 | end 18 | end 19 | 20 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # English strings go here for Rails i18n 2 | en: 3 | field_tags: "Tags" 4 | tagging_wiki_pages_inline: "Inline editing of tags for wiki pages" 5 | tagging_issues_inline: "Inline editing of tags for issues" 6 | tagging_sidebar_tagcloud: "Show tagcloud in issues sidebar" 7 | tagging_dynamic_font_size: "Font size in tag cloud based on tag use" 8 | notice_successful_detached: "Tag successfully detached" 9 | tagging_tab_label: "Tags" 10 | label_tagname: "Tag name" 11 | field_tag: "Tag title" 12 | 13 | tags: "Tags" 14 | append_tags: "Append tags instead of rewrite" 15 | detach: "Detach" 16 | tags_in_project: "Project tags" 17 | -------------------------------------------------------------------------------- /lib/redmine_tagging/patches/project_patch.rb: -------------------------------------------------------------------------------- 1 | require_dependency 'project' 2 | 3 | module RedmineTagging::Patches::ProjectPatch 4 | extend ActiveSupport::Concern 5 | 6 | def tags 7 | ActsAsTaggableOn::Tag. 8 | joins(:taggings). 9 | joins(<<-SQL 10 | inner join #{Issue.table_name} issues on 11 | issues.project_id = #{ActiveRecord::Base::sanitize(id)} and 12 | issues.id = #{ActsAsTaggableOn::Tagging.table_name}.taggable_id 13 | SQL 14 | ).order(:name).uniq 15 | end 16 | 17 | end 18 | 19 | unless Project.included_modules.include? RedmineTagging::Patches::ProjectPatch 20 | Project.send :include, RedmineTagging::Patches::ProjectPatch 21 | end 22 | -------------------------------------------------------------------------------- /lib/redmine_tagging/patches/queries_helper_patch.rb: -------------------------------------------------------------------------------- 1 | module RedmineTagging::Patches::QueriesHelperPatch 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | alias_method_chain :column_content, :tags 6 | end 7 | 8 | def column_content_with_tags(column, issue) 9 | value = column.value(issue) 10 | 11 | if array_of_issue_tags?(value) 12 | links = value.map do |issue_tag| 13 | link_to_project_tag_filter(@project, issue_tag.tag) 14 | end 15 | links.join(', ') 16 | else 17 | column_content_without_tags(column, issue) 18 | end 19 | end 20 | 21 | def array_of_issue_tags?(value) 22 | value.class.name == 'Array' && value.first.class.name == 'IssueTag' 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /assets/stylesheets/tagging.css: -------------------------------------------------------------------------------- 1 | .tagMatches { 2 | margin-left: 10px; 3 | } 4 | 5 | .tagMatches ._tag_suggestion { 6 | padding: 1px; 7 | margin-left: 1px; 8 | background-color: #0000AB; 9 | color: #fff; 10 | cursor: pointer; 11 | } 12 | 13 | #cloud_trigger, #cloud_content a { 14 | cursor: pointer; 15 | text-decoration: none; 16 | } 17 | 18 | #cloud_trigger { 19 | font-weight: bold; 20 | border-bottom: 1px dashed; 21 | } 22 | 23 | #cloud_content a { 24 | margin: 3px; 25 | line-height: normal; 26 | } 27 | 28 | #cloud_content .selected { 29 | background: #0000AB; 30 | color: #fff; 31 | } 32 | 33 | #issue_tags { 34 | width: 90%; 35 | } 36 | 37 | .filter select[name="v[tags][]"][multiple] { 38 | min-height: 200px; 39 | } 40 | -------------------------------------------------------------------------------- /app/views/tagging/_tagtab.erb: -------------------------------------------------------------------------------- 1 | <% 2 | tags = @project.tags 3 | %> 4 | 5 | <% if tags.any? %> 6 | 7 | 8 | 9 | 10 | 11 | 12 | <% for issue_tag in tags %> 13 | 14 | 17 | 23 | 24 | <% end %> 25 | 26 |
<%= l(:label_tagname) %>
15 | <%= link_to_project_tag_filter(@project, issue_tag.name) %> 16 | 18 | <%= link_to(l(:detach), {:controller => 'issue_tags', :action => 'destroy', :id => issue_tag.id, 19 | :project_id => @project.id }, 20 | :method => :delete, 21 | :class => 'icon icon-del') %> 22 |
27 | <% else %> 28 |

<%= l(:label_no_data) %>

29 | <% end %> 30 | -------------------------------------------------------------------------------- /app/views/tagging/_settings.html.erb: -------------------------------------------------------------------------------- 1 |

2 | <%= content_tag(:label, l(:tagging_wiki_pages_inline)) %> 3 | <%= check_box_tag("settings[wiki_pages_inline]", "1", Setting.plugin_redmine_tagging[:wiki_pages_inline] == "1") %> 4 |

5 |

6 | <%= content_tag(:label, l(:tagging_issues_inline)) %> 7 | <%= check_box_tag("settings[issues_inline]", "1", Setting.plugin_redmine_tagging[:issues_inline] == "1") %> 8 |

9 |

10 | <%= content_tag(:label, l(:tagging_sidebar_tagcloud)) %> 11 | <%= check_box_tag("settings[sidebar_tagcloud]", "1", Setting.plugin_redmine_tagging[:sidebar_tagcloud] == "1") %> 12 |

13 |

14 | <%= content_tag(:label, l(:tagging_dynamic_font_size)) %> 15 | <%= check_box_tag("settings[dynamic_font_size]", "1", Setting.plugin_redmine_tagging[:dynamic_font_size] == "1") %> 16 |

17 | -------------------------------------------------------------------------------- /test/functional/gantts_controller_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../test_helper' 2 | 3 | class GanttsControllerTest < ActionController::TestCase 4 | fixtures :projects, 5 | :users, 6 | :roles, 7 | :members, 8 | :member_roles, 9 | :trackers, 10 | :projects_trackers, 11 | :enabled_modules, 12 | :issue_statuses, 13 | :issues, 14 | :enumerations, 15 | :custom_fields, 16 | :custom_values, 17 | :custom_fields_trackers 18 | 19 | def setup 20 | @request.session[:user_id] = 2 21 | 22 | @some_tags = '#1, #2, #3,#4,#5' 23 | 24 | @project_with_tags = Project.find(1) 25 | 26 | @issue_with_tags = Issue.find(1) 27 | @issue_with_tags.tags_to_update = @some_tags 28 | @issue_with_tags.save! 29 | end 30 | 31 | def test_can_show_gantt 32 | get :show, project_id: @project_with_tags.id 33 | assert_response :success 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/functional/calendars_controller_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../test_helper' 2 | 3 | class CalendarsControllerTest < ActionController::TestCase 4 | fixtures :projects, 5 | :users, 6 | :roles, 7 | :members, 8 | :member_roles, 9 | :trackers, 10 | :projects_trackers, 11 | :enabled_modules, 12 | :issue_statuses, 13 | :issues, 14 | :enumerations, 15 | :custom_fields, 16 | :custom_values, 17 | :custom_fields_trackers 18 | 19 | def setup 20 | @request.session[:user_id] = 2 21 | 22 | @some_tags = '#1, #2, #3,#4,#5' 23 | 24 | @project_with_tags = Project.find(1) 25 | 26 | @issue_with_tags = Issue.find(1) 27 | @issue_with_tags.tags_to_update = @some_tags 28 | @issue_with_tags.save! 29 | end 30 | 31 | def test_can_show_calendar 32 | get :show, project_id: @project_with_tags.id 33 | assert_response :success 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Load the normal Rails helper 2 | require File.expand_path(File.dirname(__FILE__) + '/../../../test/test_helper') 3 | 4 | class ActiveSupport::TestCase 5 | def log_user(login, password) 6 | User.anonymous 7 | get '/login' 8 | assert_equal nil, session[:user_id] 9 | assert_response :success 10 | assert_template 'account/login' 11 | post '/login', username: login, password: password 12 | assert_equal login, User.find(session[:user_id]).login 13 | end 14 | 15 | def setup_wiki_page_with_tags(test_tags) 16 | public_project = Project.find(1) 17 | 18 | Wiki.create!(project_id: public_project.id, start_page: 'test_page') 19 | public_project.reload 20 | 21 | public_project.wiki.pages << WikiPage.new(title: 'some_wiki_page') 22 | page = public_project.wiki.pages.first 23 | page.tags_to_update = test_tags 24 | page.content = WikiContent.new(text: 'content') 25 | page.save! 26 | page 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /db/migrate/20161031000001_create_views.rb: -------------------------------------------------------------------------------- 1 | class CreateViews < ActiveRecord::Migration 2 | def up 3 | execute 'DROP VIEW IF EXISTS issue_tags' 4 | 5 | execute <<-SQL 6 | CREATE VIEW issue_tags AS 7 | SELECT 8 | taggings.id AS id, 9 | tags.name AS tag, 10 | taggings.taggable_id AS issue_id 11 | FROM 12 | taggings 13 | JOIN tags ON taggings.tag_id = tags.id 14 | WHERE 15 | taggable_type = 'Issue' 16 | SQL 17 | 18 | execute 'DROP VIEW IF EXISTS wiki_page_tags' 19 | 20 | execute <<-SQL 21 | CREATE VIEW wiki_page_tags AS 22 | SELECT 23 | taggings.id, 24 | tags.name AS tag, 25 | taggings.taggable_id AS wiki_page_id 26 | FROM 27 | taggings 28 | JOIN tags ON taggings.tag_id = tags.id 29 | WHERE 30 | taggable_type = 'WikiPage' 31 | SQL 32 | end 33 | 34 | def down 35 | execute 'DROP VIEW issue_tags' 36 | execute 'DROP VIEW wiki_page_tags' 37 | end 38 | end 39 | 40 | -------------------------------------------------------------------------------- /lib/redmine_tagging/patches/application_controller_patch.rb: -------------------------------------------------------------------------------- 1 | require_dependency 'application_controller' 2 | 3 | module RedmineTagging::Patches::ApplicationControllerPatch 4 | extend ActiveSupport::Concern 5 | 6 | included do 7 | before_filter :include_tagging_helper 8 | before_filter :patch_queries_helper 9 | end 10 | 11 | # A way to make plugin helpers available in all views 12 | def include_tagging_helper 13 | unless _helpers.included_modules.include? TaggingHelper 14 | self.class.helper TaggingHelper 15 | end 16 | true 17 | end 18 | 19 | def patch_queries_helper 20 | if _helpers.included_modules.include?(QueriesHelper) && 21 | !_helpers.included_modules.include?(RedmineTagging::Patches::QueriesHelperPatch) 22 | _helpers.send :include, RedmineTagging::Patches::QueriesHelperPatch 23 | end 24 | true 25 | end 26 | end 27 | 28 | unless ApplicationController.included_modules.include? RedmineTagging::Patches::ApplicationControllerPatch 29 | ApplicationController.send :include, RedmineTagging::Patches::ApplicationControllerPatch 30 | end 31 | -------------------------------------------------------------------------------- /app/controllers/issue_tags_controller.rb: -------------------------------------------------------------------------------- 1 | class IssueTagsController < ApplicationController 2 | unloadable 3 | 4 | model_object ActsAsTaggableOn::Tag 5 | before_filter :find_model_object 6 | before_filter :find_project_by_project_id 7 | 8 | def destroy 9 | tag = @object 10 | 11 | context = TaggingPlugin::ContextHelper.context_for(@project) 12 | 13 | tag.taggings.where(context: context).find_each do |tagging| 14 | if tagging.taggable_type = "Issue" 15 | affected_issue = Issue.find(tagging.taggable_id) 16 | affected_issue.init_journal(User.current) 17 | issue_tags = affected_issue.tag_list_on(context) 18 | affected_issue.tags_to_update = issue_tags.select { |t| t != tag.name } 19 | affected_issue.save 20 | else 21 | tagging.destroy 22 | end 23 | end 24 | 25 | tag.taggings.reload 26 | 27 | if tag.taggings.empty? 28 | tag.destroy 29 | end 30 | 31 | flash[:notice] = l(:notice_successful_detached) 32 | redirect_to controller: 'projects', action: 'settings', tab: 'tags', id: @project 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/unit/redmine_tagging/project_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../../test_helper' 2 | 3 | class RedmineTagging::ProjectTest < ActiveSupport::TestCase 4 | fixtures :projects, :trackers, :issue_statuses, :issues, 5 | :enumerations, :users, :issue_categories, 6 | :projects_trackers, 7 | :roles, 8 | :member_roles, 9 | :members 10 | 11 | def test_find_all_tags_for_project 12 | project1 = Project.find(1) 13 | 14 | issue = project1.issues.find(1) 15 | issue.tags_to_update = '4, 2' 16 | issue.save! 17 | 18 | issue = project1.issues.find(2) 19 | issue.tags_to_update = '3, 1' 20 | issue.save! 21 | 22 | project2 = Project.find(3) 23 | 24 | issue = project2.issues.find(5) 25 | issue.tags_to_update = '8, 6' 26 | issue.save! 27 | 28 | issue = project2.issues.find(13) 29 | issue.tags_to_update = '7, 5' 30 | issue.save! 31 | 32 | tags1 = project1.tags.map(&:name).join(',') 33 | tags2 = project2.tags.map(&:name).join(',') 34 | 35 | assert_equal '1,2,3,4', tags1 36 | assert_equal '5,6,7,8', tags2 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /db/migrate/20161031000002_scrub_body.rb: -------------------------------------------------------------------------------- 1 | class ScrubBody < ActiveRecord::Migration 2 | def up 3 | Issue.where("description like '%{{tag(%'").each {|issue| 4 | issue.description = issue.description.gsub(/[{]{2}tag[(][^)]*[)][}]{2}/i, '') 5 | issue.save! 6 | } 7 | 8 | WikiContent.where("text like '%{{tag(%'").each {|content| 9 | content.text = content.text.gsub(/[{]{2}tag[(][^)]*[)][}]{2}/i, '') 10 | content.save! 11 | } 12 | WikiContent::Version.where("data like '%{{tag(%'").each {|content| 13 | content.data = content.data.gsub(/[{]{2}tag[(][^)]*[)][}]{2}/i, '') 14 | content.save! 15 | } 16 | end 17 | 18 | contexts = Project.where(nil).collect{|p| p.identifier} 19 | contexts << contexts.collect{|c| c.gsub('-', '_')} 20 | contexts.flatten! 21 | 22 | ActsAsTaggableOn::Tag.where( 23 | "not name like '#%' and id in (select tag_id from taggings where taggable_type in ('WikiPage', 'Issue') and context in (?))", 24 | contexts 25 | ).each do |tag| 26 | tag.name = "##{tag.name}" 27 | tag.save 28 | end 29 | 30 | def down 31 | # noop 32 | end 33 | end 34 | 35 | -------------------------------------------------------------------------------- /app/models/issue_tag.rb: -------------------------------------------------------------------------------- 1 | class IssueTag < ActiveRecord::Base 2 | self.table_name = :issue_tags 3 | self.primary_key = :id 4 | 5 | belongs_to :issue 6 | 7 | def readonly? 8 | return true 9 | end 10 | 11 | def title 12 | tag.gsub(/^#/, '') 13 | end 14 | alias_method :name, :title 15 | 16 | def project 17 | # self.issue.project 18 | Issue.find(issue_id).project 19 | end 20 | 21 | # Prevent objects from being destroyed 22 | def before_destroy 23 | raise ActiveRecord::ReadOnlyRecord 24 | end 25 | 26 | def self.by_issue_status(project = nil) 27 | project_condition = project.nil? ? '' : "and i.project_id=#{project.id}" 28 | 29 | ActiveRecord::Base.connection.select_all( 30 | "select 31 | s.id as status_id, 32 | s.is_closed as closed, 33 | t.id as tag, 34 | count(i.id) as total 35 | from 36 | #{Issue.table_name} i, #{IssueStatus.table_name} s, 37 | #{ActsAsTaggableOn::Tagging.table_name} ti, #{ActsAsTaggableOn::Tag.table_name} t 38 | where 39 | i.status_id=s.id 40 | and ti.taggable_id=i.id 41 | and ti.tag_id=t.id 42 | #{project_condition} 43 | group 44 | by s.id, s.is_closed, t.id") 45 | end 46 | 47 | def to_s 48 | name 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/tagging_plugin/api_template_handler_patch.rb: -------------------------------------------------------------------------------- 1 | module TaggingPlugin 2 | module ApiTemplateHandlerPatch 3 | class << self 4 | def included(base) 5 | base.send(:extend, ClassMethods) 6 | base.class_eval do 7 | class << self 8 | alias_method_chain :call, :api_replacement 9 | end 10 | end 11 | end 12 | end 13 | 14 | module ClassMethods 15 | def call_with_api_replacement(template) 16 | template = replace_if(template, /views\/issues\/index.api.rsb$/, 'app/views/issues/index_with_tags.api.rsb') 17 | template = replace_if(template, /views\/issues\/show.api.rsb$/, 'app/views/issues/show_with_tags.api.rsb') 18 | call_without_api_replacement(template) 19 | end 20 | 21 | def replace_if(template, regexp, new_path) 22 | if template.identifier =~ regexp 23 | new_template_filename = File.join(__dir__, '../..', new_path) 24 | source = File.open(new_template_filename).read 25 | identifier = template.identifier 26 | handler = template.handler 27 | template = ActionView::Template.new(source, identifier, handler, {}) 28 | end 29 | template 30 | end 31 | end 32 | end 33 | end 34 | 35 | Redmine::Views::ApiTemplateHandler.send(:include, TaggingPlugin::ApiTemplateHandlerPatch) 36 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | 3 | services: 4 | - mysql 5 | - postgresql 6 | 7 | rvm: 8 | - 2.3.1 9 | 10 | gemfile: 11 | - $REDMINE_PATH/Gemfile 12 | 13 | env: 14 | - REDMINE_VER=3.1.7 DB=mysql 15 | - REDMINE_VER=3.2.4 DB=mysql 16 | - REDMINE_VER=3.3.1 DB=mysql 17 | - REDMINE_VER=3.1.7 DB=postgresql 18 | - REDMINE_VER=3.2.4 DB=postgresql 19 | - REDMINE_VER=3.3.1 DB=postgresql 20 | 21 | before_install: 22 | - export PLUGIN_NAME=redmine_tagging 23 | - export REDMINE_PATH=$HOME/redmine 24 | - svn co http://svn.redmine.org/redmine/tags/$REDMINE_VER $REDMINE_PATH 25 | - ln -s $TRAVIS_BUILD_DIR $REDMINE_PATH/plugins/$PLUGIN_NAME 26 | - cp config/database-$DB-travis.yml $REDMINE_PATH/config/database.yml 27 | - cp config/configuration-travis.yml $REDMINE_PATH/config/configuration.yml 28 | - echo "config.active_record.schema_format = :sql" > $REDMINE_PATH/config/additional_environment.rb 29 | - cd $REDMINE_PATH 30 | 31 | before_script: 32 | - export RAILS_ENV=test 33 | - bundle exec rake db:create 34 | - bundle exec rake db:migrate 35 | - bundle exec rake acts_as_taggable_on_engine:install:migrations 36 | - bundle exec rake db:migrate 37 | - bundle exec rake redmine:plugins:migrate 38 | - bundle exec rake db:structure:dump 39 | 40 | script: 41 | - export RUBYOPT="-W0" 42 | - bundle exec rake redmine:plugins:test 43 | 44 | -------------------------------------------------------------------------------- /app/views/reports/_simple_tags.html.erb: -------------------------------------------------------------------------------- 1 | <% if @statuses.empty? or rows.empty? %> 2 |

<%=l(:label_no_data)%>

3 | <% else %> 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | <% for tag in rows %> 13 | "> 14 | 17 | 25 | 33 | 41 | 42 | <% end %> 43 | 44 |
<%=l(:label_open_issues_plural)%><%=l(:label_closed_issues_plural)%><%=l(:label_total)%>
15 | <%= link_to_project_tag_filter(@project, tag.name) %> 16 | 18 | <%= link_to_project_tag_filter( 19 | @project, 20 | tag.name, 21 | :status => "o", 22 | :title => aggregate(data, { field_name => tag.id, "closed" => 0 })) 23 | %> 24 | 26 | <%= link_to_project_tag_filter( 27 | @project, 28 | tag.name, 29 | :status => "c", 30 | :title => aggregate(data, { field_name => tag.id, "closed" => 1 })) 31 | %> 32 | 34 | <%= link_to_project_tag_filter( 35 | @project, 36 | tag.name, 37 | :status => "*", 38 | :title => aggregate(data, { field_name => tag.id })) 39 | %> 40 |
45 | <% end 46 | reset_cycle %> 47 | -------------------------------------------------------------------------------- /lib/redmine_tagging/patches/wiki_page_patch.rb: -------------------------------------------------------------------------------- 1 | require_dependency 'wiki_page' 2 | 3 | module RedmineTagging::Patches::WikiPagePatch 4 | extend ActiveSupport::Concern 5 | 6 | included do 7 | unloadable 8 | 9 | attr_writer :tags_to_update 10 | 11 | has_many :wiki_page_tags 12 | 13 | acts_as_taggable 14 | 15 | before_save :update_tags 16 | 17 | if Redmine::VERSION::MAJOR < 3 18 | searchable_options[:columns] << "#{WikiPageTag.table_name}.tag" 19 | searchable_options[:include] ||= [] 20 | searchable_options[:include] << :wiki_page_tags 21 | else 22 | searchable_options[:columns] << "#{WikiPageTag.table_name}.tag" 23 | 24 | original_scope = searchable_options[:scope] || self 25 | 26 | searchable_options[:scope] = ->(*args) { 27 | (original_scope.respond_to?(:call) ? 28 | original_scope.call(*args) : 29 | original_scope 30 | ).includes :wiki_page_tags 31 | } 32 | end 33 | end 34 | 35 | private 36 | 37 | def update_tags 38 | if @tags_to_update 39 | project_context = TaggingPlugin::ContextHelper.context_for(project) 40 | set_tag_list_on(project_context, @tags_to_update) 41 | end 42 | 43 | true 44 | end 45 | 46 | end 47 | 48 | unless WikiPage.included_modules.include? RedmineTagging::Patches::WikiPagePatch 49 | WikiPage.send :include, RedmineTagging::Patches::WikiPagePatch 50 | end 51 | -------------------------------------------------------------------------------- /db/migrate/20161031000006_fix_broken_issue_and_wiki_contexts.rb: -------------------------------------------------------------------------------- 1 | class FixBrokenIssueAndWikiContexts < ActiveRecord::Migration 2 | def self.remove_tagging(tagging) 3 | tag = tagging.tag 4 | tagging.destroy 5 | tag.destroy unless tag.taggings.any? 6 | end 7 | 8 | def self.up 9 | 10 | ActsAsTaggableOn::Tagging.where("taggable_type = ?", "Issue").find_each do |tagging| 11 | if tagged_issue = Issue.find_by_id(tagging.taggable_id) 12 | context_should_be = TaggingPlugin::ContextHelper.context_for(tagged_issue.project) 13 | if tagging.context != context_should_be 14 | tagging.context = context_should_be 15 | tagging.save 16 | end 17 | else 18 | remove_tagging(tagging) 19 | end 20 | end 21 | 22 | ActsAsTaggableOn::Tagging.where("taggable_type = ?", "WikiPage").find_each do |tagging| 23 | if tagged_page = WikiPage.find_by_id(tagging.taggable_id) 24 | project = tagged_page.wiki.project 25 | context_should_be = TaggingPlugin::ContextHelper.context_for(project) 26 | if tagging.context != context_should_be 27 | tagging.context = context_should_be 28 | tagging.save 29 | end 30 | else 31 | remove_tagging(tagging) 32 | end 33 | end 34 | end 35 | 36 | def self.down 37 | end 38 | 39 | def up 40 | self.class.up 41 | end 42 | 43 | def down 44 | self.class.down 45 | end 46 | end 47 | 48 | -------------------------------------------------------------------------------- /lib/tagging_plugin/tagging_patches.rb: -------------------------------------------------------------------------------- 1 | module TaggingPlugin 2 | 3 | module ProjectsHelperPatch 4 | def self.included(base) # :nodoc: 5 | base.send(:include, InstanceMethods) 6 | 7 | base.class_eval do 8 | alias_method_chain :project_settings_tabs, :tags_tab 9 | end 10 | end 11 | 12 | module InstanceMethods 13 | def project_settings_tabs_with_tags_tab 14 | tabs = project_settings_tabs_without_tags_tab 15 | tabs << { name: 'tags', partial: 'tagging/tagtab', label: :tagging_tab_label } 16 | return tabs 17 | end 18 | end 19 | end 20 | 21 | module WikiControllerPatch 22 | def self.included(base) # :nodoc: 23 | base.send(:include, InstanceMethods) 24 | 25 | base.class_eval do 26 | unloadable 27 | 28 | alias_method_chain :update, :tags 29 | end 30 | end 31 | 32 | module InstanceMethods 33 | def update_with_tags 34 | if params[:wiki_page] 35 | if tags = params[:wiki_page][:tags] 36 | tags = TagsHelper.from_string(tags) 37 | @page.tags_to_update = tags 38 | end 39 | end 40 | update_without_tags 41 | end 42 | end 43 | end 44 | end 45 | 46 | WikiController.send(:include, TaggingPlugin::WikiControllerPatch) unless WikiController.included_modules.include? TaggingPlugin::WikiControllerPatch 47 | ProjectsHelper.send(:include, TaggingPlugin::ProjectsHelperPatch) unless ProjectsHelper.included_modules.include? TaggingPlugin::ProjectsHelperPatch 48 | -------------------------------------------------------------------------------- /test/functional/issue_tags_controller_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../test_helper' 2 | 3 | class IssueTagsControllerTest < ActionController::TestCase 4 | fixtures :projects, 5 | :users, 6 | :roles, 7 | :members, 8 | :member_roles, 9 | :trackers, 10 | :projects_trackers, 11 | :enabled_modules, 12 | :issue_statuses, 13 | :issues, 14 | :enumerations, 15 | :custom_fields, 16 | :custom_values, 17 | :custom_fields_trackers 18 | 19 | def setup 20 | @request.session[:user_id] = 2 21 | @some_tags = '#1, #2, #3,#4,#5' 22 | 23 | @project_with_tags = Project.find(1) 24 | @another_project = Project.find(2) 25 | 26 | @issue_with_tags = Issue.find(1) 27 | @issue_with_tags.tags_to_update = @some_tags 28 | @issue_with_tags.save! 29 | end 30 | 31 | def test_should_destroy_issue_tag 32 | @tag_id = ActsAsTaggableOn::Tag.find_by_name('#4').id 33 | 34 | delete 'destroy', project_id: @project_with_tags.id, id: @tag_id 35 | assert_response :redirect 36 | 37 | tag_rem = ActsAsTaggableOn::Tag.count 38 | assert_equal 4, tag_rem 39 | end 40 | 41 | def test_should_destroy_issue_tag_in_case_of_changed_project 42 | @issue_with_tags.project_id = @another_project.id 43 | @issue_with_tags.save! 44 | @issue_with_tags.reload 45 | 46 | @tag_id = ActsAsTaggableOn::Tag.find_by_name('#4').id 47 | delete 'destroy', project_id: @another_project.id, id: @tag_id 48 | assert_response :redirect 49 | 50 | tag_rem = ActsAsTaggableOn::Tag.count 51 | assert_equal 4, tag_rem 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /app/helpers/tagging_helper.rb: -------------------------------------------------------------------------------- 1 | module TaggingHelper 2 | def link_to_project_tag_filter(project, tag, options = {}, html_options = {}) 3 | options.reverse_merge!({ 4 | status: 'o', 5 | title: tag 6 | }) 7 | 8 | opts = { 9 | 'set_filter' => 1, 10 | 'f' => ['tags', 'status_id'], 11 | 'op[tags]' => '=', 12 | 'op[status_id]' => options[:status], 13 | 'v[tags][]' => tag_without_sharp(tag), 14 | 'v[status_id][]' => 1 15 | } 16 | 17 | if project 18 | link_to(options[:title], project_issues_path(project, opts), html_options) 19 | else 20 | link_to(options[:title], issues_path(opts), html_options) 21 | end 22 | end 23 | 24 | def tag_without_sharp(tag) 25 | tag.to_s.gsub /^\s*#/, '' 26 | end 27 | 28 | def tag_cloud_in_project(project, &each_tag) 29 | tags = {} 30 | 31 | if project 32 | context = TaggingPlugin::ContextHelper.context_for(project) 33 | 34 | Issue.tag_counts_on(context).each do |tag| 35 | tags[tag.name] = tag.count 36 | end 37 | 38 | WikiPage.tag_counts_on(context).each do |tag| 39 | tags[tag.name] = tags[tag.name].to_i + tag.count 40 | end 41 | else 42 | Issue.all_tag_counts.each do |tag| 43 | tags[tag.name] = tag.count 44 | end 45 | 46 | WikiPage.all_tag_counts.each do |tag| 47 | tags[tag.name] = tags[tag.name].to_i + tag.count 48 | end 49 | end 50 | 51 | tags = tags.reject {|key,value| value == 0 } 52 | 53 | if tags.size > 0 54 | min_max = tags.values.minmax 55 | distance = min_max[1] - min_max[0] 56 | 57 | dynamic_fonts_enabled = (Setting.plugin_redmine_tagging[:dynamic_font_size] == "1") 58 | 59 | tags.keys.sort_by { |t| t.downcase }.each do |tag| 60 | if dynamic_fonts_enabled && (distance != 0) 61 | count = tags[tag] 62 | factor = (count - min_max[0]).to_f / distance 63 | else 64 | factor = 0.0 65 | end 66 | 67 | each_tag.call(tag, factor) 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /app/views/issues/index_with_tags.api.rsb: -------------------------------------------------------------------------------- 1 | api.array :issues, api_meta(:total_count => @issue_count, :offset => @offset, :limit => @limit) do 2 | @issues.each do |issue| 3 | api.issue do 4 | api.id issue.id 5 | api.project(:id => issue.project_id, :name => issue.project.name) unless issue.project.nil? 6 | api.tracker(:id => issue.tracker_id, :name => issue.tracker.name) unless issue.tracker.nil? 7 | api.status(:id => issue.status_id, :name => issue.status.name) unless issue.status.nil? 8 | api.priority(:id => issue.priority_id, :name => issue.priority.name) unless issue.priority.nil? 9 | api.author(:id => issue.author_id, :name => issue.author.name) unless issue.author.nil? 10 | api.assigned_to(:id => issue.assigned_to_id, :name => issue.assigned_to.name) unless issue.assigned_to.nil? 11 | api.category(:id => issue.category_id, :name => issue.category.name) unless issue.category.nil? 12 | api.fixed_version(:id => issue.fixed_version_id, :name => issue.fixed_version.name) unless issue.fixed_version.nil? 13 | api.parent(:id => issue.parent_id) unless issue.parent.nil? 14 | 15 | api.subject issue.subject 16 | api.description issue.description 17 | api.start_date issue.start_date 18 | api.due_date issue.due_date 19 | api.done_ratio issue.done_ratio 20 | api.estimated_hours issue.estimated_hours 21 | 22 | render_api_custom_values issue.custom_field_values, api 23 | 24 | api.created_on issue.created_on 25 | api.updated_on issue.updated_on 26 | api.closed_on issue.closed_on 27 | 28 | api.array :tags do 29 | issue.issue_tags.each do |issue_tag| 30 | api.tag(:id => issue_tag.tag[1..-1]) 31 | end 32 | end 33 | 34 | api.array :relations do 35 | issue.relations.each do |relation| 36 | api.relation(:id => relation.id, :issue_id => relation.issue_from_id, :issue_to_id => relation.issue_to_id, :relation_type => relation.relation_type, :delay => relation.delay) 37 | end 38 | end if include_in_api_response?('relations') 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /assets/javascripts/jquery.tagcloud.js: -------------------------------------------------------------------------------- 1 | (function($) { 2 | 3 | $.fn.tagcloud = function(options) { 4 | var opts = $.extend({}, $.fn.tagcloud.defaults, options); 5 | tagWeights = this.map(function(){ 6 | return $(this).attr("rel"); 7 | }) 8 | tagWeights = jQuery.makeArray(tagWeights).sort(compareWeights); 9 | lowest = tagWeights[0]; 10 | highest = tagWeights.pop(); 11 | range = highest - lowest; 12 | if(range == 0) {range = 1}; 13 | // Sizes 14 | if (opts.size) { 15 | fontIncr = (opts.size.end - opts.size.start)/range; 16 | } 17 | // Colors 18 | if (opts.color) { 19 | colorIncr = colorIncrement (opts.color, range); 20 | } 21 | return this.each(function() { 22 | weighting = $(this).attr("rel") - lowest; 23 | if (opts.size) { 24 | $(this).css({"font-size": opts.size.start + (weighting * fontIncr) + opts.size.unit}); 25 | } 26 | if (opts.color) { 27 | $(this).css({"color": tagColor(opts.color, colorIncr, weighting)}); 28 | } 29 | }) 30 | } 31 | 32 | $.fn.tagcloud.defaults = { 33 | size: {start: 14, end: 18, unit: "pt"} 34 | }; 35 | 36 | // Converts hex to an RGB array 37 | function toRGB (code) { 38 | if (code.length == 4) { 39 | code = jQuery.map(/\w+/.exec(code), function(el) {return el + el }).join(""); 40 | } 41 | hex = /(\w{2})(\w{2})(\w{2})/.exec(code); 42 | return [parseInt("0x" + hex[1]), parseInt("0x" + hex[2]), parseInt("0x" + hex[3])]; 43 | } 44 | 45 | // Converts an RGB array to hex 46 | function toHex (ary) { 47 | return "#" + jQuery.map(ary, function(i) { 48 | hex = i.toString(16); 49 | hex = (hex.length == 1) ? "0" + hex : hex; 50 | return hex; 51 | }).join(""); 52 | } 53 | 54 | function colorIncrement (color, range) { 55 | return jQuery.map(toRGB(color.end), function(n, i) { 56 | return (n - toRGB(color.start)[i])/range; 57 | }); 58 | } 59 | 60 | function tagColor (color, increment, weighting) { 61 | rgb = jQuery.map(toRGB(color.start), function(n, i) { 62 | ref = Math.round(n + (increment[i] * weighting)); 63 | if (ref > 255) { 64 | ref = 255; 65 | } else { 66 | if (ref < 0) { 67 | ref = 0; 68 | } 69 | } 70 | return ref; 71 | }); 72 | return toHex(rgb); 73 | } 74 | 75 | function compareWeights(a, b) 76 | { 77 | return a - b; 78 | } 79 | 80 | 81 | })(jQuery); 82 | -------------------------------------------------------------------------------- /lib/tasks/reconfigure.rake: -------------------------------------------------------------------------------- 1 | namespace :redmine do 2 | namespace :tagging do 3 | desc "Reconfigure for inline/separate tag editing" 4 | task :reconfigure => :environment do 5 | 6 | if Setting.plugin_redmine_tagging[:issues_inline] == "1" 7 | puts "Adding inline tags to issues" 8 | 9 | Issue.find_each do |issue| 10 | tag_context = TaggingPlugin::ContextHelper.context_for(issue.project) 11 | tags = issue.tag_list_on(tag_context) \ 12 | .map {|tag| tag.gsub(/^#/, '') } \ 13 | .sort_by { |t| t.downcase } \ 14 | .join(', ') 15 | 16 | next if tags.blank? && issue.description.blank? 17 | 18 | tags = "{{tag(#{tags})}}" 19 | 20 | issue.description = '' if issue.description.blank? 21 | issue.description = issue.description.gsub(/[{]{2}tag[(][^)]*[)][}]{2}/i, tags) 22 | issue.description += "\n\n#{tags}" unless issue.description =~ /[{]{2}tag[(][^)]*[)][}]{2}/i 23 | 24 | issue.save! 25 | end 26 | else 27 | puts "Removing inline tags from issues" 28 | Issue.where("description like '%{{tag(%'").each {|issue| 29 | next if issue.description.blank? 30 | 31 | issue.description = issue.description.gsub(/[{]{2}tag[(][^)]*[)][}]{2}/i, '') 32 | issue.save! 33 | } 34 | end 35 | 36 | if Setting.plugin_redmine_tagging[:wiki_pages_inline] == "1" 37 | puts "Adding inline tags to wikis" 38 | 39 | WikiContent.find(:all).each {|content| 40 | tag_context = TaggingPlugin::ContextHelper.context_for(content.page.wiki.project) 41 | tags = content.page.tag_list_on(tag_context) \ 42 | .map { |tag| tag.gsub(/^#/, '') } \ 43 | .sort_by { |t| t.downcase } \ 44 | .join(', ') 45 | 46 | next if tags.blank? && content.text.blank? 47 | 48 | tags = "{{tag(#{tags})}}" 49 | 50 | content.text = '' if content.text.blank? 51 | content.text = content.text.gsub(/[{]{2}tag[(][^)]*[)][}]{2}/i, tags) 52 | content.text += "\n\n#{tags}" unless content.text =~ /[{]{2}tag[(][^)]*[)][}]{2}/i 53 | 54 | content.save! 55 | } 56 | else 57 | puts "Removing inline tags from wikis" 58 | 59 | WikiContent.where("text like '%{{tag(%'").each {|content| 60 | next if content.text.blank? 61 | 62 | content.text = content.text.gsub(/[{]{2}tag[(][^)]*[)][}]{2}/i, '') 63 | content.save! 64 | } 65 | end 66 | 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /assets/javascripts/toggle_tags.js: -------------------------------------------------------------------------------- 1 | // IE compatibility 2 | if (!Array.prototype.indexOf) { 3 | Array.prototype.indexOf = function (searchElement /*, fromIndex */ ) { 4 | "use strict" 5 | if (this == null) { 6 | throw new TypeError() 7 | } 8 | var t = Object(this) 9 | var len = t.length >>> 0 10 | if (len === 0) { 11 | return -1 12 | } 13 | var n = 0 14 | if (arguments.length > 1) { 15 | n = Number(arguments[1]); 16 | if (n != n) { // shortcut for verifying if it's NaN 17 | n = 0 18 | } else if (n != 0 && n != Infinity && n != -Infinity) { 19 | n = (n > 0 || -1) * Math.floor(Math.abs(n)); 20 | } 21 | } 22 | if (n >= len) { 23 | return -1 24 | } 25 | var k = n >= 0 ? n : Math.max(len - Math.abs(n), 0); 26 | for (; k < len; k++) { 27 | if (k in t && t[k] === searchElement) { 28 | return k 29 | } 30 | } 31 | return -1 32 | } 33 | } 34 | 35 | (function ($) { 36 | $.fn.toggleCloudViaFor = function(cloud_trigger, tag_container) { 37 | var tag_cloud = $(this) 38 | 39 | cloud_trigger.click(function(e) { 40 | tag_cloud.toggle() 41 | e.preventDefault() 42 | }) 43 | 44 | var tag_items = tag_cloud.children() 45 | 46 | $(tag_items).click(function(e) { 47 | var tag_value = $(this).attr("data-tag") 48 | $(this).toggleTagFor(tag_value, tag_container) 49 | 50 | show_selected_tags_from(tag_container, tag_cloud) 51 | 52 | e.preventDefault() 53 | }) 54 | 55 | tag_cloud.toggle(false) 56 | show_selected_tags_from(tag_container, tag_cloud) 57 | 58 | tag_container.keyup(function(e) { 59 | show_selected_tags_from(tag_container, tag_cloud) 60 | }) 61 | 62 | tag_container.change(function(e) { 63 | show_selected_tags_from(tag_container, tag_cloud) 64 | }) 65 | } 66 | 67 | function show_selected_tags_from(tag_container, tag_cloud) { 68 | var current_tags = tags_to_array(tag_container.attr("value")) 69 | update_selected_tags(current_tags, tag_cloud) 70 | } 71 | 72 | function update_selected_tags(selected_tags, tag_cloud) { 73 | tag_cloud.children().each(function(index, tag_child) { 74 | var tag_value = $(tag_child).attr("data-tag") 75 | if(selected_tags.indexOf(tag_value.toLowerCase()) == -1) 76 | $(tag_child).removeClass("selected") 77 | else { 78 | $(tag_child).addClass("selected") 79 | } 80 | }) 81 | } 82 | 83 | $.fn.toggleTagFor = function(tag_name, tag_container) { 84 | var content_tags = tags_to_array(tag_container.attr("value")) 85 | var this_tag_index = content_tags.indexOf(tag_name.toLowerCase()) 86 | 87 | if(this_tag_index == -1) { 88 | content_tags.push(tag_name) 89 | } else { 90 | content_tags.splice(this_tag_index, 1) 91 | } 92 | 93 | tag_container.attr("value", content_tags.join(" ")) 94 | } 95 | 96 | function tags_to_array(tags) { 97 | if (!tags) tags = ''; 98 | dirty_items = tags.split(/[,#\s]+/) 99 | 100 | dirty_items = $.map(dirty_items, function(val, i){ 101 | return val.toLowerCase() 102 | }) 103 | 104 | return $.grep(dirty_items, function(val, i){ 105 | return (val.length > 0) 106 | }) 107 | } 108 | })(jQuery) 109 | -------------------------------------------------------------------------------- /lib/redmine_tagging/patches/query_patch.rb: -------------------------------------------------------------------------------- 1 | require_dependency 'query' 2 | 3 | module RedmineTagging::Patches::QueryPatch 4 | extend ActiveSupport::Concern 5 | 6 | included do 7 | unloadable # Send unloadable so it will not be unloaded in development 8 | 9 | alias_method_chain :available_filters, :tags 10 | alias_method_chain :sql_for_field, :tags 11 | 12 | tag_query_column = QueryColumn.new(:issue_tags, :caption => :field_tags) 13 | add_available_column(tag_query_column) 14 | end 15 | 16 | def available_filters_with_tags 17 | unless @available_tag_filter 18 | @available_filters = available_filters_without_tags 19 | @available_tag_filter = available_tags_filter 20 | @available_filters.merge!(@available_tag_filter) 21 | end 22 | @available_filters 23 | end 24 | 25 | def available_tags_filter 26 | if project.nil? 27 | tags = ActsAsTaggableOn::Tag.where( 28 | "id in (select tag_id from taggings where taggable_type = 'Issue')" 29 | ) 30 | else 31 | context = TaggingPlugin::ContextHelper.context_for(project) 32 | tags = ActsAsTaggableOn::Tag.where( 33 | "id in (select tag_id from taggings where taggable_type = 'Issue' and context = ?)", 34 | context 35 | ) 36 | end 37 | tags = tags.sort_by { |t| t.name.downcase }.map do |tag| 38 | [tag_without_sharp(tag), tag_without_sharp(tag)] 39 | end 40 | field = 'tags' 41 | options = { 42 | type: :list_optional, 43 | values: tags, 44 | name: l(:field_tags), 45 | order: 21, 46 | } 47 | filter = ActiveSupport::OrderedHash.new 48 | filter[field] = QueryFilter.new(field, options) 49 | filter 50 | end 51 | 52 | def sql_for_field_with_tags(field, operator, v, db_table, db_field, is_custom_filter = false) 53 | if field == 'tags' 54 | tagging_sql(field, operator) 55 | else 56 | sql_for_field_without_tags(field, operator, v, db_table, db_field, is_custom_filter) 57 | end 58 | end 59 | 60 | def tagging_sql(field, operator) 61 | case operator 62 | when '!*' 63 | tagging_sql_none 64 | when '*' 65 | tagging_sql_any 66 | when '!' 67 | tagging_sql_not_equal(field) 68 | else 69 | tagging_sql_equal(field) 70 | end 71 | end 72 | 73 | def tagging_sql_none 74 | "(#{Issue.table_name}.id NOT IN (select taggable_id from taggings where taggable_type='Issue'))" 75 | end 76 | 77 | def tagging_sql_any 78 | "(#{Issue.table_name}.id IN (select taggable_id from taggings where taggable_type='Issue'))" 79 | end 80 | 81 | def tagging_sql_not_equal(field) 82 | sql = tagging_sql_equal(field) 83 | "(not #{sql})" 84 | end 85 | 86 | def tagging_sql_equal(field) 87 | selected_values = values_for(field).map { |tag| tag_with_sharp(tag).downcase } 88 | sql = selected_values.collect { |val| "'#{ActiveRecord::Base.connection.quote_string(val.gsub('\'', ''))}'" }.join(',') 89 | "(#{Issue.table_name}.id in (select taggable_id from taggings join tags on tags.id = taggings.tag_id where taggable_type='Issue' and lower(tags.name) in (#{sql})))" 90 | end 91 | 92 | def tag_without_sharp(tag) 93 | tag.to_s.gsub /^\s*#/, '' 94 | end 95 | 96 | def tag_with_sharp(tag) 97 | '#' + tag_without_sharp(tag) 98 | end 99 | end 100 | 101 | unless Query.included_modules.include? RedmineTagging::Patches::QueryPatch 102 | Query.send :include, RedmineTagging::Patches::QueryPatch 103 | end 104 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | require 'redmine' 2 | 3 | Redmine::Plugin.register :redmine_tagging do 4 | name 'Redmine Tagging Plugin' 5 | author 'Restream' 6 | description 'This plugin adds tagging features to Redmine.' 7 | version '0.1.6' 8 | 9 | settings default: { 10 | dynamic_font_size: '1', 11 | sidebar_tagcloud: '1', 12 | wiki_pages_inline: '0', 13 | issues_inline: '0' 14 | }, 15 | partial: 'tagging/settings' 16 | 17 | Redmine::WikiFormatting::Macros.register do 18 | desc 'Wiki/Issues tagcloud' 19 | macro :tagcloud do |obj, args| 20 | args, options = extract_macro_options(args, :parent) 21 | 22 | return if params[:controller] == 'mailer' 23 | 24 | if obj 25 | if obj.is_a? WikiContent 26 | project = obj.page.wiki.project 27 | else 28 | project = obj.project 29 | end 30 | else 31 | project = Project.visible.where(identifier: params[:project_id]).first 32 | end 33 | 34 | if project # this may be an attempt to render tag cloud when deleting wiki page 35 | if [WikiContent, WikiContent::Version, NilClass].include?(obj.class) 36 | render partial: 'tagging/tagcloud_search', project: project 37 | elsif [Journal, Issue].include?(obj.class) 38 | render partial: 'tagging/tagcloud', project: project 39 | end 40 | end 41 | end 42 | end 43 | 44 | Redmine::WikiFormatting::Macros.register do 45 | desc 'Wiki/Issues tag' 46 | macro :tag do |obj, args| 47 | if obj.is_a?(WikiContent) && Setting.plugin_redmine_tagging[:wiki_pages_inline] == '1' 48 | inline = true 49 | elsif obj.is_a?(Issue) && Setting.plugin_redmine_tagging[:issues_inline] == '1' 50 | inline = true 51 | else 52 | inline = false 53 | end 54 | 55 | if inline 56 | args, options = extract_macro_options(args, :parent) 57 | tags = args.collect{|a| a.split(/[#"'\s,]+/)}.flatten.select{|tag| !tag.blank?}.collect{|tag| "##{tag}" }.uniq 58 | tags.sort_by! { |t| t.downcase } 59 | 60 | if obj.is_a? WikiContent 61 | obj = obj.page 62 | project = obj.wiki.project 63 | else 64 | project = obj.project 65 | end 66 | 67 | context = TaggingPlugin::ContextHelper.context_for(project) 68 | tags_present = obj.tag_list_on(context).sort_by { |t| t.downcase }.join(',') 69 | new_tags = tags.join(',') 70 | if tags_present != new_tags 71 | obj.tags_to_update = new_tags 72 | obj.save 73 | end 74 | 75 | taglinks = tags.collect do |tag| 76 | search_url = { 77 | controller: 'search', 78 | action: 'index', 79 | id: project, 80 | q: "\"#{tag}\"" 81 | } 82 | 83 | search_url.merge!(obj.is_a?(WikiPage) ? { wiki_pages: true, issues: false } : { wiki_pages: false, issues: true }) 84 | link_to(tag, search_url) 85 | end.join(' ') 86 | 87 | raw("
#{taglinks}
") 88 | else 89 | '' 90 | end 91 | end 92 | end 93 | end 94 | 95 | ActionDispatch::Callbacks.to_prepare do 96 | require 'tagging_plugin/tagging_patches' 97 | require 'tagging_plugin/api_template_handler_patch' 98 | require 'redmine_tagging' 99 | require File.expand_path('../app/helpers/tagging_helper', __FILE__) 100 | ActionView::Base.send :include, TaggingHelper 101 | end 102 | 103 | require_dependency 'tagging_plugin/tagging_hooks' 104 | -------------------------------------------------------------------------------- /app/views/issues/show_with_tags.api.rsb: -------------------------------------------------------------------------------- 1 | api.issue do 2 | api.id @issue.id 3 | api.project(:id => @issue.project_id, :name => @issue.project.name) unless @issue.project.nil? 4 | api.tracker(:id => @issue.tracker_id, :name => @issue.tracker.name) unless @issue.tracker.nil? 5 | api.status(:id => @issue.status_id, :name => @issue.status.name) unless @issue.status.nil? 6 | api.priority(:id => @issue.priority_id, :name => @issue.priority.name) unless @issue.priority.nil? 7 | api.author(:id => @issue.author_id, :name => @issue.author.name) unless @issue.author.nil? 8 | api.assigned_to(:id => @issue.assigned_to_id, :name => @issue.assigned_to.name) unless @issue.assigned_to.nil? 9 | api.category(:id => @issue.category_id, :name => @issue.category.name) unless @issue.category.nil? 10 | api.fixed_version(:id => @issue.fixed_version_id, :name => @issue.fixed_version.name) unless @issue.fixed_version.nil? 11 | api.parent(:id => @issue.parent_id) unless @issue.parent.nil? 12 | 13 | api.subject @issue.subject 14 | api.description @issue.description 15 | api.start_date @issue.start_date 16 | api.due_date @issue.due_date 17 | api.done_ratio @issue.done_ratio 18 | api.estimated_hours @issue.estimated_hours 19 | api.spent_hours(@issue.spent_hours) if User.current.allowed_to?(:view_time_entries, @project) 20 | 21 | render_api_custom_values @issue.custom_field_values, api 22 | 23 | api.created_on @issue.created_on 24 | api.updated_on @issue.updated_on 25 | api.closed_on @issue.closed_on 26 | 27 | api.array :tags do 28 | @issue.issue_tags.each do |issue_tag| 29 | api.tag(:id => issue_tag.tag[1..-1]) 30 | end 31 | end 32 | 33 | render_api_issue_children(@issue, api) if include_in_api_response?('children') 34 | 35 | api.array :attachments do 36 | @issue.attachments.each do |attachment| 37 | render_api_attachment(attachment, api) 38 | end 39 | end if include_in_api_response?('attachments') 40 | 41 | api.array :relations do 42 | @relations.each do |relation| 43 | api.relation(:id => relation.id, :issue_id => relation.issue_from_id, :issue_to_id => relation.issue_to_id, :relation_type => relation.relation_type, :delay => relation.delay) 44 | end 45 | end if include_in_api_response?('relations') && @relations.present? 46 | 47 | api.array :changesets do 48 | @issue.changesets.each do |changeset| 49 | api.changeset :revision => changeset.revision do 50 | api.user(:id => changeset.user_id, :name => changeset.user.name) unless changeset.user.nil? 51 | api.comments changeset.comments 52 | api.committed_on changeset.committed_on 53 | end 54 | end 55 | end if include_in_api_response?('changesets') && User.current.allowed_to?(:view_changesets, @project) 56 | 57 | api.array :journals do 58 | @journals.each do |journal| 59 | api.journal :id => journal.id do 60 | api.user(:id => journal.user_id, :name => journal.user.name) unless journal.user.nil? 61 | api.notes journal.notes 62 | api.created_on journal.created_on 63 | api.array :details do 64 | journal.details.each do |detail| 65 | api.detail :property => detail.property, :name => detail.prop_key do 66 | api.old_value detail.old_value 67 | api.new_value detail.value 68 | end 69 | end 70 | end 71 | end 72 | end 73 | end if include_in_api_response?('journals') 74 | 75 | api.array :watchers do 76 | @issue.watcher_users.each do |user| 77 | api.user :id => user.id, :name => user.name 78 | end 79 | end if include_in_api_response?('watchers') && User.current.allowed_to?(:view_issue_watchers, @issue.project) 80 | end 81 | -------------------------------------------------------------------------------- /lib/redmine_tagging/patches/issue_patch.rb: -------------------------------------------------------------------------------- 1 | require_dependency 'issue' 2 | 3 | module RedmineTagging::Patches::IssuePatch 4 | extend ActiveSupport::Concern 5 | 6 | included do 7 | unloadable 8 | 9 | attr_writer :tags_to_update 10 | 11 | before_save :update_tags 12 | acts_as_taggable 13 | 14 | after_save :cleanup_tags 15 | 16 | has_many :issue_tags 17 | 18 | alias_method_chain :create_journal, :tags 19 | alias_method_chain :init_journal, :tags 20 | alias_method_chain :copy_from, :tags 21 | 22 | if Redmine::VERSION::MAJOR < 3 23 | searchable_options[:columns] << "#{IssueTag.table_name}.tag" 24 | searchable_options[:include] ||= [] 25 | searchable_options[:include] << :issue_tags 26 | else 27 | searchable_options[:columns] << "#{IssueTag.table_name}.tag" 28 | 29 | original_scope = searchable_options[:scope] || self 30 | 31 | searchable_options[:scope] = ->(*args) { 32 | (original_scope.respond_to?(:call) ? 33 | original_scope.call(*args) : 34 | original_scope 35 | ).includes :issue_tags 36 | } 37 | end 38 | end 39 | 40 | def create_journal_with_tags 41 | if @current_journal 42 | tag_context = TaggingPlugin::ContextHelper.context_for(project) 43 | before = @issue_tags_before_change 44 | after = TaggingPlugin::TagsHelper.to_string(tag_list_on(tag_context)) 45 | unless before == after 46 | @current_journal.details << JournalDetail.new( 47 | property: 'attr', 48 | prop_key: 'tags', 49 | old_value: before, 50 | value: after) 51 | end 52 | end 53 | create_journal_without_tags 54 | end 55 | 56 | def init_journal_with_tags(user, notes = "") 57 | unless project.nil? 58 | tag_context = TaggingPlugin::ContextHelper.context_for(project) 59 | @issue_tags_before_change = TaggingPlugin::TagsHelper.to_string(tag_list_on(tag_context)) 60 | end 61 | init_journal_without_tags(user, notes) 62 | end 63 | 64 | def tags 65 | issue_tags.map(&:to_s).join(' ') 66 | end 67 | 68 | def copy_from_with_tags(arg, options = {}) 69 | copy_from_without_tags(arg, options) 70 | issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg) 71 | self.tag_list_ctx = issue.tag_list_ctx 72 | self 73 | end 74 | 75 | def tag_list_ctx 76 | tag_context = TaggingPlugin::ContextHelper.context_for(project) 77 | tag_list_on(tag_context) 78 | end 79 | 80 | def tag_list_ctx=(new_list) 81 | tag_context = TaggingPlugin::ContextHelper.context_for(project) 82 | set_tag_list_on(tag_context, new_list) 83 | end 84 | 85 | private 86 | 87 | def update_tags 88 | project_context = TaggingPlugin::ContextHelper.context_for(project) 89 | 90 | # Fix context if project changed 91 | if project_id_changed? && !new_record? 92 | @new_project_id = project_id 93 | 94 | taggings.update_all(context: project_context) 95 | end 96 | 97 | if @tags_to_update 98 | set_tag_list_on(project_context, @tags_to_update) 99 | end 100 | 101 | true 102 | end 103 | 104 | def cleanup_tags 105 | if @new_project_id 106 | context = TaggingPlugin::ContextHelper.context_for(project) 107 | ActsAsTaggableOn::Tagging.where( 108 | 'context != ? AND taggable_id = ? AND taggable_type = ?', context, id, 'Issue' 109 | ).delete_all 110 | end 111 | true 112 | end 113 | end 114 | 115 | unless Issue.included_modules.include? RedmineTagging::Patches::IssuePatch 116 | Issue.send :include, RedmineTagging::Patches::IssuePatch 117 | end 118 | -------------------------------------------------------------------------------- /test/functional/issues_controller_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../test_helper' 2 | 3 | class IssuesControllerTest < ActionController::TestCase 4 | fixtures :projects, 5 | :users, 6 | :roles, 7 | :members, 8 | :member_roles, 9 | :trackers, 10 | :projects_trackers, 11 | :enabled_modules, 12 | :issue_statuses, 13 | :issues, 14 | :enumerations, 15 | :custom_fields, 16 | :custom_values, 17 | :custom_fields_trackers 18 | 19 | def setup 20 | @request.session[:user_id] = 2 21 | 22 | @some_tags = '#1, #2, #3,#4,#5' 23 | 24 | @project_with_tags = Project.find(1) 25 | @another_project = Project.find(2) 26 | 27 | @issue_with_tags = Issue.find(1) 28 | @issue_with_tags.tags_to_update = @some_tags 29 | @issue_with_tags.save! 30 | 31 | @issue_without_tags = Issue.find(2) 32 | 33 | @issues_to_bulk_edit = [@issue_with_tags, @issue_without_tags] 34 | end 35 | 36 | def test_can_index_issues_when_custom_fields_available 37 | IssueCustomField.create!( 38 | name: 'cfield', 39 | default_value: 'ok', 40 | is_filter: true, 41 | field_format: 'string', 42 | is_for_all: true 43 | ) 44 | 45 | get :index 46 | assert_response :success 47 | end 48 | 49 | def test_can_index_api 50 | get :index, format: 'json' 51 | assert_response :success 52 | end 53 | 54 | def test_can_show_api 55 | get :show, id: @issue_with_tags.id, format: 'xml' 56 | assert_response :success 57 | end 58 | 59 | def test_index_api_rsb_should_not_raise_in_project_issues 60 | get :index, project_id: @project_with_tags.id 61 | end 62 | 63 | def test_can_show_api_for_some_project 64 | get :index, format: 'json', project_id: @project_with_tags.id 65 | assert_response :success 66 | end 67 | 68 | def test_bulk_update_with_project_change_should_success 69 | tag_input = '"1 2 \\\\ 3 cool/tag 777 ' 70 | 71 | put :bulk_update, ids: @issues_to_bulk_edit.map(&:id), 72 | issue: { project_id: @another_project.id, tags: tag_input }, 73 | append_tags: 'on' 74 | 75 | assert_response :redirect 76 | 77 | @issue_with_tags.reload 78 | assert_equal @another_project.id, @issue_with_tags.project_id, 'Project should be changed' 79 | 80 | another_project_context = TaggingPlugin::ContextHelper.context_for(@another_project) 81 | tags = @issue_with_tags.tags_on(another_project_context) 82 | assert_equal %w(#1 #2 #3 #4 #5 #777 #cool/tag), tags.map(&:name).sort 83 | end 84 | 85 | def test_bulk_update_without_project_change_should_success 86 | tag_input = '"1 2 \\\\ 3 cool/tag 777 ' 87 | put :bulk_update, { ids: @issues_to_bulk_edit.map(&:id), issue: { tags: tag_input }, 'append_tags' => 'on' } 88 | assert_response :redirect 89 | 90 | @issue_with_tags.reload 91 | 92 | project_context = TaggingPlugin::ContextHelper.context_for(@project_with_tags) 93 | tags = @issue_with_tags.tags_on(project_context) 94 | assert_equal %w(#1 #2 #3 #4 #5 #777 #cool/tag), tags.map(&:name).sort 95 | end 96 | 97 | def test_bulk_update_without_tags_field_should_not_drop_tags 98 | put :bulk_update, { :ids => @issues_to_bulk_edit.map(&:id), issue: { status_id: 1 } } 99 | assert_response :redirect 100 | 101 | @issue_with_tags.reload 102 | 103 | project_context = TaggingPlugin::ContextHelper.context_for(@project_with_tags) 104 | tags = @issue_with_tags.tags_on(project_context) 105 | assert_equal %w(#1 #2 #3 #4 #5), tags.map(&:name).sort 106 | end 107 | 108 | def test_update_without_tags_field_should_not_drop_tags 109 | put :update, id: @issue_with_tags.id, issue: { status_id: 1 } 110 | assert_response :redirect 111 | 112 | @issue_with_tags.reload 113 | 114 | project_context = TaggingPlugin::ContextHelper.context_for(@project_with_tags) 115 | tags = @issue_with_tags.tags_on(project_context) 116 | assert_equal %w(#1 #2 #3 #4 #5), tags.map(&:name).sort 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redmine Tagging Plugin 2 | 3 | [![Build Status](https://travis-ci.org/Restream/redmine_tagging.svg?branch=master)](https://travis-ci.org/Restream/redmine_tagging) 4 | [![Code Climate](https://codeclimate.com/github/Restream/redmine_tagging/badges/gpa.svg)](https://codeclimate.com/github/Restream/redmine_tagging) 5 | 6 | This plugin adds useful tagging features to Redmine: 7 | 8 | * Tag cloud in the sidebar 9 | * Tag suggestion and autocomplete 10 | * Redmine search integration (possibility to search for #tag to find wikis/issues) 11 | * Issue filters based on tags 12 | * Batch assignment and detachment of tags 13 | * Logging tag changes 14 | 15 | The initial authors of the plugin are [Emiliano Heyns](mailto:emiliano.heyns@gmail.com) and [Vladimir Kiselev](https://github.com/nettsundere). 16 | 17 | ## Installation 18 | 19 | *These installation instructions are based on Redmine 2.6.0. For instructions for previous versions, see [Redmine wiki](http://www.redmine.org/projects/redmine/wiki/Plugins).* 20 | 21 | 1. To install the plugin 22 | * Download the .ZIP archive, extract files and copy the plugin directory into #{REDMINE_ROOT}/plugins. 23 | 24 | Or 25 | 26 | * Change you current directory to your Redmine root directory: 27 | 28 | cd {REDMINE_ROOT} 29 | 30 | Copy the plugin from GitHub using the following commands: 31 | 32 | git clone https://github.com/Restream/redmine_tagging.git plugins/redmine_tagging 33 | 34 | 2. Update the Gemfile.lock file by running the following commands: 35 | 36 | bundle install 37 | 38 | 3. Run the migrations generator to create tables for tags and associations: 39 | 40 | bundle exec rake acts_as_taggable_on_engine:install:migrations RAILS_ENV=production 41 | 42 | 4. Run the following commands to upgrade your database (make a database backup before): 43 | 44 | bundle exec rake db:migrate RAILS_ENV=production 45 | bundle exec rake redmine:plugins:migrate RAILS_ENV=production 46 | 47 | 5. Restart Redmine. 48 | 49 | Now you should be able to see the plugin in **Administration > Plugins**. 50 | 51 | ### For MySql users 52 | You can circumvent at any time the problem of special characters [issue 623](https://github.com/mbleigh/acts-as-taggable-on/issues/623) by setting in an initializer file: 53 | 54 | ```ruby 55 | ActsAsTaggableOn.force_binary_collation = true 56 | ``` 57 | 58 | Or by running this rake task: 59 | 60 | ```shell 61 | bundle exec rake acts_as_taggable_on_engine:tag_names:collate_bin 62 | ``` 63 | 64 | See the [configuration](https://github.com/mbleigh/acts-as-taggable-on#configuration) section in acts-as-taggable-on gem for more details. 65 | 66 | ## Usage 67 | 68 | The plugin enables you to add tags to wiki and issue pages using either the **Tags** field or inline tags. To switch between these two modes, you should enable or disable the corresponding check boxes in the plugin settings. 69 | 70 | To switch to inline tag editing, go to **Administration > Plugins**, click **Configure**, select the corresponding check boxes and click **Apply**. 71 | ![inline editing](doc/tagging_1.PNG) 72 | 73 | After changing the settings, run the following command: 74 | 75 | bundle exec rake redmine:tagging:reconfigure RAILS_ENV=production 76 | 77 | Failure to do so will result in loss of data (tags) when switching to another tagging mode. 78 | 79 | Inline tags can be added using the following syntax: 80 | 81 | {{tag(tag_name)}} 82 | 83 | ![inline tags](doc/tagging_2.PNG) 84 | 85 | Note that inline tags are saved when the object body is rendered. That's why if you want to remove all tags from an object, you must first add `{{tag}}` to its body to actually clear the tags. After that you can remove `{{tag}}` from the object body. 86 | 87 | Adding `{{tagcloud}}` will generate a tag cloud, which will be displayed in the sidebar. 88 | ![tag cloud](doc/tagging_3.PNG) 89 | 90 | The most often used tags are displayed in a larger font. 91 | 92 | By default, inline tag editing is disabled. In this mode, you can type tags into the **Tags** field to add them to an issue or wiki page. You can use spaces or commas as tag separators. 93 | ![tags field](doc/tagging_4.PNG) 94 | 95 | You can click the **Project tags** link below the **Tags** field to view all the project tags and select the required ones. 96 | ![project tags](doc/tagging_5.PNG) 97 | 98 | The autocomplete feature will suggest the available tags as you start typing the tag name in the **Tags** field. 99 | ![tags autocomplete](doc/tagging_6.PNG) 100 | 101 | All tags added to project issues are displayed on the **Tags** tab of the project settings. To detach a tag from an issue, click the **Detach** link. 102 | ![tags tab](doc/tagging_7.PNG) 103 | 104 | Tags can be used to search for issues and create issue filters: 105 | ![tag search](doc/tagging_8.PNG) 106 | ![tag filters](doc/tagging_9.PNG) 107 | 108 | ## Maintainers 109 | 110 | Danil Tashkinov, [github.com/nodecarter](https://github.com/nodecarter) 111 | 112 | ## Thanks to 113 | 114 | * https://github.com/jkraemer 115 | -------------------------------------------------------------------------------- /test/integration/tagging_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../test_helper' 2 | 3 | class TaggingTest < ActionDispatch::IntegrationTest 4 | fixtures :projects, 5 | :users, 6 | :roles, 7 | :members, 8 | :member_roles, 9 | :trackers, 10 | :projects_trackers, 11 | :enabled_modules, 12 | :issue_statuses, 13 | :issues, 14 | :enumerations, 15 | :custom_fields, 16 | :custom_values, 17 | :custom_fields_trackers 18 | 19 | def setup 20 | Mailer.stubs(:deliver_mail).returns(true) 21 | 22 | log_user('admin', 'admin') 23 | User.any_instance.stubs(:allowed_to?).returns(true) 24 | 25 | @some_tags = '#1, #2, #3,#4,#5' 26 | 27 | @project_with_tags = Project.find(1) 28 | @another_project = Project.find(2) 29 | 30 | @issue_with_tags = Issue.find(1) 31 | @issue_with_tags.tags_to_update = @some_tags 32 | @issue_with_tags.save! 33 | 34 | @issue_without_tags = Issue.find(2) 35 | 36 | @wiki_page_with_tags = setup_wiki_page_with_tags(@some_tags) 37 | @wiki_page_with_tags_content = @wiki_page_with_tags.content 38 | 39 | @project_with_wiki_tags = @wiki_page_with_tags.project 40 | 41 | @another_project_context = TaggingPlugin::ContextHelper.context_for(@another_project) 42 | @project_with_tags_context = TaggingPlugin::ContextHelper.context_for(@project_with_tags) 43 | 44 | @project_with_wiki_tags_context = TaggingPlugin::ContextHelper.context_for(@project_with_wiki_tags) 45 | end 46 | 47 | def test_should_create_issue_tags_from_input 48 | Setting.plugin_redmine_tagging[:issues_inline] = '0' 49 | 50 | @new_issue_attrs = { 51 | 'project_id' => @project_with_tags.id, 52 | 'priority_id' => @issue_with_tags.priority_id, 53 | 'subject' => 'new_issue', 54 | 'tags' => '10 11 12' 55 | } 56 | 57 | get_via_redirect(new_project_issue_path(@project_with_tags)) 58 | assert_response :success 59 | post_via_redirect(issues_path, issue: @new_issue_attrs) 60 | assert_response :success 61 | 62 | new_issue = Issue.find_by_subject('new_issue') 63 | assert_equal 3, new_issue.taggings.size 64 | assert_equal [@project_with_tags_context], new_issue.taggings.map(&:context).uniq 65 | end 66 | 67 | def test_should_update_issue_tags_from_input 68 | Setting.plugin_redmine_tagging[:issues_inline] = '0' 69 | 70 | issue_attrs = @issue_with_tags.attributes 71 | 72 | issue_attrs['project_id'] = @another_project.id 73 | issue_attrs['tracker'] = @another_project.trackers.first 74 | issue_attrs['tags'] = '10 11 12' 75 | 76 | get_via_redirect(edit_issue_path(@issue_with_tags)) 77 | assert_response :success 78 | put_via_redirect(issue_path(@issue_with_tags), issue: issue_attrs) 79 | assert_response :success 80 | get_via_redirect(issue_path(@issue_with_tags)) 81 | assert_response :success 82 | 83 | @issue_with_tags.reload 84 | assert_equal 3, @issue_with_tags.taggings.size 85 | assert_equal [@another_project_context], @issue_with_tags.taggings.map(&:context).uniq 86 | end 87 | 88 | def test_should_create_inline_issue_tags 89 | Setting.plugin_redmine_tagging[:issues_inline] = '1' 90 | 91 | @new_issue_attrs = { 92 | 'project_id' => @project_with_tags.id, 93 | 'priority_id' => @issue_with_tags.priority_id, 94 | 'subject' => 'new_issue', 95 | 'description' => '{{tag(10 11 12)}}' 96 | } 97 | 98 | get_via_redirect(new_project_issue_path(@project_with_tags)) 99 | assert_response :success 100 | post_via_redirect(issues_path, issue: @new_issue_attrs) 101 | assert_response :success 102 | 103 | new_issue = Issue.find_by_subject('new_issue') 104 | assert_equal 3, new_issue.taggings.size 105 | assert_equal [@project_with_tags_context], new_issue.taggings.map(&:context).uniq 106 | end 107 | 108 | def test_should_update_inline_issue_tags 109 | Setting.plugin_redmine_tagging[:issues_inline] = '1' 110 | 111 | issue_attrs = @issue_with_tags.attributes 112 | 113 | issue_attrs['project_id'] = @another_project.id 114 | issue_attrs['tracker'] = @another_project.trackers.first 115 | issue_attrs['description'] = '{{tag(6)}} {{tag(7 8)}}' 116 | 117 | get_via_redirect(edit_issue_path(@issue_with_tags)) 118 | assert_response :success 119 | put_via_redirect(issue_path(@issue_with_tags), issue: issue_attrs) 120 | assert_response :success 121 | get_via_redirect(issue_path(@issue_with_tags)) 122 | assert_response :success 123 | 124 | @issue_with_tags.reload 125 | assert_equal 2, @issue_with_tags.taggings.count 126 | assert_equal [@another_project_context], @issue_with_tags.taggings.map(&:context).uniq 127 | end 128 | 129 | def test_should_generate_wiki_tagcloud 130 | edit_page_path = edit_wiki_cpath(@project_with_wiki_tags, 'newpage') 131 | page_path = wiki_cpath(@project_with_wiki_tags, 'newpage') 132 | 133 | page_content = @wiki_page_with_tags_content.attributes.merge( 134 | 'text' => '{{tag(11)}} {{tag(14 15)}} {{tagcloud}}' 135 | ) 136 | 137 | page_attrs = @wiki_page_with_tags.attributes 138 | 139 | get_via_redirect(edit_page_path) 140 | assert_response :success 141 | put_via_redirect(page_path, wiki_page: page_attrs, content: page_content) 142 | assert_response :success 143 | get_via_redirect(page_path) 144 | assert_response :success 145 | end 146 | 147 | def test_should_create_wiki_page_tags_from_input 148 | Setting.plugin_redmine_tagging[:wiki_pages_inline] = '0' 149 | 150 | edit_page_path = edit_wiki_cpath(@project_with_wiki_tags, 'newpage') 151 | page_path = wiki_cpath(@project_with_wiki_tags, 'newpage') 152 | 153 | page_content = @wiki_page_with_tags_content.attributes 154 | page_attrs = @wiki_page_with_tags.attributes 155 | page_attrs['title'] = 'Newpage' 156 | page_attrs['tags'] = '10 11 12' 157 | 158 | get_via_redirect(edit_page_path) 159 | assert_response :success 160 | put_via_redirect(page_path, wiki_page: page_attrs, content: page_content) 161 | assert_response :success 162 | get_via_redirect(page_path) 163 | assert_response :success 164 | 165 | new_page = WikiPage.find_by_title('Newpage') 166 | assert_equal 3, new_page.taggings.size 167 | assert_equal [@project_with_wiki_tags_context], new_page.taggings.map(&:context).uniq 168 | end 169 | 170 | def test_should_update_wiki_page_tags_from_input 171 | Setting.plugin_redmine_tagging[:wiki_pages_inline] = '0' 172 | 173 | edit_page_path = edit_wiki_cpath(@project_with_wiki_tags, @wiki_page_with_tags.title) 174 | page_path = wiki_cpath(@project_with_wiki_tags, @wiki_page_with_tags.title) 175 | page_content = @wiki_page_with_tags_content.attributes 176 | page_attrs = @wiki_page_with_tags.attributes 177 | page_attrs['tags'] = '10 11 12' 178 | 179 | get_via_redirect(edit_page_path) 180 | assert_response :success 181 | put_via_redirect(page_path, wiki_page: page_attrs, content: page_content) 182 | assert_response :success 183 | get_via_redirect(page_path) 184 | assert_response :success 185 | 186 | @wiki_page_with_tags.reload 187 | assert_equal 3, @wiki_page_with_tags.taggings.size 188 | assert_equal [@project_with_wiki_tags_context], @wiki_page_with_tags.taggings.map(&:context).uniq 189 | end 190 | 191 | def test_should_create_inline_wiki_page_tags 192 | Setting.plugin_redmine_tagging[:wiki_pages_inline] = '1' 193 | 194 | edit_page_path = edit_wiki_cpath(@project_with_wiki_tags, 'newpage') 195 | page_path = wiki_cpath(@project_with_wiki_tags, 'newpage') 196 | 197 | page_content = @wiki_page_with_tags_content.attributes.merge( 198 | 'text' => '{{tag(11)}} {{tag(14 15)}}' 199 | ) 200 | 201 | page_attrs = @wiki_page_with_tags.attributes.merge( 202 | 'title' => 'Newpage' 203 | ) 204 | 205 | get_via_redirect(edit_page_path) 206 | assert_response :success 207 | put_via_redirect(page_path, wiki_page: page_attrs, content: page_content) 208 | assert_response :success 209 | get_via_redirect(page_path) 210 | assert_response :success 211 | 212 | new_page = WikiPage.find_by_title('Newpage') 213 | assert_equal 2, new_page.taggings.size 214 | assert_equal [@project_with_wiki_tags_context], new_page.taggings.map(&:context).uniq 215 | end 216 | 217 | def test_should_update_inline_wiki_page_tags 218 | Setting.plugin_redmine_tagging[:wiki_pages_inline] = '1' 219 | 220 | edit_page_path = edit_wiki_cpath(@project_with_wiki_tags, @wiki_page_with_tags.title) 221 | page_path = wiki_cpath(@project_with_wiki_tags, @wiki_page_with_tags.title) 222 | 223 | page_content = @wiki_page_with_tags_content.attributes.merge( 224 | 'text' => '{{tag(11)}} {{tag(14 15)}}' 225 | ) 226 | 227 | page_attrs = @wiki_page_with_tags.attributes 228 | 229 | get_via_redirect(edit_page_path) 230 | assert_response :success 231 | put_via_redirect(page_path, wiki_page: page_attrs, content: page_content) 232 | assert_response :success 233 | get_via_redirect(page_path) 234 | assert_response :success 235 | 236 | @wiki_page_with_tags.reload 237 | assert_equal 2, @wiki_page_with_tags.taggings.size 238 | assert_equal [@project_with_wiki_tags_context], @wiki_page_with_tags.taggings.map(&:context).uniq 239 | end 240 | 241 | def wiki_cpath(project, page) 242 | project_wiki_page_path(project, page) 243 | rescue NoMethodError 244 | project_wiki_path(project, page) 245 | end 246 | 247 | def edit_wiki_cpath(project, page) 248 | edit_project_wiki_page_path(project, page) 249 | rescue NoMethodError 250 | edit_project_wiki_path(project, page) 251 | end 252 | end 253 | -------------------------------------------------------------------------------- /lib/tagging_plugin/tagging_hooks.rb: -------------------------------------------------------------------------------- 1 | module TaggingPlugin 2 | module Hooks 3 | class LayoutHook < Redmine::Hook::ViewListener 4 | 5 | def view_issues_sidebar_planning_bottom(context = {}) 6 | sidebar_tagcloud? ? render_partial_to_string(context, 'tagging/tagcloud') : '' 7 | end 8 | 9 | def view_wiki_sidebar_bottom(context = {}) 10 | sidebar_tagcloud? ? render_partial_to_string(context, 'tagging/tagcloud_search') : '' 11 | end 12 | 13 | def view_layouts_base_html_head(context = {}) 14 | tagging_stylesheet = stylesheet_link_tag 'tagging', plugin: 'redmine_tagging' 15 | 16 | unless ((sidebar_tagcloud? && 17 | context[:controller].is_a?(WikiController)) || 18 | (context[:controller].is_a?(IssuesController) && 19 | context[:controller].action_name == 'bulk_edit')) 20 | return tagging_stylesheet 21 | end 22 | 23 | sidebar_tags = if sidebar_tagcloud? 24 | tag_cloud = render_partial_to_string(context, 'tagging/tagcloud_search') 25 | "$('#sidebar').append(\"#{escape_javascript(tag_cloud)}\")" 26 | else 27 | '' 28 | end 29 | 30 | <<-TAGS 31 | #{ tagging_stylesheet } 32 | #{ javascript_include_tag 'toggle_tags', plugin: 'redmine_tagging' } 33 | 41 | TAGS 42 | end 43 | 44 | def view_issues_show_details_bottom(context = {}) 45 | return '' if issues_inline_tags? 46 | 47 | issue = context[:issue] 48 | tag_context = ContextHelper.context_for(issue.project) 49 | tags = issue.tag_list_on(tag_context).sort_by { |t| t.downcase } 50 | 51 | render_partial_to_string(context, 'tagging/taglinks', tags: tags) 52 | end 53 | 54 | def view_issues_form_details_bottom(context={}) 55 | return '' if issues_inline_tags? 56 | 57 | issue = context[:issue] 58 | result = '' 59 | 60 | if context[:request].params[:issue] # update form 61 | tags = context[:request].params[:issue][:tags] 62 | result += issue_tag_field context[:form], tags 63 | else 64 | tag_context = ContextHelper.context_for(issue.project) 65 | tags = issue.tag_list_on(tag_context) \ 66 | .sort_by { |t| t.downcase } \ 67 | .map { |tag| tag.gsub(/^#/, '') } \ 68 | .join(' ') 69 | result += issue_tag_field context[:form], tags 70 | end 71 | 72 | unless context[:request].xhr? 73 | result += javascript_include_tag 'tag', plugin: 'redmine_tagging' 74 | result += javascript_include_tag 'toggle_tags', plugin: 'redmine_tagging' 75 | end 76 | 77 | result + issue_cloud_javascript(context) 78 | end 79 | 80 | def controller_issues_bulk_edit_before_save(context={}) 81 | return if issues_inline_tags? || !has_tags_in_params?(context[:params]) 82 | 83 | tags = context[:params]['issue']['tags'].to_s 84 | issue = context[:issue] 85 | 86 | if context[:params]['append_tags'] 87 | tag_context = if issue.project_id_changed? 88 | ContextHelper.context_for(Project.find(issue.project_id_was)) 89 | else 90 | ContextHelper.context_for(issue.project) 91 | end 92 | 93 | old_tags = issue.tags_on(tag_context) 94 | 95 | tags += ' ' + TagsHelper.to_string(old_tags.map(&:name)) if old_tags.present? 96 | end 97 | 98 | issue.tags_to_update = TagsHelper.from_string(tags) 99 | end 100 | 101 | def controller_issues_edit_before_save(context={}) 102 | return if issues_inline_tags? 103 | return unless has_tags_in_params?(context[:params]) 104 | 105 | issue = context[:issue] 106 | tags = context[:params]['issue']['tags'].to_s 107 | 108 | tags = TagsHelper.from_string(tags) 109 | issue.tags_to_update = tags 110 | end 111 | 112 | alias_method :controller_issues_new_before_save, :controller_issues_edit_before_save 113 | 114 | # wikis have no view hooks 115 | def view_layouts_base_content(context={}) 116 | return '' if wiki_pages_inline_tags? 117 | 118 | return '' unless context[:controller].is_a?(WikiController) 119 | 120 | request = context[:request] 121 | 122 | return '' unless request.parameters 123 | 124 | project = Project.find_by_identifier(request.parameters['project_id']) 125 | return '' unless project 126 | 127 | page = project.wiki.find_page(request.parameters['id']) 128 | 129 | tag_context = ContextHelper.context_for(project) 130 | tags = '' 131 | 132 | if page && request.parameters['action'] == 'index' 133 | tags = page.tag_list_on(tag_context).sort_by { |t| t.downcase }.map do |tag| 134 | link_to(tag, { 135 | controller: 'search', 136 | action: 'index', 137 | project_id: project, 138 | q: tag_without_sharp(tag), 139 | wiki_pages: true, 140 | issues: true }) 141 | end.join(' ') 142 | 143 | tags = "

#{ l(:field_tags) }:

#{ tags }

" if tags 144 | end 145 | 146 | action = request.parameters['action'] 147 | 148 | if action == 'edit' || (!page && action == 'show') 149 | if page 150 | tags = TagsHelper.to_string(page.tag_list_on(tag_context)) 151 | else 152 | tags = "" 153 | end 154 | 155 | tags = "

" 156 | 157 | ac = ActsAsTaggableOn::Tag.where( 158 | "id in (select tag_id from taggings where taggable_type in ('WikiPage', 'Issue') and context = ?)", 159 | tag_context 160 | ).collect { |tag| tag.name } 161 | 162 | ac = ac.collect { |tag| "'#{escape_javascript(tag.gsub(/^#/, ''))}'" }.join(', ') 163 | 164 | tags += javascript_include_tag 'tag', plugin: 'redmine_tagging' 165 | 166 | tags += <<-generatedscript 167 | 173 | generatedscript 174 | end 175 | 176 | return tags 177 | end 178 | 179 | def view_issues_bulk_edit_details_bottom(context = {}) 180 | <<-HTML 181 |

182 | 183 | #{ text_field_tag 'issue[tags]', '', size: 18 }
184 | 185 | #{ l(:append_tags) }
186 |

187 |

188 | #{ render_partial_to_string(context, 'tagging/issue_tagcloud') } 189 |

190 | HTML 191 | end 192 | 193 | def view_reports_issue_report_split_content_right(context={}) 194 | project_context = ContextHelper.context_for(context[:project]) 195 | tags = ActsAsTaggableOn::Tagging.find_all_by_context(project_context).map(&:tag).uniq 196 | tags_by_status = IssueTag.by_issue_status(context[:project]) 197 | 198 | <<-HTML 199 |

#{ l(:field_tags) }   

200 | #{ render_partial_to_string(context, 'reports/simple_tags', 201 | data: tags_by_status, field_name: 'tag', rows: tags) }
202 | HTML 203 | end 204 | 205 | private 206 | 207 | def has_tags_in_params?(params) 208 | params && params['issue'] && params['issue']['tags'] 209 | end 210 | 211 | def issues_inline_tags? 212 | Setting.plugin_redmine_tagging[:issues_inline] == '1' 213 | end 214 | 215 | def wiki_pages_inline_tags? 216 | Setting.plugin_redmine_tagging[:wiki_pages_inline] == '1' 217 | end 218 | 219 | def sidebar_tagcloud? 220 | Setting.plugin_redmine_tagging[:sidebar_tagcloud] == '1' 221 | end 222 | 223 | def render_partial_to_string(context, partial_name, options = {}) 224 | context[:controller].send :render_to_string, 225 | partial: partial_name, 226 | locals: context.merge(options) 227 | end 228 | 229 | def issue_tag_field(form, tags = '') 230 | '

' + form.text_field(:tags, autocomplete: 'off', value: tags) + '

' 231 | end 232 | 233 | def issue_cloud_javascript(context) 234 | tag_context = ContextHelper.context_for(context[:issue].project) 235 | ac = ActsAsTaggableOn::Tag.where( 236 | "id in (select tag_id from taggings where taggable_type in ('WikiPage', 'Issue') and context = ?)", 237 | tag_context) 238 | ac = ac.map { |tag| "'#{escape_javascript(tag.to_s.gsub(/^\s*#/, ''))}'" }.join(', ') 239 | 240 | cloud = render_partial_to_string(context, 'tagging/issue_tagcloud') 241 | 242 | <<-generatedscript 243 | 254 | generatedscript 255 | end 256 | 257 | end 258 | end 259 | end 260 | -------------------------------------------------------------------------------- /assets/javascripts/tag.js: -------------------------------------------------------------------------------- 1 | /* 2 | @author: remy sharp / http://remysharp.com 3 | @url: http://remysharp.com/2007/12/28/jquery-tag-suggestion/ 4 | @usage: setGlobalTags(['javascript', 'jquery', 'java', 'json']); // applied tags to be used for all implementations 5 | $('input.tags').tagSuggest(options); 6 | 7 | The selector is the element that the user enters their tag list 8 | @params: 9 | matchClass - class applied to the suggestions, defaults to 'tagMatches' 10 | tagContainer - the type of element uses to contain the suggestions, defaults to 'span' 11 | tagWrap - the type of element the suggestions a wrapped in, defaults to 'span' 12 | sort - boolean to force the sorted order of suggestions, defaults to false 13 | url - optional url to get suggestions if setGlobalTags isn't used. Must return array of suggested tags 14 | tags - optional array of tags specific to this instance of element matches 15 | delay - optional sets the delay between keyup and the request - can help throttle ajax requests, defaults to zero delay 16 | separator - optional separator string, defaults to ' ' (Brian J. Cardiff) 17 | @license: Creative Commons License - ShareAlike http://creativecommons.org/licenses/by-sa/3.0/ 18 | @version: 1.4 19 | @changes: fixed filtering to ajax hits 20 | */ 21 | 22 | (function ($) { 23 | var globalTags = []; 24 | 25 | // creates a public function within our private code. 26 | // tags can either be an array of strings OR 27 | // array of objects containing a 'tag' attribute 28 | window.setGlobalTags = function(tags /* array */) { 29 | globalTags = getTags(tags); 30 | }; 31 | 32 | function getTags(tags) { 33 | var tag, i, goodTags = []; 34 | for (i = 0; i < tags.length; i++) { 35 | tag = tags[i]; 36 | if (typeof tags[i] == 'object') { 37 | tag = tags[i].tag; 38 | } 39 | goodTags.push(tag.toLowerCase()); 40 | } 41 | 42 | return goodTags; 43 | } 44 | 45 | $.fn.tagSuggest = function (options) { 46 | var defaults = { 47 | 'matchClass' : 'tagMatches', 48 | 'tagContainer' : 'div', 49 | 'tagWrap' : 'span', 50 | 'sort' : true, 51 | 'tags' : null, 52 | 'url' : null, 53 | 'delay' : 0, 54 | 'separator' : ' ' 55 | }; 56 | 57 | var i, tag, userTags = [], settings = $.extend({}, defaults, options); 58 | 59 | if (settings.tags) { 60 | userTags = getTags(settings.tags); 61 | } else { 62 | userTags = globalTags; 63 | } 64 | 65 | return this.each(function () { 66 | var tagsElm = $(this); 67 | var elm = this; 68 | var matches, fromTab = false; 69 | var suggestionsShow = false; 70 | var workingTags = []; 71 | var currentTag = {"position": 0, tag: ""}; 72 | var tagMatches = document.createElement(settings.tagContainer); 73 | 74 | function showSuggestionsDelayed(el, key) { 75 | if (settings.delay) { 76 | if (elm.timer) clearTimeout(elm.timer); 77 | elm.timer = setTimeout(function () { 78 | showSuggestions(el, key); 79 | }, settings.delay); 80 | } else { 81 | showSuggestions(el, key); 82 | } 83 | } 84 | 85 | function showSuggestions(el, key) { 86 | workingTags = el.value.split(settings.separator); 87 | matches = []; 88 | var i, html = '', chosenTags = {}, tagSelected = false; 89 | 90 | // we're looking to complete the tag on currentTag.position (to start with) 91 | currentTag = { position: currentTags.length-1, tag: '' }; 92 | 93 | for (i = 0; i < currentTags.length && i < workingTags.length; i++) { 94 | if (!tagSelected && 95 | currentTags[i].toLowerCase() != workingTags[i].toLowerCase()) { 96 | currentTag = { position: i, tag: workingTags[i].toLowerCase() }; 97 | tagSelected = true; 98 | } 99 | // lookup for filtering out chosen tags 100 | chosenTags[currentTags[i].toLowerCase()] = true; 101 | } 102 | 103 | if (currentTag.tag) { 104 | // collect potential tags 105 | if (settings.url) { 106 | $.ajax({ 107 | 'url' : settings.url, 108 | 'dataType' : 'json', 109 | 'data' : { 'tag' : currentTag.tag }, 110 | 'async' : false, // wait until this is ajax hit is complete before continue 111 | 'success' : function (m) { 112 | matches = m; 113 | } 114 | }); 115 | } else { 116 | for (i = 0; i < userTags.length; i++) { 117 | if (userTags[i].indexOf(currentTag.tag) === 0) { 118 | matches.push(userTags[i]); 119 | } 120 | } 121 | } 122 | 123 | matches = $.grep(matches, function (v, i) { 124 | return !chosenTags[v.toLowerCase()]; 125 | }); 126 | 127 | if (settings.sort) { 128 | matches = matches.sort(); 129 | } 130 | 131 | for (i = 0; i < matches.length; i++) { 132 | html += '<' + settings.tagWrap + ' class="_tag_suggestion">' + matches[i] + ' '; 133 | } 134 | 135 | tagMatches.html(html); 136 | suggestionsShow = !!(matches.length); 137 | } else { 138 | hideSuggestions(); 139 | } 140 | } 141 | 142 | function hideSuggestions() { 143 | tagMatches.empty(); 144 | matches = []; 145 | suggestionsShow = false; 146 | } 147 | 148 | function setSelection() { 149 | var v = tagsElm.val(); 150 | 151 | // tweak for hintted elements 152 | // http://remysharp.com/2007/01/25/jquery-tutorial-text-box-hints/ 153 | if (v == tagsElm.attr('title') && tagsElm.is('.hint')) v = ''; 154 | 155 | currentTags = v.split(settings.separator); 156 | hideSuggestions(); 157 | } 158 | 159 | function chooseTag(tag) { 160 | var i, index; 161 | for (i = 0; i < currentTags.length; i++) { 162 | if (currentTags[i].toLowerCase() != workingTags[i].toLowerCase()) { 163 | index = i; 164 | break; 165 | } 166 | } 167 | 168 | if (index == workingTags.length - 1) tag = tag + settings.separator; 169 | 170 | workingTags[i] = tag; 171 | 172 | tagsElm.val(workingTags.join(settings.separator)); 173 | tagsElm.blur().focus(); 174 | tagsElm.change(); 175 | setSelection(); 176 | } 177 | 178 | function handleKeys(ev) { 179 | fromTab = false; 180 | var type = ev.type; 181 | var resetSelection = false; 182 | 183 | switch (ev.keyCode) { 184 | case 37: // ignore cases (arrow keys) 185 | case 38: 186 | case 39: 187 | case 40: { 188 | hideSuggestions(); 189 | return true; 190 | } 191 | case 224: 192 | case 17: 193 | case 16: 194 | case 18: { 195 | return true; 196 | } 197 | 198 | case 8: { 199 | // delete - hide selections if we're empty 200 | if (this.value == '') { 201 | hideSuggestions(); 202 | setSelection(); 203 | return true; 204 | } else { 205 | type = 'keyup'; // allow drop through 206 | showSuggestionsDelayed(this, ev.charCode); 207 | } 208 | break; 209 | } 210 | 211 | case 9: // return and tab 212 | case 13: { 213 | if (suggestionsShow) { 214 | // complete 215 | chooseTag(matches[0]); 216 | 217 | fromTab = true; 218 | return false; 219 | } else { 220 | return true; 221 | } 222 | } 223 | case 27: { 224 | hideSuggestions(); 225 | setSelection(); 226 | return true; 227 | } 228 | case 32: { 229 | setSelection(); 230 | return true; 231 | } 232 | } 233 | 234 | if (type == 'keyup') { 235 | switch (ev.charCode) { 236 | case 9: 237 | case 13: { 238 | return true; 239 | } 240 | } 241 | 242 | if (resetSelection) { 243 | setSelection(); 244 | } 245 | showSuggestionsDelayed(this, ev.charCode); 246 | } 247 | } 248 | 249 | tagsElm.after(tagMatches).keypress(handleKeys).keyup(handleKeys).blur(function () { 250 | if (fromTab == true || suggestionsShow) { // tweak to support tab selection for Opera & IE 251 | fromTab = false; 252 | tagsElm.focus(); 253 | } 254 | }); 255 | 256 | // replace with jQuery version 257 | tagMatches = $(tagMatches).click(function (ev) { 258 | if (ev.target.nodeName == settings.tagWrap.toUpperCase() && $(ev.target).is('._tag_suggestion')) { 259 | chooseTag(ev.target.innerHTML); 260 | } 261 | }).addClass(settings.matchClass); 262 | 263 | // initialise 264 | setSelection(); 265 | }); 266 | }; 267 | })(jQuery); 268 | --------------------------------------------------------------------------------