├── README.md ├── README.rdoc ├── app ├── controllers │ ├── my_taskboard_controller.rb │ └── taskboard_controller.rb ├── helpers │ └── taskboard_helper.rb ├── models │ ├── status_bucket.rb │ ├── task_board_assignee.rb │ ├── task_board_column.rb │ └── task_board_issue.rb └── views │ ├── my_taskboard │ └── index.html.erb │ ├── settings │ ├── _column_manager.html.erb │ ├── _project.html.erb │ ├── _redmine_task_board_settings.erb │ └── update.js.erb │ └── taskboard │ ├── _issue_description.html.erb │ └── index.html.erb ├── assets ├── javascripts │ └── task_board.js └── stylesheets │ └── taskboard.css ├── config ├── locales │ └── en.yml └── routes.rb ├── db └── migrate │ ├── 001_create_task_board_issues.rb │ ├── 002_create_task_board_columns.rb │ ├── 003_create_task_board_assignees.rb │ └── 004_create_status_buckets.rb ├── init.rb ├── lib ├── redmine_task_board_hook_listener.rb └── redmine_task_board_settings_patch.rb └── test ├── functional └── taskboard_controller_test.rb ├── test_helper.rb └── unit ├── status_bucket_test.rb ├── task_board_assignee_test.rb ├── task_board_assignees_test.rb ├── task_board_column_test.rb └── task_board_issue_test.rb /README.md: -------------------------------------------------------------------------------- 1 | redmine-task-board 2 | ================== 3 | 4 | A Redmine plugin which creates Kanban-style drag and drop taskboards per project. 5 | 6 | Screenshots 7 | ----------- 8 | 9 | See [Alley's Redmine Task Board](http://www.alleyinteractive.com/blog/alley-redmine-taskboard/) illustrated blog post 10 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = redmine_task_board 2 | 3 | Description goes here 4 | -------------------------------------------------------------------------------- /app/controllers/my_taskboard_controller.rb: -------------------------------------------------------------------------------- 1 | class MyTaskboardController < ApplicationController 2 | unloadable 3 | 4 | before_filter :my_account_or_admin 5 | 6 | def my_index 7 | index 8 | render 'index' 9 | end 10 | 11 | def index 12 | issues = Issue.select( \ 13 | "issues.id, 14 | issues.subject, 15 | issues.status_id, 16 | projects.name as project_name, 17 | trackers.name as tracker_name, 18 | issue_priority.name as priority_name, 19 | issue_priority.id as priority_id, 20 | projects.id as project_id, 21 | tba.weight, 22 | issue_statuses.name as status_name, 23 | tba.issue_id" 24 | ) \ 25 | .joins('LEFT OUTER JOIN task_board_assignees AS tba ON tba.issue_id = issues.id AND tba.assignee_id = issues.assigned_to_id') \ 26 | .joins('INNER JOIN issue_statuses ON issues.status_id = issue_statuses.id') \ 27 | .joins('INNER JOIN trackers ON trackers.id = issues.tracker_id') \ 28 | .joins('INNER JOIN projects ON projects.id = issues.project_id') \ 29 | .joins('INNER JOIN enumerations issue_priority ON issues.priority_id = issue_priority.id') \ 30 | .where("assigned_to_id = ? AND issue_statuses.is_closed = 0 AND projects.status = 1", @user.id) \ 31 | .order("weight ASC, issue_priority.position DESC") 32 | @not_prioritized = Array.new 33 | @prioritized = Array.new 34 | 35 | issues.each do |issue| 36 | if issue.weight == nil or issue.weight == 0 37 | @not_prioritized << issue 38 | else 39 | @prioritized << issue 40 | end 41 | end 42 | end 43 | 44 | def save 45 | TaskBoardAssignee.destroy_all(:assignee_id => @user.id) 46 | weight = 1; 47 | used_ids = Array.new 48 | params[:sort].each do |issue_id| 49 | unless used_ids.include? issue_id 50 | used_ids << issue_id 51 | tba = TaskBoardAssignee.where(:issue_id => issue_id, :assignee_id => @user.id).first_or_create(:weight => weight) 52 | weight += 1 53 | end 54 | end 55 | respond_to do |format| 56 | format.js{ head :ok } 57 | end 58 | end 59 | 60 | def my_account_or_admin 61 | find_user 62 | if @user.id != User.current.id 63 | require_admin 64 | end 65 | true 66 | end 67 | 68 | def find_user 69 | if params[:id] == nil 70 | params[:id] = User.current.id 71 | end 72 | 73 | if params[:id] == 'current' 74 | require_login || return 75 | @user = User.current 76 | else 77 | @user = User.find(params[:id]) 78 | end 79 | rescue ActiveRecord::RecordNotFound 80 | render_404 81 | end 82 | 83 | end -------------------------------------------------------------------------------- /app/controllers/taskboard_controller.rb: -------------------------------------------------------------------------------- 1 | class TaskboardController < ApplicationController 2 | unloadable 3 | 4 | before_filter :find_project 5 | before_filter :authorize 6 | helper_method :column_manager_locals 7 | 8 | def index 9 | @columns = TaskBoardColumn.where(:project_id => @project.id).order('weight').all() 10 | @status_names = Hash.new 11 | IssueStatus.select([:id, :name]).each do |status| 12 | @status_names[status.id] = status.name 13 | end 14 | end 15 | 16 | def save 17 | params[:sort].each do |status_id, issues| 18 | weight = 0; 19 | issues.each do |issue_id| 20 | tbi = TaskBoardIssue.find_by_issue_id(issue_id).update_attribute(:project_weight, weight) 21 | weight += 1 22 | end 23 | end 24 | if params[:move] then 25 | params[:move].each do |issue_id, new_status_id| 26 | issue = Issue.find(issue_id).update_attribute(:status_id, new_status_id) 27 | end 28 | end 29 | respond_to do |format| 30 | format.js{ head :ok } 31 | end 32 | end 33 | 34 | def archive_issues 35 | params[:ids].each do |issue_id| 36 | TaskBoardIssue.find_by_issue_id(issue_id).update_attribute(:is_archived, true) 37 | end 38 | respond_to do |format| 39 | format.js{ head :ok } 40 | end 41 | end 42 | 43 | def unarchive_issue 44 | TaskBoardIssue.find_by_issue_id(params[:issue_id]).update_attribute(:is_archived, false) 45 | respond_to do |format| 46 | format.js{ head :ok } 47 | end 48 | end 49 | 50 | def create_column 51 | @column = TaskBoardColumn.new :project => @project, :title => params[:title] 52 | @column.save 53 | render 'settings/update' 54 | end 55 | 56 | def delete_column 57 | @column = TaskBoardColumn.find(params[:column_id]) 58 | @column.delete 59 | render 'settings/update' 60 | end 61 | 62 | def update_columns 63 | params[:column].each do |column_id, new_state| 64 | column = TaskBoardColumn.find(column_id.to_i) 65 | print column.title + ' ' + new_state[:weight] + ". " 66 | column.weight = new_state[:weight].to_i 67 | column.max_issues = new_state[:max_issues].to_i 68 | column.save! 69 | column.status_buckets.clear() 70 | end 71 | params[:status].each do |column_id, statuses| 72 | statuses.each do |status_id, weight| 73 | status_id = status_id.to_i 74 | column_id = column_id.to_i 75 | StatusBucket.create!(:task_board_column_id => column_id, :issue_status_id => status_id, :weight => weight) 76 | end 77 | end 78 | render 'settings/update' 79 | end 80 | 81 | private 82 | 83 | def find_project 84 | # @project variable must be set before calling the authorize filter 85 | if (params[:project_id]) then 86 | @project = Project.find(params[:project_id]) 87 | elsif(params[:issue_id]) then 88 | @project = Issue.find(params[:issue_id]).project 89 | end 90 | end 91 | 92 | end -------------------------------------------------------------------------------- /app/helpers/taskboard_helper.rb: -------------------------------------------------------------------------------- 1 | module TaskboardHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/models/status_bucket.rb: -------------------------------------------------------------------------------- 1 | class StatusBucket < ActiveRecord::Base 2 | default_scope { 3 | order('weight ASC') 4 | } 5 | belongs_to :issue_status 6 | belongs_to :task_board_column 7 | unloadable 8 | end 9 | -------------------------------------------------------------------------------- /app/models/task_board_assignee.rb: -------------------------------------------------------------------------------- 1 | class TaskBoardAssignee < ActiveRecord::Base 2 | unloadable 3 | end 4 | -------------------------------------------------------------------------------- /app/models/task_board_column.rb: -------------------------------------------------------------------------------- 1 | class TaskBoardColumn < ActiveRecord::Base 2 | unloadable 3 | belongs_to :project 4 | has_many :status_buckets 5 | has_many :issue_statuses, :through => :status_buckets 6 | validates_presence_of :title, :project 7 | validates_length_of :title, :maximum => 255 8 | 9 | def self.empty_status(project_id, status_id) 10 | columns = TaskBoardColumn \ 11 | .select(:id) \ 12 | .joins('INNER JOIN status_buckets b ON b.task_board_column_id = task_board_columns.id') \ 13 | .where('b.issue_status_id = ? AND project_id = ?', status_id, project_id) 14 | return columns.empty? 15 | end 16 | 17 | def issues(order_column="project_weight") 18 | @column_statuses = Hash.new 19 | subproject_ids = [project.id] 20 | include_subprojects = \ 21 | Setting.plugin_redmine_task_board['include_subprojects'].to_i == 1 22 | if include_subprojects 23 | subproject_ids = project.self_and_descendants.collect {|p| p.id}.flatten 24 | end 25 | self.issue_statuses.order('status_buckets.weight').each do |status| 26 | @column_statuses[status.id] = Array.new 27 | issues = Issue.select("issues.*, tbi.is_archived, tbi.#{order_column} as weight, tbi.issue_id") \ 28 | .joins('LEFT OUTER JOIN task_board_issues AS tbi ON tbi.issue_id = issues.id') \ 29 | .where("project_id IN (?) AND status_id = ? AND (is_archived IS NULL OR is_archived = FALSE)", subproject_ids, status.id) \ 30 | .order("weight ASC, created_on ASC") 31 | issues.each do |issue| 32 | # Create a TaskBoardIssue (i.e. a Card) if one doesn't exist already. 33 | unless issue.issue_id 34 | closed_and_old = (status.is_closed? and issue.updated_on.to_date < 14.days.ago.to_date) 35 | tbi = TaskBoardIssue.new(:issue_id => issue.id, :is_archived => closed_and_old) 36 | tbi.save 37 | if closed_and_old 38 | next 39 | end 40 | end 41 | @column_statuses[status.id] << issue 42 | end 43 | end 44 | return @column_statuses 45 | end 46 | 47 | end 48 | -------------------------------------------------------------------------------- /app/models/task_board_issue.rb: -------------------------------------------------------------------------------- 1 | class TaskBoardIssue < ActiveRecord::Base 2 | unloadable 3 | belongs_to :issue 4 | end -------------------------------------------------------------------------------- /app/views/my_taskboard/index.html.erb: -------------------------------------------------------------------------------- 1 | <%= javascript_include_tag('task_board', :plugin => 'redmine_task_board') %> 2 | 3 |
4 |

5 | <%= label_tag "priority", "Priority" %> 6 | <%= select_tag("priority", options_for_select([['Any', '0']] + IssuePriority.all.collect { |m| [ m.name, m.id ] } )) %> 7 | <%= label_tag "project", "Project" %> 8 | <%= select_tag("project", options_for_select([['Any', '0']] + Project.order(:name).where(:status => 1).all().select{|project| @user.allowed_to?(:log_time, project)}.collect{|p| [p.name, p.id] })) %> 9 |

10 |
11 | 12 |
13 | 14 |
15 | 16 |
17 |
18 |

<%= translate :task_board_prioritized %>

19 | 41 |
42 |
43 |

<%= translate :task_board_not_prioritized %>

44 | 65 |
66 |
67 | 68 | 81 | 82 | <% content_for :header_tags do %> 83 | <%= stylesheet_link_tag 'taskboard', :plugin => 'redmine_task_board' %> 84 | <% end %> -------------------------------------------------------------------------------- /app/views/settings/_column_manager.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_tag project_taskboard_columns_create_path(:project_id => @project.id), :remote => true, :id => 'create-column' do %> 2 |

3 | <%= label_tag(:title, translate(:task_board_column_title)) %> 4 | <%= text_field_tag(:title) %> 5 | <%= submit_tag(translate :task_board_create_column) %> 6 |

7 | <% end %> 8 | 9 | <%= form_tag project_taskboard_columns_update_path(:project_id => @project.id), :method => 'put', :remote => true do %> 10 |
11 |
Available Statuses
12 |

Configure the taskboard columns by dragging statuses to them.

13 | 20 |
21 |
22 | <% columns.each do |column| %> 23 |
24 |
25 |
<%= column.title %>
26 |
27 | <%= link_to( 28 | l(:button_delete), 29 | project_taskboard_columns_delete_path(:project_id => @project.id, :column_id => column.id), 30 | :method => :delete, 31 | :confirm => l(:text_are_you_sure), 32 | :remote => true, 33 | :class => 'icon icon-del' 34 | ) 35 | %> 36 |

37 | <%= label_tag("column[#{column.id}][max_issues]", translate(:task_board_task_limit)) %> 38 | <%= text_field_tag("column[#{column.id}][max_issues]", column.max_issues, :class => 'column-max-issues') %> 39 | <%= hidden_field_tag("column[#{column.id}][weight]", column.weight, :class => 'column-weight') %> 40 |

41 |
42 | 47 |
48 | <% end %> 49 |
50 | 51 |
52 | <%= submit_tag(translate :task_board_save_changes) %> 53 |
54 | <% end %> 55 | 56 | -------------------------------------------------------------------------------- /app/views/settings/_project.html.erb: -------------------------------------------------------------------------------- 1 | <%= javascript_include_tag('task_board', :plugin => 'redmine_task_board') %> 2 | 3 | 22 | 23 | <%= translate :task_board_help %> 24 | 25 |
26 | <%= render :partial => 'settings/column_manager', :locals => { 27 | :columns => TaskBoardColumn.where(:project => @project.id).order("weight ASC"), 28 | :statuses => IssueStatus.all 29 | } 30 | %> 31 |
-------------------------------------------------------------------------------- /app/views/settings/_redmine_task_board_settings.erb: -------------------------------------------------------------------------------- 1 |

2 | 3 | <%= check_box_tag 'settings[include_subprojects]', 1, settings['include_subprojects'] %> 4 |

5 | -------------------------------------------------------------------------------- /app/views/settings/update.js.erb: -------------------------------------------------------------------------------- 1 | $('#column_manager').html("<%= escape_javascript( render( 2 | :partial => 'settings/column_manager', 3 | :locals => { 4 | :columns => TaskBoardColumn.where(:project => @project.id).order('weight ASC'), 5 | :statuses => IssueStatus.all 6 | })) -%>"); -------------------------------------------------------------------------------- /app/views/taskboard/_issue_description.html.erb: -------------------------------------------------------------------------------- 1 | <% if tbi = TaskBoardIssue.find_by_issue_id(@issue.id) and tbi.is_archived? then %> 2 |
3 |
4 |

<%= translate :label_task_board %>

5 |

6 | <%= translate :task_board_issue_archived %> 7 | 8 |

9 |
10 | 11 | 25 | <% end %> -------------------------------------------------------------------------------- /app/views/taskboard/index.html.erb: -------------------------------------------------------------------------------- 1 | <%= javascript_include_tag('task_board', :plugin => 'redmine_task_board') %> 2 | 3 |
4 |

5 | <%= label_tag "assignee", "Assigned to" %> 6 | <%= select_tag("assignee", options_for_select([['Anyone', '0']] + @project.members.collect { |m| [ m.name, m.user.id ] } )) %> 7 | <%= label_tag "priority", "Priority" %> 8 | <%= select_tag("priority", options_for_select([['Any', '0']] + IssuePriority.all.collect { |m| [ m.name, m.id ] } )) %> 9 | <%= label_tag "category", "Category" %> 10 | <%= select_tag("category", options_for_select([['Any', '0']] + @project.issue_categories.collect { |m| [ m.name, m.id ] } )) %> 11 |

12 |
13 | 14 |
15 | 16 | 17 |
18 | 19 |
20 | <% @columns.each do|column| %> 21 |
22 |

<%= column.title %>

23 | <% column.issues.each do |status_id, issues| %> 24 | <% unless column.issues.size == 1 %> 25 |

<%= @status_names[status_id] %>

26 | <% end %> 27 | 50 | <% end %> 51 |
52 | <% end %> 53 |
54 | 55 | 69 | 70 | <% content_for :header_tags do %> 71 | <%= stylesheet_link_tag 'taskboard', :plugin => 'redmine_task_board' %> 72 | <% end %> 73 | -------------------------------------------------------------------------------- /assets/javascripts/task_board.js: -------------------------------------------------------------------------------- 1 | /* Simple JavaScript Inheritance 2 | * By John Resig http://ejohn.org/ 3 | * MIT Licensed. 4 | */ 5 | (function(){ 6 | var initializing = false, fnTest = /xyz/.test(function(){xyz;}) ? /\b_super\b/ : /.*/; 7 | this.Class = function(){}; 8 | Class.extend = function(prop) { 9 | var _super = this.prototype; 10 | initializing = true; 11 | var prototype = new this(); 12 | initializing = false; 13 | for (var name in prop) { 14 | prototype[name] = typeof prop[name] == "function" && 15 | typeof _super[name] == "function" && fnTest.test(prop[name]) ? 16 | (function(name, fn){ 17 | return function() { 18 | var tmp = this._super; 19 | this._super = _super[name]; 20 | var ret = fn.apply(this, arguments); 21 | this._super = tmp; 22 | return ret; 23 | }; 24 | })(name, prop[name]) : 25 | prop[name]; 26 | } 27 | function Class() { 28 | if ( !initializing && this.init ) 29 | this.init.apply(this, arguments); 30 | } 31 | Class.prototype = prototype; 32 | Class.prototype.constructor = Class; 33 | Class.extend = arguments.callee; 34 | 35 | return Class; 36 | }; 37 | })(); 38 | 39 | var TaskBoardFilters = Class.extend({ 40 | filters: { 41 | priority: 0, 42 | category: 0, 43 | assignee: 0, // only used on project taskboard 44 | project: 0 // only used on "my" taskboard 45 | }, 46 | init: function() { 47 | var self = this; 48 | $('#taskboard-filters').on('change', 'select', function() { 49 | self.filters[$(this).attr('name')] = parseInt($(this).val()); 50 | self.applyFilters(); 51 | }); 52 | }, 53 | applyFilters: function() { 54 | var self = this; 55 | $('#sortable-root').find('li.card').each(function() { 56 | var minimized = false; 57 | for (var f in self.filters) { 58 | if (self.filters[f] == 0 || self.filters[f] == parseInt($(this).data(f))) { 59 | continue; 60 | } 61 | else { 62 | minimized = true; 63 | break; 64 | } 65 | } 66 | if (minimized) $(this).hide(); 67 | else $(this).show(); 68 | }); 69 | } 70 | }); 71 | 72 | var TaskBoardSortable = Class.extend({ 73 | 74 | sortable: null, 75 | id: null, 76 | options: {}, 77 | 78 | init: function(id, options) { 79 | this.id = id; 80 | this.options = options; 81 | this.options.change = this.onChange.bind(this); 82 | this.options.update = this.onUpdate.bind(this); 83 | this.root = $('#' + this.id); 84 | this.root.sortable(this.options); 85 | }, 86 | 87 | onChange: function() { }, 88 | 89 | onUpdate: function() { } 90 | 91 | }); 92 | 93 | var TaskBoardPane = TaskBoardSortable.extend({ 94 | 95 | init: function(id, options) { 96 | this._super(id, options); 97 | this.max_issues = parseInt(this.root.data('max-issues')); 98 | this.root.data('card-count', this.getNumberOfCards()); 99 | }, 100 | 101 | getNumberOfCards: function() { 102 | return this.root.find('.card').length; 103 | }, 104 | 105 | onUpdate: function(e, ui) { 106 | // Add or remove 'empty' class 107 | var list = ui.item.parent(); 108 | if (list.hasClass('empty') && list.find('.card').length > 0) { 109 | list.removeClass('empty'); 110 | } 111 | else if (list.find('.card').length == 0) { 112 | list.addClass('empty'); 113 | } 114 | 115 | // Deal with max issue limit 116 | if (this.max_issues > 0 && this.root.find('.card').length > this.max_issues) { 117 | var i = 1; 118 | var self = this; 119 | this.root.find('.card').each(function() { 120 | // Clear legal cards of the over-limit class 121 | if (i <= self.max_issues) { 122 | $(this).removeClass('over-limit'); 123 | } 124 | 125 | // Add a dashed line under the last legal issue, reset others 126 | if (self.max_issues == i) { 127 | $(this).addClass('at-limit'); 128 | } 129 | else { 130 | $(this).removeClassName('at-limit'); 131 | } 132 | 133 | // Add over-limit class to over-limit issues 134 | if (i > self.max_issues) { 135 | $(this).addClass('over-limit'); 136 | } 137 | i++; 138 | }); 139 | } 140 | else { 141 | this.root.find('.card').each(function() { 142 | $(this).removeClass('over-limit'); 143 | $(this).removeClass('at-limit'); 144 | }); 145 | } 146 | 147 | // handle card movements 148 | 149 | // a card has been moved into this column. 150 | if (this.getNumberOfCards() > list.data('card-count')) { 151 | list.find('.card').each(function() { 152 | if (list.data('status-id') != $(this).data('status-id')) { 153 | TaskBoardUtils.save([ 154 | TaskBoardUtils.column_serialize(list), // save ordering of this column 155 | TaskBoardUtils.column_serialize($('#' + 'column_' + $(this).data('status-id'))), // save ordering of previous column 156 | TaskBoardUtils.moveParam($(this).data('issue-id'), list.data('status-id')) 157 | ], { 158 | onSuccess: function() { 159 | $(this).data('status-id', list.data('status-id')); 160 | } 161 | }); 162 | } 163 | }); 164 | } 165 | 166 | // this column has been reordered 167 | else if(this.getNumberOfCards() == list.data('card-count')) { 168 | TaskBoardUtils.save([TaskBoardUtils.column_serialize(list)]); 169 | } 170 | 171 | // We don't handle (this.getNumberOfCards() < $(this.id).readAttribute('data-card-count')) 172 | // because the gaining column handles re-weighting for the losing column for AJAX efficiency. 173 | 174 | list.data('card-count', this.getNumberOfCards()); 175 | }, 176 | 177 | }); 178 | 179 | var MyTaskBoardPane = TaskBoardSortable.extend({ 180 | 181 | init: function(id, options) { 182 | this._super(id, options); 183 | this.root.data('card-count', this.getNumberOfCards()); 184 | }, 185 | 186 | getNumberOfCards: function() { 187 | return this.root.find('.card').length; 188 | }, 189 | 190 | onUpdate: function(e, ui) { 191 | // Add or remove 'empty' class 192 | var list = ui.item.parent(); 193 | if (list.hasClass('empty') && list.find('.card').length > 0) { 194 | list.removeClass('empty'); 195 | } 196 | else if (list.find('.card').length == 0) { 197 | list.addClass('empty'); 198 | } 199 | 200 | var priority_list = []; 201 | $('#prioritized').find('li.card').each(function() { 202 | priority_list.push('sort[]=' + $(this).data('issue-id')); 203 | }); 204 | TaskBoardUtils.save(priority_list); 205 | 206 | // We don't handle (this.getNumberOfCards() < $(this.id).readAttribute('data-card-count')) 207 | // because the gaining column handles re-weighting for the losing column for AJAX efficiency. 208 | 209 | 210 | list.data('card-count', this.getNumberOfCards()); 211 | }, 212 | 213 | }); 214 | 215 | var TaskBoardUtils = { 216 | 217 | column_serialize: function(list) { 218 | var params = []; 219 | list.find('.card').each(function() { 220 | params.push('sort[' + list.data('status-id') + '][]=' + $(this).data('issue-id')); 221 | }); 222 | return params.join('&'); 223 | }, 224 | 225 | moveParam: function(issue_id, new_status_id) { 226 | return 'move[' + issue_id + ']=' + new_status_id; 227 | }, 228 | 229 | save: function(params) { 230 | $('#ajax-indicator').show(); 231 | $.ajax(project_save_url, { 232 | type: 'post', 233 | data: params.join('&'), 234 | complete: function() { 235 | $('#ajax-indicator').hide(); 236 | } 237 | }); 238 | }, 239 | 240 | checkboxListener: function() { 241 | TaskBoardUtils.hideButtonsIfNoneChecked(); 242 | $(document).on('click', '.card input[type="checkbox"]', function() { 243 | if (!$('#taskboard-buttons').is(':visible') && this.checked) { 244 | $('#taskboard-buttons').show(); 245 | } 246 | if (!this.checked) { 247 | TaskBoardUtils.hideButtonsIfNoneChecked(); 248 | } 249 | }); 250 | 251 | $(document).on('click', '#edit-issues', function() { 252 | location.href = '/issues/bulk_edit?' + TaskBoardUtils.serializeCheckedButtons(); 253 | }); 254 | 255 | $(document).on('click', '#archive-issues', function() { 256 | $('#ajax-indicator').show(); 257 | $.ajax(project_archive_url, { 258 | type: 'post', 259 | data: TaskBoardUtils.serializeCheckedButtons(), 260 | complete: function() { 261 | $('#ajax-indicator').hide(); 262 | }, 263 | success: function() { 264 | $('.card input[type="checkbox"]').each(function() { 265 | if ($(this).is(':checked')) { 266 | $('#issue_' + $(this).val()).remove(); 267 | } 268 | }); 269 | } 270 | }); 271 | }); 272 | }, 273 | 274 | hideButtonsIfNoneChecked: function() { 275 | var found_checked = false; 276 | $('.card input[type="checkbox"]').each(function() { 277 | if (this.checked) { 278 | found_checked = true; 279 | return false; 280 | } 281 | }); 282 | if (!found_checked) { 283 | $('#taskboard-buttons').hide(); 284 | } 285 | }, 286 | 287 | serializeCheckedButtons: function() { 288 | var params = []; 289 | $('.card input[type="checkbox"]').each(function() { 290 | if (this.checked) { 291 | params.push('ids[]=' + $(this).val()); 292 | } 293 | }); 294 | return params.join('&'); 295 | } 296 | } 297 | 298 | var TaskBoardSettings = TaskBoardSortable.extend({ 299 | 300 | onUpdate: function(e, ui) { 301 | var weight = 0; 302 | var self = this; 303 | this.root.find(this.options.items).each(function() { 304 | var weightInput = $(this).find(self.options.weightSelector); 305 | if ($(weightInput).length > 0) $(weightInput).val(weight++); 306 | }); 307 | } 308 | 309 | }); 310 | 311 | var TaskBoardStatuses = TaskBoardSortable.extend({ 312 | init: function(cls, options) { 313 | this.cls = cls; 314 | this.options = options; 315 | this.options.update = this.setInputs; 316 | this.root = $('.' + this.cls); 317 | this.root.sortable(this.options); 318 | this.setInputs(); 319 | }, 320 | 321 | setInputs: function() { 322 | $('div.dyn-column').each(function() { 323 | var weight = 0, 324 | column_id = $(this).data('column-id'), 325 | $input_wrapper = $(this).find('div.input-wrapper'); 326 | $input_wrapper.empty(); 327 | $(this).find('.status-pill').each(function() { 328 | $input_wrapper.append( 329 | '' 330 | ); 331 | }); 332 | }); 333 | } 334 | }) -------------------------------------------------------------------------------- /assets/stylesheets/taskboard.css: -------------------------------------------------------------------------------- 1 | .taskboard-wrapper { 2 | width: 100%; 3 | overflow: auto; 4 | margin-top: 32px; 5 | } 6 | 7 | .my-taskboard-wrapper { 8 | margin-top: 32px; 9 | } 10 | 11 | .taskboard-pane { 12 | float: left; 13 | width: 250px; 14 | margin: 0 4px; 15 | } 16 | 17 | .taskboard-pane h2, .taskboard-pane h3 { 18 | border: none; 19 | } 20 | 21 | .taskboard-pane h3.status { 22 | color: #666; 23 | font-size: 14px; 24 | border-bottom: solid 1px #666; 25 | } 26 | 27 | .taskboard-pane h2 { 28 | font-size: 18px; 29 | text-align: center; 30 | background-color: transparent; 31 | } 32 | 33 | .taskboard-pane ul { 34 | border: solid 3px #eee; 35 | list-style-type: none; 36 | margin: 0 0 10px 0; 37 | padding: 6px 6px 0 6px; 38 | min-height: 50px; 39 | } 40 | 41 | .taskboard-pane ul.empty { 42 | border: dashed 3px #eee; 43 | background: #fff; 44 | } 45 | 46 | .taskboard-pane ul li.card { 47 | list-style-type: none; 48 | margin: 0 0 6px 0; 49 | padding: 0; 50 | position: static; 51 | font-size: 1em; 52 | height: auto; 53 | white-space: normal; 54 | line-height: auto; 55 | } 56 | 57 | .taskboard-pane ul li.card.over-limit div.issue { 58 | color: #aaa; 59 | background: #ddd; 60 | } 61 | 62 | .taskboard-pane ul li.card.bug div.issue { 63 | border: solid 1px #D8000C; 64 | border-top-width: 3px; 65 | } 66 | 67 | .taskboard-pane ul li.card.feature div.issue { 68 | border: solid 1px #4F8A10; 69 | border-top-width: 3px; 70 | } 71 | 72 | .taskboard-pane ul li.card.support div.issue { 73 | border: solid 1px #00529B; 74 | border-top-width: 3px; 75 | } 76 | 77 | .taskboard-pane ul li.card.task div.issue { 78 | border: solid 1px #a73ee5; 79 | border-top-width: 3px; 80 | } 81 | 82 | .taskboard-pane ul li.card.over-limit div.issue h3 a { 83 | color: #aaa; 84 | } 85 | 86 | .taskboard-pane ul li.card div.issue { 87 | padding: 3px; 88 | } 89 | 90 | .taskboard-pane ul li.card div.issue:hover { 91 | cursor: pointer; 92 | } 93 | 94 | .taskboard-pane ul li.card div.issue p.meta { 95 | font-size: 11px; 96 | margin: 0; 97 | padding: 0; 98 | } 99 | 100 | .taskboard-pane ul li.card div.issue div.issue-heading { 101 | overflow: auto; 102 | margin-bottom: 3px; 103 | } 104 | 105 | .taskboard-pane ul li.card div.issue div.issue-heading p.issue-number { 106 | float: left; 107 | } 108 | 109 | .taskboard-pane ul li.card div.issue h3 { 110 | font-size: 14px; 111 | margin-bottom: 3px; 112 | } 113 | 114 | .taskboard-pane ul li.card div.issue h3 a { 115 | color: #000; 116 | } 117 | 118 | .taskboard-pane ul li.card div.issue h3 a:hover { 119 | color: #000; 120 | text-decoration: none; 121 | border-bottom: none; 122 | } 123 | 124 | #main.nosidebar #content { 125 | position: relative; 126 | } 127 | 128 | #taskboard-filters { 129 | position: absolute; 130 | top: 0; 131 | left: 0; 132 | background: #dadada; 133 | padding: 4px; 134 | } 135 | 136 | #taskboard-filters p { 137 | margin: 0; 138 | padding: 0; 139 | } 140 | 141 | #taskboard-buttons { 142 | position: absolute; 143 | top: 0; 144 | right: 0; 145 | background: #ccc; 146 | padding: 3px; 147 | } 148 | 149 | .issue-priority { 150 | float: right; 151 | padding: 2px; 152 | font-size: 9px; 153 | margin: 0; 154 | } 155 | 156 | #prioritized-wrapper { 157 | width: 30%; 158 | float: left; 159 | } 160 | 161 | #not-prioritized-wrapper { 162 | width: 65%; 163 | float: left; 164 | } 165 | 166 | ul#not-prioritized { 167 | overflow: hidden; 168 | } 169 | 170 | #not-prioritized-wrapper ul li { 171 | float: left; 172 | width: 200px; 173 | height: 70px; 174 | margin: 0 10px 10px 0; 175 | } 176 | 177 | #not-prioritized-wrapper ul li .issue { 178 | height: 60px; 179 | } 180 | 181 | #not-prioritized-wrapper ul li .issue .issue-heading { 182 | height: 16px; 183 | } 184 | 185 | #not-prioritized-wrapper ul li .issue h3 { 186 | height: 24px; 187 | line-height: 12px; 188 | font-size: 12px; 189 | margin-bottom: 0; 190 | overflow: hidden; 191 | } 192 | 193 | .taskboard-pane ul li.card.minimized div.issue { 194 | background-color: #efefef; 195 | border: none; 196 | padding: 0px; 197 | margin-bottom: -2px; 198 | margin-top: -2px; 199 | height: 12px; 200 | overflow: hidden; 201 | } 202 | 203 | .taskboard-pane ul li.card.minimized div.issue .issue-heading, 204 | .taskboard-pane ul li.card.minimized div.issue p.meta { 205 | display: none; 206 | } 207 | 208 | .taskboard-pane ul li.card.minimized div.issue h3, 209 | .taskboard-pane ul li.card.minimized div.issue h3 a { 210 | font-size: 10px; 211 | line-height: 10px; 212 | color: #aaa; 213 | } 214 | 215 | #not-prioritized-wrapper li.card.minimized { 216 | display: none; 217 | } 218 | 219 | .clearfix:before, .clearfix:after { content: ""; display: table; } 220 | .clearfix:after { clear: both; } 221 | .clearfix { zoom: 1; } -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # English strings go here for Rails i18n 2 | en: 3 | label_task_board: Task Board 4 | task_board_help: You must create columns for your task board. Each column can represent multiple issue statuses. 5 | task_board_column_title: Column Title 6 | task_board_create_column: Create Column 7 | label_task_board_application_error: Unexpected application error 8 | task_board_statuses: Statuses 9 | task_board_columns: Columns 10 | task_board_not_shown: Not Shown 11 | task_board_visible_columns: Visible Columns 12 | task_board_task_limit: Max Tasks 13 | task_board_save_changes: Save Changes 14 | task_board_issue_archived: This issue is archived from the task board view and will not display. 15 | task_board_issue_unarchive: Unarchive Issue 16 | task_board_issue_bulk_archive: Archive Selected Issues 17 | task_board_issue_bulk_edit: Edit Selected Issues 18 | task_board_not_prioritized: Other Issues 19 | task_board_prioritized: Prioritized Issues 20 | label_task_board_include_subprojects: Include subprojects 21 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # Plugin's routes 2 | # See: http://guides.rubyonrails.org/routing.html 3 | 4 | get 'users/:id/taskboard', :to => 'my_taskboard#index' 5 | post 'users/:id/taskboard/save', :to => 'my_taskboard#save' 6 | get 'my/taskboard', :to => 'my_taskboard#my_index' 7 | get 'projects/:project_id/taskboard', :to => 'taskboard#index' 8 | post 'projects/:project_id/taskboard/save', :to => 'taskboard#save' 9 | post 'projects/:project_id/taskboard/archive-issues', :to => 'taskboard#archive_issues' 10 | post 'issues/:issue_id/taskboard-unarchive', :to => 'taskboard#unarchive_issue' 11 | post 'projects/:project_id/taskboard/columns/create', :to => 'taskboard#create_column', :as => :project_taskboard_columns_create 12 | delete 'projects/:project_id/taskboard/columns/:column_id/delete', :to => 'taskboard#delete_column', :as => :project_taskboard_columns_delete 13 | put 'projects/:project_id/taskboard/columns/update', :to => 'taskboard#update_columns', :as => :project_taskboard_columns_update -------------------------------------------------------------------------------- /db/migrate/001_create_task_board_issues.rb: -------------------------------------------------------------------------------- 1 | class CreateTaskBoardIssues < ActiveRecord::Migration 2 | def change 3 | create_table :task_board_issues do |t| 4 | t.references :issue 5 | t.integer :project_weight, :default => 0 6 | t.integer :global_weight, :default => 0 7 | t.integer :assignee_weight, :default => 0 8 | t.boolean :is_archived, :default => false 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/002_create_task_board_columns.rb: -------------------------------------------------------------------------------- 1 | class CreateTaskBoardColumns < ActiveRecord::Migration 2 | def change 3 | create_table :task_board_columns do |t| 4 | t.references :project 5 | t.string :title 6 | t.integer :weight, :default => 0 7 | t.integer :max_issues, :default => 0 8 | end 9 | 10 | create_table :issue_statuses_task_board_columns, :id => false do |t| 11 | t.references :issue_status, :task_board_column 12 | end 13 | 14 | add_index :issue_statuses_task_board_columns, [:issue_status_id, :task_board_column_id], {:name => 'issue_statuses_task_board_columns_idx'} 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /db/migrate/003_create_task_board_assignees.rb: -------------------------------------------------------------------------------- 1 | class CreateTaskBoardAssignees < ActiveRecord::Migration 2 | def change 3 | create_table :task_board_assignees do |t| 4 | t.integer :issue_id 5 | t.integer :assignee_id 6 | t.integer :weight 7 | end 8 | add_index :task_board_assignees, :issue_id 9 | add_index :task_board_assignees, :assignee_id 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/004_create_status_buckets.rb: -------------------------------------------------------------------------------- 1 | class CreateStatusBuckets < ActiveRecord::Migration 2 | def self.up 3 | create_table :status_buckets do |t| 4 | t.integer :issue_status_id 5 | t.integer :task_board_column_id 6 | t.integer :weight 7 | end 8 | 9 | column_statuses = ActiveRecord::Base.connection.execute("SELECT issue_status_id, task_board_column_id FROM issue_statuses_task_board_columns ORDER BY issue_status_id, task_board_column_id") 10 | weight = 0 11 | column_statuses.each do |status| 12 | StatusBucket.create!(:task_board_column_id => status[1], :issue_status_id => status[0], :weight => weight ) 13 | weight += 1 14 | end 15 | 16 | # finally, dump the old hatbm associations 17 | drop_table :issue_statuses_task_board_columns 18 | 19 | end 20 | end -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | require 'redmine' 2 | require 'redmine_task_board_hook_listener' 3 | 4 | Rails.configuration.to_prepare do 5 | require_dependency 'projects_helper' 6 | ProjectsHelper.send(:include, RedmineTaskBoardSettingsPatch) unless ProjectsHelper.included_modules.include?(RedmineTaskBoardSettingsPatch) 7 | end 8 | 9 | Redmine::Plugin.register :redmine_task_board do 10 | name 'Redmine Task Board' 11 | author 'Austin Smith' 12 | description 'Add a Kanban-style task board tab to projects' 13 | version '0.0.1' 14 | url 'https://github.com/netaustin/redmine_task_board' 15 | author_url 'http://www.alleyinteractive.com/' 16 | 17 | settings :partial => 'settings/redmine_task_board_settings', 18 | :default => { 19 | } 20 | 21 | project_module :taskboard do 22 | permission :edit_taskboard, {:projects => :settings, :taskboard => [:create_column, :delete_column, :update_columns]}, :require => :member 23 | permission :view_taskboard, {:taskboard => [:index, :save, :archive_issues, :unarchive_issue]}, :require => :member 24 | end 25 | menu :top_menu, :taskboard, { :controller => 'my_taskboard', :action => 'my_index' }, :caption => 'My Task Board', :before => :projects 26 | menu :project_menu, :taskboard, { :controller => 'taskboard', :action => 'index' }, :caption => 'Task Board', :before => :issues, :param => :project_id 27 | end 28 | -------------------------------------------------------------------------------- /lib/redmine_task_board_hook_listener.rb: -------------------------------------------------------------------------------- 1 | class RedmineTaskBoardHookListener < Redmine::Hook::ViewListener 2 | render_on :view_issues_show_description_bottom, :partial => "taskboard/issue_description" 3 | end -------------------------------------------------------------------------------- /lib/redmine_task_board_settings_patch.rb: -------------------------------------------------------------------------------- 1 | require_dependency 'projects_helper' 2 | 3 | module RedmineTaskBoardSettingsPatch 4 | def self.included(base) # :nodoc: 5 | base.send(:include, InstanceMethods) 6 | 7 | base.class_eval do 8 | alias_method_chain :project_settings_tabs, :taskboard_tab 9 | end 10 | end 11 | 12 | module InstanceMethods 13 | # Adds a task board tab to the user administration page 14 | def project_settings_tabs_with_taskboard_tab 15 | tabs = project_settings_tabs_without_taskboard_tab 16 | if @project.allows_to?({ :controller => "taskboard", :action => "index" }) then 17 | tabs << { :name => 'taskboard', :partial => 'settings/project', :label => :label_task_board} 18 | end 19 | return tabs 20 | end 21 | end 22 | end -------------------------------------------------------------------------------- /test/functional/taskboard_controller_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../test_helper' 2 | 3 | class TaskboardControllerTest < ActionController::TestCase 4 | # Replace this with your real tests. 5 | def test_truth 6 | assert true 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Load the normal Rails helper 2 | require File.expand_path(File.dirname(__FILE__) + '/../../../../test/test_helper') 3 | 4 | # Ensure that we are using the temporary fixture path 5 | Engines::Testing.set_fixture_path 6 | -------------------------------------------------------------------------------- /test/unit/status_bucket_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../test_helper', __FILE__) 2 | 3 | class StatusBucketTest < ActiveSupport::TestCase 4 | 5 | # Replace this with your real tests. 6 | def test_truth 7 | assert true 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/unit/task_board_assignee_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../test_helper', __FILE__) 2 | 3 | class TaskBoardAssigneeTest < ActiveSupport::TestCase 4 | 5 | # Replace this with your real tests. 6 | def test_truth 7 | assert true 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/unit/task_board_assignees_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../test_helper', __FILE__) 2 | 3 | class TaskBoardAssigneesTest < ActiveSupport::TestCase 4 | 5 | # Replace this with your real tests. 6 | def test_truth 7 | assert true 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/unit/task_board_column_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../test_helper' 2 | 3 | class TaskBoardColumnTest < ActiveSupport::TestCase 4 | 5 | # Replace this with your real tests. 6 | def test_truth 7 | assert true 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/unit/task_board_issue_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../test_helper' 2 | 3 | class TaskBoardIssueTest < ActiveSupport::TestCase 4 | 5 | # Replace this with your real tests. 6 | def test_truth 7 | assert true 8 | end 9 | end 10 | --------------------------------------------------------------------------------