Introduced candidate voting and various tweaks

Merge branch 'develop'
This commit is contained in:
Mark Moser
2016-11-22 17:35:02 -06:00
58 changed files with 1064 additions and 31 deletions

View File

@ -1,7 +1,8 @@
function handleAjaxResponse($el) {
function handleAjaxResponse($el, callback) {
var $header = $('header');
$el.on("ajax:success", function(e, data){
$header.after('<div class="success">' + data.message + '</div>');
callback(data);
}).on("ajax:error", function(e, xhr) {
if (xhr.status === 400){
$header.after('<div class="error">' + xhr.responseJSON.join('<br>') + '</div>');
@ -11,6 +12,25 @@ function handleAjaxResponse($el) {
});
}
function updateVotes(data){
$("[data-id=up-votes]").html(data.upCount);
$("[data-id=down-votes]").html(data.downCount);
$("[data-id=my-vote]").html(data.myVote);
}
function updateVeto(data){
$("[data-id=interview-request]").html(data.requestCopy);
$("[data-id=interview-decline]").html(data.declineCopy);
}
$(document).ready(function() {
$('[data-id=ajax-action]').each(function(){ handleAjaxResponse($(this)); });
});
$(document).ready(function() {
$('[data-id=vote-count]').each(function(){ handleAjaxResponse($(this), updateVotes); });
});
$(document).ready(function() {
$('[data-id=veto-status]').each(function(){ handleAjaxResponse($(this), updateVeto); });
});

View File

@ -0,0 +1,57 @@
.accordion {
margin-bottom: 0.75em;
[type="checkbox"]:checked + label,
[type="checkbox"]:checked ~ label:after,
[type="checkbox"]:not(:checked) + label,
[type="checkbox"]:not(:checked) ~ label:after,
[type="radio"]:checked + label,
[type="radio"]:checked ~ label:after,
[type="radio"]:not(:checked) + label,
[type="radio"]:not(:checked) ~ label:after {
content: "";
border: 0;
background-color: transparent;
}
[type="checkbox"]:hover:not(:disabled) + label:before,
[type="radio"]:hover:not(:disabled) + label:before,
[type="radio"]:not(:checked) ~ label:before,
[type="checkbox"]:not(:checked) ~ label:before,
[type="radio"]:checked ~ label:before,
[type="checkbox"]:checked ~ label:before {
background-color: transparent;
border: 0;
content: "+";
font-size: 1.3em;
line-height: 1;
}
[type="checkbox"]:hover:checked + label:before,
[type="radio"]:hover:checked + label:before,
[type="radio"]:checked ~ label:before,
[type="checkbox"]:checked ~ label:before {
background-color: transparent;
content: "-";
}
[type="radio"]:not(:checked) + label,
[type="checkbox"]:not(:checked) + label,
[type="radio"]:checked + label,
[type="checkbox"]:checked + label {
padding-left: 20px;
}
.accordion__copy {
display: none;
margin-top: 0.75em;
}
.accordion__toggle:checked ~ .accordion__copy {
background-color: $gray-lighter;
display: block;
padding: 2.5em 1.25em 1em 1.75em;
margin: -2.25em 0 2em -0.5em;
}
}

View File

@ -0,0 +1,31 @@
.admin-review {
counter-reset: question;
form {
margin-left: 2.3em;
position: relative;
&::before {
content: counter(question) ") ";
counter-increment: question;
font-size: 1.25em;
left: -1.8em;
position: absolute;
top: 0.4em;
}
}
}
.review_meta {
@media screen and (min-width: 768px) {
display: flex;
& > div { flex: 1 1 auto; }
}
.review_meta__votes,
.review_meta__vetos {
a { padding: 5px; }
}
}

View File

@ -22,6 +22,10 @@
display: block;
}
.code-results {
margin-bottom: -0.65em;
}
.results {
border: 1px solid $secondary-color;
clear: both;
@ -31,8 +35,15 @@
background-color: #fff;
}
fieldset:disabled .results {
border-color: #bbb;
fieldset:disabled {
.results {
border-color: #bbb;
}
.code-results,
.code-input label {
display: block;
}
}
iframe {

View File

@ -62,7 +62,7 @@ module Admin
def question_params
params.require(:question).permit(
:quiz_id, :question, :category, :input_type, :sort, :active, :input_options,
:quiz_id, :question, :category, :attachment, :input_type, :sort, :active, :input_options,
multi_choice: [], live_code: [:later, :html, :css, :js, :text]
)
end

View File

@ -0,0 +1,58 @@
# frozen_string_literal: true
module Admin
class VoteController < AdminController
def up
@candidate = Candidate.find_by(test_hash: params[:test_hash])
authorize ReviewerVote.find_by(user_id: current_user.id, candidate_id: @candidate.id)
current_user.cast_yea_on(@candidate)
results = {
message: "Vote Counted",
upCount: @candidate.votes.yea.count,
downCount: @candidate.votes.nay.count,
myVote: "yea"
}
render json: results.to_json
end
def down
@candidate = Candidate.find_by(test_hash: params[:test_hash])
authorize ReviewerVote.find_by(user_id: current_user.id, candidate_id: @candidate.id)
current_user.cast_nay_on(@candidate)
results = {
message: "Vote Counted",
upCount: @candidate.votes.yea.count,
downCount: @candidate.votes.nay.count,
myVote: "nay"
}
render json: results.to_json
end
def approve
@candidate = Candidate.find_by(test_hash: params[:test_hash])
authorize ReviewerVote.find_by(user_id: current_user.id, candidate_id: @candidate.id)
current_user.approve_candidate(@candidate)
results = {
message: "Interview requested!",
requestCopy: "Requested",
declineCopy: "Decline Interview"
}
render json: results.to_json
end
def decline
@candidate = Candidate.find_by(test_hash: params[:test_hash])
authorize ReviewerVote.find_by(user_id: current_user.id, candidate_id: @candidate.id)
current_user.decline_candidate(@candidate)
results = {
message: "Interview declined.",
requestCopy: "Request Interview",
declineCopy: "Declined"
}
render json: results.to_json
end
end
end

View File

@ -36,6 +36,7 @@ class QuizController < ApplicationController
def complete_and_email
if current_candidate.update_attributes(completed: true)
current_candidate.build_reviews
CandidateMailer.submitted(current_candidate).deliver_later
RecruiterMailer.candidate_submitted(current_candidate).deliver_later
ReviewerMailer.candidate_submission(current_candidate).deliver_later

View File

@ -4,6 +4,6 @@ class ReviewerMailer < ApplicationMailer
@candidate = candidate
recipients = candidate.quiz.reviewers.map(&:email)
mail to: recipients, subject: "Skills Assessment Results"
mail to: recipients, subject: "Skills Assessment Results - #{@candidate.test_hash}"
end
end

View File

@ -4,6 +4,8 @@ class Candidate < ApplicationRecord
has_many :questions, -> { order("sort") }, through: :quiz
has_many :answers
belongs_to :recruiter, class_name: "User"
has_many :votes, class_name: "ReviewerVote"
has_many :reviewers, through: :quiz
serialize :email, CryptSerializer
@ -15,6 +17,18 @@ class Candidate < ApplicationRecord
validates :email, uniqueness: true, presence: true, email_format: true
validates :test_hash, uniqueness: true, presence: true
enum review_status: {
pending: 0,
approved: 1,
declined: 2
}
def build_reviews
reviewers.each do |reviewer|
votes.find_or_create_by(user_id: reviewer.id)
end
end
def submitted_answers
answers.where(submitted: true)
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class ReviewerVote < ApplicationRecord
belongs_to :candidate
belongs_to :user
validates :user_id, uniqueness: { scope: :candidate_id }
enum vote: {
undecided: 0,
yea: 1,
nay: 2
}
enum veto: {
approved: 1,
rejected: 2
}
end

View File

@ -4,6 +4,9 @@ class User < ApplicationRecord
has_many :candidates, foreign_key: :recruiter_id
has_many :reviewer_to_quizzes
has_many :quizzes, through: :reviewer_to_quizzes
has_many :votes, class_name: 'ReviewerVote'
has_many :reviewees, through: :quizzes, source: :candidates
validates :email, presence: true, uniqueness: true
validates :name, presence: true
@ -15,6 +18,42 @@ class User < ApplicationRecord
save
end
# Voting
def cast_yea_on candidate
vote = votes.find_by(candidate_id: candidate.to_i)
vote.vote = :yea
vote.save
end
def cast_nay_on candidate
vote = votes.find_by(candidate_id: candidate.to_i)
vote.vote = :nay
vote.save
end
def approve_candidate candidate
candidate = Candidate.find(candidate.to_i)
vote = votes.find_by(candidate_id: candidate.to_i)
vote.veto = :approved
candidate.update_attribute(:review_status, :approved) if vote.save
end
def decline_candidate candidate
candidate = Candidate.find(candidate.to_i)
vote = votes.find_by(candidate_id: candidate.to_i)
vote.veto = :rejected
candidate.update_attribute(:review_status, :declined) if vote.save
end
def my_vote candidate
candidate = Candidate.find(candidate.to_i)
my_vote = votes.find_by(candidate_id: candidate.id)
my_vote.vote unless my_vote.nil?
end
# Roles
def admin?
'admin' == role
@ -45,7 +84,7 @@ class User < ApplicationRecord
end
def acts_as_reviewer?
%w(admin reviewer).include? role
%w(admin manager reviewer).include? role
end
private

View File

@ -0,0 +1,45 @@
# frozen_string_literal: true
class ReviewerVotePolicy < ApplicationPolicy
# Voting Policy
#
# Only Reviewers, Managers, and Admins, can cast a vote on a quiz result
#
# Reviewers can vote any quiz they are linked to
# Only Managers, and Admins, can veto a quiz result
def up?
return true if user.acts_as_admin?
return false unless record.candidate.reviewers.include? user
user.acts_as_reviewer?
end
def down?
return true if user.acts_as_admin?
return false unless record.candidate.reviewers.include? user
user.acts_as_reviewer?
end
def approve?
return true if user.acts_as_admin?
return false unless record.candidate.reviewers.include? user
user.acts_as_manager?
end
def decline?
return true if user.acts_as_admin?
return false unless record.candidate.reviewers.include? user
user.acts_as_manager?
end
class Scope < Scope
def resolve
return ReviewerVote.none if user.recruiter?
if user.reviewer?
scope.where(user_id: user.id)
else
scope
end
end
end
end

View File

@ -1,11 +1,11 @@
<%
content_for :main_class, "intro_tpl"
content_for :title, "Skills Assessment Admin"
%>
<h1>Admin Login</h1>
<%= form_for :auth, url: admin_login_path do |form| %>
<% if flash[:error].present? %>
<div class="form-group">
Need a <%= link_to "password reset", admin_reset_request_path %>?

View File

@ -1,5 +1,6 @@
<%
content_for :main_class, "intro_tpl"
content_for :title, "Skills Assessment Admin"
%>
<h1>Password Reset</h1>

View File

@ -1,5 +1,6 @@
<%
content_for :main_class, "intro_tpl"
content_for :title, "Skills Assessment Admin"
%>
<h1>Password Reset</h1>

View File

@ -1,3 +1,7 @@
<%
content_for :title, "Edit Candidate - Skills Assessment Admin"
%>
<main class="intro_tpl">
<h1>Edit: <%= @candidate.name %></h1>
<p><strong>Test ID: </strong><%= @candidate.test_hash %></p>

View File

@ -1,5 +1,6 @@
<%
content_for :section_title, "Candidates"
content_for :title, "Candidates - Skills Assessment Admin"
%>
<main class="summary_tpl">
<%= link_to(admin_new_candidate_path, { class: 'secondary-btn' }) do %>
@ -15,6 +16,7 @@
<th>Progress</th>
<th>Completed</th>
<th>Reminded</th>
<th>Interview Request</th>
</tr>
<% @candidates.each do |candidate| %>
@ -28,8 +30,9 @@
</td>
<td><%= candidate.experience %> years</td>
<td><%= candidate.status %></td>
<td><%= candidate.completed ? "Submitted" : "" %></td>
<td><%= candidate.completed ? link_to("Submitted", admin_result_path(candidate.test_hash)) : "" %></td>
<td><%= candidate.reminded ? "Yes" : "" %></td>
<td><%= candidate.review_status unless candidate.pending? %></td>
</tr>
<% end %>
</table>

View File

@ -1,3 +1,7 @@
<%
content_for :title, "New Candidate - Skills Assessment Admin"
%>
<main class="intro_tpl">
<h1>New Candidate</h1>

View File

@ -1,5 +1,6 @@
<%
content_for :section_title, "Edit: #{@user.name}"
content_for :title, "Profile - Skills Assessment Admin"
%>
<%= render partial: 'shared/form_model_errors', locals: {obj: @user} %>

View File

@ -1,5 +1,6 @@
<%
content_for :section_title, "Profile"
content_for :title, "Profile - Skills Assessment Admin"
%>
<p>Name: <%= current_user.name %></p>

View File

@ -1,5 +1,6 @@
<%
content_for :section_title, "Questions"
content_for :title, "Edit Question - Skills Assessment Admin"
%>
<h1><%= @question.quiz.name %></h1>

View File

@ -1,5 +1,6 @@
<%
content_for :section_title, "Questions"
content_for :title, "Questions - Skills Assessment Admin"
%>
<% quizzes = @questions.group_by{ |q| q.quiz.name } %>

View File

@ -1,5 +1,6 @@
<%
content_for :section_title, "New Question"
content_for :title, "New Question - Skills Assessment Admin"
%>
<%= render partial: 'form', locals: {question: @question, action: admin_create_question_path } %>

View File

@ -1,5 +1,6 @@
<%
content_for :section_title, "Question for #{@question.quiz.name}"
content_for :title, "Question - Skills Assessment Admin"
%>
<table cellspacing="0" cellpadding="0">

View File

@ -1,5 +1,6 @@
<%
content_for :section_title, "Quizzes"
content_for :title, "Quizzes - Skills Assessment Admin"
%>
<%= render partial: 'admin/quiz/table_list', locals: { quizzes: @quizzes } %>

View File

@ -1,5 +1,6 @@
<%
content_for :section_title, "New Quiz"
content_for :title, "New Quiz - Skills Assessment Admin"
%>
<%= render partial: 'form', locals: { quiz: @quiz, action: admin_create_quiz_path } %>

View File

@ -1,5 +1,6 @@
<%
content_for :section_title, "#{@quiz.name}"
content_for :title, "Quiz - Skills Assessment Admin"
%>
<p><%= @quiz.name %></p>

View File

@ -0,0 +1,34 @@
<% # TODO: This needs to be extracted into a decorator, or something. It is only a quick hack solution. %>
<% if current_user.acts_as_reviewer? %>
<div class="review_meta__votes" data-id="vote-count">
<strong>Votes: </strong>
<%= link_to admin_up_vote_path(test_hash: @candidate.test_hash), remote: true do %>
Yea (<span data-id="up-votes"><%= @candidate.votes.yea.count %></span>)
<% end %>
<%= link_to admin_down_vote_path(test_hash: @candidate.test_hash), remote: true do %>
Nay (<span data-id="down-votes"><%= @candidate.votes.nay.count %></span>)
<% end %>
<small>(Your vote: <span data-id="my-vote"><%= current_user.my_vote(@candidate) %></span>)</small>
</div>
<% end %>
<% if current_user.acts_as_manager? %>
<div class="review_meta__vetos" data-id="veto-status">
<strong>Manager Vetos: </strong>
<%= link_to admin_approve_vote_path(test_hash: @candidate.test_hash), remote: true do %>
<span data-id="interview-request">
<%= @candidate.approved? ? "Requested" : "Request Interview" %>
</span>
<% end %>
<%= link_to admin_decline_vote_path(test_hash: @candidate.test_hash), remote: true do %>
<span data-id="interview-decline">
<%= @candidate.declined? ? "Declined" : "Decline Interview" %>
</span>
<% end %>
</div>
<% else %>
<strong>Candidate Interview Status: </strong><%= @candidate.review_status %>
<% end %>

View File

@ -1,5 +1,6 @@
<%
content_for :section_title, "Completed Tests"
content_for :title, "Quiz Results - Skills Assessment Admin"
%>
<main class="summary_tpl">
<table cellspacing="0" cellpadding="0">
@ -7,6 +8,7 @@
<th>Test ID</th>
<th>Experience</th>
<th>Recruiter</th>
<th>Interview Request</th>
</tr>
<% @candidates.each do |candidate| %>
@ -14,6 +16,7 @@
<td><%= link_to candidate.test_hash, admin_result_path(candidate.test_hash) %></td>
<td><%= candidate.experience %> years</td>
<td><%= mail_to(candidate.recruiter.email) %></td>
<td><%= candidate.review_status unless candidate.pending? %></td>
</tr>
<% end %>
</table>

View File

@ -1,10 +1,19 @@
<main class="summary_tpl">
<%
content_for :title, "Quiz Review - Skills Assessment Admin"
%>
<main class="summary_tpl admin-review">
<h2 class="prft-heading">Quiz Review</h2>
<p>
<strong>Test ID:</strong> <%= @candidate.test_hash %><br />
<strong>Years of Experience:</strong> <%= @candidate.experience %><br />
<strong>Recruiter Email:</strong> <%= mail_to @candidate.recruiter.name, @candidate.recruiter.email %><br />
</p>
<div class="review_meta">
<div>
<strong>Test ID:</strong> <%= @candidate.test_hash %><br />
<strong>Years of Experience:</strong> <%= @candidate.experience %><br />
<strong>Recruiter Email:</strong> <%= mail_to @candidate.recruiter.name, @candidate.recruiter.email %><br />
</div>
<div><%= render partial: 'voting' %></div>
</div>
<% @quiz.each do |question| %>
<%= form_for(:answer, url: '#never-post', html:{id: 'summary-form'}) do |form| %>

View File

@ -15,12 +15,20 @@
<%= form.select :role, admin_role_options(user.role), include_blank: false %>
</div>
<%= form.collection_check_boxes(:quiz_ids, Quiz.all, :id, :name, {}, {class: 'checkbox'}) do | quiz | %>
<div class="form-group-multiples">
<%= quiz.check_box( checked: user.quizzes.include?(quiz.object)) %>
<%= quiz.label %>
</div>
<% end %>
<div class="form-group">
<div><strong>Quiz Review List</strong></div>
<p>
Quizzes this user should be reviewing the results of.<br />
Admins and Recruiters should not have any checked, unless they are expected
to participate in the technical review for that quiz.
</p>
<%= form.collection_check_boxes(:quiz_ids, Quiz.all, :id, :name, {}, {class: 'checkbox'}) do | quiz | %>
<div class="form-group-multiples">
<%= quiz.check_box( checked: user.quizzes.include?(quiz.object)) %>
<%= quiz.label %>
</div>
<% end %>
</div>
<%= form.submit %>
<% end %>

View File

@ -1,3 +1,5 @@
<% content_for :title, "Skills Assessment" %>
<main class="intro_tpl">
<h1 class="prft-heading">Oops!</h1>
<p>

View File

@ -1,3 +1,4 @@
<% content_for :title, "Saved! - Skills Assessment" %>
<main class="styleguide_tpl">
<p>
Your test results have been saved. You can visit again later with your Test ID to complete

View File

@ -1,3 +1,4 @@
<% content_for :title, "Thank You - Skills Assessment" %>
<main class="styleguide_tpl">
<h1>Thank you!</h1>
<p>

View File

@ -45,6 +45,17 @@
</noscript>
</div>
<div class="accordion" id="accordion<%= question.question_id %>" style="display: none;">
<input type="checkbox" class="accordion__toggle" id="accordion-toggle-live-coder" />
<label class="accordion__label" for="accordion-toggle-live-coder">How to use the live coder</label>
<p class="accordion__copy">
This is our own nifty creation, and it works similarly to CodePen. To use: type any HTML, CSS,
or JS inside their corresponding boxes, and watch the Results window below the boxes update
with your changes. Once youre happy with your code and how it renders in the Results window,
move on to the next question!
</p>
</div>
<div id="answer<%= question.question_id %>" data-id="live-coder-answer" style="display: none;">
<label for="answer_answer_hash_text">Enter answer here</label>
<%= text_area_tag 'answer[answer_hash][text]', value_text, { disabled: true, data: {last: answers['text']}} %>
@ -64,6 +75,7 @@
<%= text_area_tag 'answer[answer_hash][js]', value_js, { disabled: true, data: {id: 'code-js', last: answers['js']}, class: 'code-answer code-js' } %>
</div>
<label class="code-results">Results</label>
<div class="results" data-id="results"></div>
</div>
@ -71,6 +83,7 @@
<% # removes the no-js message %>
document.getElementById("nojs<%= question.question_id %>").style.display = "none";
document.getElementById("answer<%= question.question_id %>").style.display = "";
document.getElementById("accordion<%= question.question_id %>").style.display = "";
<% # we want the coders disabled until JS is confirmed, so form post is easier to validate %>
var coders = document.querySelectorAll("[data-id=live-coder-answer] textarea");

View File

@ -1,5 +1,5 @@
<%
content_for :title, "Skills Assessment"
content_for :title, "Summary - Skills Assessment"
content_for :footer_title, "Skills Assessment"
content_for :progress, @status.progress.to_s
content_for_javascript_once 'summary-edit' do