pairer 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +7 -0
- data/README.md +134 -0
- data/Rakefile +20 -0
- data/app/assets/config/pairer_manifest.js +3 -0
- data/app/assets/images/pairer/favicon.ico +0 -0
- data/app/assets/images/pairer/sweep.png +0 -0
- data/app/assets/javascripts/pairer/application.js +47 -0
- data/app/assets/stylesheets/pairer/application.scss +82 -0
- data/app/assets/stylesheets/pairer/utility.scss +221 -0
- data/app/controllers/pairer/application_controller.rb +31 -0
- data/app/controllers/pairer/boards_controller.rb +202 -0
- data/app/controllers/pairer/sessions_controller.rb +32 -0
- data/app/helpers/pairer/application_helper.rb +4 -0
- data/app/jobs/pairer/application_job.rb +5 -0
- data/app/jobs/pairer/cleanup_boards_job.rb +9 -0
- data/app/mailers/pairer/application_mailer.rb +5 -0
- data/app/models/pairer/application_record.rb +19 -0
- data/app/models/pairer/board.rb +226 -0
- data/app/models/pairer/group.rb +45 -0
- data/app/models/pairer/person.rb +7 -0
- data/app/views/layouts/pairer/application.html.slim +53 -0
- data/app/views/layouts/pairer/application.js.erb +5 -0
- data/app/views/pairer/boards/_current_groups.html.slim +11 -0
- data/app/views/pairer/boards/_group.html.slim +17 -0
- data/app/views/pairer/boards/_group_lock_button.html.slim +4 -0
- data/app/views/pairer/boards/_person.html.slim +9 -0
- data/app/views/pairer/boards/_recently_accessed_boards.html.slim +26 -0
- data/app/views/pairer/boards/_role.html.slim +6 -0
- data/app/views/pairer/boards/_stats.html.slim +37 -0
- data/app/views/pairer/boards/create_group.js.erb +3 -0
- data/app/views/pairer/boards/create_person.js.erb +7 -0
- data/app/views/pairer/boards/delete_group.js.erb +7 -0
- data/app/views/pairer/boards/index.html.slim +28 -0
- data/app/views/pairer/boards/lock_group.js.erb +1 -0
- data/app/views/pairer/boards/lock_person.js.erb +1 -0
- data/app/views/pairer/boards/show.html.slim +240 -0
- data/app/views/pairer/boards/update_group.js.erb +0 -0
- data/app/views/pairer/exceptions/show.html.slim +8 -0
- data/app/views/pairer/sessions/sign_in.html.slim +10 -0
- data/app/views/pairer/shared/_flash.html.slim +6 -0
- data/app/views/pairer/shared/svg/_broom.html.slim +2 -0
- data/config/locales/en.yml +5 -0
- data/config/routes.rb +26 -0
- data/db/migrate/20210821001344_add_pairer_tables.rb +34 -0
- data/db/seeds.rb +0 -0
- data/lib/pairer/config.rb +38 -0
- data/lib/pairer/engine.rb +47 -0
- data/lib/pairer/version.rb +3 -0
- data/lib/pairer.rb +24 -0
- data/lib/tasks/pairer_engine_tasks.rake +4 -0
- metadata +261 -0
@@ -0,0 +1,202 @@
|
|
1
|
+
require_dependency "pairer/application_controller"
|
2
|
+
|
3
|
+
module Pairer
|
4
|
+
class BoardsController < ApplicationController
|
5
|
+
before_action do
|
6
|
+
if !signed_in?
|
7
|
+
redirect_to sign_in_path
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
before_action :get_board, except: [:index, :new, :create]
|
12
|
+
|
13
|
+
helper_method :people_by_id
|
14
|
+
|
15
|
+
def index
|
16
|
+
if params[:password].present?
|
17
|
+
@board = Pairer::Board.find_by(org_id: session[:pairer_current_org_id], password: params[:password])
|
18
|
+
|
19
|
+
if @board
|
20
|
+
session[:pairer_current_board_id] = @board.to_param
|
21
|
+
redirect_to action: :show, id: session[:pairer_current_board_id]
|
22
|
+
else
|
23
|
+
flash.now.alert = "Board not found."
|
24
|
+
|
25
|
+
render
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def create
|
31
|
+
@board = Pairer::Board.new(password: params[:password], org_id: session[:pairer_current_org_id])
|
32
|
+
|
33
|
+
if @board.save
|
34
|
+
session[:pairer_current_board_id] = @board.to_param
|
35
|
+
flash.notice = "Board created."
|
36
|
+
redirect_to(action: :show, id: @board.to_param)
|
37
|
+
else
|
38
|
+
other_board = Pairer::Board.find_by(org_id: session[:pairer_current_org_id], password: params[:password])
|
39
|
+
|
40
|
+
if other_board
|
41
|
+
session[:pairer_current_board_id] = other_board.to_param
|
42
|
+
flash.notice = "Existing board found."
|
43
|
+
redirect_to(action: :show, id: other_board.to_param)
|
44
|
+
else
|
45
|
+
flash.alert = "Board not saved. Please choose a different password."
|
46
|
+
redirect_to(action: :index)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def show
|
52
|
+
session_key = :pairer_board_access_list
|
53
|
+
|
54
|
+
access_list = session[session_key] || {}
|
55
|
+
|
56
|
+
access_list[@board.public_id] = Time.now.to_s
|
57
|
+
|
58
|
+
session[session_key] = access_list
|
59
|
+
end
|
60
|
+
|
61
|
+
def update
|
62
|
+
if params[:add_role_name]
|
63
|
+
params[:board] = {}
|
64
|
+
params[:board][:roles] = @board.roles_array + [params[:add_role_name]]
|
65
|
+
elsif params[:remove_role_name]
|
66
|
+
params[:board] = {}
|
67
|
+
params[:board][:roles] = @board.roles_array - [params[:remove_role_name]]
|
68
|
+
end
|
69
|
+
|
70
|
+
if params[:clear_board]
|
71
|
+
@board.groups.destroy_all
|
72
|
+
@board.update!(current_iteration_number: 0)
|
73
|
+
saved = true
|
74
|
+
else
|
75
|
+
saved = @board.update(params.require(:board).permit(:name, :password, :group_size, :num_iterations_to_track, roles: []))
|
76
|
+
end
|
77
|
+
|
78
|
+
if saved
|
79
|
+
if params.dig(:board, :password)
|
80
|
+
flash.notice = "Password updated."
|
81
|
+
else
|
82
|
+
flash.notice = "Board updated."
|
83
|
+
end
|
84
|
+
else
|
85
|
+
if @board.errors[:num_iterations_to_track].present?
|
86
|
+
flash.alert = "Number of Iterations to Track #{@board.errors[:num_iterations_to_track].first}"
|
87
|
+
else
|
88
|
+
flash.alert = "Board not saved."
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
redirect_to(action: :show)
|
93
|
+
end
|
94
|
+
|
95
|
+
def destroy
|
96
|
+
@board.destroy!
|
97
|
+
flash.notice = "Board deleted."
|
98
|
+
redirect_to(action: :index)
|
99
|
+
end
|
100
|
+
|
101
|
+
def shuffle
|
102
|
+
@board.shuffle!
|
103
|
+
redirect_to(action: :show)
|
104
|
+
end
|
105
|
+
|
106
|
+
def create_person
|
107
|
+
@person = @board.people.create(name: params[:name])
|
108
|
+
|
109
|
+
if request.format.js?
|
110
|
+
render
|
111
|
+
else
|
112
|
+
redirect_to(action: :show)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def lock_person
|
117
|
+
@person = @board.people.find_by!(public_id: params.require(:person_id))
|
118
|
+
|
119
|
+
@person.toggle!(:locked)
|
120
|
+
|
121
|
+
if request.format.js?
|
122
|
+
render
|
123
|
+
else
|
124
|
+
redirect_to(action: :show)
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
def delete_person
|
129
|
+
@person = @board.people.find_by!(public_id: params.require(:person_id))
|
130
|
+
|
131
|
+
@person.destroy!
|
132
|
+
|
133
|
+
redirect_to(action: :show)
|
134
|
+
end
|
135
|
+
|
136
|
+
def create_group
|
137
|
+
@group = @board.groups.create!(board_iteration_number: @board.current_iteration_number)
|
138
|
+
|
139
|
+
if request.format.js?
|
140
|
+
render
|
141
|
+
else
|
142
|
+
redirect_to(action: :show)
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
def lock_group
|
147
|
+
@group = @board.groups.find_by!(public_id: params.require(:group_id))
|
148
|
+
|
149
|
+
@group.toggle!(:locked)
|
150
|
+
|
151
|
+
if request.format.js?
|
152
|
+
render
|
153
|
+
else
|
154
|
+
redirect_to(action: :show)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def delete_group
|
159
|
+
@group = @board.groups.find_by!(public_id: params.require(:group_id))
|
160
|
+
|
161
|
+
@group.destroy!
|
162
|
+
|
163
|
+
if request.format.js?
|
164
|
+
render
|
165
|
+
else
|
166
|
+
redirect_to(action: :show)
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def update_group
|
171
|
+
@group = @board.groups.find_by!(public_id: params.require(:group_id))
|
172
|
+
|
173
|
+
attrs = {
|
174
|
+
person_ids: (params[:person_ids] if params[:person_ids].present?),
|
175
|
+
roles: (params[:roles] if params[:roles].present?),
|
176
|
+
}.compact
|
177
|
+
|
178
|
+
@group.update!(attrs)
|
179
|
+
|
180
|
+
if request.format.js?
|
181
|
+
render
|
182
|
+
else
|
183
|
+
redirect_to(action: :show)
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
private
|
188
|
+
|
189
|
+
def get_board
|
190
|
+
if session[:pairer_current_board_id].blank?
|
191
|
+
redirect_to(action: :index)
|
192
|
+
end
|
193
|
+
|
194
|
+
@board = Pairer::Board.find_by!(org_id: session[:pairer_current_org_id], public_id: params[:id])
|
195
|
+
end
|
196
|
+
|
197
|
+
def people_by_id
|
198
|
+
@people_by_id ||= @board.people.map{|x| [x.to_param, x] }.to_h
|
199
|
+
end
|
200
|
+
|
201
|
+
end
|
202
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require_dependency "pairer/application_controller"
|
2
|
+
|
3
|
+
module Pairer
|
4
|
+
class SessionsController < ApplicationController
|
5
|
+
|
6
|
+
def sign_in
|
7
|
+
if request.method == "GET"
|
8
|
+
if signed_in?
|
9
|
+
redirect_to boards_path
|
10
|
+
end
|
11
|
+
|
12
|
+
elsif request.method == "POST"
|
13
|
+
if Pairer.config.allowed_org_ids.include?(params[:org_id]&.downcase)
|
14
|
+
session[:pairer_current_org_id] = params[:org_id].downcase
|
15
|
+
redirect_to boards_path
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def sign_out
|
21
|
+
if !signed_in?
|
22
|
+
redirect_to action: :sign_in
|
23
|
+
else
|
24
|
+
session.delete(:pairer_current_org_id)
|
25
|
+
session.delete(:pairer_current_board_id)
|
26
|
+
flash.notice = "Signed out"
|
27
|
+
redirect_to sign_in_path
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Pairer
|
2
|
+
class ApplicationRecord < ActiveRecord::Base
|
3
|
+
self.abstract_class = true
|
4
|
+
|
5
|
+
after_create do
|
6
|
+
salt = Pairer.config.hash_id_salt
|
7
|
+
pepper = self.class.table_name
|
8
|
+
|
9
|
+
self.update_columns(public_id: Hashids.new("#{salt}_#{pepper}", 8).encode(id))
|
10
|
+
end
|
11
|
+
|
12
|
+
validates :public_id, uniqueness: {case_sensitive: true, allow_blank: true}
|
13
|
+
|
14
|
+
def to_param
|
15
|
+
try(:public_id) || id
|
16
|
+
end
|
17
|
+
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,226 @@
|
|
1
|
+
module Pairer
|
2
|
+
class Board < ApplicationRecord
|
3
|
+
RECENT_RESHUFFLE_DURATION = 1.minute.freeze
|
4
|
+
NUM_CANDIDATE_GROUPINGS = 5
|
5
|
+
|
6
|
+
has_many :people, class_name: "Pairer::Person", dependent: :destroy
|
7
|
+
has_many :groups, class_name: "Pairer::Group", dependent: :destroy
|
8
|
+
|
9
|
+
validates :name, presence: true
|
10
|
+
validates :org_id, presence: true, inclusion: {in: ->(x){ Pairer.config.allowed_org_ids }}
|
11
|
+
validates :password, presence: true, uniqueness: {message: "invalid password, please use a different one", scope: :org_id}
|
12
|
+
validates :current_iteration_number, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
13
|
+
validates :num_iterations_to_track, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: ->(x){ Pairer.config.max_iterations_to_track } }
|
14
|
+
validates :group_size, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
15
|
+
|
16
|
+
before_validation on: :create do
|
17
|
+
self.name ||= "New Board"
|
18
|
+
self.current_iteration_number ||= 0
|
19
|
+
self.num_iterations_to_track ||= 15
|
20
|
+
self.group_size ||= 2
|
21
|
+
end
|
22
|
+
|
23
|
+
def current_groups
|
24
|
+
groups.where(board_iteration_number: current_iteration_number)
|
25
|
+
end
|
26
|
+
|
27
|
+
def tracked_groups
|
28
|
+
groups
|
29
|
+
.order(board_iteration_number: :desc)
|
30
|
+
.where("board_iteration_number > #{current_iteration_number - num_iterations_to_track}")
|
31
|
+
end
|
32
|
+
|
33
|
+
def roles=(val)
|
34
|
+
if val.is_a?(Array)
|
35
|
+
self[:roles] = val.map{|x| x.presence&.strip }.uniq(&:downcase).compact.sort.join(";;")
|
36
|
+
else
|
37
|
+
raise "invalid behaviour"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def roles_array
|
42
|
+
self[:roles]&.split(";;") || []
|
43
|
+
end
|
44
|
+
|
45
|
+
def shuffle!
|
46
|
+
new_groups = []
|
47
|
+
|
48
|
+
prev_iteration_number = current_iteration_number
|
49
|
+
next_iteration_number = current_iteration_number + 1
|
50
|
+
|
51
|
+
available_person_ids = people.select{|x| !x.locked? }.collect(&:public_id)
|
52
|
+
available_roles = roles_array
|
53
|
+
|
54
|
+
### Build New Groups
|
55
|
+
groups.where(board_iteration_number: prev_iteration_number).each do |g|
|
56
|
+
if g.locked?
|
57
|
+
### Clone Locked Groups
|
58
|
+
|
59
|
+
new_group = g.dup
|
60
|
+
|
61
|
+
new_group.assign_attributes(
|
62
|
+
public_id: nil,
|
63
|
+
board_iteration_number: next_iteration_number,
|
64
|
+
)
|
65
|
+
|
66
|
+
new_groups << new_group
|
67
|
+
|
68
|
+
available_person_ids = (available_person_ids - g.person_ids_array)
|
69
|
+
|
70
|
+
available_roles = (available_roles - new_group.roles_array)
|
71
|
+
else
|
72
|
+
### Retain Position of Locked People within Existing Groups
|
73
|
+
|
74
|
+
group_locked_person_ids = (g.person_ids_array - available_person_ids)
|
75
|
+
|
76
|
+
if group_locked_person_ids.any?
|
77
|
+
new_group = groups.new(
|
78
|
+
board_iteration_number: next_iteration_number,
|
79
|
+
roles: [],
|
80
|
+
person_ids: group_locked_person_ids,
|
81
|
+
)
|
82
|
+
|
83
|
+
new_groups << new_group
|
84
|
+
|
85
|
+
available_person_ids = (available_person_ids - group_locked_person_ids)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
self.increment!(:current_iteration_number)
|
91
|
+
|
92
|
+
if available_person_ids.any?
|
93
|
+
pair_stats_hash = stats_hash_for_two_pairs
|
94
|
+
|
95
|
+
### Assign People to Non-Full Unlocked Groups
|
96
|
+
new_groups.select{|x| !x.locked? }.each do |g|
|
97
|
+
break if available_person_ids.empty?
|
98
|
+
|
99
|
+
num_to_add = self.group_size - g.person_ids_array.size
|
100
|
+
|
101
|
+
next if num_to_add <= 0
|
102
|
+
|
103
|
+
if available_person_ids.size < num_to_add
|
104
|
+
### Add to group whatever is left
|
105
|
+
|
106
|
+
g.person_ids = g.person_ids_array + available_person_ids
|
107
|
+
|
108
|
+
available_person_ids = []
|
109
|
+
|
110
|
+
break
|
111
|
+
end
|
112
|
+
|
113
|
+
group_size_combinations = available_person_ids.combination(num_to_add).map{|x| x + g.person_ids_array }.shuffle
|
114
|
+
|
115
|
+
### Choose group using minimum score
|
116
|
+
chosen_person_ids = group_size_combinations.min_by do |person_ids|
|
117
|
+
person_ids.combination(2).map(&:sort).sum{|k| pair_stats_hash[k] || 0 }
|
118
|
+
end
|
119
|
+
|
120
|
+
g.person_ids = (g.person_ids_array | chosen_person_ids).sort
|
121
|
+
end
|
122
|
+
|
123
|
+
### Assign People to New Groups
|
124
|
+
while available_person_ids.any? do
|
125
|
+
if available_person_ids.size <= self.group_size
|
126
|
+
### Create group using whats left
|
127
|
+
|
128
|
+
new_groups << groups.new(
|
129
|
+
board_iteration_number: next_iteration_number,
|
130
|
+
person_ids: available_person_ids,
|
131
|
+
)
|
132
|
+
|
133
|
+
available_person_ids = []
|
134
|
+
else
|
135
|
+
group_size_combinations = available_person_ids.combination(self.group_size).to_a.shuffle
|
136
|
+
|
137
|
+
### Choose group using minimum score
|
138
|
+
chosen_person_ids = group_size_combinations.min_by do |person_ids|
|
139
|
+
person_ids.combination(2).map(&:sort).sum{|k| pair_stats_hash[k] || 0 }
|
140
|
+
end
|
141
|
+
|
142
|
+
new_groups << groups.new(
|
143
|
+
board_iteration_number: next_iteration_number,
|
144
|
+
person_ids: chosen_person_ids,
|
145
|
+
)
|
146
|
+
|
147
|
+
available_person_ids = (available_person_ids - chosen_person_ids)
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
### Shuffle Roles
|
152
|
+
available_roles = available_roles.shuffle
|
153
|
+
|
154
|
+
unlocked_new_groups = new_groups.select{|x| !x.locked? }
|
155
|
+
|
156
|
+
### Assign Roles to Groups
|
157
|
+
available_roles.in_groups(unlocked_new_groups.size, false).each_with_index do |roles, i|
|
158
|
+
unlocked_new_groups[i].roles = unlocked_new_groups[i].roles_array + roles
|
159
|
+
available_roles = available_roles - roles
|
160
|
+
end
|
161
|
+
|
162
|
+
### Save New Groups
|
163
|
+
new_groups.each{|x| x.save! }
|
164
|
+
end
|
165
|
+
|
166
|
+
### Delete empty groups
|
167
|
+
groups
|
168
|
+
.where(person_ids: [nil, ""])
|
169
|
+
.each{|x| x.destroy! }
|
170
|
+
|
171
|
+
### Delete outdated groups
|
172
|
+
groups
|
173
|
+
.where.not(id: tracked_groups.collect(&:id))
|
174
|
+
.each{|x| x.destroy! }
|
175
|
+
|
176
|
+
### Ensure stats do not contain bogus entries caused by re-shuffling, groups created less than x time ago are deleted upon shuffle
|
177
|
+
if !Rails.env.test? || (Rails.env.test? && ENV['DELETE_RECENT_RESHUFFLED'].to_s == "true")
|
178
|
+
groups
|
179
|
+
.where.not(id: new_groups.collect(&:id))
|
180
|
+
.where("#{Pairer::Group.table_name}.created_at >= ?", RECENT_RESHUFFLE_DURATION.ago)
|
181
|
+
.each{|x| x.destroy! }
|
182
|
+
end
|
183
|
+
|
184
|
+
### Reload groups to fix any issues with caching after creations and deletions
|
185
|
+
groups.reload
|
186
|
+
|
187
|
+
return true
|
188
|
+
end
|
189
|
+
|
190
|
+
def stats
|
191
|
+
array = []
|
192
|
+
|
193
|
+
stats_hash_for_two_pairs.sort_by{|k,count| -count }.each do |person_ids, count|
|
194
|
+
array << [person_ids, count]
|
195
|
+
end
|
196
|
+
|
197
|
+
return array
|
198
|
+
end
|
199
|
+
|
200
|
+
private
|
201
|
+
|
202
|
+
def stats_hash_for_two_pairs
|
203
|
+
h = {}
|
204
|
+
|
205
|
+
tracked_groups.each do |group|
|
206
|
+
group_person_ids = group.person_ids_array
|
207
|
+
|
208
|
+
### For combinations size, we use 2 instead of self.group_size as we are running stats on pairs, not groups
|
209
|
+
if group_person_ids.size == 1
|
210
|
+
h[group_person_ids] ||= 0
|
211
|
+
h[group_person_ids] += 1
|
212
|
+
else
|
213
|
+
combinations = group_person_ids.combination(2)
|
214
|
+
|
215
|
+
combinations.map{|x| x.sort }.each do |sorted_pair_person_ids|
|
216
|
+
h[sorted_pair_person_ids] ||= 0
|
217
|
+
h[sorted_pair_person_ids] += 1
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
return h
|
223
|
+
end
|
224
|
+
|
225
|
+
end
|
226
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module Pairer
|
2
|
+
class Group < ApplicationRecord
|
3
|
+
belongs_to :board, class_name: "Pairer::Board"
|
4
|
+
|
5
|
+
validates :board_id, presence: true
|
6
|
+
validates :board_iteration_number, presence: true, numericality: { only_integer: true, minimum: 0 }
|
7
|
+
|
8
|
+
def person_ids=(val)
|
9
|
+
if val.is_a?(Array)
|
10
|
+
sanitized_array = val.map{|x| x.presence&.strip }.uniq(&:downcase).compact
|
11
|
+
|
12
|
+
if !new_record?
|
13
|
+
sanitized_array = sanitized_array.intersection(board.people.map(&:public_id)) ### This may slow the query down
|
14
|
+
end
|
15
|
+
|
16
|
+
self[:person_ids] = sanitized_array.join(";;")
|
17
|
+
else
|
18
|
+
raise "invalid behaviour"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def person_ids_array
|
23
|
+
self[:person_ids]&.split(";;") || []
|
24
|
+
end
|
25
|
+
|
26
|
+
def roles=(val)
|
27
|
+
if val.is_a?(Array)
|
28
|
+
sanitized_array = self[:roles] = val.map{|x| x.presence&.strip }.uniq(&:downcase).compact
|
29
|
+
|
30
|
+
if !new_record?
|
31
|
+
sanitized_array = sanitized_array.intersection(board.roles_array) ### This may slow the query down
|
32
|
+
end
|
33
|
+
|
34
|
+
self[:roles] = sanitized_array.join(";;")
|
35
|
+
else
|
36
|
+
raise "invalid behaviour"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def roles_array
|
41
|
+
self[:roles]&.split(";;") || []
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
- @title ||= 'Pairer'
|
2
|
+
- @description ||= "A tool to help assign working pairs"
|
3
|
+
|
4
|
+
doctype html
|
5
|
+
html
|
6
|
+
head
|
7
|
+
title = @title
|
8
|
+
|
9
|
+
= csrf_meta_tags
|
10
|
+
meta name="viewport" content="width=device-width, height=device-height, initial-scale=1.0"
|
11
|
+
|
12
|
+
= favicon_link_tag "pairer/favicon.ico"
|
13
|
+
|
14
|
+
= stylesheet_link_tag 'pairer/application', media: 'all'
|
15
|
+
|
16
|
+
script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js" referrerpolicy="no-referrer"
|
17
|
+
|
18
|
+
link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/3.2.1/css/font-awesome.min.css" integrity="sha512-IJ+BZHGlT4K43sqBGUzJ90pcxfkREDVZPZxeexRigVL8rzdw/gyJIflDahMdNzBww4k0WxpyaWpC2PLQUWmMUQ==" crossorigin="anonymous" referrerpolicy="no-referrer"
|
19
|
+
|
20
|
+
link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css" integrity="sha512-SfTiTlX6kk+qitfevl/7LibUOeJWlt9rbyDn92a1DqWOw9vWG2MFoays0sgObmWazO5BQPiFucnnEAjpAB+/Sw==" crossorigin="anonymous" referrerpolicy="no-referrer"
|
21
|
+
|
22
|
+
link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.13.2/themes/base/jquery-ui.min.css" integrity="sha512-ELV+xyi8IhEApPS/pSj66+Jiw+sOT1Mqkzlh8ExXihe4zfqbWkxPRi8wptXIO9g73FSlhmquFlUOuMSoXz5IRw==" crossorigin="anonymous" referrerpolicy="no-referrer"
|
23
|
+
script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.13.2/jquery-ui.min.js" integrity="sha512-57oZ/vW8ANMjR/KQ6Be9v/+/h6bq9/l3f0Oc7vn6qMqyhvPd1cvKBRWWpzu0QoneImqr2SkmO4MSqU+RpHom3Q==" crossorigin="anonymous" referrerpolicy="no-referrer"
|
24
|
+
|
25
|
+
= javascript_include_tag 'pairer/application'
|
26
|
+
|
27
|
+
body
|
28
|
+
nav.navbar.navbar-inverse.navbar-fixed-top
|
29
|
+
.container-fluid
|
30
|
+
.navbar-header
|
31
|
+
i.fa.fa-bars.navbar-toggle.visible-sm.visible-xs style="color: white; cursor: pointer;" data-toggle="collapse" data-target="#nav" title="Show/Hide Menu"
|
32
|
+
h1.hidden = @title
|
33
|
+
a.navbar-brand href=root_path = "#{@title}"
|
34
|
+
|
35
|
+
.navbar-collapse.collapse#nav
|
36
|
+
- if signed_in?
|
37
|
+
ul.nav.navbar-nav
|
38
|
+
li class=('active' if params[:action] == "index")
|
39
|
+
a href=boards_path Find Board
|
40
|
+
- if @board
|
41
|
+
li.active
|
42
|
+
a href=board_path(@board) View Board
|
43
|
+
|
44
|
+
ul.nav.navbar-nav.navbar-right
|
45
|
+
li
|
46
|
+
a href=sign_out_path
|
47
|
+
i.fa.fa-sign-out
|
48
|
+
span.space-left.space-left Sign Out
|
49
|
+
|
50
|
+
.container-fluid
|
51
|
+
= render "pairer/shared/flash"
|
52
|
+
|
53
|
+
= yield
|
@@ -0,0 +1,11 @@
|
|
1
|
+
table.table-bordered.table.current-groups
|
2
|
+
thead
|
3
|
+
th.text-center style="width: 130px;"
|
4
|
+
= link_to shuffle_board_path(@board), class: "btn btn-success btn-sm", data: {method: :post} do
|
5
|
+
i.icon-refresh.space-right
|
6
|
+
span Shuffle
|
7
|
+
th style="width: 50%; padding-left: 25px;" People
|
8
|
+
th style="padding-left: 25px;" Roles
|
9
|
+
tbody
|
10
|
+
- @board.current_groups.each do |group|
|
11
|
+
= render "group", group: group
|
@@ -0,0 +1,17 @@
|
|
1
|
+
tr.group-row data-group-id=group.public_id
|
2
|
+
td.text-center
|
3
|
+
.space-above2
|
4
|
+
= render "group_lock_button", group: group
|
5
|
+
|
6
|
+
= link_to delete_group_board_path(@board, group_id: group), data: {method: :delete}, remote: true, class: 'btn btn-xs space-left2 group-sweep-btn', title: "Sweep" do
|
7
|
+
= render "pairer/shared/svg/broom"
|
8
|
+
|
9
|
+
td.person-list data-group-id=group.public_id data-prev-person-ids=group.person_ids_array style="padding-left: 20px;"
|
10
|
+
- group.person_ids_array.each do |person_public_id|
|
11
|
+
- person = people_by_id[person_public_id]
|
12
|
+
- if person
|
13
|
+
= render 'person', person: person
|
14
|
+
|
15
|
+
td.roles-list data-group-id=group.public_id data-prev-roles=group.roles_array style="padding-left: 20px;"
|
16
|
+
- group.roles_array.each do |role|
|
17
|
+
= render 'role', role: role
|
@@ -0,0 +1,4 @@
|
|
1
|
+
= link_to lock_group_board_path(@board, group_id: group), data: {method: :post}, remote: true, class: "btn btn-xs group-lock-btn #{group.locked? ? 'btn-highlight' : 'btn-default'}", title: "Group #{group.locked? ? "Locked" : "Unlocked"}" do
|
2
|
+
i class=(group.locked? ? 'icon-lock' : 'icon-unlock')
|
3
|
+
- if group.locked?
|
4
|
+
= " Locked"
|
@@ -0,0 +1,9 @@
|
|
1
|
+
span.person data-person-id=person.public_id
|
2
|
+
= person.name
|
3
|
+
|
4
|
+
= link_to lock_person_board_path(@board, person_id: person), data: {method: :post}, remote: true, class: "btn btn-xs space-left2 #{person.locked? ? 'btn-highlight' : 'btn-default'}", title: "#{person.locked? ? "Locked" : "Unlocked"}" do
|
5
|
+
i class=(person.locked? ? 'icon-lock' : 'icon-unlock')
|
6
|
+
|
7
|
+
span.space-left.delete
|
8
|
+
= link_to delete_person_board_path(@board, person_id: person), data: {method: :delete, confirm: "Are you sure you want to delete person '#{person.name}'?"}, title: "Delete Person", class: 'btn btn-xs btn-danger' do
|
9
|
+
i.icon-trash
|