├── .github └── FUNDING.yml ├── app ├── helpers │ ├── issue_helper.rb │ ├── journal_helper.rb │ └── kanban_helper.rb ├── views │ ├── issue │ │ └── index.html.erb │ ├── journal │ │ └── index.html.erb │ ├── kanban │ │ ├── _small_card.html.erb │ │ ├── _normal_card_estimated_hours.html.erb │ │ ├── _normal_card_days_left.html.erb │ │ ├── _normal_card_spent_hours.html.erb │ │ └── index.html.erb │ └── my │ │ └── blocks │ │ └── _kanban_widget.html.erb └── controllers │ ├── issue_controller.rb │ ├── journal_controller.rb │ └── kanban_controller.rb ├── assets ├── images │ ├── filters_ss.png │ ├── show_pearent_ss.png │ ├── roles_management.png │ ├── kanban_board_overview.png │ └── kanban_board_small_card.png ├── stylesheets │ └── kanban.css └── javascripts │ ├── kanban.js │ ├── hotkeys.js │ └── jquery.floatThead.js ├── test ├── test_helper.rb └── functional │ ├── issue_controller_test.rb │ ├── kanban_controller_test.rb │ └── journal_controller_test.rb ├── config ├── routes.rb └── locales │ ├── ja.yml │ ├── en.yml │ ├── de.yml │ ├── pt-BR.yml │ ├── it.yml │ ├── fr.yml │ ├── pl.yml │ ├── es.yml │ └── ru.yml ├── init.rb ├── LICENSE ├── lib └── kanban │ └── constants.rb └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | liberapay: happy-se-life 2 | -------------------------------------------------------------------------------- /app/helpers/issue_helper.rb: -------------------------------------------------------------------------------- 1 | module IssueHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/journal_helper.rb: -------------------------------------------------------------------------------- 1 | module JournalHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/views/issue/index.html.erb: -------------------------------------------------------------------------------- 1 |

ChangeController#index

2 | -------------------------------------------------------------------------------- /app/views/journal/index.html.erb: -------------------------------------------------------------------------------- 1 |

JournalController#index

2 | -------------------------------------------------------------------------------- /assets/images/filters_ss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/happy-se-life/kanban/HEAD/assets/images/filters_ss.png -------------------------------------------------------------------------------- /assets/images/show_pearent_ss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/happy-se-life/kanban/HEAD/assets/images/show_pearent_ss.png -------------------------------------------------------------------------------- /assets/images/roles_management.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/happy-se-life/kanban/HEAD/assets/images/roles_management.png -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Load the Redmine helper 2 | require File.expand_path(File.dirname(__FILE__) + '/../../../test/test_helper') 3 | -------------------------------------------------------------------------------- /assets/images/kanban_board_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/happy-se-life/kanban/HEAD/assets/images/kanban_board_overview.png -------------------------------------------------------------------------------- /assets/images/kanban_board_small_card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/happy-se-life/kanban/HEAD/assets/images/kanban_board_small_card.png -------------------------------------------------------------------------------- /test/functional/issue_controller_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../test_helper', __FILE__) 2 | 3 | class IssueControllerTest < ActionController::TestCase 4 | # Replace this with your real tests. 5 | def test_truth 6 | assert true 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/functional/kanban_controller_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../test_helper', __FILE__) 2 | 3 | class KanbanControllerTest < ActionController::TestCase 4 | # Replace this with your real tests. 5 | def test_truth 6 | assert true 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/functional/journal_controller_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../test_helper', __FILE__) 2 | 3 | class JournalControllerTest < ActionController::TestCase 4 | # Replace this with your real tests. 5 | def test_truth 6 | assert true 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # Plugin's routes 2 | # See: http://guides.rubyonrails.org/routing.html 3 | Rails.application.routes.draw do 4 | resources :kanban 5 | post 'update_status', to: 'issue#update_status' 6 | post 'get_journal', to: 'journal#get_journal' 7 | post 'put_journal', to: 'journal#put_journal' 8 | end -------------------------------------------------------------------------------- /app/views/kanban/_small_card.html.erb: -------------------------------------------------------------------------------- 1 |
4 |

5 | 6 | <% if @show_ancestors == "1" then %> 7 | <%= render_issue_ancestors(issue) %> 8 | <% end %> 9 | <%= issue.subject %> 10 |

11 |
-------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | Redmine::Plugin.register :kanban do 2 | name 'Kanban plugin' 3 | author 'Kohei Nomura' 4 | description 'Kanban plugin for redmine' 5 | version '0.0.12' 6 | url 'https://github.com/happy-se-life/kanban' 7 | author_url 'mailto:kohei_nom@yahoo.co.jp' 8 | 9 | # Display application common menu 10 | menu :application_menu, :display_menu_link, { :controller => 'kanban', :action => 'index' }, :caption => :kanban_menu_caption, :if => Proc.new { User.current.logged? } 11 | 12 | # Display menu at project page 13 | menu :project_menu, :display_menu_link, { :controller => 'kanban', :action => 'index' }, :caption => :kanban_menu_caption, :param => :project_id 14 | 15 | # Enable permission for each project 16 | project_module :kanban do 17 | permission :display_menu_link, {:kanban => [:index]} 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/helpers/kanban_helper.rb: -------------------------------------------------------------------------------- 1 | module KanbanHelper 2 | def render_issue_ancestors(issue) 3 | s = +'' 4 | ancestors = issue.root? ? [] : issue.ancestors.visible.to_a 5 | ancestors.each do |ancestor| 6 | s << '
' + content_tag('p', ancestor_details(ancestor, :project => (issue.project_id != ancestor.project_id), :truncate => 40 )) 7 | end 8 | s << '
' * (ancestors.size) 9 | s.html_safe 10 | end 11 | 12 | def ancestor_details(issue, options={}) 13 | title = nil 14 | subject = nil 15 | text = options[:tracker] == false ? "##{issue.id}" : "#{issue.tracker} ##{issue.id}" 16 | if options[:subject] == false 17 | title = issue.subject.truncate(60) 18 | else 19 | subject = issue.subject 20 | if truncate_length = options[:truncate] 21 | subject = subject.truncate(truncate_length) 22 | end 23 | end 24 | only_path = options[:only_path].nil? ? true : options[:only_path] 25 | s = h("(#{text} : #{subject})") 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 happy-se-life 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/views/kanban/_normal_card_estimated_hours.html.erb: -------------------------------------------------------------------------------- 1 |
4 |
<%= issue.project.name %>
5 |

<%= issue.tracker.name %> #<%= issue.id %>

6 | <% if @show_ancestors == "1" then %> 7 | <%= render_issue_ancestors(issue) %> 8 | <% end %> 9 |

<%= issue.subject %>

10 |
11 | 12 |
<%= (issue.estimated_hours || 0.0) %><%= I18n.t(:kanban_hours_abbreviation) %>
13 | 14 |

<% if issue.assigned_to != nil %><%= issue.assigned_to.name %><% else %>Not assigned<% end %>

15 |
16 | -------------------------------------------------------------------------------- /app/views/kanban/_normal_card_days_left.html.erb: -------------------------------------------------------------------------------- 1 |
4 |
<%= issue.project.name %>
5 |

<%= issue.tracker.name %> #<%= issue.id %>

6 | <% if @show_ancestors == "1" then %> 7 | <%= render_issue_ancestors(issue) %> 8 | <% end %> 9 |

<%= issue.subject %>

10 |
11 | 12 | <% if issue.due_date != nil and issue.done_ratio < 100 then %> 13 | <% diff = issue.due_date - Date.today %> 14 | <% if diff.to_i == 0 then %>
<%= I18n.t(:kanban_label_today) %>
<% end %> 15 | <% if diff.to_i == 1 then %>
<%= I18n.t(:kanban_label_tommorow) %>
<% end %> 16 | <% if diff.to_i >= 2 then %>
<%= I18n.t(:kanban_label_days_left, :value => diff.to_i) %>
<% end %> 17 | <% if diff.to_i < 0 then %>
<%= I18n.t(:kanban_label_overdue) %>
<% end %> 18 | <% end %> 19 | 20 |

<% if issue.assigned_to != nil %><%= issue.assigned_to.name %><% else %>Not assigned<% end %>

21 |
-------------------------------------------------------------------------------- /app/views/kanban/_normal_card_spent_hours.html.erb: -------------------------------------------------------------------------------- 1 |
4 |
<%= issue.project.name %>
5 |

<%= issue.tracker.name %> #<%= issue.id %>

6 | 7 | <% if @show_ancestors == "1" then %> 8 | <%= render_issue_ancestors(issue) %> 9 | <% end %> 10 |

<%= issue.subject %>

11 |
12 | 13 | 14 |
15 | 16 |

17 | <% if issue.assigned_to.present? %> 18 | <%= issue.assigned_to.name %> 19 | <% else %> 20 | Not assigned 21 | <% end %> 22 |

23 | 24 | 25 |
26 | 27 |
28 | <%= (issue.spent_hours || 0.0) %><%= I18n.t(:kanban_hours_abbreviation) %> 29 | <%= link_to new_time_entry_path(issue_id: issue.id), class: 'icon-only icon-time', title: l(:label_spent_time) do %> 30 | 31 | <% end %> 32 |
33 | 34 |
35 | (<%= (issue.estimated_hours || 0.0) %><%= I18n.t(:kanban_hours_abbreviation) %>) 36 |
37 |
38 |
39 |
40 | -------------------------------------------------------------------------------- /config/locales/ja.yml: -------------------------------------------------------------------------------- 1 | # Japanese strings go here for Rails i18n 2 | ja: 3 | kanban_menu_caption: かんばん 4 | 5 | kanban_label_title: さんのかんばん 6 | kanban_label_unspecified: 指定なし 7 | kanban_label_wip_limits: WIP制限 8 | kanban_label_wip_limit_exceeded: WIP制限超過 9 | kanban_label_fields: フィールド 10 | kanban_label_update: 更新 11 | 12 | kanban_label_within_1_day: 1日以内 13 | kanban_label_within_3_days: 3日以内 14 | kanban_label_within_1_week: 1週間以内 15 | kanban_label_within_2_weeks: 2週間以内 16 | kanban_label_within_1_month: 1ヶ月以内 17 | kanban_label_within_2_months: 2ヶ月以内 18 | kanban_label_within_3_months: 3ヶ月以内 19 | 20 | kanban_label_recent_history_is_here: ここに最近の履歴が表示されます。 21 | kanban_label_recent_history: 最近の履歴 22 | kanban_label_add_notes: 注記の追加 23 | 24 | kanban_label_overdue: 期限切れ 25 | kanban_label_today: 今日 26 | kanban_label_tommorow: 明日 27 | kanban_label_thisweek: 今週 28 | kanban_label_nextweek: 来週 29 | 30 | kanban_label_days_left: "あと%{value}日" 31 | 32 | kanban_label_card_size: カードサイズ 33 | kanban_label_card_size_small: スモール 34 | kanban_label_card_size_normal_days_left: ノーマル(期日との差) 35 | kanban_label_card_size_normal_estimated_hours: ノーマル(予定工数) 36 | kanban_label_card_size_normal_spent_hours: ノーマル(作業時間) 37 | 38 | kanban_label_show_ancestors: 親タスクを表示する 39 | 40 | button_keyboard_shortcuts: ショートカット 41 | 42 | kanban_keyboard_shortcuts_help_0: ログインユーザーのカードを表示 43 | kanban_keyboard_shortcuts_help_1: プロジェクト全員のカードを表示 44 | kanban_keyboard_shortcuts_help_2: 期限日を指定なしに設定 45 | kanban_keyboard_shortcuts_help_3: 期限日を期限切れに設定 46 | kanban_keyboard_shortcuts_help_4: 期限日を本日に設定 47 | kanban_keyboard_shortcuts_help_5: 期限日を今週に設定 48 | kanban_keyboard_shortcuts_help_6: 通常のカードサイズで表示(残日数) 49 | kanban_keyboard_shortcuts_help_7: 通常のカードサイズで表示(予定工数) 50 | kanban_keyboard_shortcuts_help_8: 小さいカードサイズで表示 51 | kanban_keyboard_shortcuts_help_9: 全ての未解決のカードを選択 52 | kanban_keyboard_shortcuts_help_10: このヘルプを表示 53 | kanban_keyboard_shortcuts_help_11: 通常のカードサイズで表示(作業時間) 54 | 55 | # MyPage Widget 56 | kanban_widget: かんばん 57 | 58 | # Settings 59 | label_hide_done_issues: 完了チケットを非表示 60 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # English strings go here for Rails i18n 2 | en: 3 | kanban_menu_caption: Kanban 4 | 5 | kanban_label_title: "'s Kanban" 6 | kanban_label_unspecified: Any 7 | kanban_label_wip_limits: WIP limits 8 | kanban_label_wip_limit_exceeded: WIP limit exceeded 9 | kanban_label_fields: Fields 10 | kanban_label_update: Update 11 | 12 | kanban_label_within_1_day: within 1day 13 | kanban_label_within_3_days: within 3days 14 | kanban_label_within_1_week: within 1week 15 | kanban_label_within_2_weeks: within 2weeks 16 | kanban_label_within_1_month: within 1month 17 | kanban_label_within_2_months: within 2months 18 | kanban_label_within_3_months: within 3months 19 | 20 | kanban_label_recent_history_is_here: Recent history is displayed here 21 | kanban_label_recent_history: Recent History 22 | kanban_label_add_notes: Add Notes 23 | 24 | kanban_label_overdue: Overdue 25 | kanban_label_today: Today 26 | kanban_label_tommorow: Tomorrow 27 | kanban_label_thisweek: This week 28 | kanban_label_nextweek: Next week 29 | 30 | kanban_label_days_left: "%{value} days left" 31 | kanban_hours_abbreviation: 'h' 32 | 33 | kanban_label_card_size: Card size 34 | kanban_label_card_size_small: Small 35 | kanban_label_card_size_normal_days_left: Normal (diff. of due date) 36 | kanban_label_card_size_normal_estimated_hours: Normal (estimated hours) 37 | kanban_label_card_size_normal_spent_hours: Normal (spent hours) 38 | 39 | kanban_label_show_ancestors: Show parent task 40 | 41 | button_keyboard_shortcuts: Shortcuts 42 | 43 | kanban_keyboard_shortcuts_help_0: Show login user only. 44 | kanban_keyboard_shortcuts_help_1: Show everyone in the project. 45 | kanban_keyboard_shortcuts_help_2: Set due date to any dates. 46 | kanban_keyboard_shortcuts_help_3: Set due date to overdue. 47 | kanban_keyboard_shortcuts_help_4: Set due date to today. 48 | kanban_keyboard_shortcuts_help_5: Set due date to this week. 49 | kanban_keyboard_shortcuts_help_6: Show by normal size card with days left. 50 | kanban_keyboard_shortcuts_help_7: Show by normal size card with estimated hours. 51 | kanban_keyboard_shortcuts_help_8: Show by small size card. 52 | kanban_keyboard_shortcuts_help_9: Select all open issues. 53 | kanban_keyboard_shortcuts_help_10: Show this help. 54 | kanban_keyboard_shortcuts_help_11: Show by normal size card with spent hours. 55 | 56 | # MyPage Widget 57 | kanban_widget: Kanban 58 | 59 | # Settings 60 | label_hide_done_issues: Hide done issues 61 | -------------------------------------------------------------------------------- /lib/kanban/constants.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Constant definition 3 | # 4 | module Kanban 5 | module Constants 6 | 7 | # Limit selection strategy 8 | # 0: No Limit 9 | # 1: Limited by `SELECT_LIMIT` value 10 | SELECT_LIMIT_STRATEGY = 0 11 | # Max number of selections 12 | SELECT_LIMIT = 250 13 | 14 | # Days since updated date 15 | # Please choose "1" "3" "7" "14" "31" "62" "93" "unspecified" 16 | DEFAULT_VALUE_UPDATED_WITHIN = "62" 17 | 18 | # Days since closed date 19 | # Please choose "1" "3" "7" "14" "31" "62" "93" "unspecified" 20 | DEFAULT_VALUE_DONE_WITHIN = "14" 21 | 22 | # Max number of WIP issue 23 | DEFAULT_VALUE_WIP_MAX = "2" 24 | 25 | # Array of status IDs to be displayed initially 26 | # Please customize this array for your environment 27 | # 1: New 28 | # 2: In Progress 29 | # 3: Resolved 30 | # 4: Feedback 31 | # 5: Closed 32 | # 6: Rejected 33 | DEFAULT_STATUS_FIELD_VALUE_ARRAY = [1, 2, 3, 4, 5] 34 | 35 | # Status ID for WIP count 36 | WIP_COUNT_STATUS_FIELD = 2 37 | 38 | # Max number of notes on the sidebar 39 | MAX_NOTES = 3 40 | 41 | # Order of note on the sidebar 42 | # Please choose "ASC" "DESC" 43 | ORDER_NOTES = "DESC" 44 | 45 | # Max length of note on sidebar (bytes) 46 | MAX_NOTES_BYTESIZE = 350 47 | 48 | # Enable to display user's avatar at user lane 49 | # 0: None 50 | # 1: Display avatar 51 | DISPLAY_USER_AVATAR = 1 52 | 53 | # Enable hide user without issues 54 | # 0: Hide 55 | # 1: Show 56 | DISPLAY_USER_WITHOUT_ISSUES = 1 57 | 58 | # Display comment dialog when issue was dropped 59 | # 0: Not display 60 | # 1: Display 61 | DISPLAY_COMMENT_DIALOG_WHEN_DROP = 0 62 | 63 | # Default Card Size 64 | # Please choose "normal_days_left" "normal_estimated_hours" "small" 65 | DEFAULT_CARD_SIZE = "normal_estimated_hours" 66 | 67 | # Default High Priority issue id 68 | # Default is 3 to back compatibility 69 | # All issues >= DEFAULT_HIGH_ PRIORITY_ISSUE_ID will be seen as high-priority issues 70 | DEFAULT_HIGH_PRIORITY_ISSUE_ID = 3 71 | 72 | # Default Normal Priority issue id 73 | # Default is 2 to back compatibility 74 | # All issues == DEFAULT_HIGH_ DEFAULT_NORMAL_PRIORITY_ISSUE_ID will be seen as normal priority issues 75 | DEFAULT_NORMAL_PRIORITY_ISSUE_ID = 4 76 | # Default Show ancestors 77 | # 0: Not display 78 | # 1: Display 79 | DEFAULT_SHOW_ANCESTORS = "1" 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /app/controllers/issue_controller.rb: -------------------------------------------------------------------------------- 1 | class IssueController < ApplicationController 2 | 3 | def index 4 | end 5 | 6 | # 7 | # Change ticket status and assignee 8 | # 9 | def update_status 10 | # Get POST value 11 | card_id = params[:card_id] 12 | field_id = params[:field_id] 13 | comment = params[:comment] 14 | 15 | # Target issue ID 16 | issue_array = card_id.split("-") 17 | issue_id = issue_array[1].to_i 18 | 19 | # Status ID to change status 20 | field_array = field_id.split("-") 21 | status_id = field_array[1].to_i 22 | 23 | # User ID to change assignee 24 | if field_array.length == 3 then 25 | user_id = field_array[2].to_i 26 | else 27 | user_id = nil 28 | end 29 | 30 | # Target Issue 31 | issue = Issue.find(issue_id) 32 | 33 | # Status IDs allowed to change 34 | allowd_statuses = issue.new_statuses_allowed_to 35 | allowd_statuses_array = [] 36 | allowd_statuses.each {|status| 37 | allowd_statuses_array << status.id 38 | } 39 | 40 | # Declaring variable to return 41 | result_hash = {} 42 | 43 | # Change status and assignee 44 | if allowd_statuses_array.include?(status_id) == true then 45 | if (issue.status_id != status_id) || (issue.assigned_to_id != user_id) then 46 | begin 47 | # Update issue 48 | old_status_id = issue.status_id 49 | old_done_ratio = issue.done_ratio 50 | old_assigned_to_id = issue.assigned_to_id 51 | issue.status_id = status_id 52 | issue.assigned_to_id = user_id 53 | issue.save! 54 | # Create and Add a journal 55 | note = Journal.new(:journalized => issue, :notes => comment, user: User.current) 56 | note.details << JournalDetail.new(:property => 'attr', :prop_key => 'status_id', :old_value => old_status_id,:value => status_id) 57 | note.details << JournalDetail.new(:property => 'attr', :prop_key => 'done_ratio', :old_value => old_done_ratio,:value => issue.done_ratio) 58 | note.details << JournalDetail.new(:property => 'attr', :prop_key => 'assigned_to_id', :old_value => old_assigned_to_id,:value => user_id) 59 | note.save! 60 | result_hash["result"] = "OK" 61 | result_hash["user_id"] = issue.assigned_to_id 62 | rescue => error 63 | result_hash["result"] = "NG" 64 | end 65 | else 66 | result_hash["result"] = "NO" 67 | end 68 | else 69 | result_hash["result"] = "NG" 70 | end 71 | 72 | render json: result_hash 73 | end 74 | 75 | end 76 | -------------------------------------------------------------------------------- /config/locales/de.yml: -------------------------------------------------------------------------------- 1 | # German strings go here for Rails i18n 2 | de: 3 | kanban_menu_caption: Kanban 4 | 5 | kanban_label_title: "'s Kanban" 6 | kanban_label_unspecified: alle 7 | kanban_label_wip_limits: WIP Limits 8 | kanban_label_wip_limit_exceeded: WIP Limit überschritten 9 | kanban_label_fields: Felder 10 | kanban_label_update: Aktualisieren 11 | 12 | kanban_label_within_1_day: in 1 Tag 13 | kanban_label_within_3_days: in 3 Tagen 14 | kanban_label_within_1_week: in 1 Woche 15 | kanban_label_within_2_weeks: in 2 Wochen 16 | kanban_label_within_1_month: in 1 Monat 17 | kanban_label_within_2_months: in 2 Monaten 18 | kanban_label_within_3_months: in 3 Monaten 19 | 20 | kanban_label_recent_history_is_here: Die jüngste Historie wird hier angezeigt 21 | kanban_label_recent_history: Jüngste Historie 22 | kanban_label_add_notes: Notiz hinzufügen 23 | 24 | kanban_label_overdue: Überfällig 25 | kanban_label_today: Heute 26 | kanban_label_tommorow: Morgen 27 | kanban_label_thisweek: Diese Woche 28 | kanban_label_nextweek: Nächste Woche 29 | 30 | kanban_label_days_left: "%{value} Tage verbleiben" 31 | kanban_hours_abbreviation: 'h' 32 | 33 | kanban_label_card_size: Kartengröße 34 | kanban_label_card_size_small: Klein 35 | kanban_label_card_size_normal_days_left: Normal (Diff. bis Fälligkeit) 36 | kanban_label_card_size_normal_estimated_hours: Normal (Geschätzte Stunden) 37 | kanban_label_card_size_normal_spent_hours: Normal (Benötigte Stunden) 38 | 39 | kanban_label_show_ancestors: Zeige übergeordnete Aufgabe 40 | 41 | button_keyboard_shortcuts: Tastenkürzel 42 | 43 | kanban_keyboard_shortcuts_help_0: Zeige nur angemeldeten Nutzer 44 | kanban_keyboard_shortcuts_help_1: Zeige jeden aus dem Projekt 45 | kanban_keyboard_shortcuts_help_2: Setze Fälligkeit auf unspezifiziert. 46 | kanban_keyboard_shortcuts_help_3: Setze Fälligkeit auf überfällig. 47 | kanban_keyboard_shortcuts_help_4: Setze Fälligkeit auf heute. 48 | kanban_keyboard_shortcuts_help_5: Setze Fälligkeit auf diese Woche. 49 | kanban_keyboard_shortcuts_help_6: Zeige als normaler Kartengröße, wie viele Tage verbleiben. 50 | kanban_keyboard_shortcuts_help_7: Zeige als normaler Kartengröße die geschätzten Stunden. 51 | kanban_keyboard_shortcuts_help_8: Zeige als kleine Kartengröße. 52 | kanban_keyboard_shortcuts_help_9: Wähle alle offenen Tickets. 53 | kanban_keyboard_shortcuts_help_10: Zeige diese Hilfe. 54 | kanban_keyboard_shortcuts_help_11: Zeige als normaler Kartengröße die benötigten Stunden. 55 | 56 | # MyPage Widget 57 | kanban_widget: Kanban 58 | 59 | # Settings 60 | label_hide_done_issues: Abgeschlossene Tickets ausblenden 61 | -------------------------------------------------------------------------------- /config/locales/pt-BR.yml: -------------------------------------------------------------------------------- 1 | # Brazilian Portugues strings go here for Rails i18n 2 | pt-BR: 3 | kanban_menu_caption: Kanban 4 | 5 | kanban_label_title: "'s Kanban" 6 | kanban_label_unspecified: sem definição 7 | kanban_label_wip_limits: Limites WIP 8 | kanban_label_wip_limit_exceeded: Limites excedidos do WIP 9 | kanban_label_fields: Campos 10 | kanban_label_update: Atualização 11 | 12 | kanban_label_within_1_day: dentro de 1 dia 13 | kanban_label_within_3_days: dentro de 3 dias 14 | kanban_label_within_1_week: dentro de 1 semana 15 | kanban_label_within_2_weeks: dentro de 2 semanas 16 | kanban_label_within_1_month: dentro de 1 mês 17 | kanban_label_within_2_months: dentro de 2 meses 18 | kanban_label_within_3_months: dentro de 3 meses 19 | 20 | kanban_label_recent_history_is_here: Históricos recentes são dispóniveis aqui 21 | kanban_label_recent_history: Histórico Recente 22 | kanban_label_add_notes: Adicionar Notas 23 | 24 | kanban_label_overdue: Atrasado 25 | kanban_label_today: Hoje 26 | kanban_label_tommorow: Amanhã 27 | kanban_label_thisweek: Essa Semana 28 | kanban_label_nextweek: Próxima Semana 29 | 30 | kanban_label_days_left: "%{value} dias restantes" 31 | kanban_hours_abbreviation: 'h' 32 | 33 | kanban_label_card_size: Tamanho do cartão 34 | kanban_label_card_size_small: Pequeno 35 | kanban_label_card_size_normal_days_left: Normal (diferença da data de conclusão) 36 | kanban_label_card_size_normal_estimated_hours: Normal (hora estimada) 37 | kanban_label_card_size_normal_spent_hours: Normal (horas gastas) 38 | 39 | kanban_label_show_ancestors: Exibir tarefas relacionadas. 40 | 41 | button_keyboard_shortcuts: Atalhos 42 | 43 | kanban_keyboard_shortcuts_help_0: Exibir somente login de usuarios. 44 | kanban_keyboard_shortcuts_help_1: Exibir projetos para todos. 45 | kanban_keyboard_shortcuts_help_2: Definir data de conclusão não especificada. 46 | kanban_keyboard_shortcuts_help_3: Definir data de conclusão atrasada. 47 | kanban_keyboard_shortcuts_help_4: Definir data de conclusão hoje. 48 | kanban_keyboard_shortcuts_help_5: Definir data de conclusão semanal. 49 | kanban_keyboard_shortcuts_help_6: Exibir cartão normal por dias restantes. 50 | kanban_keyboard_shortcuts_help_7: Exibir cartão normal por horas estimadas. 51 | kanban_keyboard_shortcuts_help_8: Exibir cartão em tamanho pequeno. 52 | kanban_keyboard_shortcuts_help_9: Selecione todas as Tarefas. 53 | kanban_keyboard_shortcuts_help_10: Exibir ajuda. 54 | kanban_keyboard_shortcuts_help_11: Exibir cartão normal por horas gastas.. 55 | 56 | # MyPage Widget 57 | kanban_widget: Kanban 58 | 59 | # Settings 60 | label_hide_done_issues: Ocultar tarefas concluídas 61 | -------------------------------------------------------------------------------- /config/locales/it.yml: -------------------------------------------------------------------------------- 1 | # Italian strings go here for Rails i18n 2 | it: 3 | kanban_menu_caption: Kanban 4 | 5 | kanban_label_title: " - Kanban" 6 | kanban_label_unspecified: non specificato 7 | kanban_label_wip_limits: Limite voci WIP 8 | kanban_label_wip_limit_exceeded: superato limite voci WIP 9 | kanban_label_fields: Campi 10 | kanban_label_update: Aggiorna 11 | 12 | kanban_label_within_1_day: entro 1 giorno 13 | kanban_label_within_3_days: entro 3 giorni 14 | kanban_label_within_1_week: entro 1 settimana 15 | kanban_label_within_2_weeks: entro 2 settimane 16 | kanban_label_within_1_month: entro 1 mese 17 | kanban_label_within_2_months: entro 2 mesi 18 | kanban_label_within_3_months: entro 3 mesi 19 | 20 | kanban_label_recent_history_is_here: Lo storico recente è visualizzato qui 21 | kanban_label_recent_history: Storico recente 22 | kanban_label_add_notes: Aggiungi note 23 | 24 | kanban_label_overdue: In ritardo 25 | kanban_label_today: Oggi 26 | kanban_label_tommorow: Domani 27 | kanban_label_thisweek: Questa settimana 28 | kanban_label_nextweek: Prossima settimana 29 | 30 | kanban_label_days_left: "scade tra %{value} giorni" 31 | kanban_hours_abbreviation: 'h' 32 | 33 | kanban_label_card_size: Dimensione card 34 | kanban_label_card_size_small: Piccola 35 | kanban_label_card_size_normal_days_left: Normale (giorni alla scadenza) 36 | kanban_label_card_size_normal_estimated_hours: Normale (ore stimate) 37 | kanban_label_card_size_normal_spent_hours: Normale (ore lavorate) 38 | 39 | kanban_label_show_ancestors: Mostra predecessori 40 | 41 | button_keyboard_shortcuts: Scorciatoie 42 | 43 | kanban_keyboard_shortcuts_help_0: Mostra solo l'utente connesso. 44 | kanban_keyboard_shortcuts_help_1: Mostra tutti gli utenti del progetto. 45 | kanban_keyboard_shortcuts_help_2: Imposta la data di scadenza a non specificata. 46 | kanban_keyboard_shortcuts_help_3: Imposta la data di scadenza a scaduto. 47 | kanban_keyboard_shortcuts_help_4: Imposta la data di scadenza a oggi. 48 | kanban_keyboard_shortcuts_help_5: Imposta la data di scadenza a questa settimana. 49 | kanban_keyboard_shortcuts_help_6: Card di dimensione normale (giorni mancanti). 50 | kanban_keyboard_shortcuts_help_7: Card di dimensione normale (ore stimate). 51 | kanban_keyboard_shortcuts_help_8: Mostra card di dimensione ridotta. 52 | kanban_keyboard_shortcuts_help_9: Seleziona tutte le segnalazioni aperte. 53 | kanban_keyboard_shortcuts_help_10: Mostra questo menu di aiuto. 54 | kanban_keyboard_shortcuts_help_11: Card di dimensione normale (ore lavorate). 55 | 56 | # MyPage Widget 57 | kanban_widget: Kanban 58 | 59 | # Settings 60 | label_hide_done_issues: Nascondi ticket completati 61 | -------------------------------------------------------------------------------- /config/locales/fr.yml: -------------------------------------------------------------------------------- 1 | # French strings go here for Rails i18n 2 | fr: 3 | kanban_menu_caption: Kanban 4 | 5 | kanban_label_title: ": Kanban" 6 | kanban_label_unspecified: indéfini 7 | kanban_label_wip_limits: WIP maximum 8 | kanban_label_wip_limit_exceeded: Nombre de WIP dépassé 9 | kanban_label_fields: Champs 10 | kanban_label_update: Actualiser 11 | 12 | kanban_label_within_1_day: dernières 24 heures 13 | kanban_label_within_3_days: derniers 3 jour 14 | kanban_label_within_1_week: dernère semaine 15 | kanban_label_within_2_weeks: dernières 2 semaines 16 | kanban_label_within_1_month: dernier mois 17 | kanban_label_within_2_months: dernier 2 mois 18 | kanban_label_within_3_months: dernier 3 mois 19 | 20 | kanban_label_recent_history_is_here: Activité récente 21 | kanban_label_recent_history: Activité récente 22 | kanban_label_add_notes: Ajouter une note 23 | 24 | kanban_label_overdue: Atrasado 25 | kanban_label_today: Aujourd'hui 26 | kanban_label_tommorow: Demain 27 | kanban_label_thisweek: Semaine en cours 28 | kanban_label_nextweek: Prochaine semaine 29 | 30 | kanban_label_days_left: "%{value} jours restants" 31 | 32 | kanban_label_card_size: Taille des blocs 33 | kanban_label_card_size_small: Réduit 34 | kanban_label_card_size_normal_days_left: Normal (en jours) 35 | kanban_label_card_size_normal_estimated_hours: Normal (en heures) 36 | kanban_label_card_size_normal_spent_hours: Normal (en heures de travail) 37 | 38 | kanban_label_show_ancestors: Afficher la tâche parente 39 | 40 | button_keyboard_shortcuts: Raccourcis clavier 41 | 42 | kanban_keyboard_shortcuts_help_0: Afficher l'utilisateur de connexion uniquement. 43 | kanban_keyboard_shortcuts_help_1: Afficher tout le monde dans le projet. 44 | kanban_keyboard_shortcuts_help_2: Définissez la date d'échéance sur non spécifiée. 45 | kanban_keyboard_shortcuts_help_3: Définir la date d'échéance en retard. 46 | kanban_keyboard_shortcuts_help_4: Fixez la date d'échéance à aujourd'hui. 47 | kanban_keyboard_shortcuts_help_5: Fixez la date d'échéance à cette semaine. 48 | kanban_keyboard_shortcuts_help_6: Afficher par carte de taille normale avec les jours restants. 49 | kanban_keyboard_shortcuts_help_7: Afficher par carte de taille normale avec des heures estimées. 50 | kanban_keyboard_shortcuts_help_8: Afficher par carte de petite taille. 51 | kanban_keyboard_shortcuts_help_9: Sélectionnez tous les problèmes ouverts. 52 | kanban_keyboard_shortcuts_help_10: Montrez cette aide. 53 | kanban_keyboard_shortcuts_help_11: Afficher par carte de taille normale avec des heures de travail. 54 | 55 | # MyPage Widget 56 | kanban_widget: Kanban 57 | 58 | # Settings 59 | label_hide_done_issues: Masquer les tickets terminés 60 | -------------------------------------------------------------------------------- /config/locales/pl.yml: -------------------------------------------------------------------------------- 1 | # Polish strings go here for Rails i18n 2 | pl: 3 | kanban_menu_caption: Kanban 4 | 5 | kanban_label_title: "'s Kanban" 6 | kanban_label_unspecified: nieokreslony 7 | kanban_label_wip_limits: WIP limit 8 | kanban_label_wip_limit_exceeded: limit WIP przekroczony 9 | kanban_label_fields: Kategorie 10 | kanban_label_update: Update 11 | 12 | kanban_label_within_1_day: "ostatni dzień" 13 | kanban_label_within_3_days: "ostatnie 3 dni" 14 | kanban_label_within_1_week: "ostatni tydzień" 15 | kanban_label_within_2_weeks: "ostatnie 2 tygodnie" 16 | kanban_label_within_1_month: "ostatni miesiąc" 17 | kanban_label_within_2_months: "ostatnie 2 miesiące" 18 | kanban_label_within_3_months: "ostatnie 3 miesiące" 19 | 20 | kanban_label_recent_history_is_here: "Ostatnie historia wyswietla sie tutaj" 21 | kanban_label_recent_history: Ostatnio 22 | kanban_label_add_notes: "Dodaj notatki" 23 | 24 | kanban_label_overdue: "Zaległe" 25 | kanban_label_today: "Dzisiaj" 26 | kanban_label_tommorow: "Jutro" 27 | kanban_label_thisweek: "W tym tygodniu" 28 | kanban_label_nextweek: "W następnym tygodniu" 29 | 30 | kanban_label_days_left: "%{value} dni zostało" 31 | 32 | kanban_label_card_size: "Wielkość karty" 33 | kanban_label_card_size_small: "Mała" 34 | kanban_label_card_size_normal_days_left: "Normalna(wyszczególnij czas oddania)" 35 | kanban_label_card_size_normal_estimated_hours: "Normalna (wyszczególnij szacowany czas)" 36 | kanban_label_card_size_normal_spent_hours: "Normalna (wyszczególnij przepracowany czas)" 37 | 38 | kanban_label_show_ancestors: Pokaż zadanie nadrzędne 39 | 40 | button_keyboard_shortcuts: "Skróty" 41 | 42 | kanban_keyboard_shortcuts_help_0: "Pokaż tylko zalogowanego użytkownika." 43 | kanban_keyboard_shortcuts_help_1: "Pokaż wszystkim w projekcie." 44 | kanban_keyboard_shortcuts_help_2: "Ustaw termin na nieokreślony." 45 | kanban_keyboard_shortcuts_help_3: "Ustaw termin zaległy." 46 | kanban_keyboard_shortcuts_help_4: "Ustaw termin do dzisiaj." 47 | kanban_keyboard_shortcuts_help_5: "Ustaw termin na ten tydzień." 48 | kanban_keyboard_shortcuts_help_6: "Pokaż według karty normalnego rozmiaru z pozostałymi dniami." 49 | kanban_keyboard_shortcuts_help_7: "Pokaż według karty o normalnym rozmiarze z szacowanymi godzinami." 50 | kanban_keyboard_shortcuts_help_8: "Pokaż według małej karty rozmiaru." 51 | kanban_keyboard_shortcuts_help_9: "Wybierz wszystkie otwarte problemy." 52 | kanban_keyboard_shortcuts_help_10: "Pokaż tę pomoc." 53 | kanban_keyboard_shortcuts_help_11: "Pokaż według karty normalnego rozmiaru z spędzonymi godzinami." 54 | 55 | # MyPage Widget 56 | kanban_widget: Kanban 57 | 58 | # Settings 59 | label_hide_done_issues: Ukryj zakończone zadania 60 | -------------------------------------------------------------------------------- /config/locales/es.yml: -------------------------------------------------------------------------------- 1 | # Español strings go here for Rails i18n 2 | es: 3 | kanban_menu_caption: Kanban 4 | 5 | kanban_label_title: ": Kanban" 6 | kanban_label_unspecified: no definido 7 | kanban_label_wip_limits: Límites WIP 8 | kanban_label_wip_limit_exceeded: Límite WIP superado 9 | kanban_label_fields: Campos 10 | kanban_label_update: Actualizar 11 | 12 | kanban_label_within_1_day: últimas 24 horas 13 | kanban_label_within_3_days: últimos 3 días 14 | kanban_label_within_1_week: última semana 15 | kanban_label_within_2_weeks: últimas 2 semanas 16 | kanban_label_within_1_month: último mes 17 | kanban_label_within_2_months: últimos 2 meses 18 | kanban_label_within_3_months: úĺtimos 3 meses 19 | 20 | kanban_label_recent_history_is_here: Aquí se muestra el historial reciente 21 | kanban_label_recent_history: Histórico reciente 22 | kanban_label_add_notes: Agregar Nota 23 | 24 | kanban_label_overdue: Atrasado 25 | kanban_label_today: Hoy 26 | kanban_label_tommorow: Mañana 27 | kanban_label_thisweek: Esta semana 28 | kanban_label_nextweek: Próxima semana 29 | 30 | kanban_label_days_left: "%{value} días faltantes" 31 | 32 | kanban_label_card_size: Tamaño de tarjeta 33 | kanban_label_card_size_small: Pequeño 34 | kanban_label_card_size_normal_days_left: Normal 1 (con atrasos) 35 | kanban_label_card_size_normal_estimated_hours: Normal 2 (con horas estimadas) 36 | kanban_label_card_size_normal_spent_hours: Normal 3 (con horas dedicadas) 37 | 38 | kanban_label_show_ancestors: Mostrar tarea principal 39 | 40 | button_keyboard_shortcuts: Accesos rápidos 41 | 42 | kanban_keyboard_shortcuts_help_0: Mostrar solo el usuario de inicio de sesión. 43 | kanban_keyboard_shortcuts_help_1: Muestre a todos en el proyecto. 44 | kanban_keyboard_shortcuts_help_2: Establezca la fecha de vencimiento en no especificado. 45 | kanban_keyboard_shortcuts_help_3: Establezca la fecha de vencimiento como vencida. 46 | kanban_keyboard_shortcuts_help_4: Establezca la fecha de vencimiento hoy. 47 | kanban_keyboard_shortcuts_help_5: Establezca la fecha de vencimiento para esta semana. 48 | kanban_keyboard_shortcuts_help_6: Mostrar por tarjeta de tamaño normal con días restantes. 49 | kanban_keyboard_shortcuts_help_7: Mostrar por tarjeta de tamaño normal con horas estimadas. 50 | kanban_keyboard_shortcuts_help_8: Mostrar por tarjeta de tamaño pequeño. 51 | kanban_keyboard_shortcuts_help_9: Seleccione todos los problemas abiertos. 52 | kanban_keyboard_shortcuts_help_10: Muestre esta ayuda. 53 | kanban_keyboard_shortcuts_help_11: Mostrar por tarjeta de tamaño normal con horas dedicadas. 54 | 55 | # MyPage Widget 56 | kanban_widget: Kanban 57 | 58 | # Settings 59 | label_hide_done_issues: Ocultar tickets completados 60 | -------------------------------------------------------------------------------- /config/locales/ru.yml: -------------------------------------------------------------------------------- 1 | # Russian strings go here for Rails i18n 2 | ru: 3 | kanban_menu_caption: "Канбан" 4 | 5 | kanban_label_title: " - Личный канбан пользователя" 6 | kanban_label_unspecified: "не задано" 7 | kanban_label_wip_limits: "Лимит задач в работе" 8 | kanban_label_wip_limit_exceeded: "Превышен лимит задач в работе!" 9 | kanban_label_fields: "Столбцы" 10 | kanban_label_update: "Обновить" 11 | 12 | kanban_label_within_1_day: "в течение этого дня" 13 | kanban_label_within_3_days: "в течение последних 3 дней" 14 | kanban_label_within_1_week: "в течение последней недели" 15 | kanban_label_within_2_weeks: "в течение последних 2 недель" 16 | kanban_label_within_1_month: "в течение последнего месяца" 17 | kanban_label_within_2_months: "в течение последних 2 месяцев" 18 | kanban_label_within_3_months: "в течение последних 3 месяцев" 19 | 20 | kanban_label_recent_history_is_here: "Здесь будет показана недавняя история" 21 | kanban_label_recent_history: "Последние записи" 22 | kanban_label_add_notes: "Добавить запись" 23 | 24 | kanban_label_overdue: "просрочена" 25 | kanban_label_today: "сегодня" 26 | kanban_label_tommorow: "завтра" 27 | kanban_label_thisweek: "на этой неделе" 28 | kanban_label_nextweek: "на следующей неделе" 29 | 30 | kanban_label_days_left: "Осталось %{value} дней" 31 | kanban_hours_abbreviation: 'ч' 32 | 33 | kanban_label_card_size: "Размер карточек" 34 | kanban_label_card_size_small: "Маленький" 35 | kanban_label_card_size_normal_days_left: "Нормальный (срок завершения)" 36 | kanban_label_card_size_normal_estimated_hours: "Нормальный (расчетное время)" 37 | kanban_label_card_size_normal_spent_hours: "Нормальный (Рабочее время)" 38 | 39 | kanban_label_show_ancestors: "Показать pодительская задача" 40 | 41 | button_keyboard_shortcuts: "Горячие клавиши" 42 | 43 | kanban_keyboard_shortcuts_help_0: "Показать задачи текущего пользователя." 44 | kanban_keyboard_shortcuts_help_1: "Показать задачи в этом проекте для всех." 45 | kanban_keyboard_shortcuts_help_2: "Показать задачи без заданных сроков." 46 | kanban_keyboard_shortcuts_help_3: "Показать просроченные задачи." 47 | kanban_keyboard_shortcuts_help_4: "Показать задачи, истекающие сегодня." 48 | kanban_keyboard_shortcuts_help_5: "Показать задачи, истекающие на этой неделе." 49 | kanban_keyboard_shortcuts_help_6: "Показать карточки норм. размера (оставшиеся дни)." 50 | kanban_keyboard_shortcuts_help_7: "Показать карточки норм. размера (расчетное время)." 51 | kanban_keyboard_shortcuts_help_8: "Показать карточки малого размера." 52 | kanban_keyboard_shortcuts_help_9: "Выбрать все открытые вопросы." 53 | kanban_keyboard_shortcuts_help_10: "Показать эту справку." 54 | kanban_keyboard_shortcuts_help_11: "Показать карточки норм. размера (Рабочее время)." 55 | 56 | # MyPage Widget 57 | kanban_widget: Kanban 58 | 59 | # Settings 60 | label_hide_done_issues: Скрыть завершенные задачи 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redmine kanban plugin 2 | This plugin provides the Kanban board. 3 | 4 | ## What's new 5 | * #68 #69: Add D&D feature to MyPage widget. 6 | * #68 #69: Implemented MyPage widget – feedback appreciated. 7 | * Fix #67: Show private issues based on issues visibility. 8 | * Fix #66: Showing tickets for users who have lost their roles. 9 | 10 | ## Features 11 | * Issues can be displayed in a card form by status. 12 | * You can change the issue status and assignee by dragging and dropping. 13 | * You can view all issues by a group or project members. 14 | * You can display some notes of a issue by mouse-over and write the note easily. 15 | * There are many filters for display. 16 | * Warning can be displayed if the WIP limit is exceeded. 17 | * Supports English, German, Spanish, French, Polish, Russian, European Portuguese and Japanese language. 18 | 19 | ## Screenshots 20 | 21 | ### Overview 22 | 23 | 24 | ### Ticket filters 25 | 26 | 27 | ### Small card view 28 | You can display more cards than normal size cards at once. 29 | 30 | 31 | ### Show parent task in issues cards 32 | It is possible to show\hide the issue parent from the filter section. 33 | 34 | 35 | ## Keyboard Shortcuts 36 | - o : Show login user only. 37 | - e : Show everyone in the project. 38 | - d+u : Due date set to unspecified. 39 | - d+o : Due date set to overdue. 40 | - d+t : Due date set to today. 41 | - d+w : Due date set to this week. 42 | - n : Show by normal size card with days left. 43 | - k : Show by normal size card with estimated hours. 44 | - p : Show by normal size card with spent hours. 45 | - s : Show by small size card. 46 | - l : Select all open issues. 47 | - h : Show this help. 48 | 49 | ## Required Redmine version 50 | * 3.4.6.stable ~ 6.0.2.stable 51 | 52 | ## Install 53 | 54 | 1. Move to `plugins` folder. 55 |
 56 | git clone https://github.com/happy-se-life/kanban.git
 57 | 
58 | 59 | 2. Edit `lib/kanban/constants.rb` for your environment. 60 | 61 | 3. Restart redmine. 62 | 63 | 4. Enable role permission to each users groups 64 | 65 | 66 | 67 | 5. Enable modules for each project. 68 | 69 | ## Uninstall 70 | 71 | 1. Move to plugins folder. 72 | 73 | 2. Remove plugins folder. 74 |
 75 | rm -rf kanban
 76 | 
77 | 78 | 3. Restart redmine. 79 | 80 | ## Notes 81 | * It has only been used by small organizations upto 30 members before. 82 | * Therefore, the visibility authority is loosely implemented. 83 | * However, I think it is a trade-off with ease of use. 84 | 85 | ## License 86 | * MIT Lisense 87 | 88 | ## Thanks for the great contributors who helped with the localization 89 | * @aivorra for Spanish 90 | * @camlafit for French 91 | * @karnow98 for Polish 92 | * @deryaba for Russian 93 | * @HMT-HRO-MaMe for German 94 | * @guilhermelinhares for European Portuguese 95 | * @Wrightie for Italian 96 | 97 | ## Thanks for the great library 98 | * [mkoryak/floatThead](https://github.com/mkoryak/floatThead) 99 | * [jaywcjlove/hotkeys](https://github.com/jaywcjlove/hotkeys) 100 | 101 | ## Contact me 102 | * If you have any questions or ideas for improvement, please register with issue. 103 | -------------------------------------------------------------------------------- /assets/stylesheets/kanban.css: -------------------------------------------------------------------------------- 1 | /* Normal priority issue */ 2 | .my-issue-card { 3 | padding: 5px; 4 | border: solid 1px #d5d5d5; 5 | background-color: #ffffdd; 6 | margin: 8px 4px 8px 4px; 7 | word-wrap: break-word; 8 | text-align: left; 9 | white-space: normal; 10 | cursor: pointer; 11 | position: relative; 12 | border-radius:3px; 13 | } 14 | 15 | /* High priority issue */ 16 | .my-issue-card-high-priority { 17 | padding: 5px; 18 | border: solid 1px #d5d5d5; 19 | /* background-color: #ffafaf; */ 20 | background-color: #FFD4D4; 21 | margin: 8px 4px 8px 4px; 22 | word-wrap: break-word; 23 | text-align: left; 24 | white-space: normal; 25 | cursor: pointer; 26 | position: relative; 27 | border-radius:3px; 28 | } 29 | 30 | /* Low priority issue */ 31 | .my-issue-card-low-priority { 32 | padding: 5px; 33 | border: solid 1px #d5d5d5; 34 | background-color: #EAF7FF; 35 | margin: 8px 4px 8px 4px; 36 | word-wrap: break-word; 37 | text-align: left; 38 | white-space: normal; 39 | cursor: pointer; 40 | position: relative; 41 | border-radius:3px; 42 | } 43 | 44 | /* Tilt the card wheb dragged */ 45 | .dragged-issue-card { 46 | transform: rotate(-10deg); 47 | } 48 | 49 | /* Note on sidebar */ 50 | .my-journal-table { 51 | width: 100%; 52 | border: solid 1px #d5d5d5; 53 | background-color: #ffffff; 54 | margin: 8px 0px 8px 0px; 55 | word-break : break-all; 56 | text-align: left; 57 | white-space: normal; 58 | border-collapse: separate; 59 | border-radius:4px; 60 | } 61 | .my-journal-table td { 62 | text-align: left !important; 63 | } 64 | 65 | /* New comment table */ 66 | .my-comment-table { 67 | width: 100%; 68 | margin: 8px 0px 8px 0px; 69 | } 70 | 71 | /* New comment textarea */ 72 | .my-comment-textarea { 73 | box-sizing:border-box; 74 | resize: none; 75 | width: 100%; 76 | } 77 | 78 | /* String ellipsis */ 79 | .my-string-ellipsis { 80 | overflow: hidden; 81 | text-overflow: ellipsis; 82 | white-space: nowrap; 83 | } 84 | 85 | /* Normal priority issue (small size) */ 86 | .my-issue-card-small { 87 | padding: 3px; 88 | border: solid 1px #d5d5d5; 89 | background-color: #ffffdd; 90 | margin: 4px 4px 4px 4px; 91 | word-wrap: break-word; 92 | text-align: left; 93 | white-space: normal; 94 | cursor: pointer; 95 | position: relative; 96 | border-radius:3px; 97 | } 98 | /* High priority issue (small size) */ 99 | .my-issue-card-high-priority-small { 100 | padding: 3px; 101 | border: solid 1px #d5d5d5; 102 | /* background-color: #ffafaf; */ 103 | background-color: #FFD4D4; 104 | margin: 4px 4px 4px 4px; 105 | word-wrap: break-word; 106 | text-align: left; 107 | white-space: normal; 108 | cursor: pointer; 109 | position: relative; 110 | border-radius:3px; 111 | } 112 | 113 | /* Low priority issue (small size) */ 114 | .my-issue-card-low-priority-small { 115 | padding: 3px; 116 | border: solid 1px #d5d5d5; 117 | background-color: #EAF7FF; 118 | margin: 4px 4px 4px 4px; 119 | word-wrap: break-word; 120 | text-align: left; 121 | white-space: normal; 122 | cursor: pointer; 123 | position: relative; 124 | border-radius:3px; 125 | } 126 | 127 | .ancestor{ 128 | color:rgb(119, 151, 194); 129 | font-size: 10px; 130 | } 131 | .context-menu-selection p { 132 | color:rgb(184, 224, 244); 133 | } 134 | 135 | #kanban_table { 136 | width: 100%; 137 | table-layout: auto; 138 | } 139 | 140 | .floatThead-container { 141 | width: 100% !important; 142 | } 143 | -------------------------------------------------------------------------------- /app/controllers/journal_controller.rb: -------------------------------------------------------------------------------- 1 | class JournalController < ApplicationController 2 | 3 | def index 4 | end 5 | 6 | # 7 | # Get journals to display on sidebar 8 | # 9 | def get_journal 10 | # Get POST value 11 | card_id = params[:card_id] 12 | 13 | # Target issue ID 14 | issue_array = card_id.split("-") 15 | issue_id = issue_array[1].to_i 16 | 17 | # Declaring variable to return 18 | result_hash = {} 19 | 20 | # Target issue 21 | issue = Issue.find(issue_id); 22 | 23 | # ヘッダ表示 24 | notes_string ="

#" + issue_id.to_s + "

" 25 | notes_string ="

#" + issue_id.to_s + "

" 26 | 27 | # Building html of issue description 28 | if !issue.description.blank? then 29 | user = User.find(issue.author_id) 30 | notes_string +="

" + I18n.t(:field_description) + "

" 31 | notes_string += "" 32 | notes_string += "" 33 | notes_string += "" 40 | notes_string += "" 41 | notes_string += "" 42 | notes_string += "" 45 | notes_string += "" 46 | notes_string += "
" 34 | notes_string += "" 35 | notes_string += issue.created_on.strftime("%Y-%m-%d %H:%M:%S") 36 | notes_string += "" 37 | notes_string += " " 38 | notes_string += user.name 39 | notes_string += "
" 43 | notes_string += CGI.escapeHTML(trim_notes(issue.description)) 44 | notes_string += "
" 47 | end 48 | 49 | # Get newer 3 journals 50 | notes = Journal.where(journalized_id: issue_id) 51 | .where(journalized_type: "Issue") 52 | .where(private_notes: 0) 53 | .where("notes IS NOT NULL") 54 | .where("notes <> ''") 55 | .order(created_on: :desc) 56 | .limit(Kanban::Constants::MAX_NOTES) 57 | 58 | # Adding string for recent history 59 | if !notes.blank? then 60 | notes_string +="

" + I18n.t(:kanban_label_recent_history) + "

" 61 | end 62 | 63 | # Building html of notes 64 | if Kanban::Constants::ORDER_NOTES == "ASC" then 65 | notes.reverse_each {|note| 66 | notes_string += note_to_html(issue_id, note) 67 | } 68 | else 69 | # DESC 70 | notes.each {|note| 71 | notes_string += note_to_html(issue_id, note) 72 | } 73 | end 74 | 75 | # Buiding input area for new note 76 | notes_string += "

" + I18n.t(:kanban_label_add_notes) + "

" 77 | notes_string += "
" 78 | notes_string += "" 79 | notes_string += "

" 80 | notes_string += "
" 81 | 82 | # Ignoring failing 83 | result_hash["result"] = "OK" 84 | result_hash["notes"] = notes_string 85 | render json: result_hash 86 | end 87 | 88 | # 89 | # Shorten strings 90 | # 91 | def trim_notes(notes) 92 | str = notes.byteslice(0, Kanban::Constants::MAX_NOTES_BYTESIZE).scrub('') 93 | if notes.bytesize >= Kanban::Constants::MAX_NOTES_BYTESIZE then 94 | str += "..." 95 | end 96 | return str 97 | end 98 | 99 | # 100 | # Add new journal 101 | # 102 | def put_journal 103 | # Get POST value 104 | card_id = params[:card_id] 105 | note = params[:note] 106 | 107 | # Declaring variable to return 108 | result_hash = {} 109 | 110 | # Target issue ID 111 | issue_array = card_id.split("-") 112 | issue_id = issue_array[1].to_i 113 | 114 | # Target issue 115 | issue = Issue.find(issue_id) 116 | 117 | # Create new journal and save 118 | note = Journal.new(:journalized => issue, :notes => note, user: User.current) 119 | note.save! 120 | 121 | # Ignoring failing 122 | result_hash["result"] = "OK" 123 | render json: result_hash 124 | end 125 | 126 | # 127 | # Return html of note 128 | # 129 | def note_to_html(issue_id, note) 130 | html = "" 131 | if !note.notes.blank? then 132 | user = User.find(note.user_id) 133 | html += "" 134 | html += "" 135 | html += "" 142 | html += "" 143 | html += "" 144 | html += "" 147 | html += "" 148 | html += "
" 136 | html += "" 137 | html += note.created_on.strftime("%Y-%m-%d %H:%M:%S") 138 | html += "" 139 | html += " " 140 | html += user.name 141 | html += "
" 145 | html += CGI.escapeHTML(trim_notes(note.notes)) 146 | html += "
" 149 | end 150 | return html 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /app/views/my/blocks/_kanban_widget.html.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | <%= link_to sprite_icon('settings', l(:label_options)), '#', 5 | :class => 'icon-only icon-settings', 6 | :title => l(:label_options), 7 | :data => {:toggle => 'kanban-settings'} %> 8 |
9 | 10 | 11 | 28 | 29 | <% 30 | # Direct data build. 31 | 32 | # Get user 33 | @current_user = User.current 34 | 35 | # Get all status order by position 36 | @issue_statuses = IssueStatus.all.order("position ASC") 37 | @issue_statuses_hash = {} 38 | @issue_statuses.each {|issue_status| 39 | @issue_statuses_hash[issue_status.id.to_i] = issue_status.name 40 | } 41 | 42 | # Get statuses for issue done 43 | @done_issue_statuses_array = IssueStatus.where(is_closed: 1).pluck(:id) 44 | 45 | # Array of status ID for display 46 | @status_fields_array = Kanban::Constants::DEFAULT_STATUS_FIELD_VALUE_ARRAY 47 | 48 | # Note: We keep all statuses in @status_fields_array to show columns 49 | # but will filter out done statuses from @issues_hash when setting is enabled 50 | 51 | # Get projects for current user 52 | @user_projects = Project.visible(@current_user).order(:name) 53 | 54 | # Count issues per project 55 | @project_issues_count = {} 56 | @user_projects.each do |project| 57 | project_issues_count = 0 58 | @status_fields_array.each do |status_id| 59 | project_issues = Issue.where(assigned_to_id: @current_user, project_id: project.id, status: status_id) 60 | project_issues_count += project_issues.count 61 | end 62 | @project_issues_count[project.id] = project_issues_count 63 | end 64 | 65 | # Remove projects with 0 issues 66 | @user_projects = @user_projects.select { |project| @project_issues_count[project.id] > 0 } 67 | 68 | # Declaring variables 69 | @issues_hash = {} 70 | 71 | # Get issues using status loop 72 | @status_fields_array.each {|status_id| 73 | if @done_issue_statuses_array.include?(status_id) == false then 74 | # Case not done status 75 | issues = Issue.where(assigned_to_id: @current_user).where(status: status_id) 76 | @issues_hash[status_id] = issues.order(updated_on: "DESC").limit(Kanban::Constants::SELECT_LIMIT) 77 | else 78 | # Case done status - show empty array if hiding done issues 79 | if settings[:hide_done_issues] == '1' then 80 | @issues_hash[status_id] = [] 81 | else 82 | issues = Issue.where(assigned_to_id: @current_user).where(status: status_id) 83 | @issues_hash[status_id] = issues.order(updated_on: "DESC").limit(Kanban::Constants::SELECT_LIMIT) 84 | end 85 | end 86 | } 87 | 88 | # Count issues 89 | @issues_count = 0 90 | @issues_hash.each {|status_id, issues| 91 | @issues_count += issues.count 92 | } 93 | %> 94 | 95 | 96 | <%= stylesheet_link_tag 'kanban', :plugin => 'kanban' %> 97 | 98 |

Kanban (<%= @issues_count %>)

99 | 100 | 101 |
102 | 103 | 104 | 105 | 106 | <% @status_fields_array.each do |status_id| %> 107 | 108 | <% end %> 109 | 110 | 111 | 112 | <% @user_projects.each do |project| %> 113 | 114 | 115 | <% @status_fields_array.each do |status_id| %> 116 | 160 | <% end %> 161 | 162 | <% end %> 163 | 164 |
<%= I18n.t(:field_project) %><%= @issue_statuses_hash[status_id] %>
<%= link_to project.name, project_path(project), class: 'project-link' %> 117 | <% 118 | # Get issues for this project and status 119 | issues_for_status = @issues_hash[status_id] || [] 120 | project_issues = issues_for_status.select { |issue| issue.project_id == project.id } 121 | %> 122 | <% project_issues.each do |issue| %> 123 | <% 124 | # Determine CSS class based on priority 125 | priority_class = if issue.priority_id >= Kanban::Constants::DEFAULT_HIGH_PRIORITY_ISSUE_ID 126 | 'my-issue-card-high-priority' 127 | elsif issue.priority_id == Kanban::Constants::DEFAULT_NORMAL_PRIORITY_ISSUE_ID 128 | 'my-issue-card' 129 | else 130 | 'my-issue-card-low-priority' 131 | end 132 | %> 133 |
134 |
135 | <%= issue.tracker.name %> #<%= issue.id %> 136 |
137 |
138 | <%= link_to issue.subject, issue_path(issue), class: 'issue-link' %> 139 |
140 |
141 |
142 | 143 | <% if issue.due_date %> 144 | Due: <%= issue.due_date.strftime('%m/%d') %> 145 | <% else %> 146 | Due: - 147 | <% end %> 148 | 149 | 150 | <% if issue.estimated_hours %> 151 | Est: <%= issue.estimated_hours %>h 152 | <% else %> 153 | Est: - 154 | <% end %> 155 | 156 |
157 |
158 | <% end %> 159 |
165 |
166 | 167 | 279 | 280 | 281 | 346 | 347 |
-------------------------------------------------------------------------------- /assets/javascripts/kanban.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | 3 | // Redraw table header 4 | $('#upper_filters').on('click',function(){ 5 | $('#kanban_table').floatThead('reflow') 6 | }); 7 | 8 | // Redraw table header 9 | $('#lower_filters').on('click',function(){ 10 | $('#kanban_table').floatThead('reflow') 11 | }); 12 | 13 | // Show sidebar 14 | $('#main').removeClass('nosidebar'); 15 | 16 | // Override sidebar style 17 | $('#sidebar').css({"cssText" : "padding : 0 8px 0px 8px !important"}); 18 | 19 | // Floating table header 20 | $('#kanban_table').floatThead({ 21 | responsiveContainer: function($table) { 22 | return $table.closest('#content'); // Use the right container 23 | }, 24 | zIndex: 39 25 | }); 26 | //Moved from beginning of file so that float table loads after sidebar. 27 | //Placing it here, after the sidebar fixes an issue with Kanban Board overflowing to end of page until reflow. 28 | 29 | // Initial message when no note 30 | var initial_string = "
" + label_recent_history_is_here + "
" 31 | $('#sidebar').html(initial_string); 32 | let timeout; 33 | // When mouse over 34 | $('[id^=issue-]').hover(function() { 35 | var card = $(this); 36 | // Exec. after 500ms 37 | timeout = setTimeout(function() { 38 | var card_id = card.attr('id'); 39 | // Get journal 40 | getJournal(card_id); 41 | }, 500) 42 | }, function() { 43 | // Cancel before exec. 44 | clearTimeout(timeout) 45 | }); 46 | 47 | // When window scrolled 48 | $(window).scroll(function() { 49 | var top = $(this).scrollTop(); 50 | var content = $('#content').offset(); 51 | // Make sidebar follow the scroll 52 | if (content.top < top) { 53 | $('#sidebar').offset({ top: top + 10 }); 54 | } else { 55 | $('#sidebar').offset({ top: content.top }); 56 | } 57 | // Save hidden 58 | $("#scroll_top").val(top); 59 | }); 60 | 61 | // Definition of dialog when card dropped 62 | $("#comment-dialog").dialog({ 63 | title: label_add_notes, 64 | width: 400, 65 | autoOpen: false, 66 | modal: true, 67 | buttons: { 68 | "OK": function() { 69 | $(this).dialog("close"); 70 | // Save ticket (change status and assignee) 71 | saveTicket( 72 | $('#save_card_id').val(), 73 | $('#save_from_field_id').val(), 74 | $('#save_to_field_id').val(), 75 | $('#comment-of-dialog').val() 76 | ); 77 | // Clear inputs 78 | $('#comment-of-dialog').val(''); 79 | }, 80 | "Cancel": function() { 81 | $(this).dialog("close"); 82 | // Reload page 83 | $('#form1').submit(); 84 | } 85 | } 86 | }); 87 | 88 | // Card can be draggable 89 | $("[id^=issue-]").draggable({ stack: "[id^=issue-]", drag: function( event, ui ) {ui.helper.addClass("dragged-issue-card")}}); 90 | 91 | // Card can be droppable 92 | $("[id^=field-]").droppable({ 93 | accept : "[id^=issue]" , 94 | drop : function(event , ui){ 95 | // Level the card 96 | ui.helper.removeClass("dragged-issue-card"); 97 | // Field ID when drag 98 | $('#save_card_id').val(ui.draggable.attr('id')); 99 | $('#save_from_field_id').val(ui.draggable.parent().attr('id')); 100 | // Field ID when drop 101 | $('#save_to_field_id').val($(this).attr('id')); 102 | // Insert card to 103 | if (ui.position.top < 0) { 104 | // Insert to top 105 | $(this).prepend(ui.draggable.css('left','').css('top','')); 106 | } else { 107 | // Insert to bottom 108 | $(this).append(ui.draggable.css('left','').css('top','')); 109 | } 110 | // Display comment dialog when drop 111 | if (option_display_comment_dialog_when_drop == "1") { 112 | $("#comment-dialog").dialog("open"); 113 | } else { 114 | // Save ticket (change status and assignee) 115 | saveTicket( 116 | $('#save_card_id').val(), 117 | $('#save_from_field_id').val(), 118 | $('#save_to_field_id').val(), 119 | "" 120 | ); 121 | } 122 | } 123 | }); 124 | 125 | // Description of keyboard shortcuts dialog 126 | $("#keyboard-chortcut-dialog").dialog({ 127 | title: "Keyboard Shortcuts", 128 | width: 480, 129 | autoOpen: false, 130 | modal: true, 131 | buttons: { 132 | "OK": function() { 133 | $(this).dialog("close"); 134 | } 135 | } 136 | }); 137 | 138 | // Only open versions checkbox 139 | $("#cbx").on("click", function(){ 140 | if($(this).prop("checked") == true){ 141 | $("#open_versions").val("1"); 142 | }else{ 143 | $("#open_versions").val("0"); 144 | } 145 | }); 146 | 147 | // Show ancestor checkbox 148 | $("#cb_ancestor").on("click", function(){ 149 | if($(this).prop("checked") == true){ 150 | $("#show_ancestors").val("1"); 151 | }else{ 152 | $("#show_ancestors").val("0"); 153 | } 154 | }); 155 | }); 156 | 157 | // 158 | // Save ticket (change status and assignee, save comment) 159 | // 160 | function saveTicket(card_id, from_field_id, to_field_id, comment) { 161 | // AJAX 162 | $.ajax({ 163 | url:'./update_status', 164 | type:'POST', 165 | data:{ 166 | 'card_id' :card_id, 167 | 'field_id' :to_field_id, 168 | 'comment' :comment 169 | }, 170 | dataType: 'json', 171 | async: true 172 | }) 173 | // Case ajax succeed 174 | .done( (data) => { 175 | console.log(data.result); 176 | if (data.result == "OK") { 177 | // Count up counter on 178 | var tmp1 = to_field_id.split('-'); 179 | var to_counter_id = 'counter-' + tmp1[1]; 180 | var to_value = Number($('#' + to_counter_id).html()) + 1; 181 | $('#'+to_counter_id).html(to_value); 182 | // Count down counter on 183 | var tmp2 = from_field_id.split('-'); 184 | var from_counter_id = 'counter-' + tmp2[1]; 185 | var from_value = Number($('#' + from_counter_id).html()) - 1; 186 | $('#'+from_counter_id).html(from_value); 187 | // Get WIP limit value 188 | var wip_field = Number($('#wip-field').html()); 189 | var wip_limit = $('#wip_max option:selected').val(); 190 | // Case card move to WIP field (count up) 191 | if (tmp1[1] == wip_field) { 192 | if (tmp1[2] == tmp2[2]) { // Case same user 193 | var wip_next1 = Number($('#wip-' + tmp1[2]).html()) + 1; 194 | $('#wip-' + tmp1[2]).html(wip_next1); 195 | } else { // Case different user 196 | if (tmp1[2] == tmp2[2]) { // Case same status 197 | if (data.user_id != null) { 198 | var wip_next1 = Number($('#wip-' + tmp1[2]).html()) + 1; 199 | $('#wip-' + tmp1[2]).html(wip_next1); 200 | } 201 | var wip_next2 = Number($('#wip-' + tmp2[2]).html()) - 1; 202 | $('#wip-' + tmp2[2]).html(wip_next2); 203 | } else { // Case different status 204 | var wip_next1 = Number($('#wip-' + tmp1[2]).html()) + 1; 205 | $('#wip-' + tmp1[2]).html(wip_next1); 206 | } 207 | } 208 | // Show or hide WIP warning 209 | if (wip_next1 > Number(wip_limit)) { 210 | $('#' + to_field_id).prepend($('#wip_error-' + tmp1[2])); 211 | $('#wip_error-' + tmp1[2]).show(); 212 | } else { 213 | $('#wip_error-' + tmp1[2]).hide(); 214 | } 215 | } 216 | // Case card move from WIP field (count down) 217 | if (tmp2[1] == wip_field) { 218 | if (tmp1[2] == tmp2[2]) { // Case same user 219 | var wip_next2 = Number($('#wip-' + tmp2[2]).html()) - 1; 220 | $('#wip-' + tmp2[2]).html(wip_next2); 221 | } else { // Case different user 222 | if (tmp1[2] == tmp2[2]) { // Case same status 223 | if (data.user_id != null) { 224 | var wip_next1 = Number($('#wip-' + tmp1[2]).html()) + 1; 225 | $('#wip-' + tmp1[2]).html(wip_next1); 226 | } 227 | var wip_next2 = Number($('#wip-' + tmp2[2]).html()) - 1; 228 | $('#wip-' + tmp2[2]).html(wip_next2); 229 | } else { // Case different status 230 | var wip_next2 = Number($('#wip-' + tmp2[2]).html()) - 1; 231 | $('#wip-' + tmp2[2]).html(wip_next2); 232 | } 233 | } 234 | // Show or hide WIP warning 235 | if (wip_next2 > Number(wip_limit)) { 236 | $('#' + from_field_id).prepend($('#wip_error-' + tmp2[2])); 237 | $('#wip_error-' + tmp2[2]).show(); 238 | } else { 239 | $('#wip_error-' + tmp2[2]).hide(); 240 | } 241 | } 242 | // Write user name on card 243 | if (data.user_id != null) { 244 | $('#user_name_' + card_id).html($('#user_name_user_id-' + data.user_id).html()); 245 | } else { 246 | $('#user_name_' + card_id).html("

Not assigned

"); 247 | } 248 | } 249 | if (data.result == "NG") { 250 | alert("Operation not permitted") 251 | // Reload page 252 | $('#form1').submit(); 253 | } 254 | }) 255 | // Case ajax failed 256 | .fail( (data) => { 257 | console.log("AJAX FAILED."); 258 | // Reload page 259 | $('#form1').submit(); 260 | }); 261 | } 262 | 263 | // 264 | // Get journal 265 | // 266 | function getJournal(card_id) { 267 | // AJAX 268 | $.ajax({ 269 | url:'./get_journal', 270 | type:'POST', 271 | data:{ 272 | 'card_id' :card_id , 273 | }, 274 | dataType: 'json', 275 | async: true, 276 | global: false 277 | }) 278 | // Case ajax succeed 279 | .done( (data) => { 280 | console.log(data.result); 281 | if (data.result == "OK") { 282 | // Display on sidebar 283 | $('#sidebar').html(data.notes); 284 | // Register click event 285 | $('#submit-journal-button').on('click',function(){ 286 | putJournal(card_id); 287 | }); 288 | } 289 | }) 290 | // Case ajax failed 291 | .fail( (data) => { 292 | console.log("AJAX FAILED."); 293 | }) 294 | } 295 | 296 | // 297 | // Add new journal 298 | // 299 | function putJournal(card_id) { 300 | var note = $('#comment_area').val(); 301 | // AJAX 302 | $.ajax({ 303 | url:'./put_journal', 304 | type:'POST', 305 | data:{ 306 | 'card_id' :card_id , 307 | 'note' : note 308 | }, 309 | dataType: 'json', 310 | async: true, 311 | }) 312 | // Case ajax succeed 313 | .done( (data) => { 314 | console.log(data.result); 315 | if (data.result == "OK") { 316 | // Reread journal 317 | getJournal(card_id); 318 | } 319 | }) 320 | // Case ajax failed 321 | .fail( (data) => { 322 | console.log("AJAX FAILED."); 323 | }) 324 | } 325 | 326 | // Suppress messages "Leave this site?" 327 | Object.defineProperty(window, 'onbeforeunload', { 328 | set(newValue) { 329 | if (typeof newValue === 'function') window.onbeforeunload = null; 330 | } 331 | }); 332 | 333 | // Keyboard Shortcuts 334 | hotkeys('o,e,k,d+u,d+o,d+t,d+w,n,s,h,l,p', function(event,handler) { 335 | switch(handler.key){ 336 | // assignee == (login user) 337 | case "o": 338 | $('#user_id').val(login_user_id); 339 | break; 340 | // assignee == (everyone in the project) 341 | case "e": 342 | $('#user_id').val("unspecified"); 343 | break; 344 | // due_date == unspecified 345 | case "d+u": 346 | $('#due_date').val("unspecified"); 347 | break; 348 | // due_date == overdue 349 | case "d+o": 350 | $('#due_date').val("overdue"); 351 | break; 352 | // due_date == today 353 | case "d+t": 354 | $('#due_date').val("today"); 355 | break; 356 | // due_date == thisweek 357 | case "d+w": 358 | $('#due_date').val("thisweek"); 359 | break; 360 | // card_size == normal_days_left 361 | case "n": 362 | $('#card_size').val("normal_days_left"); 363 | break; 364 | // card_size == normal_estimated_hours 365 | case "k": 366 | $('#card_size').val("normal_estimated_hours"); 367 | break; 368 | // card_size == normal_spent_hours 369 | case "p": 370 | $('#card_size').val("normal_spent_hours"); 371 | break; 372 | // card_size == small 373 | case "s": 374 | $('#card_size').val("small"); 375 | break; 376 | // show this 377 | case "h": 378 | $('#keyboard-chortcut-dialog').dialog('open'); 379 | return; 380 | // select all open issues 381 | case "l": 382 | var tmp = $('#open-field-ids').html().split(' '); 383 | for(let i in tmp) { 384 | $("[id^=field-" + tmp[i] + "-]").find('input').click(); 385 | } 386 | return; 387 | } 388 | // reload page 389 | $('#form1').submit(); 390 | }); 391 | -------------------------------------------------------------------------------- /assets/javascripts/hotkeys.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * hotkeys-js v3.8.1 3 | * A simple micro-library for defining and dispatching keyboard shortcuts. It has no dependencies. 4 | * 5 | * Copyright (c) 2020 kenny wong 6 | * http://jaywcjlove.github.io/hotkeys 7 | * 8 | * Licensed under the MIT license. 9 | */ 10 | 11 | (function (global, factory) { 12 | typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : 13 | typeof define === 'function' && define.amd ? define(factory) : 14 | (global = global || self, global.hotkeys = factory()); 15 | }(this, (function () { 'use strict'; 16 | 17 | var isff = typeof navigator !== 'undefined' ? navigator.userAgent.toLowerCase().indexOf('firefox') > 0 : false; // 绑定事件 18 | 19 | function addEvent(object, event, method) { 20 | if (object.addEventListener) { 21 | object.addEventListener(event, method, false); 22 | } else if (object.attachEvent) { 23 | object.attachEvent("on".concat(event), function () { 24 | method(window.event); 25 | }); 26 | } 27 | } // 修饰键转换成对应的键码 28 | 29 | 30 | function getMods(modifier, key) { 31 | var mods = key.slice(0, key.length - 1); 32 | 33 | for (var i = 0; i < mods.length; i++) { 34 | mods[i] = modifier[mods[i].toLowerCase()]; 35 | } 36 | 37 | return mods; 38 | } // 处理传的key字符串转换成数组 39 | 40 | 41 | function getKeys(key) { 42 | if (typeof key !== 'string') key = ''; 43 | key = key.replace(/\s/g, ''); // 匹配任何空白字符,包括空格、制表符、换页符等等 44 | 45 | var keys = key.split(','); // 同时设置多个快捷键,以','分割 46 | 47 | var index = keys.lastIndexOf(''); // 快捷键可能包含',',需特殊处理 48 | 49 | for (; index >= 0;) { 50 | keys[index - 1] += ','; 51 | keys.splice(index, 1); 52 | index = keys.lastIndexOf(''); 53 | } 54 | 55 | return keys; 56 | } // 比较修饰键的数组 57 | 58 | 59 | function compareArray(a1, a2) { 60 | var arr1 = a1.length >= a2.length ? a1 : a2; 61 | var arr2 = a1.length >= a2.length ? a2 : a1; 62 | var isIndex = true; 63 | 64 | for (var i = 0; i < arr1.length; i++) { 65 | if (arr2.indexOf(arr1[i]) === -1) isIndex = false; 66 | } 67 | 68 | return isIndex; 69 | } 70 | 71 | var _keyMap = { 72 | backspace: 8, 73 | tab: 9, 74 | clear: 12, 75 | enter: 13, 76 | return: 13, 77 | esc: 27, 78 | escape: 27, 79 | space: 32, 80 | left: 37, 81 | up: 38, 82 | right: 39, 83 | down: 40, 84 | del: 46, 85 | delete: 46, 86 | ins: 45, 87 | insert: 45, 88 | home: 36, 89 | end: 35, 90 | pageup: 33, 91 | pagedown: 34, 92 | capslock: 20, 93 | '⇪': 20, 94 | ',': 188, 95 | '.': 190, 96 | '/': 191, 97 | '`': 192, 98 | '-': isff ? 173 : 189, 99 | '=': isff ? 61 : 187, 100 | ';': isff ? 59 : 186, 101 | '\'': 222, 102 | '[': 219, 103 | ']': 221, 104 | '\\': 220 105 | }; // Modifier Keys 106 | 107 | var _modifier = { 108 | // shiftKey 109 | '⇧': 16, 110 | shift: 16, 111 | // altKey 112 | '⌥': 18, 113 | alt: 18, 114 | option: 18, 115 | // ctrlKey 116 | '⌃': 17, 117 | ctrl: 17, 118 | control: 17, 119 | // metaKey 120 | '⌘': 91, 121 | cmd: 91, 122 | command: 91 123 | }; 124 | var modifierMap = { 125 | 16: 'shiftKey', 126 | 18: 'altKey', 127 | 17: 'ctrlKey', 128 | 91: 'metaKey', 129 | shiftKey: 16, 130 | ctrlKey: 17, 131 | altKey: 18, 132 | metaKey: 91 133 | }; 134 | var _mods = { 135 | 16: false, 136 | 18: false, 137 | 17: false, 138 | 91: false 139 | }; 140 | var _handlers = {}; // F1~F12 special key 141 | 142 | for (var k = 1; k < 20; k++) { 143 | _keyMap["f".concat(k)] = 111 + k; 144 | } 145 | 146 | var _downKeys = []; // 记录摁下的绑定键 147 | 148 | var _scope = 'all'; // 默认热键范围 149 | 150 | var elementHasBindEvent = []; // 已绑定事件的节点记录 151 | // 返回键码 152 | 153 | var code = function code(x) { 154 | return _keyMap[x.toLowerCase()] || _modifier[x.toLowerCase()] || x.toUpperCase().charCodeAt(0); 155 | }; // 设置获取当前范围(默认为'所有') 156 | 157 | 158 | function setScope(scope) { 159 | _scope = scope || 'all'; 160 | } // 获取当前范围 161 | 162 | 163 | function getScope() { 164 | return _scope || 'all'; 165 | } // 获取摁下绑定键的键值 166 | 167 | 168 | function getPressedKeyCodes() { 169 | return _downKeys.slice(0); 170 | } // 表单控件控件判断 返回 Boolean 171 | // hotkey is effective only when filter return true 172 | 173 | 174 | function filter(event) { 175 | var target = event.target || event.srcElement; 176 | var tagName = target.tagName; 177 | var flag = true; // ignore: isContentEditable === 'true', and 329 | 330 | 331 | 383 | 384 | <% string_open_fields="" %> 385 | <% @status_fields_array.each {|status_id| %> 386 | <% if @done_issue_statuses_array.include?(status_id) == false then %> 387 | <% string_open_fields = string_open_fields + " " + status_id.to_s %> 388 | <% end %> 389 | <% } %> 390 | 391 | -------------------------------------------------------------------------------- /app/controllers/kanban_controller.rb: -------------------------------------------------------------------------------- 1 | class KanbanController < ApplicationController 2 | if Redmine::VERSION::MAJOR < 4 || (Redmine::VERSION::MAJOR == 4 && Redmine::VERSION::MINOR < 1) 3 | unloadable 4 | end 5 | before_action :global_authorize 6 | # 7 | # Display kanban board 8 | # 9 | def index 10 | # Do not cache on browser back 11 | response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0, post-check=0, pre-check=0' 12 | response.headers['Pragma'] = 'no-cache' 13 | 14 | # Discard session 15 | if params[:clear].to_i == 1 then 16 | discard_session 17 | end 18 | 19 | # Restore session 20 | restore_params_from_session 21 | 22 | # Initialize params 23 | initialize_params 24 | 25 | # Store session 26 | store_params_to_session 27 | 28 | # Get user to display avator 29 | if @user_id == "unspecified" then 30 | @user = User.find(@current_user.id) 31 | else 32 | @user = User.find(@user_id.to_i) 33 | end 34 | 35 | # Get current project 36 | if @project_id.blank? then 37 | @project = nil 38 | else 39 | @project = Project.find(@project_id) 40 | end 41 | 42 | # Get users for assignee filetr 43 | if @project_all == "1" then 44 | @selectable_users = User.where(type: "User").where(status: 1) 45 | else 46 | # Get users who have roles in the project 47 | project_users = @project.users 48 | 49 | # Get users who are assigned to issues in the project (including those without roles) 50 | assigned_user_ids = Issue.where(project_id: @project.id) 51 | .where.not(assigned_to_id: nil) 52 | .pluck(:assigned_to_id) 53 | .uniq 54 | 55 | assigned_users = User.where(id: assigned_user_ids, type: "User", status: 1) 56 | 57 | # Combine project users and assigned users 58 | @selectable_users = User.where(id: (project_users.pluck(:id) + assigned_users.pluck(:id)).uniq) 59 | .where(type: "User", status: 1) 60 | end 61 | 62 | # Get groups for group filetr 63 | if @project_all == "1" then 64 | @selectable_groups = Group.where(type: "Group") 65 | else 66 | members = Member.where(project_id: @project.id) 67 | member_user_ids = [] 68 | members.each {|member| 69 | member_user_ids << member.user_id 70 | } 71 | @selectable_groups = Group.where(type: "Group").where(id: member_user_ids) 72 | end 73 | 74 | # Create array of dispaly user IDs belongs to the group 75 | @user_id_array = [] 76 | if @group_id == "unspecified" then 77 | # Case group is unspecified 78 | if @user_id == "unspecified" then 79 | # Case user is unspecified 80 | @user_id_array = @selectable_users.ids 81 | else 82 | # Case user is specified 83 | @user_id_array << @user.id 84 | end 85 | else 86 | # Case group is specified 87 | @selectable_groups.each {|group| 88 | if group.id == @group_id.to_i 89 | @user_id_array = group.user_ids 90 | end 91 | } 92 | end 93 | 94 | # Remove inactive users from array of display users 95 | copied_user_id_array = @user_id_array.dup 96 | copied_user_id_array.each {|id| 97 | if !@selectable_users.ids.include?(id) then 98 | @user_id_array.delete(id) 99 | end 100 | } 101 | 102 | # Move current user to head 103 | selected_user_index = @user_id_array.index(@user.id) 104 | if @user_id_array.length > 1 && selected_user_index != nil then 105 | swap_id = @user_id_array[0] 106 | @user_id_array[selected_user_index] = swap_id 107 | @user_id_array[0] = @user.id 108 | end 109 | 110 | # When system settings of issue_group_assignment is true, 111 | # add group ID to array of display users 112 | @group_id_array = [] 113 | if Setting.issue_group_assignment? then 114 | if @group_id == "unspecified" then 115 | @selectable_groups.each {|group| 116 | @user_id_array << group.id 117 | @group_id_array << group.id 118 | } 119 | else 120 | @user_id_array << @group_id.to_i 121 | @group_id_array << @group_id.to_i 122 | end 123 | end 124 | 125 | # Create hash of users/groups name 126 | @user_and_group_names_hash = {} 127 | @selectable_users.each {|user| 128 | @user_and_group_names_hash[user.id] = user.name 129 | } 130 | @selectable_groups.each {|group| 131 | @user_and_group_names_hash[group.id] = group.name 132 | } 133 | 134 | # Get all status orderby position 135 | @issue_statuses = IssueStatus.all.order("position ASC") 136 | @issue_statuses_hash = {} 137 | @issue_statuses.each {|issue_status| 138 | @issue_statuses_hash[issue_status.id.to_i] = issue_status.name 139 | } 140 | 141 | # Get statuses for issue closed 142 | @done_issue_statuses = IssueStatus.where(is_closed: 1) 143 | @done_issue_statuses_array = [] 144 | @done_issue_statuses.each {|issue_status| 145 | @done_issue_statuses_array << issue_status.id 146 | } 147 | 148 | # Get trackers 149 | if @project.nil? then 150 | @trackers = Tracker.sorted 151 | else 152 | @trackers = @project.rolled_up_trackers 153 | end 154 | 155 | # Updated datetime for filter 156 | if @updated_within == "unspecified" then 157 | updated_from = "1970-01-01 00:00:00" 158 | else 159 | time_from = Time.now - 3600 * 24 * @updated_within.to_i 160 | updated_from = time_from.strftime("%Y-%m-%d 00:00:00") 161 | end 162 | 163 | # Closed datetime for filter 164 | if @done_within == "unspecified" then 165 | closed_from = "1970-01-01 00:00:00" 166 | else 167 | time_from = Time.now - 3600 * 24 * @done_within.to_i 168 | closed_from = time_from.strftime("%Y-%m-%d 00:00:00") 169 | end 170 | 171 | # Due datetime for filetr 172 | due_from = "" 173 | due_to = "" 174 | due_now = Time.now 175 | case @due_date 176 | when "overdue" then 177 | due_from = "1970-01-01" 178 | due_to = due_now.yesterday.strftime("%Y-%m-%d") 179 | when "today" then 180 | due_from = due_now.strftime("%Y-%m-%d") 181 | due_to = due_from 182 | when "tommorow" then 183 | due_from = due_now.tomorrow.strftime("%Y-%m-%d") 184 | due_to = due_from 185 | when "thisweek" then 186 | due_from = due_now.beginning_of_week.strftime("%Y-%m-%d") 187 | due_to = due_now.end_of_week.strftime("%Y-%m-%d") 188 | when "nextweek" then 189 | due_from = due_now.next_week.beginning_of_week.strftime("%Y-%m-%d") 190 | due_to = due_now.next_week.end_of_week.strftime("%Y-%m-%d") 191 | end 192 | 193 | # Get issues related to display users 194 | issues_for_projects = Issue.where(assigned_to_id: @user_id_array) 195 | .where("updated_on >= '" + updated_from + "'") 196 | .where(get_issues_visibility_condition) 197 | 198 | if Kanban::Constants::SELECT_LIMIT_STRATEGY == 1 then 199 | issues_for_projects = issues_for_projects.limit(Kanban::Constants::SELECT_LIMIT) 200 | end 201 | 202 | # Unique project IDs 203 | unique_project_id_array = [] 204 | if @project_all == "1" then 205 | issues_for_projects.each {|issue| 206 | if unique_project_id_array.include?(issue.project.id.to_i) == false then 207 | unique_project_id_array << issue.project.id.to_i 208 | end 209 | } 210 | else 211 | # When select one project, add subproject IDs 212 | fill_subproject_ids(@project.id.to_i, unique_project_id_array) 213 | end 214 | 215 | # Display no assignee issue 216 | @user_id_array << nil; 217 | 218 | # Declaring variables 219 | @issues_hash = {} 220 | @wip_hash = {} 221 | 222 | # Get issues using status loop 223 | @status_fields_array.each {|status_id| 224 | if @done_issue_statuses_array.include?(status_id) == false then 225 | # Case not closed status 226 | issues = Issue.where(assigned_to_id: @user_id_array) 227 | .where(project_id: unique_project_id_array) 228 | .where(status: status_id) 229 | .where(get_issues_visibility_condition) 230 | .where("updated_on >= '" + updated_from + "'") 231 | if @version_id != "unspecified" then 232 | issues = issues.where(fixed_version_id: @version_id) 233 | end 234 | if @due_date != "unspecified" then 235 | issues = issues.where("due_date >= '" + due_from + "'").where("due_date <= '" + due_to + "'") 236 | end 237 | if @tracker_id != "unspecified" then 238 | issues = issues.where(tracker_id: @tracker_id) 239 | end 240 | @issues_hash[status_id] = issues.order(updated_on: "DESC").limit(Kanban::Constants::SELECT_LIMIT) 241 | # Count WIP issues 242 | if status_id == Kanban::Constants::WIP_COUNT_STATUS_FIELD then 243 | @user_id_array.each {|uid| 244 | wip_counter = 0 245 | @issues_hash[status_id].each {|issue| 246 | if issue.assigned_to_id == uid then 247 | wip_counter += 1 248 | end 249 | } 250 | # Save count value 251 | if uid != nil then 252 | @wip_hash[uid] = wip_counter 253 | end 254 | } 255 | end 256 | else 257 | # Case closed status 258 | issues = Issue.where(assigned_to_id: @user_id_array) 259 | .where(project_id: unique_project_id_array) 260 | .where(status: status_id) 261 | .where(get_issues_visibility_condition) 262 | .where("updated_on >= '" + closed_from + "'") 263 | if @version_id != "unspecified" then 264 | issues = issues.where(fixed_version_id: @version_id) 265 | end 266 | if @due_date != "unspecified" then 267 | issues = issues.where("due_date >= '" + due_from + "'").where("due_date <= '" + due_to + "'") 268 | end 269 | if @tracker_id != "unspecified" then 270 | issues = issues.where(tracker_id: @tracker_id) 271 | end 272 | @issues_hash[status_id] = issues.order(updated_on: "DESC").limit(Kanban::Constants::SELECT_LIMIT) 273 | end 274 | } 275 | 276 | # Hide user without issues 277 | if Kanban::Constants::DISPLAY_USER_WITHOUT_ISSUES != 1 then 278 | remove_user_without_issues 279 | end 280 | end 281 | 282 | private 283 | 284 | # 285 | # Get issues visibility condition based on user's role settings 286 | # 287 | def get_issues_visibility_condition 288 | if @current_user.admin? 289 | # Admin can see all issues 290 | return '1=1' 291 | end 292 | 293 | # Get user's roles for the current project 294 | if @project 295 | roles = @current_user.roles_for_project(@project) 296 | else 297 | # For all projects, use the most permissive role 298 | roles = @current_user.roles.to_a 299 | end 300 | 301 | return '1=0' if roles.empty? 302 | 303 | # Find the most permissive issues_visibility setting 304 | visibility_levels = roles.map(&:issues_visibility).compact 305 | return '1=0' if visibility_levels.empty? 306 | 307 | # Determine the most permissive setting 308 | if visibility_levels.include?('all') 309 | # 'all' - can see all issues including private ones 310 | return '1=1' 311 | elsif visibility_levels.include?('default') 312 | # 'default' - can see non-private issues or issues created by/assigned to user 313 | user_ids = [@current_user.id] + @current_user.groups.pluck(:id).compact 314 | return "(#{Issue.table_name}.is_private = #{Issue.connection.quoted_false} " \ 315 | "OR #{Issue.table_name}.author_id = #{@current_user.id} " \ 316 | "OR #{Issue.table_name}.assigned_to_id IN (#{user_ids.join(',')}))" 317 | elsif visibility_levels.include?('own') 318 | # 'own' - can only see issues created by or assigned to user 319 | user_ids = [@current_user.id] + @current_user.groups.pluck(:id).compact 320 | return "(#{Issue.table_name}.author_id = #{@current_user.id} OR " \ 321 | "#{Issue.table_name}.assigned_to_id IN (#{user_ids.join(',')}))" 322 | else 323 | # Fallback to most restrictive 324 | return '1=0' 325 | end 326 | end 327 | 328 | # 329 | # Discard session 330 | # 331 | def discard_session 332 | session[:kanban] = nil 333 | end 334 | 335 | # 336 | # Store session 337 | # 338 | def store_params_to_session 339 | 340 | session_hash = {} 341 | session_hash["updated_within"] = @updated_within 342 | session_hash["done_within"] = @done_within 343 | session_hash["due_date"] = @due_date 344 | session_hash["tracker_id"] = @tracker_id 345 | session_hash["user_id"] = @user_id 346 | session_hash["group_id"] = @group_id 347 | session_hash["project_all"] = @project_all 348 | session_hash["version_id"] = @version_id 349 | session_hash["open_versions"] = @open_versions 350 | session_hash["status_fields"] = @status_fields.to_json 351 | session_hash["wip_max"] = @wip_max 352 | session_hash["card_size"] = @card_size 353 | session_hash["show_ancestors"] = @show_ancestors 354 | session[:kanban] = session_hash 355 | end 356 | 357 | # 358 | # Restore session 359 | # 360 | def restore_params_from_session 361 | session_hash = session[:kanban] 362 | 363 | # Days since upadated date 364 | if !session_hash.blank? && params[:updated_within].blank? 365 | @updated_within = session_hash["updated_within"] 366 | else 367 | @updated_within = params[:updated_within] 368 | end 369 | 370 | # Days since closed date 371 | if !session_hash.blank? && params[:done_within].blank? 372 | @done_within = session_hash["done_within"] 373 | else 374 | @done_within = params[:done_within] 375 | end 376 | 377 | # Due date 378 | if !session_hash.blank? && params[:due_date].blank? 379 | @due_date = session_hash["due_date"] 380 | else 381 | @due_date = params[:due_date] 382 | end 383 | 384 | # Display tracker ID 385 | if !session_hash.blank? && params[:tracker_id].blank? 386 | @tracker_id = session_hash["tracker_id"] 387 | else 388 | @tracker_id = params[:tracker_id] 389 | end 390 | 391 | # Display user ID 392 | if !session_hash.blank? && params[:user_id].blank? 393 | @user_id = session_hash["user_id"] 394 | else 395 | @user_id = params[:user_id] 396 | end 397 | 398 | # Display group ID 399 | if !session_hash.blank? && params[:group_id].blank? 400 | @group_id = session_hash["group_id"] 401 | else 402 | @group_id = params[:group_id] 403 | end 404 | 405 | # Project display flag 406 | if !session_hash.blank? && params[:project_all].blank? 407 | @project_all = session_hash["project_all"] 408 | else 409 | @project_all = params[:project_all] 410 | end 411 | 412 | # Display version ID 413 | if !session_hash.blank? && params[:version_id].blank? 414 | @version_id = session_hash["version_id"] 415 | else 416 | @version_id = params[:version_id] 417 | end 418 | 419 | # Only open versions flag 420 | if !session_hash.blank? && params[:open_versions].blank? 421 | @open_versions = session_hash["open_versions"] 422 | else 423 | @open_versions = params[:open_versions] 424 | end 425 | 426 | # Selected statuses 427 | if !session_hash.blank? && params[:status_fields].blank? 428 | if !session_hash["status_fields"].blank? 429 | @status_fields = JSON.parse( session_hash["status_fields"]) 430 | else 431 | @status_fields = "" 432 | end 433 | else 434 | @status_fields = params[:status_fields] 435 | end 436 | 437 | # Max number of WIP issue 438 | if !session_hash.blank? && params[:wip_max].blank? 439 | @wip_max = session_hash["wip_max"] 440 | else 441 | @wip_max = params[:wip_max] 442 | end 443 | 444 | # Card size 445 | if !session_hash.blank? && params[:card_size].blank? 446 | @card_size = session_hash["card_size"] 447 | else 448 | @card_size = params[:card_size] 449 | end 450 | 451 | # Show ancestors 452 | if !session_hash.blank? && params[:show_ancestors].blank? 453 | @show_ancestors = session_hash["show_ancestors"] 454 | else 455 | @show_ancestors = params[:show_ancestors] 456 | end 457 | end 458 | 459 | # 460 | # Initialize params 461 | # When value is invalid, set it to default. 462 | # 463 | def initialize_params 464 | # Days since upadated date 465 | if @updated_within.nil? || (@updated_within.to_i == 0 && @updated_within != "unspecified") then 466 | @updated_within = Kanban::Constants::DEFAULT_VALUE_UPDATED_WITHIN 467 | end 468 | 469 | # Days since closed date 470 | if @done_within.nil? || (@done_within.to_i == 0 && @done_within != "unspecified") then 471 | @done_within = Kanban::Constants::DEFAULT_VALUE_DONE_WITHIN 472 | end 473 | 474 | # Due date 475 | due_date_values = ["overdue", "today", "tommorow", "thisweek", "nextweek", "unspecified"] 476 | if @due_date.nil? || !due_date_values.include?(@due_date.to_s) then 477 | @due_date = "unspecified" 478 | end 479 | 480 | # Tracker ID 481 | if @tracker_id.nil? || @tracker_id.to_i == 0 then 482 | @tracker_id = "unspecified" 483 | end 484 | 485 | # User ID 486 | if @user_id.nil? || (@user_id.to_i == 0 && @user_id != "unspecified") then 487 | @user_id = @current_user.id 488 | end 489 | 490 | # Group ID 491 | if @group_id.nil? || @group_id.to_i == 0 then 492 | @group_id = "unspecified" 493 | end 494 | 495 | # Project display flag 496 | @project_id = params[:project_id] 497 | if @project_id.blank? then 498 | # Case move from application menu 499 | @project_all = "1" 500 | else 501 | # Case move from project menu 502 | if @project_all.blank? then 503 | @project_all = "0" 504 | end 505 | end 506 | 507 | # Version ID 508 | if @version_id.nil? || @version_id.to_i == 0 || @project_all == "1" then 509 | @version_id = "unspecified" 510 | end 511 | 512 | # Only open versions flag 513 | if @open_versions.nil? then 514 | @open_versions = "1" 515 | end 516 | 517 | # Array of status ID for display 518 | @status_fields_array = [] 519 | if !@status_fields.blank? then 520 | @status_fields.each {|id,chk| 521 | if chk == "1" 522 | @status_fields_array << id.to_i 523 | end 524 | } 525 | else 526 | # Default 527 | @status_fields_array = Kanban::Constants::DEFAULT_STATUS_FIELD_VALUE_ARRAY 528 | end 529 | 530 | # Max number of WIP issue (default) 531 | if @wip_max.nil? || @wip_max.to_i == 0 then 532 | @wip_max = Kanban::Constants::DEFAULT_VALUE_WIP_MAX 533 | end 534 | 535 | # Card size (default) 536 | if @card_size.nil? || (@card_size != "normal_days_left" && @card_size != "normal_estimated_hours" && @card_size != "normal_spent_hours" && @card_size != "small") then 537 | @card_size = Kanban::Constants::DEFAULT_CARD_SIZE 538 | end 539 | 540 | # Show ancestors (default) 541 | if @show_ancestors.nil? then 542 | @show_ancestors = Kanban::Constants::DEFAULT_SHOW_ANCESTORS 543 | end 544 | end 545 | 546 | # 547 | # Remove user without issues from @user_id_array 548 | # 549 | def remove_user_without_issues 550 | copied_user_id_array = @user_id_array.dup 551 | copied_user_id_array.each {|uid| 552 | number_of_issues = 0 553 | @status_fields_array.each {|status_id| 554 | @issues_hash[status_id].each {|issue| 555 | if issue.assigned_to_id == uid then 556 | number_of_issues += 1 557 | end 558 | } 559 | } 560 | if !uid.nil? && number_of_issues == 0 then 561 | @user_id_array.delete(uid) 562 | end 563 | } 564 | end 565 | 566 | # 567 | # User logged in 568 | # 569 | def set_user 570 | @current_user ||= User.current 571 | end 572 | 573 | # 574 | # Need Login 575 | # 576 | def global_authorize 577 | set_user 578 | render_403 unless @current_user.type == 'User' 579 | end 580 | 581 | # 582 | # Get all subprojects 583 | # 584 | def fill_subproject_ids(project_id, unique_project_id_array) 585 | if unique_project_id_array.include?(project_id) == true then 586 | return 587 | end 588 | 589 | unique_project_id_array << project_id 590 | subprojects = Project.where(parent_id: project_id) 591 | subprojects.each {|subproject| 592 | fill_subproject_ids(subproject.id.to_i, unique_project_id_array) 593 | } 594 | end 595 | 596 | end 597 | -------------------------------------------------------------------------------- /assets/javascripts/jquery.floatThead.js: -------------------------------------------------------------------------------- 1 | /** @preserve jQuery.floatThead 2.2.5 - https://mkoryak.github.io/floatThead/ - Copyright (c) 2012 - 2023 Misha Koryak **/ 2 | // @license MIT 3 | 4 | /* @author Misha Koryak 5 | * @projectDescription position:fixed on steroids. Lock a table header in place while scrolling. 6 | * 7 | * Dependencies: 8 | * jquery 1.9.0+ [required] OR jquery 1.7.0+ jquery UI core 9 | * 10 | * https://mkoryak.github.io/floatThead/ 11 | * 12 | * Tested on FF13+, Chrome 21+, IE9, IE10, IE11, EDGE 13 | */ 14 | (function( $ ) { 15 | /** 16 | * provides a default config object. You can modify this after including this script if you want to change the init defaults 17 | * @type {!Object} 18 | */ 19 | $.floatThead = $.floatThead || {}; 20 | $.floatThead.defaults = { 21 | headerCellSelector: 'tr:visible:first>*:visible', //thead cells are this. 22 | zIndex: 1001, //zindex of the floating thead (actually a container div) 23 | position: 'auto', // 'fixed', 'absolute', 'auto'. auto picks the best for your table scrolling type. 24 | top: 0, //String or function($table) - offset from top of window where the header should not pass above 25 | bottom: 0, //String or function($table) - offset from the bottom of the table where the header should stop scrolling 26 | scrollContainer: function($table) { // or boolean 'true' (use offsetParent) | function -> if the table has horizontal scroll bars then this is the container that has overflow:auto and causes those scroll bars 27 | return $([]); 28 | }, 29 | responsiveContainer: function($table) { // only valid if scrollContainer is not used (ie window scrolling). this is the container which will control y scrolling at some mobile breakpoints 30 | return $([]); 31 | }, 32 | getSizingRow: function($table, $cols, $fthCells){ // this is only called when using IE, 33 | // override it if the first row of the table is going to contain colgroups (any cell spans greater than one col) 34 | // it should return a jquery object containing a wrapped set of table cells comprising a row that contains no col spans and is visible 35 | return $table.find('tbody tr:visible:first>*:visible'); 36 | }, 37 | ariaLabel: function($table, $headerCell, columnIndex) { // This function will run for every header cell that exists in the table when we add aria-labels. 38 | // Override to customize the aria-label. NOTE: These labels will be added to the 'sizer cells' which get added to the real table and are not visible by the user (only screen readers), 39 | // The number of sizer columns might not match the header columns in your real table - I insert one sizer header cell per column. This means that if your table uses colspans or multiple header rows, 40 | // this will not be reflected by sizer cells. This is why I am giving you the `columnIndex`. 41 | return $headerCell.text(); 42 | }, 43 | floatTableClass: 'floatThead-table', 44 | floatWrapperClass: 'floatThead-wrapper', 45 | floatContainerClass: 'floatThead-container', 46 | copyTableClass: true, //copy 'class' attribute from table into the floated table so that the styles match. 47 | autoReflow: false, //(undocumented) - use MutationObserver api to reflow automatically when internal table DOM changes 48 | debug: false, //print possible issues (that don't prevent script loading) to console, if console exists. 49 | support: { //should we bind events that expect these frameworks to be present and/or check for them? 50 | bootstrap: true, 51 | datatables: true, 52 | jqueryUI: true, 53 | perfectScrollbar: true 54 | }, 55 | floatContainerCss: {"overflow-x": "hidden"} // undocumented - css applied to the floatContainer 56 | }; 57 | 58 | var util = (function underscoreShim(){ 59 | var that = {}; 60 | var hasOwnProperty = Object.prototype.hasOwnProperty, isThings = ['Arguments', 'Function', 'String', 'Number', 'Date', 'RegExp']; 61 | that.has = function(obj, key) { 62 | return hasOwnProperty.call(obj, key); 63 | }; 64 | that.keys = Object.keys || function(obj) { 65 | if (obj !== Object(obj)) throw new TypeError('Invalid object'); 66 | var keys = []; 67 | for (var key in obj) if (that.has(obj, key)) keys.push(key); 68 | return keys; 69 | }; 70 | var idCounter = 0; 71 | that.uniqueId = function(prefix) { 72 | var id = ++idCounter + ''; 73 | return prefix ? prefix + id : id; 74 | }; 75 | $.each(isThings, function(){ 76 | var name = this; 77 | that['is' + name] = function(obj) { 78 | return Object.prototype.toString.call(obj) === '[object ' + name + ']'; 79 | }; 80 | }); 81 | that.debounce = function(func, wait, immediate) { 82 | var timeout, args, context, timestamp, result; 83 | return function() { 84 | context = this; 85 | args = arguments; 86 | timestamp = new Date(); 87 | var later = function() { 88 | var last = (new Date()) - timestamp; 89 | if (last < wait) { 90 | timeout = setTimeout(later, wait - last); 91 | } else { 92 | timeout = null; 93 | if (!immediate) result = func.apply(context, args); 94 | } 95 | }; 96 | var callNow = immediate && !timeout; 97 | if (!timeout) { 98 | timeout = setTimeout(later, wait); 99 | } 100 | if (callNow) result = func.apply(context, args); 101 | return result; 102 | }; 103 | }; 104 | return that; 105 | })(); 106 | 107 | var globalCanObserveMutations = typeof MutationObserver !== 'undefined'; 108 | 109 | //browser stuff 110 | var ieVersion = function(){for(var a=3,b=document.createElement("b"),c=b.all||[];a = 1+a,b.innerHTML="",c[0];);return 4').css('width', '0').append( 123 | $('').css('max-width', '100%').append( 124 | $('').append( 125 | $('elements, but webkit cannot 140 | 141 | var $window = $(window); 142 | 143 | var buggyMatchMedia = isFF && window.matchMedia; // TODO remove when fixed: https://bugzilla.mozilla.org/show_bug.cgi?id=774398 144 | 145 | if(!window.matchMedia || buggyMatchMedia) { 146 | var _beforePrint = window.onbeforeprint; 147 | var _afterPrint = window.onafterprint; 148 | window.onbeforeprint = function () { 149 | _beforePrint && _beforePrint(); 150 | $window.triggerHandler("fth-beforeprint"); 151 | }; 152 | window.onafterprint = function () { 153 | _afterPrint && _afterPrint(); 154 | $window.triggerHandler("fth-afterprint"); 155 | }; 156 | } 157 | 158 | /** 159 | * @param eventName 160 | * @param cb 161 | */ 162 | function windowResize(eventName, cb){ 163 | if(ieVersion === 8){ //ie8 is crap: https://github.com/mkoryak/floatThead/issues/65 164 | var winWidth = $window.width(); 165 | var debouncedCb = util.debounce(function(){ 166 | var winWidthNew = $window.width(); 167 | if(winWidth !== winWidthNew){ 168 | winWidth = winWidthNew; 169 | cb(); 170 | } 171 | }, 1); 172 | $window.on(eventName, debouncedCb); 173 | } else { 174 | $window.on(eventName, util.debounce(cb, 1)); 175 | } 176 | } 177 | 178 | function getClosestScrollContainer($elem) { 179 | var elem = $elem[0]; 180 | var parent = elem.parentElement; 181 | 182 | do { 183 | var pos = window 184 | .getComputedStyle(parent) 185 | .getPropertyValue('overflow'); 186 | 187 | if (pos !== 'visible') break; 188 | 189 | } while (parent = parent.parentElement); 190 | 191 | if(parent === document.body){ 192 | return $([]); 193 | } 194 | return $(parent); 195 | } 196 | 197 | function debug(str){ 198 | window && window.console && window.console.error && window.console.error("jQuery.floatThead: " + str); 199 | } 200 | 201 | //returns fractional pixel widths 202 | function getOffsetWidth(el) { 203 | var rect = el.getBoundingClientRect(); 204 | return rect.width || rect.right - rect.left; 205 | } 206 | 207 | /** 208 | * try to calculate the scrollbar width for your browser/os 209 | * @return {Number} 210 | */ 211 | function scrollbarWidth() { 212 | var d = document.createElement("scrolltester"); 213 | d.style.cssText = 'width:100px;height:100px;overflow:scroll!important;position:absolute;top:-9999px;display:block'; 214 | document.body.appendChild(d); 215 | var result = d.offsetWidth - d.clientWidth; 216 | document.body.removeChild(d); 217 | return result; 218 | } 219 | 220 | /** 221 | * Check if a given table has been datatableized (https://datatables.net) 222 | * @param $table 223 | * @return {Boolean} 224 | */ 225 | function isDatatable($table){ 226 | if($table.dataTableSettings){ 227 | for(var i = 0; i < $table.dataTableSettings.length; i++){ 228 | var table = $table.dataTableSettings[i].nTable; 229 | if($table[0] === table){ 230 | return true; 231 | } 232 | } 233 | } 234 | return false; 235 | } 236 | 237 | function tableWidth($table, $fthCells, isOuter){ 238 | // see: https://github.com/mkoryak/floatThead/issues/108 239 | var fn = isOuter ? "outerWidth": "width"; 240 | if(isTableWidthBug && $table.css("max-width")){ 241 | var w = 0; 242 | if(isOuter) { 243 | w += parseInt($table.css("borderLeft"), 10); 244 | w += parseInt($table.css("borderRight"), 10); 245 | } 246 | for(var i=0; i < $fthCells.length; i++){ 247 | w += getOffsetWidth($fthCells.get(i)); 248 | } 249 | return w; 250 | } else { 251 | return $table[fn](); 252 | } 253 | } 254 | $.fn.floatThead = function(map){ 255 | map = map || {}; 256 | 257 | if(ieVersion < 8){ 258 | return this; //no more crappy browser support. 259 | } 260 | 261 | if(util.isFunction(isTableWidthBug)) { 262 | isTableWidthBug = isTableWidthBug(); 263 | } 264 | 265 | if(util.isString(map)){ 266 | var command = map; 267 | var args = Array.prototype.slice.call(arguments, 1); 268 | var ret = this; 269 | this.filter('table').each(function(){ 270 | var $this = $(this); 271 | var opts = $this.data('floatThead-lazy'); 272 | if(opts){ 273 | $this.floatThead(opts); 274 | } 275 | var obj = $this.data('floatThead-attached'); 276 | if(obj && util.isFunction(obj[command])){ 277 | var r = obj[command].apply(this, args); 278 | if(r !== undefined){ 279 | ret = r; 280 | } 281 | } 282 | }); 283 | return ret; 284 | } 285 | var opts = $.extend({}, $.floatThead.defaults || {}, map); 286 | 287 | $.each(map, function(key, val){ 288 | if((!(key in $.floatThead.defaults)) && opts.debug){ 289 | debug("Used ["+key+"] key to init plugin, but that param is not an option for the plugin. Valid options are: "+ (util.keys($.floatThead.defaults)).join(', ')); 290 | } 291 | }); 292 | if(opts.debug){ 293 | var v = $.fn.jquery.split("."); 294 | if(parseInt(v[0], 10) === 1 && parseInt(v[1], 10) <= 7){ 295 | debug("jQuery version "+$.fn.jquery+" detected! This plugin supports 1.8 or better, or 1.7.x with jQuery UI 1.8.24 -> http://jqueryui.com/resources/download/jquery-ui-1.8.24.zip") 296 | } 297 | } 298 | 299 | this.filter(':not(.'+opts.floatTableClass+')').each(function(){ 300 | var floatTheadId = util.uniqueId(); 301 | var $table = $(this); 302 | if($table.data('floatThead-attached')){ 303 | return true; //continue the each loop 304 | } 305 | if(!$table.is('table')){ 306 | throw new Error('jQuery.floatThead must be run on a table element. ex: $("table").floatThead();'); 307 | } 308 | var canObserveMutations = opts.autoReflow && globalCanObserveMutations; //option defaults to false! 309 | var mObs = null; //mutation observer lives in here if we can use it / make it 310 | var $header = $table.children('thead:first'); 311 | var $tbody = $table.children('tbody:first'); 312 | if($header.length === 0 || $tbody.length === 0){ 313 | if(opts.debug) { 314 | if($header.length === 0){ 315 | debug('The thead element is missing.'); 316 | } else{ 317 | debug('The tbody element is missing.'); 318 | } 319 | } 320 | $table.data('floatThead-lazy', opts); 321 | $table.unbind("reflow").one('reflow', function(){ 322 | $table.floatThead(opts); 323 | }); 324 | return; 325 | } 326 | if($table.data('floatThead-lazy')){ 327 | $table.unbind("reflow"); 328 | } 329 | $table.data('floatThead-lazy', false); 330 | 331 | var headerFloated = true; 332 | var scrollingTop, scrollingBottom; 333 | var scrollbarOffset = {vertical: 0, horizontal: 0}; 334 | if(util.isFunction(scrollbarWidth)) { 335 | scrollbarWidth = scrollbarWidth(); 336 | } 337 | 338 | var lastColumnCount = 0; //used by columnNum() 339 | 340 | if(opts.scrollContainer === true){ 341 | opts.scrollContainer = getClosestScrollContainer; 342 | } 343 | 344 | var $scrollContainer = opts.scrollContainer($table) || $([]); //guard against returned nulls 345 | var locked = $scrollContainer.length > 0; 346 | var $responsiveContainer = locked ? $([]) : opts.responsiveContainer($table) || $([]); 347 | var responsive = isResponsiveContainerActive(); 348 | 349 | var useAbsolutePositioning = null; 350 | 351 | if (opts.position === 'auto') { 352 | useAbsolutePositioning = null; 353 | } else if (opts.position === 'fixed') { 354 | useAbsolutePositioning = false; 355 | } else if (opts.position === 'absolute'){ 356 | useAbsolutePositioning = true; 357 | } else if (opts.debug) { 358 | debug('Invalid value given to "position" option, valid is "fixed", "absolute" and "auto". You passed: ', opts.position); 359 | } 360 | 361 | if(useAbsolutePositioning == null){ //defaults: locked=true, !locked=false 362 | useAbsolutePositioning = locked; 363 | } 364 | var $caption = $table.find("caption"); 365 | var haveCaption = $caption.length === 1; 366 | if(haveCaption){ 367 | var captionAlignTop = ($caption.css("caption-side") || $caption.attr("align") || "top") === "top"; 368 | } 369 | 370 | var $fthGrp = $('').css({ 371 | 'display': 'table-footer-group', 372 | 'border-spacing': '0', 373 | 'height': '0', 374 | 'border-collapse': 'collapse', 375 | 'visibility': 'hidden' 376 | }); 377 | 378 | var wrappedContainer = false; //used with absolute positioning enabled. did we need to wrap the scrollContainer/table with a relative div? 379 | var $wrapper = $([]); //used when absolute positioning enabled - wraps the table and the float container 380 | var absoluteToFixedOnScroll = ieVersion <= 9 && !locked && useAbsolutePositioning; //on IE using absolute positioning doesn't look good with window scrolling, so we change position to fixed on scroll, and then change it back to absolute when done. 381 | var $floatTable = $("
').append( 126 | $('
').css('min-width', '100px').text('X') 127 | ) 128 | ) 129 | ) 130 | ); 131 | $("body").append($test); 132 | var ret = ($test.find("table").width() === 0); 133 | $test.remove(); 134 | return ret; 135 | } 136 | return false; 137 | }; 138 | 139 | var createElements = !isFF && !ieVersion; //FF can read width from
"); 382 | var $floatColGroup = $(""); 383 | var $tableColGroup = $table.children('colgroup:first'); 384 | var existingColGroup = true; 385 | if($tableColGroup.length === 0){ 386 | $tableColGroup = $(""); 387 | existingColGroup = false; 388 | } 389 | var colSelector = existingColGroup ? "col:visible" : "col"; 390 | var $fthRow = $('').css({ //created unstyled elements (used for sizing the table because chrome can't read width) 391 | 'display': 'table-row', 392 | 'border-spacing': '0', 393 | 'height': '0', 394 | 'border-collapse': 'collapse' 395 | }); 396 | var $floatContainer = $('
').css(opts.floatContainerCss).attr('aria-hidden', 'true'); 397 | var floatTableHidden = false; //this happens when the table is hidden and we do magic when making it visible 398 | var $newHeader = $("
"); 399 | var $sizerRow = $(''); 400 | var $sizerCells = $([]); 401 | var $tableCells = $([]); //used for sizing - either $sizerCells or $tableColGroup cols. $tableColGroup cols are only created in chrome for borderCollapse:collapse because of a chrome bug. 402 | var $headerCells = $([]); 403 | var $fthCells = $([]); //created elements 404 | 405 | $newHeader.append($sizerRow); 406 | $table.prepend($tableColGroup); 407 | if(createElements){ 408 | $fthGrp.append($fthRow); 409 | $table.append($fthGrp); 410 | } 411 | 412 | $floatTable.append($floatColGroup); 413 | $floatContainer.append($floatTable); 414 | if(opts.copyTableClass){ 415 | $floatTable.attr('class', $table.attr('class')); 416 | } 417 | $floatTable.attr({ //copy over some deprecated table attributes that people still like to use. Good thing people don't use colgroups... 418 | 'cellpadding': $table.attr('cellpadding'), 419 | 'cellspacing': $table.attr('cellspacing'), 420 | 'border': $table.attr('border') 421 | }); 422 | var tableDisplayCss = $table.css('display'); 423 | $floatTable.css({ 424 | 'borderCollapse': $table.css('borderCollapse'), 425 | 'border': $table.css('border'), 426 | 'display': tableDisplayCss 427 | }); 428 | if(!locked){ 429 | $floatTable.css('width', 'auto'); 430 | } 431 | if(tableDisplayCss === 'none'){ 432 | floatTableHidden = true; 433 | } 434 | 435 | $floatTable.addClass(opts.floatTableClass).css({'margin': '0', 'border-bottom-width': '0'}); //must have no margins or you won't be able to click on things under floating table 436 | 437 | if(useAbsolutePositioning){ 438 | var makeRelative = function($container, alwaysWrap){ 439 | var positionCss = $container.css('position'); 440 | var relativeToScrollContainer = (positionCss === "relative" || positionCss === "absolute"); 441 | var $containerWrap = $container; 442 | if(!relativeToScrollContainer || alwaysWrap){ 443 | var css = {"paddingLeft": $container.css('paddingLeft'), "paddingRight": $container.css('paddingRight')}; 444 | $floatContainer.css(css); 445 | $containerWrap = $container.data('floatThead-containerWrap') || $container.wrap( 446 | $('
').addClass(opts.floatWrapperClass).css({ 447 | 'position': 'relative', 448 | 'clear': 'both' 449 | }) 450 | ).parent(); 451 | $container.data('floatThead-containerWrap', $containerWrap); //multiple tables inside one scrolling container - #242 452 | wrappedContainer = true; 453 | } 454 | return $containerWrap; 455 | }; 456 | if(locked){ 457 | $wrapper = makeRelative($scrollContainer, true); 458 | $wrapper.prepend($floatContainer); 459 | } else { 460 | $wrapper = makeRelative($table); 461 | $table.before($floatContainer); 462 | } 463 | } else { 464 | $table.before($floatContainer); 465 | } 466 | 467 | $floatContainer.css({ 468 | position: useAbsolutePositioning ? 'absolute' : 'fixed', 469 | marginTop: '0', 470 | top: useAbsolutePositioning ? '0' : 'auto', 471 | zIndex: opts.zIndex, 472 | willChange: 'transform' 473 | }); 474 | $floatContainer.addClass(opts.floatContainerClass); 475 | updateScrollingOffsets(); 476 | 477 | var layoutFixed = {'table-layout': 'fixed'}; 478 | var layoutAuto = {'table-layout': $table.css('tableLayout') || 'auto'}; 479 | var originalTableWidth = $table[0].style.width || ""; //setting this to auto is bad: #70 480 | var originalTableMinWidth = $table.css('minWidth') || ""; 481 | 482 | function eventName(name){ 483 | return name+'.fth-'+floatTheadId+'.floatTHead' 484 | } 485 | 486 | function setHeaderHeight(){ 487 | var headerHeight = 0; 488 | $header.children("tr:visible").each(function(){ 489 | headerHeight += $(this).outerHeight(true); 490 | }); 491 | if($table.css('border-collapse') === 'collapse') { 492 | var tableBorderTopHeight = parseInt($table.css('border-top-width'), 10); 493 | var cellBorderTopHeight = parseInt($table.find("thead tr:first").find(">*:first").css('border-top-width'), 10); 494 | if(tableBorderTopHeight > cellBorderTopHeight) { 495 | headerHeight -= (tableBorderTopHeight / 2); //id love to see some docs where this magic recipe is found.. 496 | } 497 | } 498 | $sizerRow.outerHeight(headerHeight); 499 | $sizerCells.outerHeight(headerHeight); 500 | } 501 | 502 | function setFloatWidth(){ 503 | var tw = tableWidth($table, $fthCells, true); 504 | var $container = responsive ? $responsiveContainer : $scrollContainer; 505 | var width = $container.length ? getOffsetWidth($container[0]) : tw; 506 | var floatContainerWidth = $container.css("overflow-y") !== 'hidden' ? width - scrollbarOffset.vertical : width; 507 | $floatContainer.width(floatContainerWidth); 508 | if(locked){ 509 | var percent = 100 * tw / (floatContainerWidth); 510 | $floatTable.css('width', percent+'%'); 511 | } else { 512 | $floatTable.css('width', tw+'px'); 513 | } 514 | } 515 | 516 | function updateScrollingOffsets(){ 517 | scrollingTop = (util.isFunction(opts.top) ? opts.top($table) : opts.top) || 0; 518 | scrollingBottom = (util.isFunction(opts.bottom) ? opts.bottom($table) : opts.bottom) || 0; 519 | } 520 | 521 | /** 522 | * get the number of columns and also rebuild resizer rows if the count is different than the last count 523 | */ 524 | function columnNum(){ 525 | var count; 526 | var $headerColumns = $header.find(opts.headerCellSelector); 527 | if(existingColGroup){ 528 | count = $tableColGroup.find(colSelector).length; 529 | } else { 530 | count = 0; 531 | $headerColumns.each(function () { 532 | count += parseInt(($(this).attr('colspan') || 1), 10); 533 | }); 534 | } 535 | if(count !== lastColumnCount){ 536 | lastColumnCount = count; 537 | var cells = [], cols = [], psuedo = []; 538 | $sizerRow.empty(); 539 | for(var x = 0; x < count; x++){ 540 | var cell = document.createElement('th'); 541 | var span = document.createElement('span'); 542 | span.setAttribute('aria-label', opts.ariaLabel($table, $headerColumns.eq(x), x)); 543 | cell.appendChild(span); 544 | cell.className = 'floatThead-col'; 545 | $sizerRow[0].appendChild(cell); 546 | cols.push('
'); 547 | psuedo.push( 548 | $('').css({ 549 | 'display': 'table-cell', 550 | 'height': '0', 551 | 'width': 'auto' 552 | }) 553 | ); 554 | } 555 | 556 | if(existingColGroup){ 557 | cols = $tableColGroup.html(); 558 | } else { 559 | cols = cols.join(''); 560 | } 561 | 562 | if(createElements){ 563 | $fthRow.empty(); 564 | $fthRow.append(psuedo); 565 | $fthCells = $fthRow.find('fthtd'); 566 | } 567 | 568 | $sizerCells = $sizerRow.find("th"); 569 | if(!existingColGroup){ 570 | $tableColGroup.html(cols); 571 | } 572 | $tableCells = $tableColGroup.find(colSelector); 573 | $floatColGroup.html(cols); 574 | $headerCells = $floatColGroup.find(colSelector); 575 | 576 | } 577 | return count; 578 | } 579 | 580 | function refloat(){ //make the thing float 581 | if(!headerFloated){ 582 | headerFloated = true; 583 | if(useAbsolutePositioning){ //#53, #56 584 | var tw = tableWidth($table, $fthCells, true); 585 | var wrapperWidth = $wrapper.width(); 586 | if(tw > wrapperWidth){ 587 | $table.css('minWidth', tw); 588 | } 589 | } 590 | $table.css(layoutFixed); 591 | $floatTable.css(layoutFixed); 592 | $floatTable.append($header); //append because colgroup must go first in chrome 593 | $tbody.before($newHeader); 594 | setHeaderHeight(); 595 | } 596 | } 597 | function unfloat(){ //put the header back into the table 598 | if(headerFloated){ 599 | headerFloated = false; 600 | if(useAbsolutePositioning){ //#53, #56 601 | $table.width(originalTableWidth); 602 | } 603 | $newHeader.detach(); 604 | $table.prepend($header); 605 | $table.css(layoutAuto); 606 | $floatTable.css(layoutAuto); 607 | $table.css('minWidth', originalTableMinWidth); //this looks weird, but it's not a bug. Think about it!! 608 | $table.css('minWidth', tableWidth($table, $fthCells)); //#121 609 | } 610 | } 611 | var isHeaderFloatingLogical = false; //for the purpose of this event, the header is/isnt floating, even though the element 612 | //might be in some other state. this is what the header looks like to the user 613 | function triggerFloatEvent(isFloating){ 614 | if(isHeaderFloatingLogical !== isFloating){ 615 | isHeaderFloatingLogical = isFloating; 616 | $table.triggerHandler("floatThead", [isFloating, $floatContainer]) 617 | } 618 | } 619 | function changePositioning(isAbsolute){ 620 | if(useAbsolutePositioning !== isAbsolute){ 621 | useAbsolutePositioning = isAbsolute; 622 | $floatContainer.css({ 623 | position: useAbsolutePositioning ? 'absolute' : 'fixed' 624 | }); 625 | } 626 | } 627 | function getSizingRow($table, $cols, $fthCells, ieVersion){ 628 | if(createElements){ 629 | return $fthCells; 630 | } else if(ieVersion) { 631 | return opts.getSizingRow($table, $cols, $fthCells); 632 | } else { 633 | return $cols; 634 | } 635 | } 636 | 637 | /** 638 | * returns a function that updates the floating header's cell widths. 639 | * @return {Function} 640 | */ 641 | function reflow(){ 642 | var i; 643 | var numCols = columnNum(); //if the tables columns changed dynamically since last time (datatables), rebuild the sizer rows and get a new count 644 | 645 | return function(){ 646 | //Cache the current scrollLeft value so that it can be reset post reflow 647 | var scrollLeft = $floatContainer.scrollLeft(); 648 | $tableCells = $tableColGroup.find(colSelector); 649 | var $rowCells = getSizingRow($table, $tableCells, $fthCells, ieVersion); 650 | 651 | if($rowCells.length === numCols && numCols > 0){ 652 | if(!existingColGroup){ 653 | for(i=0; i < numCols; i++){ 654 | $tableCells.eq(i).css('width', ''); 655 | } 656 | } 657 | unfloat(); 658 | var widths = []; 659 | for(i=0; i < numCols; i++){ 660 | widths[i] = getOffsetWidth($rowCells.get(i)); 661 | } 662 | for(i=0; i < numCols; i++){ 663 | $headerCells.eq(i).width(widths[i]); 664 | $tableCells.eq(i).width(widths[i]); 665 | } 666 | refloat(); 667 | } else { 668 | $floatTable.append($header); 669 | $table.css(layoutAuto); 670 | $floatTable.css(layoutAuto); 671 | setHeaderHeight(); 672 | } 673 | //Set back the current scrollLeft value on floatContainer 674 | $floatContainer.scrollLeft(scrollLeft); 675 | $table.triggerHandler("reflowed", [$floatContainer]); 676 | }; 677 | } 678 | 679 | function floatContainerBorderWidth(side){ 680 | var border = $scrollContainer.css("border-"+side+"-width"); 681 | var w = 0; 682 | if (border && ~border.indexOf('px')) { 683 | w = parseInt(border, 10); 684 | } 685 | return w; 686 | } 687 | 688 | function isResponsiveContainerActive(){ 689 | return $responsiveContainer.css("overflow-x") === 'auto'; 690 | } 691 | /** 692 | * first performs initial calculations that we expect to not change when the table, window, or scrolling container are scrolled. 693 | * returns a function that calculates the floating container's top and left coords. takes into account if we are using page scrolling or inner scrolling 694 | * @return {Function} 695 | */ 696 | function calculateFloatContainerPosFn(){ 697 | var scrollingContainerTop = $scrollContainer.scrollTop(); 698 | 699 | //this floatEnd calc was moved out of the returned function because we assume the table height doesn't change (otherwise we must reinit by calling calculateFloatContainerPosFn) 700 | var floatEnd; 701 | var tableContainerGap = 0; 702 | var captionHeight = haveCaption ? $caption.outerHeight(true) : 0; 703 | var captionScrollOffset = captionAlignTop ? captionHeight : -captionHeight; 704 | 705 | var floatContainerHeight = $floatContainer.height(); 706 | var tableOffset = $table.offset(); 707 | var tableLeftGap = 0; //can be caused by border on container (only in locked mode) 708 | var tableTopGap = 0; 709 | if(locked){ 710 | var containerOffset = $scrollContainer.offset(); 711 | tableContainerGap = tableOffset.top - containerOffset.top + scrollingContainerTop; 712 | if(haveCaption && captionAlignTop){ 713 | tableContainerGap += captionHeight; 714 | } 715 | tableLeftGap = floatContainerBorderWidth('left'); 716 | tableTopGap = floatContainerBorderWidth('top'); 717 | tableContainerGap -= tableTopGap; 718 | } else { 719 | floatEnd = tableOffset.top - scrollingTop - floatContainerHeight + scrollingBottom + scrollbarOffset.horizontal; 720 | } 721 | var windowTop = $window.scrollTop(); 722 | var windowLeft = $window.scrollLeft(); 723 | var getScrollContainerLeft = function(){ 724 | return (isResponsiveContainerActive() ? $responsiveContainer : $scrollContainer).scrollLeft() || 0; 725 | }; 726 | var scrollContainerLeft = getScrollContainerLeft(); 727 | 728 | return function(eventType){ 729 | responsive = isResponsiveContainerActive(); 730 | 731 | var isTableHidden = $table[0].offsetWidth <= 0 && $table[0].offsetHeight <= 0; 732 | if(!isTableHidden && floatTableHidden) { 733 | floatTableHidden = false; 734 | setTimeout(function(){ 735 | $table.triggerHandler("reflow"); 736 | }, 1); 737 | return null; 738 | } 739 | if(isTableHidden){ //it's hidden 740 | floatTableHidden = true; 741 | if(!useAbsolutePositioning){ 742 | return null; 743 | } 744 | } 745 | 746 | if(eventType === 'windowScroll'){ 747 | windowTop = $window.scrollTop(); 748 | windowLeft = $window.scrollLeft(); 749 | } else if(eventType === 'containerScroll'){ 750 | if($responsiveContainer.length){ 751 | if(!responsive){ 752 | return; //we dont care about the event if we arent responsive right now 753 | } 754 | scrollContainerLeft = $responsiveContainer.scrollLeft(); 755 | } else { 756 | scrollingContainerTop = $scrollContainer.scrollTop(); 757 | scrollContainerLeft = $scrollContainer.scrollLeft(); 758 | } 759 | } else if(eventType !== 'init') { 760 | windowTop = $window.scrollTop(); 761 | windowLeft = $window.scrollLeft(); 762 | scrollingContainerTop = $scrollContainer.scrollTop(); 763 | scrollContainerLeft = getScrollContainerLeft(); 764 | } 765 | if(isWebkit && (windowTop < 0 || (isRTL && windowLeft > 0 ) || ( !isRTL && windowLeft < 0 )) ){ 766 | //chrome overscroll effect at the top of the page - breaks fixed positioned floated headers 767 | return; 768 | } 769 | 770 | if(absoluteToFixedOnScroll){ 771 | if(eventType === 'windowScrollDone'){ 772 | changePositioning(true); //change to absolute 773 | } else { 774 | changePositioning(false); //change to fixed 775 | } 776 | } else if(eventType === 'windowScrollDone'){ 777 | return null; //event is fired when they stop scrolling. ignore it if not 'absoluteToFixedOnScroll' 778 | } 779 | 780 | tableOffset = $table.offset(); 781 | if(haveCaption && captionAlignTop){ 782 | tableOffset.top += captionHeight; 783 | } 784 | var top, left; 785 | var tableHeight = $table.outerHeight(); 786 | 787 | if(locked && useAbsolutePositioning){ //inner scrolling, absolute positioning 788 | if (tableContainerGap >= scrollingContainerTop) { 789 | var gap = tableContainerGap - scrollingContainerTop + tableTopGap; 790 | top = gap > 0 ? gap : 0; 791 | triggerFloatEvent(false); 792 | } else if(scrollingContainerTop - tableContainerGap > tableHeight - floatContainerHeight){ 793 | // scrolled past table but there is space in the container under it.. 794 | top = tableHeight - floatContainerHeight - scrollingContainerTop + tableContainerGap; 795 | } else { 796 | top = wrappedContainer ? tableTopGap : scrollingContainerTop; 797 | //headers stop at the top of the viewport 798 | triggerFloatEvent(true); 799 | } 800 | left = tableLeftGap; 801 | } else if(!locked && useAbsolutePositioning) { //window scrolling, absolute positioning 802 | if(windowTop > floatEnd + tableHeight + captionScrollOffset){ 803 | top = tableHeight - floatContainerHeight + captionScrollOffset + scrollingBottom; //scrolled past table 804 | } else if (tableOffset.top >= windowTop + scrollingTop) { 805 | top = 0; //scrolling to table 806 | unfloat(); 807 | triggerFloatEvent(false); 808 | } else { 809 | top = scrollingTop + windowTop - tableOffset.top + tableContainerGap + (captionAlignTop ? captionHeight : 0); 810 | refloat(); //scrolling within table. header floated 811 | triggerFloatEvent(true); 812 | } 813 | left = scrollContainerLeft; 814 | } else if(locked && !useAbsolutePositioning){ //inner scrolling, fixed positioning 815 | if (tableContainerGap > scrollingContainerTop || scrollingContainerTop - tableContainerGap > tableHeight) { 816 | top = tableOffset.top - windowTop; 817 | unfloat(); 818 | triggerFloatEvent(false); 819 | } else { 820 | top = tableOffset.top + scrollingContainerTop - windowTop - tableContainerGap; 821 | refloat(); 822 | triggerFloatEvent(true); 823 | //headers stop at the top of the viewport 824 | } 825 | left = tableOffset.left + scrollContainerLeft - windowLeft; 826 | } else if(!locked && !useAbsolutePositioning) { //window scrolling, fixed positioning 827 | if(windowTop > floatEnd + tableHeight + captionScrollOffset){ 828 | top = tableHeight + scrollingTop - windowTop + floatEnd + captionScrollOffset; 829 | //scrolled past the bottom of the table 830 | } else if (tableOffset.top > windowTop + scrollingTop) { 831 | top = tableOffset.top - windowTop; 832 | refloat(); 833 | triggerFloatEvent(false); //this is a weird case, the header never gets unfloated and i have no no way to know 834 | //scrolled past the top of the table 835 | } else { 836 | //scrolling within the table 837 | top = scrollingTop; 838 | triggerFloatEvent(true); 839 | } 840 | left = tableOffset.left + scrollContainerLeft - windowLeft; 841 | } 842 | return {top: Math.round(top), left: Math.round(left)}; 843 | }; 844 | } 845 | /** 846 | * returns a function that caches old floating container position and only updates css when the position changes 847 | * @return {Function} 848 | */ 849 | function repositionFloatContainerFn(){ 850 | var oldTop = null; 851 | var oldLeft = null; 852 | var oldScrollLeft = null; 853 | return function(pos, setWidth, setHeight){ 854 | if(pos != null && (oldTop !== pos.top || oldLeft !== pos.left)){ 855 | if(ieVersion === 8){ 856 | $floatContainer.css({ 857 | top: pos.top, 858 | left: pos.left 859 | }); 860 | } else { 861 | var transform = 'translateX(' + pos.left + 'px) translateY(' + pos.top + 'px)'; 862 | var cssObj = { 863 | '-webkit-transform' : transform, 864 | '-moz-transform' : transform, 865 | '-ms-transform' : transform, 866 | '-o-transform' : transform, 867 | 'transform' : transform, 868 | 'top': '0', 869 | 'left': '0', 870 | }; 871 | $floatContainer.css(cssObj); 872 | } 873 | oldTop = pos.top; 874 | oldLeft = pos.left; 875 | } 876 | if(setWidth){ 877 | setFloatWidth(); 878 | } 879 | if(setHeight){ 880 | setHeaderHeight(); 881 | } 882 | var scrollLeft = (responsive ? $responsiveContainer : $scrollContainer).scrollLeft(); 883 | if(!useAbsolutePositioning || oldScrollLeft !== scrollLeft){ 884 | $floatContainer.scrollLeft(scrollLeft); 885 | oldScrollLeft = scrollLeft; 886 | } 887 | } 888 | } 889 | 890 | /** 891 | * checks if THIS table has scrollbars, and finds their widths 892 | */ 893 | function calculateScrollBarSize(){ //this should happen after the floating table has been positioned 894 | if($scrollContainer.length){ 895 | if(opts.support && opts.support.perfectScrollbar && $scrollContainer.data().perfectScrollbar){ 896 | scrollbarOffset = {horizontal:0, vertical:0}; 897 | } else { 898 | if($scrollContainer.css('overflow-x') === 'scroll'){ 899 | scrollbarOffset.horizontal = scrollbarWidth; 900 | } else { 901 | var sw = $scrollContainer.width(), tw = tableWidth($table, $fthCells); 902 | var offsetv = sh < th ? scrollbarWidth : 0; 903 | scrollbarOffset.horizontal = sw - offsetv < tw ? scrollbarWidth : 0; 904 | } 905 | if($scrollContainer.css('overflow-y') === 'scroll'){ 906 | scrollbarOffset.vertical = scrollbarWidth; 907 | } else { 908 | var sh = $scrollContainer.height(), th = $table.height(); 909 | var offseth = sw < tw ? scrollbarWidth : 0; 910 | scrollbarOffset.vertical = sh - offseth < th ? scrollbarWidth : 0; 911 | } 912 | } 913 | } 914 | } 915 | //finish up. create all calculation functions and bind them to events 916 | calculateScrollBarSize(); 917 | 918 | var flow; 919 | 920 | var ensureReflow = function(){ 921 | flow = reflow(); 922 | flow(); 923 | }; 924 | 925 | ensureReflow(); 926 | 927 | var calculateFloatContainerPos = calculateFloatContainerPosFn(); 928 | var repositionFloatContainer = repositionFloatContainerFn(); 929 | 930 | repositionFloatContainer(calculateFloatContainerPos('init'), true); //this must come after reflow because reflow changes scrollLeft back to 0 when it rips out the thead 931 | 932 | var windowScrollDoneEvent = util.debounce(function(){ 933 | repositionFloatContainer(calculateFloatContainerPos('windowScrollDone'), false); 934 | }, 1); 935 | 936 | var windowScrollEvent = function(){ 937 | repositionFloatContainer(calculateFloatContainerPos('windowScroll'), false); 938 | if(absoluteToFixedOnScroll){ 939 | windowScrollDoneEvent(); 940 | } 941 | }; 942 | var containerScrollEvent = function(){ 943 | repositionFloatContainer(calculateFloatContainerPos('containerScroll'), false); 944 | }; 945 | 946 | 947 | var windowResizeEvent = function(){ 948 | if($table.is(":hidden")){ 949 | return; 950 | } 951 | updateScrollingOffsets(); 952 | calculateScrollBarSize(); 953 | ensureReflow(); 954 | calculateFloatContainerPos = calculateFloatContainerPosFn(); 955 | repositionFloatContainer = repositionFloatContainerFn(); 956 | repositionFloatContainer(calculateFloatContainerPos('resize'), true, true); 957 | }; 958 | var reflowEvent = util.debounce(function(){ 959 | if($table.is(":hidden")){ 960 | return; 961 | } 962 | calculateScrollBarSize(); 963 | updateScrollingOffsets(); 964 | ensureReflow(); 965 | calculateFloatContainerPos = calculateFloatContainerPosFn(); 966 | repositionFloatContainer(calculateFloatContainerPos('reflow'), true, true); 967 | }, 1); 968 | 969 | /////// printing stuff 970 | var beforePrint = function(){ 971 | unfloat(); 972 | }; 973 | var afterPrint = function(){ 974 | refloat(); 975 | }; 976 | var printEvent = function(mql){ 977 | //make printing the table work properly on IE10+ 978 | if(mql.matches) { 979 | beforePrint(); 980 | } else { 981 | afterPrint(); 982 | } 983 | }; 984 | 985 | var matchMediaPrint = null; 986 | if(window.matchMedia && window.matchMedia('print').addListener && !buggyMatchMedia){ 987 | matchMediaPrint = window.matchMedia("print"); 988 | matchMediaPrint.addListener(printEvent); 989 | } else { 990 | $window.on('fth-beforeprint', beforePrint); 991 | $window.on('fth-afterprint', afterPrint); 992 | } 993 | ////// end printing stuff 994 | 995 | if(locked){ //internal scrolling 996 | if(useAbsolutePositioning){ 997 | $scrollContainer.on(eventName('scroll'), containerScrollEvent); 998 | } else { 999 | $scrollContainer.on(eventName('scroll'), containerScrollEvent); 1000 | $window.on(eventName('scroll'), windowScrollEvent); 1001 | } 1002 | } else { //window scrolling 1003 | $responsiveContainer.on(eventName('scroll'), containerScrollEvent); 1004 | $window.on(eventName('scroll'), windowScrollEvent); 1005 | } 1006 | 1007 | $window.on(eventName('load'), reflowEvent); //for tables with images 1008 | 1009 | windowResize(eventName('resize'), windowResizeEvent); 1010 | $table.on('reflow', reflowEvent); 1011 | if(opts.support && opts.support.datatables && isDatatable($table)){ 1012 | $table 1013 | .on('filter', reflowEvent) 1014 | .on('sort', reflowEvent) 1015 | .on('page', reflowEvent); 1016 | } 1017 | 1018 | if(opts.support && opts.support.bootstrap) { 1019 | $window.on(eventName('shown.bs.tab'), reflowEvent); // people cant seem to figure out how to use this plugin with bs3 tabs... so this :P 1020 | } 1021 | if(opts.support && opts.support.jqueryUI) { 1022 | $window.on(eventName('tabsactivate'), reflowEvent); // same thing for jqueryui 1023 | } 1024 | 1025 | if (canObserveMutations) { 1026 | var mutationElement = null; 1027 | if(util.isFunction(opts.autoReflow)){ 1028 | mutationElement = opts.autoReflow($table, $scrollContainer) 1029 | } 1030 | if(!mutationElement) { 1031 | mutationElement = $scrollContainer.length ? $scrollContainer[0] : $table[0] 1032 | } 1033 | mObs = new MutationObserver(function(e){ 1034 | var wasTableRelated = function(nodes){ 1035 | return nodes && nodes[0] && (nodes[0].nodeName === "THEAD" || nodes[0].nodeName === "TD"|| nodes[0].nodeName === "TH"); 1036 | }; 1037 | for(var i=0; i < e.length; i++){ 1038 | if(!(wasTableRelated(e[i].addedNodes) || wasTableRelated(e[i].removedNodes))){ 1039 | reflowEvent(); 1040 | break; 1041 | } 1042 | } 1043 | }); 1044 | mObs.observe(mutationElement, { 1045 | childList: true, 1046 | subtree: true 1047 | }); 1048 | } 1049 | 1050 | //attach some useful functions to the table. 1051 | $table.data('floatThead-attached', { 1052 | destroy: function(){ 1053 | var ns = '.fth-'+floatTheadId; 1054 | unfloat(); 1055 | $table.css(layoutAuto); 1056 | $tableColGroup.remove(); 1057 | createElements && $fthGrp.remove(); 1058 | if($newHeader.parent().length){ //only if it's in the DOM 1059 | $newHeader.replaceWith($header); 1060 | } 1061 | triggerFloatEvent(false); 1062 | if(canObserveMutations){ 1063 | mObs.disconnect(); 1064 | mObs = null; 1065 | } 1066 | $table.off('reflow reflowed'); 1067 | $scrollContainer.off(ns); 1068 | $responsiveContainer.off(ns); 1069 | if (wrappedContainer) { 1070 | if ($scrollContainer.length) { 1071 | $scrollContainer.unwrap(); 1072 | } 1073 | else { 1074 | $table.unwrap(); 1075 | } 1076 | } 1077 | if(locked){ 1078 | $scrollContainer.data('floatThead-containerWrap', false); 1079 | } else { 1080 | $table.data('floatThead-containerWrap', false); 1081 | } 1082 | $table.css('minWidth', originalTableMinWidth); 1083 | $floatContainer.remove(); 1084 | $table.data('floatThead-attached', false); 1085 | $window.off(ns); 1086 | $window.off('fth-beforeprint fth-afterprint'); // Not bound with id, so cant use ns. 1087 | if (matchMediaPrint) { 1088 | matchMediaPrint.removeListener(printEvent); 1089 | } 1090 | beforePrint = afterPrint = function(){}; 1091 | 1092 | return function reinit(){ 1093 | return $table.floatThead(opts); 1094 | } 1095 | }, 1096 | reflow: function(){ 1097 | reflowEvent(); 1098 | }, 1099 | setHeaderHeight: function(){ 1100 | setHeaderHeight(); 1101 | }, 1102 | getFloatContainer: function(){ 1103 | return $floatContainer; 1104 | }, 1105 | getRowGroups: function(){ 1106 | if(headerFloated){ 1107 | return $floatContainer.find('>table>thead').add($table.children("tbody,tfoot")); 1108 | } else { 1109 | return $table.children("thead,tbody,tfoot"); 1110 | } 1111 | } 1112 | }); 1113 | }); 1114 | return this; 1115 | }; 1116 | })((function(){ 1117 | var $ = window.jQuery; 1118 | if(typeof module !== 'undefined' && module.exports && !$) { 1119 | // only use cjs if they dont have a jquery for me to use, and we have commonjs 1120 | $ = require('jquery'); 1121 | } 1122 | return $; 1123 | })()); --------------------------------------------------------------------------------