├── 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 |
20 | <% @prioritized.each do|issue| %>
21 | -
22 |
23 |
24 |
25 |
26 |
27 | <%= link_to "#{issue.tracker_name} ##{issue.id.to_s}", :controller => :issues, :action => :show, :id => issue.id %>
28 |
29 |
30 | <%= issue.priority_name %>
31 |
32 |
33 |
<%= link_to issue.subject, :controller => :issues, :action => :show, :id => issue.id %>
34 |
35 | <%= link_to issue.project_name, :controller => :projects, :action => :show, :id => issue.project_id %> / <%= issue.status_name %>
36 |
37 |
38 |
39 | <% end %>
40 |
41 |
42 |
43 |
<%= translate :task_board_not_prioritized %>
44 |
45 | <% @not_prioritized.each do|issue| %>
46 | -
47 |
48 |
49 |
50 |
51 | <%= link_to "#{issue.tracker_name} ##{issue.id.to_s}", :controller => :issues, :action => :show, :id => issue.id %>
52 |
53 |
54 | <%= issue.priority_name %>
55 |
56 |
57 |
<%= link_to issue.subject, :controller => :issues, :action => :show, :id => issue.id %>
58 |
59 | <%= link_to issue.project_name, :controller => :projects, :action => :show, :id => issue.project_id %> / <%= issue.status_name %>
60 |
61 |
62 |
63 | <% end %>
64 |
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 |
14 | <% statuses.each do |status| %>
15 | <% if TaskBoardColumn.empty_status(@project.id, status.id) %>
16 | - <%= status.name %>
17 | <% end %>
18 | <% end %>
19 |
20 |
21 |
22 | <% columns.each do |column| %>
23 |
24 |
25 |
<%= column.title %>
26 |
42 |
43 | <% column.issue_statuses.each do |status| %>
44 | - <%= status.name %>
45 | <% end %>
46 |
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 |
class="empty"<% end %>>
28 | <% issues.each do|issue| %>
29 | -
30 |
31 |
32 |
33 |
34 | <%= link_to "#{issue.tracker} ##{issue.id.to_s}", :controller => :issues, :action => :show, :id => issue.id %>
35 |
36 |
37 | <%= issue.priority.name %>
38 |
39 |
40 |
<%= link_to issue.subject, :controller => :issues, :action => :show, :id => issue.id %>
41 | <% if defined? issue.assigned_to.name %>
42 |
43 | <%= link_to issue.assigned_to.name, :controller => :users, :action => :show, :id => issue.assigned_to.id %>
44 |
45 | <% end %>
46 |
47 |
48 | <% end %>
49 |
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 |
--------------------------------------------------------------------------------