diff --git a/.codeclimate.yml b/.codeclimate.yml index 2cbfe4ee..557e5b2f 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -9,6 +9,9 @@ plugins: rubocop: enabled: true channel: rubocop-1-56-3 + checks: + Rubocop/Rails/ActionControllerFlashBeforeRender: + enabled: false exclude_patterns: - .nix-bundler - config/ diff --git a/Gemfile.lock b/Gemfile.lock index d463532b..66543f85 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -160,11 +160,11 @@ GEM activesupport (>= 5.0.0) jsbundling-rails (1.3.1) railties (>= 6.0.0) - json (2.8.2) + json (2.9.0) jwt (2.9.3) base64 language_server-protocol (3.17.0.3) - logger (1.6.1) + logger (1.6.2) loofah (2.23.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) @@ -267,7 +267,7 @@ GEM rake (13.2.1) rdoc (6.8.1) psych (>= 4.0.0) - regexp_parser (2.9.2) + regexp_parser (2.9.3) reline (0.5.11) io-console (~> 0.5) rexml (3.3.9) @@ -290,17 +290,17 @@ GEM rspec-support (3.13.1) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.68.0) + rubocop (1.69.1) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.4, < 3.0) - rubocop-ast (>= 1.32.2, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.36.2, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.36.1) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.36.2) parser (>= 3.3.1.0) rubocop-capybara (2.21.0) rubocop (~> 1.41) @@ -319,7 +319,7 @@ GEM ruby-progressbar (1.13.0) rubyzip (2.3.2) securerandom (0.3.2) - selenium-webdriver (4.26.0) + selenium-webdriver (4.27.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -341,7 +341,9 @@ GEM railties (>= 6.0.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (2.6.0) + unicode-display_width (3.1.2) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) uniform_notifier (1.16.0) uri (1.0.2) useragent (0.16.10) diff --git a/app/assets/uswds/_uswds-theme.scss b/app/assets/uswds/_uswds-theme.scss index 710282da..f7c6d98e 100644 --- a/app/assets/uswds/_uswds-theme.scss +++ b/app/assets/uswds/_uswds-theme.scss @@ -57,6 +57,10 @@ Add a list of changed settings in the form $setting: value. filter: invert(99%) sepia(2%) saturate(3661%) hue-rotate(191deg) brightness(117%) contrast(80%); } +.width-half { + width: 50%; +} + .usa-social-link { background-color: white; } diff --git a/app/controllers/evaluator_submission_assignments_controller.rb b/app/controllers/evaluator_submission_assignments_controller.rb new file mode 100644 index 00000000..a40dd5bf --- /dev/null +++ b/app/controllers/evaluator_submission_assignments_controller.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +# Controller for evaluator submissions assignments index and update status +class EvaluatorSubmissionAssignmentsController < ApplicationController + before_action -> { authorize_user('challenge_manager') } + before_action :set_challenge_and_phase + before_action :set_evaluator, only: [:index] + before_action :set_assignment, only: [:update] + + def index + @evaluator_assignments = @phase.evaluator_submission_assignments.includes(:submission).where(user_id: @evaluator.id) + @assigned_submissions = @evaluator_assignments. + where(status: %i[assigned recused]). + includes(:evaluation). + ordered_by_status + @unassigned_submissions = @evaluator_assignments. + where(status: %i[unassigned recused_unassigned]). + ordered_by_status + @submissions_count = calculate_submissions_count(@assigned_submissions) + end + + # update only the status of the evaluation submission assignment to unassign or reassign an evaluator + def update + new_status = status_from_params + + unless valid_status?(new_status) + return render_invalid_status_error + end + + if update_assignment_status(new_status) + handle_successful_update(new_status) + else + handle_failed_update(new_status) + end + end + + private + + def set_challenge_and_phase + @phase = Phase.where(challenge: current_user.challenge_manager_challenges).find(params[:phase_id]) + @challenge = @phase.challenge + end + + def set_evaluator + @evaluator = @phase.evaluators.find(params[:evaluator_id]) + end + + def set_assignment + @assignment = @phase.evaluator_submission_assignments.find(params[:id]) + end + + def status_from_params + status = params[:status] || params.dig(:evaluator_submission_assignment, :status) + status&.to_sym + end + + def valid_status?(status) + EvaluatorSubmissionAssignment.statuses.keys.map(&:to_sym).include?(status) + end + + def render_invalid_status_error + render json: { success: false, message: 'Invalid status' }, status: :unprocessable_entity + end + + def update_assignment_status(new_status) + @assignment.update(status: new_status) + end + + def handle_successful_update(new_status) + flash[:success] = t("evaluator_submission_assignments.#{new_status}.success") + respond_to do |format| + format.html { redirect_to_assignment_path } + format.json { render json: { success: true, message: flash[:success] } } + end + end + + def handle_failed_update(new_status) + flash[:error] = t("evaluator_submission_assignments.#{new_status}.failure") + respond_to do |format| + format.html { redirect_to_assignment_path } + format.json { render json: { success: false, message: flash[:error] }, status: :unprocessable_entity } + end + end + + def redirect_to_assignment_path + redirect_to phase_evaluator_submission_assignments_path( + @phase, + evaluator_id: params[:evaluator_id] + ) + end + + def calculate_submissions_count(assignments) + counts = count_by_status(assignments) + counts.merge("total" => calculate_total(counts)) + end + + def count_by_status(assignments) + { + "completed" => count_completed(assignments), + "in_progress" => count_in_progress(assignments), + "not_started" => count_not_started(assignments), + "recused" => count_recused(assignments) + } + end + + def count_completed(assignments) = assignments.count { |a| a.evaluation&.completed_at.present? } + + def count_in_progress(assignments) = assignments.count { |a| a.evaluation.present? && a.evaluation.completed_at.nil? } + + def count_not_started(assignments) = assignments.count { |a| a.assigned? && a.evaluation.nil? } + + def count_recused(assignments) = assignments.count(&:recused?) + + def calculate_total(counts) = counts.values.sum +end diff --git a/app/helpers/evaluators_helper.rb b/app/helpers/evaluators_helper.rb index bfde479e..cb832a2e 100644 --- a/app/helpers/evaluators_helper.rb +++ b/app/helpers/evaluators_helper.rb @@ -2,22 +2,44 @@ # View helpers for rendering users with the evaluator role. module EvaluatorsHelper + STATUS_COLORS = { + not_started: 'bg-error-dark', + in_progress: 'bg-accent-warm-dark', + completed: 'bg-success-dark', + recused: 'bg-base', + unassigned: 'bg-base', + recused_unassigned: 'bg-base' + }.freeze + def user_status(evaluator) - if evaluator.is_a?(User) - evaluator.status == 'active' ? "Available" : "Awaiting Approval" - else - "Invite Sent" - end + return "Invite Sent" unless evaluator.is_a?(User) + + evaluator.status == 'active' ? "Available" : "Awaiting Approval" end def assigned_submissions_count(evaluator, challenge, phase) - if evaluator.is_a?(User) - evaluator.evaluator_submission_assignments. - joins(:submission). - where(submissions: { challenge:, phase: }). - count - else - 0 - end + return 0 unless evaluator.is_a?(User) + + evaluator.evaluator_submission_assignments. + joins(:submission). + where(submissions: { challenge:, phase: }). + where(status: :assigned). + count + end + + def evaluation_submission_assignment_color(assignment) + status = if assignment.is_a?(EvaluatorSubmissionAssignment) + assignment.evaluation_status + else + assignment.to_sym + end + + STATUS_COLORS[status] + end + + def display_score(assignment) + return 'N/A' unless assignment.evaluation_status == :completed + + assignment.evaluation&.total_score || 'N/A' end end diff --git a/app/javascript/controllers/evaluation_criteria_controller.js b/app/javascript/controllers/evaluation_criteria_controller.js index 964dd898..97873799 100644 --- a/app/javascript/controllers/evaluation_criteria_controller.js +++ b/app/javascript/controllers/evaluation_criteria_controller.js @@ -100,6 +100,18 @@ export default class extends Controller { }); } + checkPointsOrWeightMax(event) { + const input = event.target; + const min = parseInt(input.min); + const max = parseInt(input.max); + const value = parseInt(input.value); + + // If invalid value is entered then pop up error message + if (value && (value < min || value > max)) { + event.target.reportValidity(); + } + } + updateScoringOptions(row, scoringType) { const options = { scaleOptions: row.querySelector(".criteria-scale-options"), diff --git a/app/javascript/controllers/evaluation_form_controller.js b/app/javascript/controllers/evaluation_form_controller.js index 90605314..1d9fc0e0 100644 --- a/app/javascript/controllers/evaluation_form_controller.js +++ b/app/javascript/controllers/evaluation_form_controller.js @@ -1,54 +1,85 @@ -import { Controller } from "@hotwired/stimulus" +import { Controller } from "@hotwired/stimulus"; // Connects to data-controller="evaluation-form" export default class extends Controller { static targets = ["challengeID", "phaseID", "startDate", "datePicker"]; handleChallengeSelect(e) { - let id, phase_id, end_date - [id, phase_id, end_date] = e.target.value.split(".") + let id, phase_id, end_date; + [id, phase_id, end_date] = e.target.value.split("."); if (id) { - // set values of hidden form fields - this.challengeIDTarget.value = id - this.phaseIDTarget.value = phase_id + // set values of hidden form fields + this.challengeIDTarget.value = id; + this.phaseIDTarget.value = phase_id; - // set the start date of the evaluation form + // set the start date of the evaluation form // to be the challenge's end date - this.startDateTarget.innerHTML = end_date || "mm/dd/yyyy" - let day, month, year - [month, day, year] = end_date.split("/") - this.datePickerTarget.setAttribute("data-min-date", `${year}-${month}-${day}`) + this.startDateTarget.innerHTML = end_date || "mm/dd/yyyy"; + let day, month, year; + [month, day, year] = end_date.split("/"); + this.datePickerTarget.setAttribute( + "data-min-date", + `${year}-${month}-${day}` + ); - this.updateErrorMessage("evaluation_form_challenge_id", "") - this.updateErrorMessage("evaluation_form_phase_id", "") + this.updateErrorMessage("evaluation_form_challenge_id", ""); + this.updateErrorMessage("evaluation_form_phase_id", ""); } else { - this.updateErrorMessage("evaluation_form_challenge_id", "can't be blank") - this.startDateTarget.innerHTML = "mm/dd/yyyy" + this.updateErrorMessage("evaluation_form_challenge_id", "can't be blank"); + this.startDateTarget.innerHTML = "mm/dd/yyyy"; } } + // Opens all accordions, remove existing points/weights, update max points/weights values updateMaxPoints(e) { const form = e.target.closest('form[data-controller="evaluation-form"]'); const pointsWeights = form.querySelectorAll(".points-or-weight"); - if (e.target.id == 'weighted_scale') { - pointsWeights.forEach((input) => input.max = "100") - } else { - pointsWeights.forEach((input) => input.max = "9999") + const weightedScale = e.target.value === "true"; + + if (weightedScale && this.hasValuesOverLimit(pointsWeights, 100)) { + this.expandAllAccordions(form); } + + this.updateMaxValues(pointsWeights, weightedScale ? 100 : 9999); + } + + // Helper: Check if any input values exceed a given limit + hasValuesOverLimit(inputs, limit) { + return Array.from(inputs).some( + (input) => parseInt(input.value.trim()) > limit + ); + } + + // Helper: Update max values for inputs + updateMaxValues(inputs, maxValue) { + inputs.forEach((input) => (input.max = maxValue)); + Array.from(inputs).every((input) => { + input.reportValidity(); + }); + } + + // Helper: Expand all accordions + expandAllAccordions(form) { + const accordionButtons = form.querySelectorAll(".usa-accordion__button"); + const accordions = form.querySelectorAll(".usa-accordion__content"); + + accordionButtons.forEach((button) => + button.setAttribute("aria-expanded", true) + ); + accordions.forEach((content) => content.removeAttribute("hidden")); } validatePresence(e) { if (!e.target.value) { - e.target.classList.add("border-secondary") - this.updateErrorMessage(e.target.id, "can't be blank") - + e.target.classList.add("border-secondary"); + this.updateErrorMessage(e.target.id, "can't be blank"); } else { - e.target.classList.remove("border-secondary") - this.updateErrorMessage(e.target.id, "") + e.target.classList.remove("border-secondary"); + this.updateErrorMessage(e.target.id, ""); } } updateErrorMessage(field, message) { - document.getElementById(field + "_error").innerHTML = message + document.getElementById(field + "_error").innerHTML = message; } } diff --git a/app/javascript/controllers/hotdog_controller.js b/app/javascript/controllers/hotdog_controller.js new file mode 100644 index 00000000..e81941bb --- /dev/null +++ b/app/javascript/controllers/hotdog_controller.js @@ -0,0 +1,34 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="hotdog" +export default class extends Controller { + static targets = ["rightPane", "leftPane", "hotdogCollapse", "hotdogExpand", "burgerCollapse", "burgerExpand", "burgerCollapsible"]; + + hotdogCollapse(e) { + this.rightPaneTarget.classList.add("display-none") + this.hotdogCollapseTarget.classList.add("display-none") + this.hotdogExpandTarget.classList.remove("display-none") + this.leftPaneTarget.classList.add("width-full") + this.leftPaneTarget.classList.remove("width-half") + } + + hotdogExpand(e) { + this.rightPaneTarget.classList.remove("display-none") + this.hotdogCollapseTarget.classList.remove("display-none") + this.hotdogExpandTarget.classList.add("display-none") + this.leftPaneTarget.classList.remove("width-full") + this.leftPaneTarget.classList.add("width-half") + } + + burgerCollapse(e) { + this.burgerCollapsibleTarget.classList.add("display-none") + this.burgerCollapseTarget.classList.add("display-none") + this.burgerExpandTarget.classList.remove("display-none") + } + + burgerExpand(e) { + this.burgerCollapsibleTarget.classList.remove("display-none") + this.burgerCollapseTarget.classList.remove("display-none") + this.burgerExpandTarget.classList.add("display-none") + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 076ccf60..82491138 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -4,10 +4,17 @@ import { application } from "./application"; -import EvaluationFormController from "./evaluation_form_controller"; +import DeleteEvaluatorModalController from "./delete_evaluator_modal_controller"; +application.register("delete-evaluator-modal", DeleteEvaluatorModalController); + +import UnassignEvaluatorSubmissionModalController from "./unassign_evaluator_submission_modal_controller"; +application.register("unassign-evaluator-submission-modal", UnassignEvaluatorSubmissionModalController); + import EvaluationCriteriaController from "./evaluation_criteria_controller"; -application.register("evaluation-form", EvaluationFormController); application.register("evaluation-criteria", EvaluationCriteriaController); -import DeleteEvaluatorModalController from "./delete_evaluator_modal_controller" -application.register("delete-evaluator-modal", DeleteEvaluatorModalController) +import EvaluationFormController from "./evaluation_form_controller"; +application.register("evaluation-form", EvaluationFormController); + +import HotdogController from "./hotdog_controller"; +application.register("hotdog", HotdogController); \ No newline at end of file diff --git a/app/javascript/controllers/unassign_evaluator_submission_modal_controller.js b/app/javascript/controllers/unassign_evaluator_submission_modal_controller.js new file mode 100644 index 00000000..8e5dfd22 --- /dev/null +++ b/app/javascript/controllers/unassign_evaluator_submission_modal_controller.js @@ -0,0 +1,66 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["modal"] + static values = { + phaseId: String, + assignmentId: String + } + + connect() { + this.modalTarget.addEventListener('click', this.handleOutsideClick.bind(this)); + } + + disconnect() { + this.modalTarget.removeEventListener('click', this.handleOutsideClick.bind(this)); + } + + open(event) { + event.preventDefault(); + this.setValues(event.currentTarget.dataset); + this.modalTarget.showModal(); + } + + close() { + this.modalTarget.close(); + } + + handleOutsideClick(event) { + if (event.target === this.modalTarget) { + this.close(); + } + } + + setValues(dataset) { + this.assignmentIdValue = dataset.assignmentId; + this.phaseIdValue = dataset.phaseId; + } + + unassignEvaluatorSubmission() { + const csrfToken = document.querySelector('meta[name="csrf-token"]').content; + fetch(`/phases/${this.phaseIdValue}/evaluator_submission_assignments/${this.assignmentIdValue}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken, + 'Accept': 'application/json' + }, + body: JSON.stringify({ + evaluator_submission_assignment: { status: 'unassigned' } + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + const evaluatorId = new URLSearchParams(window.location.search).get('evaluator_id'); + window.location.href = `/phases/${this.phaseIdValue}/evaluator_submission_assignments?evaluator_id=${evaluatorId}`; + } else { + throw new Error(data.message || 'Failed to unassign evaluator from submission'); + } + }) + .catch(error => { + console.error('Error:', error); + alert(error.message || 'An error occurred while unassigning the evaluator from the submission'); + }); + } +} diff --git a/app/models/evaluator_submission_assignment.rb b/app/models/evaluator_submission_assignment.rb index 3f246292..48e300cc 100644 --- a/app/models/evaluator_submission_assignment.rb +++ b/app/models/evaluator_submission_assignment.rb @@ -12,9 +12,51 @@ # updated_at :datetime not null # class EvaluatorSubmissionAssignment < ApplicationRecord + ORDER_VALUES = { + recused: 0, + unassigned: 1, + recused_unassigned: 2, + not_started: 3, + in_progress: 4, + completed: 5 + }.freeze + + # Associations belongs_to :submission belongs_to :evaluator, class_name: "User", foreign_key: :user_id, inverse_of: :assigned_submissions has_one :evaluation, dependent: :destroy - enum :status, { assigned: 0, unassigned: 1, recused: 2 } + has_one :phase, through: :submission + + enum :status, { + assigned: 0, + unassigned: 1, + recused: 2, + recused_unassigned: 3 + } + + def self.ordered_by_status + select('evaluator_submission_assignments.*, evaluations.id AS evaluation_id, evaluations.completed_at'). + left_joins(:evaluation). + to_a. + sort_by { |assignment| ORDER_VALUES[assignment.evaluation_status] } + end + + def evaluation_status + return status.to_sym unless assigned? + + assigned_evaluation_status + end + + private + + def assigned_evaluation_status + if evaluation&.completed_at.present? + :completed + elsif evaluation.present? + :in_progress + else + :not_started + end + end end diff --git a/app/views/evaluation_forms/_evaluation_criterion_fields.html.erb b/app/views/evaluation_forms/_evaluation_criterion_fields.html.erb index f324f4ba..9250bafc 100644 --- a/app/views/evaluation_forms/_evaluation_criterion_fields.html.erb +++ b/app/views/evaluation_forms/_evaluation_criterion_fields.html.erb @@ -65,7 +65,10 @@ placeholder: "Add criteria points/weight here", required: true, disabled: is_template || form_disabled, - data: {"evaluation-criteria-target": "pointsOrWeightField"} + data: { + "evaluation-criteria-target": "pointsOrWeightField", + action: "input->evaluation-criteria#checkPointsOrWeightMax blur->evaluation-criteria#checkPointsOrWeightMax" + } %>
diff --git a/app/views/evaluation_forms/_form.html.erb b/app/views/evaluation_forms/_form.html.erb index d0738518..6c327f2a 100644 --- a/app/views/evaluation_forms/_form.html.erb +++ b/app/views/evaluation_forms/_form.html.erb @@ -100,7 +100,7 @@ type="radio" name="evaluation_form[weighted_scoring]" value="false" - data-action="input->evaluation-form#updateMaxPoints" + data-action="click->evaluation-form#updateMaxPoints" <%= 'checked' if !evaluation_form.weighted_scoring? && evaluation_form.persisted? %> <%= 'disabled' if disabled %> required @@ -114,7 +114,7 @@ type="radio" name="evaluation_form[weighted_scoring]" value="true" - data-action="input->evaluation-form#updateMaxPoints" + data-action="click->evaluation-form#updateMaxPoints" <%= 'checked' if evaluation_form.weighted_scoring? %> <%= 'disabled' if disabled %> > diff --git a/app/views/evaluation_forms/edit.html.erb b/app/views/evaluation_forms/edit.html.erb index 27efdc86..a80e3def 100644 --- a/app/views/evaluation_forms/edit.html.erb +++ b/app/views/evaluation_forms/edit.html.erb @@ -2,4 +2,4 @@

<%= if eval_form_disabled?(@evaluation_form) then "View" else "Editing" end %> evaluation form

-<%= render "form", evaluation_form: @evaluation_form %> +<%= render "form", evaluation_form: @evaluation_form, available_phases: @available_phases %> diff --git a/app/views/evaluation_forms/new.html.erb b/app/views/evaluation_forms/new.html.erb index 03293697..a5702c5a 100644 --- a/app/views/evaluation_forms/new.html.erb +++ b/app/views/evaluation_forms/new.html.erb @@ -2,4 +2,4 @@

Create Evaluation Form

-<%= render "form", evaluation_form: @evaluation_form %> \ No newline at end of file +<%= render "form", evaluation_form: @evaluation_form %> diff --git a/app/views/evaluator_submission_assignments/_assignment_stats.html.erb b/app/views/evaluator_submission_assignments/_assignment_stats.html.erb new file mode 100644 index 00000000..f8f366cd --- /dev/null +++ b/app/views/evaluator_submission_assignments/_assignment_stats.html.erb @@ -0,0 +1,42 @@ +
+
+
+
+
+
+ <%= @assigned_submissions.count %> +
+
+

+ Assigned Submissions +

+

+ Evaluations due by <%= @phase.evaluation_form.closing_date.strftime('%m/%d/%Y') %> +

+
+
+
+ +
+ +
+
+ <%= @submissions_count["completed"] || 0 %>
+ Completed +
+
+ <%= @submissions_count["in_progress"] || 0 %>
+ In Progress +
+
+ <%= @submissions_count["not_started"] || 0 %>
+ Not Started +
+
+
+
+
diff --git a/app/views/evaluator_submission_assignments/_submission_row.html.erb b/app/views/evaluator_submission_assignments/_submission_row.html.erb new file mode 100644 index 00000000..329b7e35 --- /dev/null +++ b/app/views/evaluator_submission_assignments/_submission_row.html.erb @@ -0,0 +1,24 @@ + + + <% if assignment.status == "recused" %> + <%= image_tag('images/usa-icons/error.svg', class: "usa-icon--size-3", alt: "Recused", style: "vertical-align: middle; margin-right: 5px;") %> + <% end %> + <%= assignment.submission.id %> + + + <%= assignment.evaluation_status.to_s.titleize %> + + <%= display_score(assignment) %> + +
+ <% if assignment.evaluation_status == :completed %> + <%= link_to "View Evaluation", submissions_path(@challenge), class: 'usa-button font-body-3xs margin-right-1' %> + <% end %> + <%= button_tag "Unassign", type: 'button', class: 'usa-button usa-button--outline font-body-3xs', data: { + action: "click->unassign-evaluator-submission-modal#open", + assignment_id: assignment.id, + phase_id: @phase.id + } %> +
+ + diff --git a/app/views/evaluator_submission_assignments/_submission_table.html.erb b/app/views/evaluator_submission_assignments/_submission_table.html.erb new file mode 100644 index 00000000..4ac1d454 --- /dev/null +++ b/app/views/evaluator_submission_assignments/_submission_table.html.erb @@ -0,0 +1,15 @@ +
+ + + + + + + + + + + <%= yield %> + +
Submission IDEvaluation statusScore
+
diff --git a/app/views/evaluator_submission_assignments/_unassign_evaluator_submission_modal.html.erb b/app/views/evaluator_submission_assignments/_unassign_evaluator_submission_modal.html.erb new file mode 100644 index 00000000..513b82b9 --- /dev/null +++ b/app/views/evaluator_submission_assignments/_unassign_evaluator_submission_modal.html.erb @@ -0,0 +1,41 @@ + +
+
+ +
+ +
+ +
+
+
diff --git a/app/views/evaluator_submission_assignments/_unassigned_submission_row.html.erb b/app/views/evaluator_submission_assignments/_unassigned_submission_row.html.erb new file mode 100644 index 00000000..32f43a27 --- /dev/null +++ b/app/views/evaluator_submission_assignments/_unassigned_submission_row.html.erb @@ -0,0 +1,22 @@ + + + <% if assignment.recused? %> + <%= image_tag('images/usa-icons/error.svg', class: "usa-icon--size-3", alt: "Recused", style: "vertical-align: middle; margin-right: 5px;") %> + <% end %> + <%= assignment.submission.id %> + + + <%= assignment.evaluation_status.to_s.titleize %> + + <%= display_score(assignment) %> + +
+ <% if assignment.unassigned? %> + <%= button_to "Reassign", phase_evaluator_submission_assignment_path(@phase, assignment), + method: :patch, + params: { status: :assigned, evaluator_id: evaluator.id }, + class: 'usa-button usa-button--outline font-body-3xs' %> + <% end %> +
+ + diff --git a/app/views/evaluator_submission_assignments/index.html.erb b/app/views/evaluator_submission_assignments/index.html.erb new file mode 100644 index 00000000..6bbd9e3f --- /dev/null +++ b/app/views/evaluator_submission_assignments/index.html.erb @@ -0,0 +1,46 @@ +
+ + <%= render 'shared/back_link', path: phase_evaluators_path(@phase) %> + +

<%= @challenge.title %> - <%= @phase.title %>

+

View submissions assigned to an evalutor.

+ +

Evaluator: <%= "#{@evaluator.first_name} #{@evaluator.last_name}" %>

+ +

Assigned Submissions

+

<%= t('evaluator_submission_assignments.assigned_submissions') %>

+ + <%= render 'assignment_stats' %> + + <% if @assigned_submissions.any? { |assignment| assignment.status == "recused" } %> + <%= render 'shared/alert_error', alert_heading: t('alerts.recused_submission.heading'), alert_text: t('alerts.recused_submission.text') %> + <% end %> + + <% if @assigned_submissions.any? %> + <%= render layout: 'submission_table' do %> + <% @assigned_submissions.each do |assignment| %> + <%= render 'submission_row', assignment: assignment, evaluator: @evaluator %> + <% end %> + <% end %> + <% else %> +

+ <%= t('evaluator_submission_assignments.empty_state') %> +

+ <% end %> + + <% if @unassigned_submissions.any? %> +

Unassigned Submissions

+

<%= t('evaluator_submission_assignments.unassigned_submissions') %>

+ + <%= render layout: 'submission_table' do %> + <% @unassigned_submissions.each do |assignment| %> + <%= render 'unassigned_submission_row', assignment: assignment, evaluator: @evaluator, phase: @phase %> + <% end %> + <% end %> + <% end %> + + <%= render 'unassign_evaluator_submission_modal' %> +
diff --git a/app/views/evaluators/index.html.erb b/app/views/evaluators/index.html.erb index 7e5a6ab0..db59a1a3 100644 --- a/app/views/evaluators/index.html.erb +++ b/app/views/evaluators/index.html.erb @@ -1,11 +1,6 @@
-
- <%= link_to phases_path, class: "usa-link display-inline-flex flex-align-center" do %> - <%= image_tag('images/usa-icons/arrow_back.svg', class: "usa-icon--size-3", alt: "Back to previous page") %> - Back - <% end %> -
+ <%= render 'shared/back_link', path: phases_path(@phase) %>

<%= @challenge.title %> - <%= @phase.title %>

Create and manage a list of evaluators for the challenge.

@@ -75,7 +70,7 @@ <%= assigned_submissions_count(evaluator, @challenge, @phase) %>
- <%= link_to phases_path(@challenge), class: 'usa-button font-body-3xs', style: 'white-space: nowrap;' do %> + <%= link_to phase_evaluator_submission_assignments_path(@phase, evaluator_id: evaluator.id), class: 'usa-button font-body-3xs', style: 'white-space: nowrap;' do %> View Submissions <% end %> <%= button_tag "Delete", type: 'button', class: 'usa-button usa-button--outline font-body-3xs', data: { diff --git a/app/views/layouts/_hotdog.html.erb b/app/views/layouts/_hotdog.html.erb new file mode 100644 index 00000000..c6559f5d --- /dev/null +++ b/app/views/layouts/_hotdog.html.erb @@ -0,0 +1,31 @@ +<%# burger orientation, for mobile %> +
+
+ <%= render partial: right %> +
+
+ <%= image_tag('images/usa-icons/arrow_upward.svg', class: "usa-icon--size-3 icon-white margin-bottom-05", alt: "Hide #{name}") %> + Hide <%= name %> +
+ + <%= render partial: left %> +
+ +<%# hotdog orientation, for tablet and above %> + \ No newline at end of file diff --git a/app/views/phases/_phases_table.html.erb b/app/views/phases/_phases_table.html.erb index cd6d57f8..902adf41 100644 --- a/app/views/phases/_phases_table.html.erb +++ b/app/views/phases/_phases_table.html.erb @@ -12,10 +12,11 @@ <% @challenges.each do |challenge| %> <% challenge.phases.each do |phase| %> - - <%= challenge_phase_title(challenge, phase) %> - - + + <%= challenge_phase_title(challenge, phase) %> + <%= challenge.status.capitalize %> + + <%= phase.submissions_count %> diff --git a/app/views/shared/_back_link.html.erb b/app/views/shared/_back_link.html.erb new file mode 100644 index 00000000..06bc3f6e --- /dev/null +++ b/app/views/shared/_back_link.html.erb @@ -0,0 +1,6 @@ +
+ <%= link_to path, class: "usa-link display-inline-flex flex-align-center" do %> + <%= image_tag('images/usa-icons/arrow_back.svg', class: "usa-icon--size-3", alt: "Back to previous page") %> + Back + <% end %> +
\ No newline at end of file diff --git a/app/views/submissions/_comment_form.html.erb b/app/views/submissions/_comment_form.html.erb index 4197dd9d..54a920d6 100644 --- a/app/views/submissions/_comment_form.html.erb +++ b/app/views/submissions/_comment_form.html.erb @@ -1,4 +1,4 @@ -<%= form_with(model: @submission, url: submission_path(@submission), class: "width-mobile-lg") do |form| %> +<%= form_with(model: @submission, url: submission_path(@submission), class: "max-width-mobile-lg") do |form| %>
<%= form.label :comments, "Comments and notes:", class: "usa-label" %> <%= form.text_area :comments, class: "usa-textarea", default: @submission.comments %> diff --git a/app/views/submissions/show.html.erb b/app/views/submissions/show.html.erb index 5f74664e..2722ae36 100644 --- a/app/views/submissions/show.html.erb +++ b/app/views/submissions/show.html.erb @@ -1,5 +1,4 @@

Submission ID <%= @submission.id %>

View submission information and assign evaluators to evaluate the submission.

-<%= render partial: "submission_materials" %> -<%= render partial: "comment_form" %> \ No newline at end of file +<%= render partial: "layouts/hotdog", locals: {left: 'submissions/comment_form', right: 'submissions/submission_materials', name: 'Submission Materials'} %> diff --git a/config/locales/en.yml b/config/locales/en.yml index f092aca2..c98189a1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -63,6 +63,16 @@ en: evaluation_criterion_unique_title_in_form_error: "Evaluation criteria title must be unique within the same form." evaluation_form_criteria_weight_total_error: "The total weight of all evaluation criteria must add up to 100 when weighted scoring is enabled." evaluation_criteria_form_title_placeholder: "Add criteria title here" + evaluator_submission_assignments: + assigned_submissions: "A list of submissions assigned to the user." + unassigned_submissions: "A list of recused and unassigned submissions. Reassigning a user to a submission will make the submission available for the user to evaluate." + empty_state: "There currently are no assigned submissions to this evaluator. Please assign submissions to this evaluator to view." + assigned: + success: "Evaluator reassigned successfully" + failure: "Failed to reassign evaluator" + unassigned: + success: "Evaluator unassigned successfully" + failure: "Failed to unassign evaluator" evaluations: unique_user_for_evaluation_form_and_submission_error: "already has an evaluation for this form and submission" unique_evaluator_submission_assignment: "already has an evaluation" @@ -75,3 +85,6 @@ en: no_evaluation_form: heading: "No Evaluation Form" text: "This challenge does not have an assigned evaluation form. Please create an evaluation form and assign it to the challenge." + recused_submission: + heading: "Recused" + text: "A user recused from evaluations for one of the challenge submissions. Please review the list of submissions and unassign a recused evaluator." diff --git a/config/routes.rb b/config/routes.rb index 6192eeca..2e7fff68 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -27,6 +27,7 @@ post 'resend_invite' end end + resources :evaluator_submission_assignments, only: [:index, :update] end resources :submissions, only: [:index, :show, :update] diff --git a/spec/helpers/evaluators_helper_spec.rb b/spec/helpers/evaluators_helper_spec.rb index cd95919b..4464c736 100644 --- a/spec/helpers/evaluators_helper_spec.rb +++ b/spec/helpers/evaluators_helper_spec.rb @@ -3,17 +3,16 @@ require 'rails_helper' RSpec.describe EvaluatorsHelper, type: :helper do - describe '#assigned_submissions_count' do - let(:challenge) { create(:challenge) } - let(:phase) { create(:phase, challenge: challenge) } - let(:evaluator) { create(:user, role: :evaluator) } - let(:submission) { create(:submission, challenge: challenge, phase: phase) } + let(:challenge) { create(:challenge) } + let(:phase) { create(:phase, challenge: challenge) } + let(:evaluator) { create(:user, role: :evaluator) } + let(:submission) { create(:submission, challenge: challenge, phase: phase) } + describe '#assigned_submissions_count' do it 'returns the correct count of assigned submissions' do - create(:evaluator_submission_assignment, evaluator: evaluator, submission: submission) - create(:evaluator_submission_assignment, evaluator: evaluator, submission: submission) - create(:evaluator_submission_assignment, evaluator: evaluator, - submission: create(:submission, challenge: challenge, phase: phase)) + create(:evaluator_submission_assignment, evaluator: evaluator, submission: submission, status: :assigned) + create(:evaluator_submission_assignment, evaluator: evaluator, submission: create(:submission, challenge: challenge, phase: phase), status: :assigned) + create(:evaluator_submission_assignment, evaluator: evaluator, submission: create(:submission, challenge: challenge, phase: phase), status: :assigned) expect(helper.assigned_submissions_count(evaluator, challenge, phase)).to eq(3) end @@ -27,11 +26,54 @@ end it 'only counts submissions for the specified challenge and phase' do - create(:evaluator_submission_assignment, evaluator: evaluator, submission: submission) + create(:evaluator_submission_assignment, evaluator: evaluator, submission: submission, status: :assigned) create(:evaluator_submission_assignment, evaluator: evaluator, - submission: create(:submission, challenge: create(:challenge), phase: create(:phase))) + submission: create(:submission, challenge: create(:challenge), phase: create(:phase)), + status: :assigned) expect(helper.assigned_submissions_count(evaluator, challenge, phase)).to eq(1) end + + it 'does not count unassigned or recused submissions' do + create(:evaluator_submission_assignment, evaluator: evaluator, submission: submission, status: :assigned) + create(:evaluator_submission_assignment, evaluator: evaluator, submission: create(:submission, challenge: challenge, phase: phase), status: :unassigned) + create(:evaluator_submission_assignment, evaluator: evaluator, submission: create(:submission, challenge: challenge, phase: phase), status: :recused) + + expect(helper.assigned_submissions_count(evaluator, challenge, phase)).to eq(1) + end + end + + describe '#display_score' do + let(:assignment) { create(:evaluator_submission_assignment, evaluator: evaluator, submission: submission, status: :assigned) } + + context 'when assignment is completed and has an evaluation with a total score' do + it 'returns the total score' do + create(:evaluation, evaluator_submission_assignment: assignment, total_score: 85, completed_at: Time.current) + expect(helper.display_score(assignment)).to eq(85) + end + end + + context 'when assignment is not completed' do + it 'returns N/A for in-progress evaluation' do + create(:evaluation, evaluator_submission_assignment: assignment, total_score: 85, completed_at: nil) + expect(helper.display_score(assignment)).to eq('N/A') + end + + it 'returns N/A for not started evaluation' do + expect(helper.display_score(assignment)).to eq('N/A') + end + end + + context 'when assignment is not assigned' do + it 'returns N/A for unassigned status' do + assignment.update(status: :unassigned) + expect(helper.display_score(assignment)).to eq('N/A') + end + + it 'returns N/A for recused status' do + assignment.update(status: :recused) + expect(helper.display_score(assignment)).to eq('N/A') + end + end end end diff --git a/spec/requests/evaluator_submission_assignments_spec.rb b/spec/requests/evaluator_submission_assignments_spec.rb new file mode 100644 index 00000000..6f705269 --- /dev/null +++ b/spec/requests/evaluator_submission_assignments_spec.rb @@ -0,0 +1,92 @@ +require 'rails_helper' + +RSpec.describe EvaluatorSubmissionAssignmentsController, type: :request do + let(:challenge_manager) { create_and_log_in_user(role: 'challenge_manager') } + let(:challenge) { create(:challenge) } + let(:phase) { create(:phase, challenge: challenge) } + let(:evaluator) { create(:user, role: 'evaluator') } + let(:submission) { create(:submission, challenge: challenge, phase: phase) } + let(:unassigned_submission) { create(:submission, challenge: challenge, phase: phase) } + let!(:evaluation_form) { create(:evaluation_form, phase: phase, challenge: challenge, closing_date: 1.month.from_now) } + + let!(:assigned_assignment) do + create(:evaluator_submission_assignment, + submission: submission, + evaluator: evaluator, + status: :assigned) + end + + let!(:unassigned_assignment) do + create(:evaluator_submission_assignment, + submission: unassigned_submission, + evaluator: evaluator, + status: :unassigned) + end + + before do + ChallengeManager.create(user: challenge_manager, challenge: challenge) + ChallengePhasesEvaluator.create!(challenge: challenge, phase: phase, user: evaluator) + end + + describe 'GET #index' do + it 'renders the index page successfully' do + get phase_evaluator_submission_assignments_path(phase, evaluator_id: evaluator.id) + expect(response).to have_http_status(:success) + expect(response.body).to include(evaluator.first_name) + end + + it 'displays the correct counts for assigned submissions' do + get phase_evaluator_submission_assignments_path(phase, evaluator_id: evaluator.id) + expect(response.body).to include('Assigned Submissions') + expect(response.body).to include(assigned_assignment.submission.id.to_s) + expect(response.body).to include(unassigned_assignment.submission.id.to_s) + end + end + + describe 'PATCH #update' do + context 'when reassigning' do + it 'reassigns the evaluator successfully and updates counts' do + + patch phase_evaluator_submission_assignment_path(phase, unassigned_assignment), + params: { status: :assigned, evaluator_id: evaluator.id } + + expect(unassigned_assignment.reload.status).to eq('assigned') + expect(flash[:success]).to eq(I18n.t('evaluator_submission_assignments.assigned.success')) + expect(response).to redirect_to(phase_evaluator_submission_assignments_path(phase, evaluator_id: evaluator.id)) + end + + it 'fails to reassign when the assignment is invalid' do + allow_any_instance_of(EvaluatorSubmissionAssignment).to receive(:update).and_return(false) + + patch phase_evaluator_submission_assignment_path(phase, unassigned_assignment), + params: { status: :assigned, evaluator_id: evaluator.id } + + expect(unassigned_assignment.reload.status).to eq('unassigned') + expect(flash[:error]).to eq(I18n.t('evaluator_submission_assignments.assigned.failure')) + expect(response).to redirect_to(phase_evaluator_submission_assignments_path(phase, evaluator_id: evaluator.id)) + end + end + + context 'when unassigning' do + it 'unassigns the evaluator successfully' do + patch phase_evaluator_submission_assignment_path(phase, assigned_assignment), + params: { status: :unassigned, evaluator_id: evaluator.id } + + expect(assigned_assignment.reload.status).to eq('unassigned') + expect(flash[:success]).to eq(I18n.t('evaluator_submission_assignments.unassigned.success')) + expect(response).to redirect_to(phase_evaluator_submission_assignments_path(phase, evaluator_id: evaluator.id)) + end + + it 'fails to unassign when the assignment is invalid' do + allow_any_instance_of(EvaluatorSubmissionAssignment).to receive(:update).and_return(false) + + patch phase_evaluator_submission_assignment_path(phase, assigned_assignment), + params: { status: :unassigned, evaluator_id: evaluator.id } + + expect(assigned_assignment.reload.status).to eq('assigned') + expect(flash[:error]).to eq(I18n.t('evaluator_submission_assignments.unassigned.failure')) + expect(response).to redirect_to(phase_evaluator_submission_assignments_path(phase, evaluator_id: evaluator.id)) + end + end + end +end diff --git a/spec/system/evaluation_form_spec.rb b/spec/system/evaluation_form_spec.rb index 37e7ccef..03ec9b67 100644 --- a/spec/system/evaluation_form_spec.rb +++ b/spec/system/evaluation_form_spec.rb @@ -214,6 +214,64 @@ save_form expect(page).to have_content("Evaluation Form Saved") end + + it "expands all criteria if switching to weighted scale with value over 100" do + visit new_evaluation_form_path + + fill_in_base_form_info + select_scale_type("point") + + # Fill in criteria with one being over 100 + fill_in_numeric_criteria_type(initial: true) + fill_in_criterion_points_weight(0, 10) + fill_in_numeric_criteria_type + fill_in_criterion_points_weight(1, 101) + fill_in_numeric_criteria_type + fill_in_criterion_points_weight(2, 102) + + toggle_all_criteria_accordions(open: false) + + check_criteria_accordion_expanded(0, false) + check_criteria_accordion_expanded(1, false) + check_criteria_accordion_expanded(2, false) + + select_scale_type("weighted") + + check_criteria_accordion_expanded(0, true) + check_criteria_accordion_expanded(1, true) + check_criteria_accordion_expanded(2, true) + + # Scale type should be weighted + expect_form_scale_type_to_equal(true) + end + + it "does nothing if switching to weighted scale with no value over 100" do + visit new_evaluation_form_path + + fill_in_base_form_info + select_scale_type("point") + + # Fill in criteria with one being over 100 + fill_in_numeric_criteria_type(initial: true) + fill_in_criterion_points_weight(0, 10) + fill_in_numeric_criteria_type + fill_in_criterion_points_weight(1, 100) + + toggle_all_criteria_accordions(open: false) + + check_criteria_accordion_expanded(0, false) + check_criteria_accordion_expanded(1, false) + + select_scale_type("weighted") + + check_criteria_accordion_expanded(0, false) + check_criteria_accordion_expanded(1, false) + + # Scale type should be weighted + expect_form_scale_type_to_equal(true) + expect_criterion_points_or_weight_to_equal(0, 10) + expect_criterion_points_or_weight_to_equal(1, 100) + end end describe "update evaluation form page" do diff --git a/spec/system/evaluator_submission_assignment_spec.rb b/spec/system/evaluator_submission_assignment_spec.rb new file mode 100644 index 00000000..9d02c722 --- /dev/null +++ b/spec/system/evaluator_submission_assignment_spec.rb @@ -0,0 +1,64 @@ +require 'rails_helper' + +RSpec.describe 'Evaluator Submission Assignments', type: :system, js: true do + let(:user) { create_user(role: "challenge_manager", status: "active") } + let(:challenge) { create(:challenge) } + let(:phase) { create(:phase, challenge: challenge) } + let(:evaluator) { create(:user, role: 'evaluator') } + let(:submission) { create(:submission, phase: phase) } + let!(:evaluation_form) { create(:evaluation_form, phase: phase, challenge: challenge, closing_date: 1.month.from_now) } + + before do + ChallengeManager.create!(user: user, challenge: challenge) + ChallengePhasesEvaluator.create!(challenge: challenge, phase: phase, user: evaluator) + system_login_user(user) + end + + it 'is accessible' do + visit phase_evaluator_submission_assignments_path(phase, evaluator_id: evaluator.id) + expect(page).to have_content(evaluator.first_name) + expect(page).to be_axe_clean + end + + it 'allows unassigning a submission from an evaluator', js: true do + assigned_assignment = create( + :evaluator_submission_assignment, + submission: submission, + evaluator: evaluator, + status: :assigned + ) + + visit phase_evaluator_submission_assignments_path(phase, evaluator_id: evaluator.id) + expect(page).to have_content('Assigned Submissions') + + unassign_button = find("button[data-assignment-id='#{assigned_assignment.id}']", text: 'Unassign') + expect(unassign_button).to be_visible + unassign_button.click + + expect(page).to have_selector('#unassign-evaluator-submission-modal', visible: true) + expect(page).to have_content('Are you sure you want to unassign an evaluator from this submission?') + + within('#unassign-evaluator-submission-modal') do + click_button 'Yes' + end + end + + it 'allows assigning a submission to an evaluator' do + unassigned_assignment = create( + :evaluator_submission_assignment, + submission: submission, + evaluator: evaluator, + status: :unassigned + ) + visit phase_evaluator_submission_assignments_path(phase, evaluator_id: evaluator.id) + + expect(page).to have_content('Unassigned Submissions') + + within('table', text: unassigned_assignment.submission.id.to_s) do + click_button 'Reassign', match: :first + end + + expect(page).to have_content(I18n.t('evaluator_submission_assignments.assigned.success')) + expect(unassigned_assignment.reload.status).to eq('assigned') + end +end