diff --git a/app/controllers/phases_controller.rb b/app/controllers/phases_controller.rb index 4ec8550f..368d30f4 100644 --- a/app/controllers/phases_controller.rb +++ b/app/controllers/phases_controller.rb @@ -10,28 +10,25 @@ def index end def submissions - @submissions = @phase.submissions + @submissions = @phase.submissions.includes(evaluator_submission_assignments: [:evaluator, :evaluation]) set_submission_counts set_submission_statuses - apply_filters - apply_sorting + @submissions = SubmissionsSortAndFilterService.new( + @submissions, + params, + { + not_started: @not_started, + in_progress: @in_progress, + completed: @completed + } + ).sort_and_filter @filtered_count = @submissions.unscope(:group).distinct.count(:id) @submissions = paginate_submissions(@submissions) - respond_to do |format| - format.html do - if params[:partial] - render partial: 'submissions_table_rows', - locals: { submissions: @submissions }, - formats: [:html] - else - render :submissions - end - end - end + render_response end private @@ -41,6 +38,10 @@ def set_phase @challenge = @phase.challenge end + def evaluator_assignments? + EvaluatorSubmissionAssignment.exists?(submission_id: @submissions.select(:id)) + end + def set_submission_counts @submissions_count = @submissions.count @eligible_count = @submissions.eligible_for_evaluation.count @@ -62,65 +63,23 @@ def set_submission_statuses } end - def apply_filters - filter_by_eligibility - filter_by_status - end - - def filter_by_eligibility - return unless params[:eligible_for_evaluation] == 'true' || - params[:selected_to_advance] == 'true' - - @submissions = apply_eligibility_filter(@submissions) - end - - def filter_by_status - return unless params[:status] - - @submissions = apply_status_filter(@submissions) - end - - def apply_status_filter(submissions) - case params[:status] - when 'not_started' then @not_started - when 'in_progress' then @in_progress - when 'completed' then @completed - when 'recused' then filter_recused_submissions - else submissions - end - end - - def filter_recused_submissions - @submissions.joins(:evaluator_submission_assignments). - where(evaluator_submission_assignments: { status: :recused }) - end - - def apply_eligibility_filter(submissions) - if params[:selected_to_advance] == 'true' - submissions.where(judging_status: %w[winner]) - elsif params[:eligible_for_evaluation] == 'true' - submissions.where(judging_status: %w[selected winner]) - else - submissions - end - end - - def apply_sorting - case params[:sort] - when 'average_score_high_to_low' - @submissions = @submissions.order_by_average_score(:desc) - when 'average_score_low_to_high' - @submissions = @submissions.order_by_average_score(:asc) - when 'submission_id_high_to_low' - @submissions = @submissions.order(id: :desc) - when 'submission_id_low_to_high' - @submissions = @submissions.order(id: :asc) - end - end - def paginate_submissions(submissions) page = (params[:page] || 1).to_i per_page = 20 submissions.offset((page - 1) * per_page).limit(per_page) end + + def render_response + respond_to do |format| + format.html do + if params[:partial] + render partial: 'submissions_table_rows', + locals: { submissions: @submissions }, + formats: [:html] + else + render :submissions + end + end + end + end end diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index 93d5f1bc..b813d627 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -8,11 +8,12 @@ class SubmissionsController < ApplicationController def show; end def update - if @submission.update!(submission_params) - flash.now[:success] = I18n.t("submission_updated") - render :show, submission: @submission - else - render :show, status: :unprocessable_entity, submission: @submission + respond_to do |format| + if @submission.update(submission_params) + handle_successful_update(format) + else + handle_failed_update(format) + end end end @@ -26,4 +27,17 @@ def submission_params def set_submission @submission = Submission.by_user(current_user).find(params[:id]) end + + def handle_successful_update(format) + format.html do + flash[:success] = I18n.t("submission_updated") + redirect_to submission_path(@submission) + end + format.json { render json: { submission: @submission } } + end + + def handle_failed_update(format) + format.html { render :show } + format.json { render json: { errors: @submission.errors }, status: :unprocessable_entity } + end end diff --git a/app/helpers/evaluations_helper.rb b/app/helpers/evaluations_helper.rb new file mode 100644 index 00000000..48ad2989 --- /dev/null +++ b/app/helpers/evaluations_helper.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# View helpers for calculating evaluation & submission details. +module EvaluationsHelper + Score = Struct.new(:raw_score, :formatted_score, :display_score) + + # individual evaluator score + def evaluator_score(assignment) + score = display_score(assignment) + return Score.new(0, "0", "N/A") if score == 'N/A' + + Score.new(score, score.to_s, score.to_s) + end + + def average_score(submission) + score = submission.average_score + return Score.new(0, "0", "N/A") if score.zero? + + Score.new(score, score.to_s, score.to_s) + end +end diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 48446227..7e86587e 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -16,11 +16,14 @@ application.register("evaluation-form", EvaluationFormController); import HotdogController from "./hotdog_controller"; application.register("hotdog", HotdogController); +import LoadMoreController from "./load_more_controller"; +application.register("load-more", LoadMoreController); + import SubmissionDetailsController from "./submission_details_controller"; application.register("submission-details", SubmissionDetailsController); -import LoadMoreController from "./load_more_controller"; -application.register("load-more", LoadMoreController); +import SubmissionJudgingStatusController from "./submission_judging_status_controller"; +application.register("submission-judging-status", SubmissionJudgingStatusController); import ModalController from "./modal_controller"; application.register("modal", ModalController); diff --git a/app/javascript/controllers/submission_judging_status_controller.js b/app/javascript/controllers/submission_judging_status_controller.js new file mode 100644 index 00000000..566c8c55 --- /dev/null +++ b/app/javascript/controllers/submission_judging_status_controller.js @@ -0,0 +1,45 @@ +// app/javascript/controllers/submission_judging_status_controller.js +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["eligibilityForm", "advancementForm", "eligibilityCheckbox", "advancementCheckbox"] + + toggleEligibility(event) { + event.preventDefault() + const formData = new FormData(this.eligibilityFormTarget) + formData.set('submission[judging_status]', + event.target.checked ? 'selected' : 'not_selected' + ) + this.submitForm(formData, this.eligibilityFormTarget) + } + + toggleAdvancement(event) { + event.preventDefault() + const formData = new FormData(this.advancementFormTarget) + formData.set('submission[judging_status]', + event.target.checked ? 'winner' : 'selected' + ) + + if (event.target.checked) { + this.eligibilityCheckboxTarget.checked = true + } + this.submitForm(formData, this.advancementFormTarget) + } + + submitForm(formData, form) { + const csrfToken = document.querySelector('[name="csrf-token"]').content + + fetch(form.action, { + method: 'PATCH', + headers: { + 'X-CSRF-Token': csrfToken, + 'Accept': 'application/json' + }, + body: formData + }) + .catch(() => { + const checkbox = form.querySelector('input[type="checkbox"]') + checkbox.checked = !checkbox.checked + }) + } +} diff --git a/app/models/evaluator_submission_assignment.rb b/app/models/evaluator_submission_assignment.rb index 48e300cc..32070c7c 100644 --- a/app/models/evaluator_submission_assignment.rb +++ b/app/models/evaluator_submission_assignment.rb @@ -43,9 +43,12 @@ def self.ordered_by_status end def evaluation_status - return status.to_sym unless assigned? + return :recused if recused? + return :unassigned if unassigned? + return :recused_unassigned if recused_unassigned? + return assigned_evaluation_status if assigned? - assigned_evaluation_status + status&.to_sym end private diff --git a/app/models/submission.rb b/app/models/submission.rb index 1dcdaf94..9259d042 100644 --- a/app/models/submission.rb +++ b/app/models/submission.rb @@ -48,6 +48,10 @@ class Submission < ApplicationRecord # Validations validates :title, presence: true + validate :can_be_selected_to_advance, + if: -> { judging_status_change == %w[selected winner] } + validate :can_be_ineligible_for_evaluation, + if: -> { judging_status_change == %w[selected not_selected] } scope :by_user, lambda { |user| case user.role @@ -63,20 +67,7 @@ class Submission < ApplicationRecord } scope :eligible_for_evaluation, -> { where(judging_status: [:selected, :winner]) } - def eligible_for_evaluation? - selected? or winner? - end - - def selected_to_advance? - winner? - end - - def average_score - avg = evaluations.average(:total_score) - avg ? avg.round : 0 - end - - def self.order_by_average_score(direction) + scope :order_by_average_score, lambda { |direction| direction_sql = direction == :desc ? 'DESC' : 'ASC' joins( @@ -90,5 +81,49 @@ def self.order_by_average_score(direction) "submissions.id #{direction_sql}" ) ) + } + + def eligible_for_evaluation? + selected? or winner? + end + + def average_score + avg = evaluations.joins(:evaluator_submission_assignment). + where(evaluator_submission_assignments: { status: :assigned }). + where.not(completed_at: nil). + average(:total_score) + + avg ? avg.round : 0 + end + + def selected_to_advance? + winner? + end + + def evaluators_assigned? + evaluator_submission_assignments.exists?(status: [:assigned, :recused]) + end + + def evaluations_missing_or_incomplete? + !eligible_for_evaluation? || !all_evaluations_completed? || evaluator_submission_assignments.empty? + end + + private + + def all_evaluations_completed? + evaluator_submission_assignments. + all? { |assignment| assignment.evaluation_status == :completed } + end + + def can_be_selected_to_advance + return unless evaluations_missing_or_incomplete? + + errors.add(:judging_status, "can't be selected to advance until all evaluations are complete") + end + + def can_be_ineligible_for_evaluation + return unless evaluators_assigned? + + errors.add(:judging_status, "must remain eligible for evaluation when evaluators are assigned") end end diff --git a/app/services/submissions_sort_and_filter_service.rb b/app/services/submissions_sort_and_filter_service.rb new file mode 100644 index 00000000..2b970204 --- /dev/null +++ b/app/services/submissions_sort_and_filter_service.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# This service handles sort and filtering submissions. +class SubmissionsSortAndFilterService + def initialize(submissions, params, submission_statuses = {}) + @submissions = submissions + @params = params + @not_started = submission_statuses[:not_started] + @in_progress = submission_statuses[:in_progress] + @completed = submission_statuses[:completed] + end + + def sort_and_filter + apply_filters + apply_sorting + @submissions + end + + private + + def apply_filters + filter_by_eligibility + filter_by_status + end + + def filter_by_eligibility + return unless @params[:eligible_for_evaluation] == 'true' || + @params[:selected_to_advance] == 'true' + + @submissions = apply_eligibility_filter(@submissions) + end + + def filter_by_status + return unless @params[:status] + + @submissions = apply_status_filter(@submissions) + end + + def apply_status_filter(submissions) + case @params[:status] + when 'not_started' then @not_started + when 'in_progress' then @in_progress + when 'completed' then @completed + when 'recused' then filter_recused_submissions + else submissions + end + end + + def filter_recused_submissions + @submissions.joins(:evaluator_submission_assignments). + where(evaluator_submission_assignments: { status: :recused }) + end + + def apply_eligibility_filter(submissions) + if @params[:selected_to_advance] == 'true' + submissions.where(judging_status: %w[winner]) + elsif @params[:eligible_for_evaluation] == 'true' + submissions.where(judging_status: %w[selected winner]) + else + submissions + end + end + + def apply_sorting + case @params[:sort] + when 'average_score_high_to_low' + @submissions = @submissions.order_by_average_score(:desc) + when 'average_score_low_to_high' + @submissions = @submissions.order_by_average_score(:asc) + when 'submission_id_high_to_low' + @submissions = @submissions.order(id: :desc) + when 'submission_id_low_to_high' + @submissions = @submissions.order(id: :asc) + end + end +end diff --git a/app/views/phases/_submissions_table.html.erb b/app/views/phases/_submissions_table.html.erb index 67303e2e..3e6d6ce9 100644 --- a/app/views/phases/_submissions_table.html.erb +++ b/app/views/phases/_submissions_table.html.erb @@ -14,12 +14,12 @@ - - - - - - + + + + + + diff --git a/app/views/phases/_submissions_table_rows.html.erb b/app/views/phases/_submissions_table_rows.html.erb index f6c8b800..2283c56a 100644 --- a/app/views/phases/_submissions_table_rows.html.erb +++ b/app/views/phases/_submissions_table_rows.html.erb @@ -1,72 +1,153 @@ <% @submissions.each do |submission| %> - - - + + + + - + + - - - - + + <% else %> + No evaluators assigned to this submission + <% end %> + + + + + + <% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index b3a6e2f8..a3839cf4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -33,7 +33,7 @@ en: evaluation_form_destroyed: "Evaluation form was successfully destroyed." evaluation_form_saved: "Evaluation form was saved successfully." comments_saved: "Comments saved succesfully." - submission_updated: "Submission was updated succesfully." + submission_updated: "Submission was updated successfully." login_error: "There was an issue with logging in. Please try again." please_try_again: "Please try again." session_expired_alert: "Your session has expired. Please log in again." diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 5dadca69..4f9a7d5c 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -84,4 +84,12 @@ config.before(:suite) do FactoryBot.reload end + + config.before(:each, bullet: :skip) do + Bullet.enable = false + end + + config.after(:each, bullet: :skip) do + Bullet.enable = true + end end diff --git a/spec/requests/submissions_spec.rb b/spec/requests/submissions_spec.rb index 80500ddf..96a6ee85 100644 --- a/spec/requests/submissions_spec.rb +++ b/spec/requests/submissions_spec.rb @@ -2,69 +2,138 @@ RSpec.describe "Submissions" do let(:user) { nil } - let(:challenge) { create_challenge(user: user, title: "Boston Tea Party Cleanup") } - let(:phase) { create_phase(challenge_id: challenge.id) } + let(:challenge) { create(:challenge, user: user, title: "Boston Tea Party Cleanup") } + let(:phase) { create(:phase, challenge: challenge) } before { log_in_user(user) } - describe "GET /phases/:id/submissions" do - context "when logged in as a super admin" do - let(:user) { create_user(role: "super_admin") } + describe "GET /submissions/:id" do + context "when logged in as a challenge manager" do + let(:user) { create_user(role: "challenge_manager") } - it "redirects to the phoenix app" do - get phases_path + it "renders a details page for an individual submission" do + submission = create(:submission, challenge: phase.challenge, brief_description: "This submission has legs.") - expect(response).to redirect_to(ENV.fetch("PHOENIX_URI", nil)) + get submission_path(submission) + expect(response.body).to include(submission.id.to_s) + expect(response.body).to include(submission.brief_description) end - end - - context "when logged in as a admin" do - let(:user) { create_user(role: "admin") } - it "redirects to the phoenix app" do - get phases_path + it "does not render submission details for a challenge the user is not assigned to" do + challenge = create(:challenge) + phase = create(:phase, challenge:) + submission = create(:submission, challenge: phase.challenge, brief_description: "This submission has teeth.") - expect(response).to redirect_to(ENV.fetch("PHOENIX_URI", nil)) + get submission_path(submission) + expect(response).to have_http_status(:not_found) end end + end + describe "PATCH /submissions/:id" do context "when logged in as a challenge manager" do let(:user) { create_user(role: "challenge_manager") } + let(:submission) { create(:submission, challenge: challenge, phase: phase) } + let(:evaluator) { create(:user, role: 'evaluator') } - it "renders an empty list of submissions for a user's challenge" do - get submissions_phase_path(phase) - expect(response.body).to include("Boston Tea Party Cleanup") + before do + ChallengeManager.create(user: user, challenge: challenge) + end + + context "when updating judging status" do + it "updates eligibility status to selected" do + patch submission_path(submission), params: { + submission: { judging_status: 'selected' } + }, as: :json + + expect(response).to have_http_status(:ok) + expect(submission.reload.judging_status).to eq('selected') + end + + it "prevents deselecting evaluation eligibility when evaluators are assigned" do + submission.update!(judging_status: 'selected') + create(:evaluator_submission_assignment, + submission: submission, + evaluator: evaluator, + status: :assigned) + + patch submission_path(submission), params: { + submission: { judging_status: 'not_selected' } + }, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(submission.reload.judging_status).to eq('selected') + expect(response.parsed_body['errors']).to include( + "judging_status" => ["must remain eligible for evaluation when evaluators are assigned"] + ) + end + + it "allows advancing to winner when eligible and evaluations complete" do + submission.update!(judging_status: 'selected') + create(:evaluator_submission_assignment, + submission: submission, + evaluator: evaluator, + status: :assigned, + evaluation: create(:evaluation, completed_at: Time.current)) - expect(response.body).to include("No submissions found.") + patch submission_path(submission), params: { + submission: { judging_status: 'winner' } + }, as: :json + + expect(response).to have_http_status(:ok) + expect(submission.reload.judging_status).to eq('winner') + end + + it "prevents selected to advance when evaluations are incomplete" do + submission.update!(judging_status: 'selected') + create(:evaluator_submission_assignment, + submission: submission, + evaluator: evaluator, + status: :assigned) + + patch submission_path(submission), params: { + submission: { judging_status: 'winner' } + }, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(submission.reload.judging_status).to eq('selected') + expect(response.parsed_body['errors']).to include( + "judging_status" => ["can't be selected to advance until all evaluations are complete"] + ) + end end - it "renders a list of submissions for a user's challenge" do - submission = create(:submission, challenge: challenge, phase: phase) + context "when updating comments" do + it "successfully updates comments" do + patch submission_path(submission), params: { + submission: { comments: "Comment about submission here" } + }, as: :json - get submissions_phase_path(phase) - expect(response.body).to include("Boston Tea Party Cleanup") - expect(response.body).to include(submission.id.to_s) + expect(response).to have_http_status(:ok) + expect(submission.reload.comments).to eq("Comment about submission here") + end end + end + end - it "does not render submissions for a challenge the user is not assigned to" do - challenge = create_challenge(title: "Star Spangled Banister") - phase = create_phase(challenge_id: challenge.id) + describe "GET /phases/:id/submissions" do + context "when logged in as a super admin" do + let(:user) { create_user(role: "super_admin") } - get submissions_phase_path(phase) - expect(response).to have_http_status(:not_found) + it "redirects to the phoenix app" do + get phases_path + + expect(response).to redirect_to(ENV.fetch("PHOENIX_URI", nil)) end + end - it "renders submission statistics" do - create(:submission, challenge: challenge, phase: phase) - create(:submission, challenge: challenge, phase: phase, judging_status: "selected") + context "when logged in as a admin" do + let(:user) { create_user(role: "admin") } - get submissions_phase_path(phase) - expect(response.body).to include("Boston Tea Party Cleanup") - # total submission count - expect(response.body).to have_css("h3.text-primary", text: "Total Submissions") - expect(response.body).to have_css("span.font-sans-3xl.text-primary.text-bold", text: "2") - # selected to advance - expect(response.body).to have_css("span.text-primary", text: "1 of 2") + it "redirects to the phoenix app" do + get phases_path + + expect(response).to redirect_to(ENV.fetch("PHOENIX_URI", nil)) end end @@ -87,213 +156,221 @@ expect(response).to redirect_to(ENV.fetch("PHOENIX_URI", nil)) end end - end - describe "GET /submissions/:id" do context "when logged in as a challenge manager" do let(:user) { create_user(role: "challenge_manager") } - it "renders a details page for an individual submission" do - submission = create(:submission, challenge: phase.challenge, brief_description: "This submission has legs.") + context "when the challenge manager is not assigned to the challenge phase" do + it "renders :not_found" do + challenge = create(:challenge, title: "Star Spangled Banister") + phase = create(:phase, challenge: challenge) - get submission_path(submission) - expect(response.body).to include(submission.id.to_s) - expect(response.body).to include(submission.brief_description) + get submissions_phase_path(phase) + expect(response).to have_http_status(:not_found) + end end - it "does not render submission details for a challenge the user is not assigned to" do - challenge = create_challenge - phase = create_phase(challenge_id: challenge.id) - submission = create(:submission, challenge: phase.challenge, brief_description: "This submission has teeth.") - - get submission_path(submission) - expect(response).to have_http_status(:not_found) + context "when there are no submissions for the challenge phase" do + it "renders an empty list for the challenge phase" do + get submissions_phase_path(phase) + expect(response.body).to include("Boston Tea Party Cleanup") + expect(response.body).to include("No submissions found.") + end end - end - end - describe 'GET /phases/:id/submissions' do - let(:user) { create_user(role: "challenge_manager") } - let(:challenge) { create(:challenge, user: user) } - let(:phase) { create(:phase, challenge: challenge) } - - before do - ChallengeManager.create!(user: user, challenge: challenge) - log_in_user(user) - end + context "when there are submissions for the challenge phase" do + before do + ChallengeManager.create!(user: user, challenge: challenge) + end - context 'when viewing submissions' do - let!(:not_started_submission) { create(:submission, challenge: challenge, phase: phase) } - let!(:in_progress_submission) do - submission = create(:submission, challenge: challenge, phase: phase) - assignment = create(:evaluator_submission_assignment, submission: submission, status: :assigned) - create(:evaluation, evaluator_submission_assignment: assignment, completed_at: nil) - submission - end - let!(:completed_submission) do - submission = create(:submission, challenge: challenge, phase: phase) - assignment = create(:evaluator_submission_assignment, submission: submission) - create(:evaluation, evaluator_submission_assignment: assignment, completed_at: Time.current) - submission - end - let!(:eligible_submission) { create(:submission, challenge: challenge, phase: phase, judging_status: 'selected') } - let!(:selected_submission) do - submission = create(:submission, challenge: challenge, phase: phase, judging_status: 'winner') - assignment = create(:evaluator_submission_assignment, submission: submission) - create(:evaluation, evaluator_submission_assignment: assignment, completed_at: Time.current) - submission + it "renders submission statistics" do + create(:submission, challenge: challenge, phase: phase) + create(:submission, challenge: challenge, phase: phase, judging_status: "selected") + + get submissions_phase_path(phase) + expect(response.body).to include("Boston Tea Party Cleanup") + # total submission count + expect(response.body).to have_css("h3.text-primary", text: "Total Submissions") + expect(response.body).to have_css("span.font-sans-3xl.text-primary.text-bold", text: "2") + # selected to advance + expect(response.body).to have_css("span.text-primary", text: "1 of 2") + end end - it 'displays all submissions and their status counts' do - get submissions_phase_path(phase) - - [not_started_submission, in_progress_submission, completed_submission, - eligible_submission, selected_submission].each do |submission| - expect(response.body).to have_css("[data-submission-id='#{submission.id}']") + context 'when viewing submissions' do + let!(:not_started_submission) { create(:submission, challenge: challenge, phase: phase) } + let!(:in_progress_submission) do + submission = create(:submission, challenge: challenge, phase: phase) + assignment = create(:evaluator_submission_assignment, submission: submission, status: :assigned) + create(:evaluation, evaluator_submission_assignment: assignment, completed_at: nil) + submission + end + let!(:completed_submission) do + submission = create(:submission, challenge: challenge, phase: phase) + assignment = create(:evaluator_submission_assignment, submission: submission) + create(:evaluation, evaluator_submission_assignment: assignment, completed_at: Time.current) + submission + end + let!(:eligible_submission) do + create(:submission, challenge: challenge, phase: phase, judging_status: 'selected') + end + let!(:selected_submission) do + submission = create(:submission, challenge: challenge, phase: phase, judging_status: 'winner') + assignment = create(:evaluator_submission_assignment, submission: submission) + create(:evaluation, evaluator_submission_assignment: assignment, completed_at: Time.current) + submission end - expect(response.body).to have_css('.text-secondary-dark.text-bold', text: '2') # not_started, eligible - expect(response.body).to have_css('.text-orange.text-bold', text: '1') # in_progress - expect(response.body).to have_css('.text-green.text-bold', text: '2') # completed, selected - end + it 'displays all submissions and their status counts' do + get submissions_phase_path(phase) - context 'when filtering submissions' do - it 'shows only submissions matching the selected status' do - get submissions_phase_path(phase), params: { status: 'not_started' } + [not_started_submission, in_progress_submission, completed_submission, + eligible_submission, selected_submission].each do |submission| + expect(response.body).to have_css("[data-submission-id='#{submission.id}']") + end - expect(response.body).to have_css("[data-submission-id='#{not_started_submission.id}']") - expect(response.body).to have_css("[data-submission-id='#{eligible_submission.id}']") - expect(response.body).not_to have_css("[data-submission-id='#{selected_submission.id}']") - expect(response.body).not_to have_css("[data-submission-id='#{in_progress_submission.id}']") - expect(response.body).not_to have_css("[data-submission-id='#{completed_submission.id}']") + expect(response.body).to have_css('.text-secondary-dark.text-bold', text: '2') # not_started, eligible + expect(response.body).to have_css('.text-orange.text-bold', text: '1') # in_progress + expect(response.body).to have_css('.text-green.text-bold', text: '2') # completed, selected end - it 'shows only completed submissions' do - get submissions_phase_path(phase), params: { status: 'completed' } + context 'when filtering submissions' do + it 'shows only submissions matching the selected status' do + get submissions_phase_path(phase), params: { status: 'not_started' } - expect(response.body).to have_css("[data-submission-id='#{completed_submission.id}']") - expect(response.body).to have_css("[data-submission-id='#{selected_submission.id}']") - expect(response.body).not_to have_css("[data-submission-id='#{not_started_submission.id}']") - expect(response.body).not_to have_css("[data-submission-id='#{in_progress_submission.id}']") - expect(response.body).not_to have_css("[data-submission-id='#{eligible_submission.id}']") - end - end + expect(response.body).to have_css("[data-submission-id='#{not_started_submission.id}']") + expect(response.body).to have_css("[data-submission-id='#{eligible_submission.id}']") + expect(response.body).to have_no_css("[data-submission-id='#{selected_submission.id}']") + expect(response.body).to have_no_css("[data-submission-id='#{in_progress_submission.id}']") + expect(response.body).to have_no_css("[data-submission-id='#{completed_submission.id}']") + end - context 'when filtering by eligibility' do - it 'displays only eligible for evaluation submissions' do - get submissions_phase_path(phase), params: { eligible_for_evaluation: 'true' } + it 'shows only completed submissions', bullet: :skip do + get submissions_phase_path(phase), params: { status: 'completed' } - expect(response.body).to have_css("[data-submission-id='#{eligible_submission.id}']") - expect(response.body).to have_css("[data-submission-id='#{selected_submission.id}']") - expect(response.body).not_to have_css("[data-submission-id='#{not_started_submission.id}']") - expect(response.body).not_to have_css("[data-submission-id='#{in_progress_submission.id}']") - expect(response.body).not_to have_css("[data-submission-id='#{completed_submission.id}']") + expect(response.body).to have_css("[data-submission-id='#{completed_submission.id}']") + expect(response.body).to have_css("[data-submission-id='#{selected_submission.id}']") + expect(response.body).to have_no_css("[data-submission-id='#{not_started_submission.id}']") + expect(response.body).to have_no_css("[data-submission-id='#{in_progress_submission.id}']") + expect(response.body).to have_no_css("[data-submission-id='#{eligible_submission.id}']") + end end - it 'displays only selected to advance submissions' do - get submissions_phase_path(phase), params: { selected_to_advance: 'true' } + context 'when filtering by eligibility' do + it 'displays only eligible for evaluation submissions', bullet: :skip do + get submissions_phase_path(phase), params: { eligible_for_evaluation: 'true' } - expect(response.body).to have_css("[data-submission-id='#{selected_submission.id}']") - expect(response.body).not_to have_css("[data-submission-id='#{eligible_submission.id}']") - expect(response.body).not_to have_css("[data-submission-id='#{not_started_submission.id}']") - expect(response.body).not_to have_css("[data-submission-id='#{in_progress_submission.id}']") - expect(response.body).not_to have_css("[data-submission-id='#{completed_submission.id}']") - end - end + expect(response.body).to have_css("[data-submission-id='#{eligible_submission.id}']") + expect(response.body).to have_css("[data-submission-id='#{selected_submission.id}']") + expect(response.body).to have_no_css("[data-submission-id='#{not_started_submission.id}']") + expect(response.body).to have_no_css("[data-submission-id='#{in_progress_submission.id}']") + expect(response.body).to have_no_css("[data-submission-id='#{completed_submission.id}']") + end - context 'when sorting submissions' do - before do - create(:evaluation, - evaluator_submission_assignment: create(:evaluator_submission_assignment, submission: in_progress_submission), - total_score: 80 - ) + it 'displays only selected to advance submissions', bullet: :skip do + get submissions_phase_path(phase), params: { selected_to_advance: 'true' } - create(:evaluation, - evaluator_submission_assignment: create(:evaluator_submission_assignment, submission: completed_submission), - total_score: 90 - ) + expect(response.body).to have_css("[data-submission-id='#{selected_submission.id}']") + expect(response.body).to have_no_css("[data-submission-id='#{eligible_submission.id}']") + expect(response.body).to have_no_css("[data-submission-id='#{not_started_submission.id}']") + expect(response.body).to have_no_css("[data-submission-id='#{in_progress_submission.id}']") + expect(response.body).to have_no_css("[data-submission-id='#{completed_submission.id}']") + end end - it 'orders submissions by score high to low' do - get submissions_phase_path(phase), params: { sort: 'average_score_high_to_low' } + context 'when sorting submissions' do + before do + create(:evaluation, + evaluator_submission_assignment: create(:evaluator_submission_assignment, + submission: in_progress_submission), + total_score: 80) - expect(response.body).to have_selector( - "tr[data-submission-id='#{completed_submission.id}']" \ - " ~ tr[data-submission-id='#{in_progress_submission.id}']" - ) - end + create(:evaluation, + evaluator_submission_assignment: create(:evaluator_submission_assignment, + submission: completed_submission), + total_score: 90) + end - it 'orders submissions by score low to high' do - get submissions_phase_path(phase), params: { sort: 'average_score_low_to_high' } + it 'orders submissions by score high to low' do + get submissions_phase_path(phase), params: { sort: 'average_score_high_to_low' } - expect(response.body).to have_selector( - "tr[data-submission-id='#{in_progress_submission.id}']" \ - " ~ tr[data-submission-id='#{completed_submission.id}']" - ) - end - end - end - - context 'when paginating submissions' do - let!(:submissions) do - (1..25).map do |n| - create(:submission, challenge: challenge, phase: phase) - end - end + expect(response.body).to have_css( + "tr[data-submission-id='#{completed_submission.id}'] " \ + "~ tr[data-submission-id='#{in_progress_submission.id}']" + ) + end - it 'returns first page of submissions' do - get submissions_phase_path(phase) - expect(response.body).to have_css('tr[data-submission-id]', count: 20) - expect(response.body).to have_button('Load more') - end + it 'orders submissions by score low to high' do + get submissions_phase_path(phase), params: { sort: 'average_score_low_to_high' } - it 'returns next page of submissions via partial' do - get submissions_phase_path(phase, page: 2, partial: true) - expect(response).to have_http_status(:success) - expect(response.body).to have_css('tr[data-submission-id]', count: 5) - expect(response.body).not_to have_button('Load more') + expect(response.body).to have_css( + "tr[data-submission-id='#{in_progress_submission.id}'] " \ + "~ tr[data-submission-id='#{completed_submission.id}']" + ) + end + end end - context 'when sorting by average score' do - let!(:scored_submissions) do - submissions[0..24].each_with_index do |submission, index| - create(:evaluation, - evaluator_submission_assignment: create(:evaluator_submission_assignment, submission: submission), - total_score: (index + 1) * 20 - ) + context 'when paginating submissions' do + let!(:submissions) do + (1..25).map do |n| + create(:submission, challenge: challenge, phase: phase, title: "submission #{n}") end end - it 'paginates correctly when sorted by score' do - get submissions_phase_path(phase, page: 1, sort: 'average_score_high_to_low') - expect(response).to have_http_status(:success) - first_page_scores = response.body.scan(/data-score="(\d+)"/).flatten - expect(first_page_scores.count).to eq(20) - expect(first_page_scores.map(&:to_i)).to eq(first_page_scores.map(&:to_i).sort.reverse) + it 'returns first page of submissions' do + get submissions_phase_path(phase) + expect(response.body).to have_css('tr[data-submission-id]', count: 20) expect(response.body).to have_button('Load more') + end - get submissions_phase_path(phase, page: 2, partial: true, sort: 'average_score_high_to_low') + it 'returns next page of submissions via partial' do + get submissions_phase_path(phase, page: 2, partial: true) expect(response).to have_http_status(:success) - second_page_scores = response.body.scan(/data-score="(\d+)"/).flatten - expect(second_page_scores.count).to eq(5) - expect(second_page_scores.map(&:to_i)).to eq(second_page_scores.map(&:to_i).sort.reverse) + expect(response.body).to have_css('tr[data-submission-id]', count: 5) + expect(response.body).to have_no_button('Load more') end - end - context 'when filtering submissions' do - let!(:eligible_submissions) do - submissions[0..22].each { |s| s.update!(judging_status: 'selected') } + context 'when sorting by average score' do + before do + submissions[0..24].each_with_index do |submission, index| + create(:evaluation, + evaluator_submission_assignment: create(:evaluator_submission_assignment, submission: submission), + total_score: (index + 1) * 20) + end + end + + it 'paginates correctly when sorted by score', bullet: :skip do + get submissions_phase_path(phase, page: 1, sort: 'average_score_high_to_low') + expect(response).to have_http_status(:success) + first_page_scores = response.body.scan(/data-score="(\d+)"/).flatten + expect(first_page_scores.count).to eq(20) + expect(first_page_scores.map(&:to_i)).to eq(first_page_scores.map(&:to_i).sort.reverse) + expect(response.body).to have_button('Load more') + + get submissions_phase_path(phase, page: 2, partial: true, sort: 'average_score_high_to_low') + expect(response).to have_http_status(:success) + second_page_scores = response.body.scan(/data-score="(\d+)"/).flatten + expect(second_page_scores.count).to eq(5) + expect(second_page_scores.map(&:to_i)).to eq(second_page_scores.map(&:to_i).sort.reverse) + end end - it 'paginates correctly with eligible filter' do - get submissions_phase_path(phase, page: 1, eligible_for_evaluation: 'true') - expect(response).to have_http_status(:success) - expect(response.body.scan(/data-submission-id="(\d+)"/).flatten.count).to eq(20) - expect(response.body).to have_button('Load more') + context 'when filtering submissions' do + before do + submissions[0..22].each { |s| s.update!(judging_status: 'selected') } + end - get submissions_phase_path(phase, page: 2, partial: true, eligible_for_evaluation: 'true') - expect(response).to have_http_status(:success) - expect(response.body.scan(/data-submission-id="(\d+)"/).flatten.count).to eq(3) + it 'paginates correctly with eligible filter' do + get submissions_phase_path(phase, page: 1, eligible_for_evaluation: 'true') + expect(response).to have_http_status(:success) + expect(response.body.scan(/data-submission-id="(\d+)"/).flatten.count).to eq(20) + expect(response.body).to have_button('Load more') + + get submissions_phase_path(phase, page: 2, partial: true, eligible_for_evaluation: 'true') + expect(response).to have_http_status(:success) + expect(response.body.scan(/data-submission-id="(\d+)"/).flatten.count).to eq(3) + end end end end diff --git a/spec/system/submission_details_spec.rb b/spec/system/submission_details_spec.rb index 0b06b6c9..c58a9e16 100644 --- a/spec/system/submission_details_spec.rb +++ b/spec/system/submission_details_spec.rb @@ -6,10 +6,14 @@ describe "Logged-in as a Challenge Manager" do let(:user) { create_user(role: "challenge_manager") } let(:challenge) { create_challenge(user: user, title: "Boston Tea Party Cleanup") } - let(:submission) { create(:submission, manager: user, challenge: challenge) } + let(:phase) { create(:phase, challenge: challenge) } + let(:submission) { create(:submission, manager: user, challenge:, phase:) } + let(:fake_comments) { Faker::Lorem.sentence } - - before { system_login_user(user) } + before do + create(:challenge_manager, challenge:, user:) + system_login_user(user) + end it "submission details page is accessible" do visit submission_path(submission) @@ -18,28 +22,43 @@ expect(page).to(be_axe_clean) end - it "allows manipulation of judging status" do + it "allows marking judging status eligible for evaluation" do visit submission_path(submission) + eligible_input = page.find_by_id('eligible-for-evaluation').find('input.usa-checkbox__input', visible: :hidden) + expect(eligible_input).not_to be_checked find_by_id('eligible-for-evaluation').click - click_on('Save') - updated_submission = Submission.find(submission.id) - expect(updated_submission.judging_status).to eq('selected') + click_on "Save" + expect(page).to have_css("p.usa-alert__text", text: "Submission was updated successfully.") + eligible_input = page.find_by_id('eligible-for-evaluation').find('input.usa-checkbox__input', visible: :hidden) + expect(eligible_input).to be_checked + expect(submission.reload.judging_status).to eq("selected") + end + it "allows marking judging status selected to advance" do + # evaluations must exist and all be completed before selecting the submission to advance + evaluator = create_user(role: "evaluator") + submission.update(judging_status: :selected) + assignment = create(:evaluator_submission_assignment, status: :assigned, evaluator:, submission:) + create(:evaluation, evaluator_submission_assignment: assignment, completed_at: Time.current) + + visit submission_path(submission) + selected_input = page.find_by_id('selected-to-advance').find('input.usa-checkbox__input', visible: :hidden) + expect(selected_input).not_to be_checked find_by_id('selected-to-advance').click - click_on('Save') - updated_submission = Submission.find(submission.id) - expect(updated_submission.judging_status).to eq('winner') + click_on "Save" + expect(page).to have_css("p.usa-alert__text", text: "Submission was updated successfully.") + selected_input = page.find_by_id('selected-to-advance').find('input.usa-checkbox__input', visible: :hidden) + expect(selected_input).to be_checked + expect(submission.reload.judging_status).to eq("winner") end it "saves comments" do visit submission_path(submission) - comments = Faker::Lorem.sentence - - fill_in "Comments and notes:", with: comments - click_on('Save') - updated_submission = Submission.find(submission.id) - expect(updated_submission.comments).to eq(comments) + fill_in "Comments and notes:", with: fake_comments + click_on "Save" + expect(page).to have_css("p.usa-alert__text", text: "Submission was updated successfully.") + assert_text(fake_comments) end end end
Submission IDEligible for EvaluationSelected to AdvanceAssigned EvaluatorsAverage ScoreView SubmissionSubmission IDEligible for EvaluationSelected to AdvanceAssigned EvaluatorsAverage ScoreView Submission
- Submission ID <%= submission.id %> - -
- <% if submission.eligible_for_evaluation? %> - -
- <%= image_tag( - "images/usa-icons/verified.svg", - class: "usa-icon--size-3 margin-right-1", - alt: "" - )%> - Eligible for Evaluation -
- <% else %> - -
- <%= image_tag( - "images/usa-icons/highlight_off.svg", - class: "usa-icon--size-3 margin-right-1", - alt: "" - )%> - Not Eligible for Evaluation -
+
+ Submission ID <%= submission.id %> + + + + <% if submission.eligible_for_evaluation? %> +
+ <%= image_tag( + "images/usa-icons/verified.svg", + class: "usa-icon--size-3 margin-right-1", + alt: "" + )%> + Eligible for Evaluation +
+ <% else %> +
+ <%= image_tag( + "images/usa-icons/highlight_off.svg", + class: "usa-icon--size-3 margin-right-1", + alt: "" + )%> + Not Eligible for Evaluation +
+ <% end %> +
+ -
- <% if submission.selected_to_advance? %> - -
- <%= image_tag( - "images/usa-icons/star.svg", - class: "usa-icon--size-3 margin-right-1", - alt: "" - )%> - Selected to Advance +
+
+ + <% if submission.selected_to_advance? %> +
+ <%= image_tag( + "images/usa-icons/star.svg", + class: "usa-icon--size-3 margin-right-1", + alt: "" + )%> + Selected to Advance +
+ <% else %> +
+ <%= image_tag( + "images/usa-icons/star_outline.svg", + class: "usa-icon--size-3 margin-right-1", + alt: "" + )%> + Not Selected to Advance +
+ <% end %> +
+ <% active_assignments = submission.evaluator_submission_assignments.select { |a| ['assigned', 'recused'].include?(a.status) } %> + <% if active_assignments.any? %> +
+ <% active_assignments.each do |assignment| %> +
+
+ + <%= "#{assignment.evaluator.first_name} #{assignment.evaluator.last_name}" %> +
- <% else %> - -
- <%= image_tag( - "images/usa-icons/star_outline.svg", - class: "usa-icon--size-3 margin-right-1", - alt: "" - )%> - Not Selected to Advance -
+
+ <%= evaluator_score(assignment).formatted_score %> + + <%= assignment.evaluation_status.to_s.titleize %> + +
+
<% end %> -
-
- No evaluators assigned to this submission - - <%= score %> - -
- <%= link_to submission_path(submission) do %> - - <% end %> -
-
+ <%= average_score(submission).formatted_score %> + +
+ <%= link_to submission_path(submission) do %> + + <% end %> +
+