diff --git a/app/controllers/evaluators_controller.rb b/app/controllers/evaluators_controller.rb index 123f4523..97f67cd9 100644 --- a/app/controllers/evaluators_controller.rb +++ b/app/controllers/evaluators_controller.rb @@ -13,6 +13,8 @@ def index end def create + @evaluator_invitation = EvaluatorInvitation.new(evaluator_invitation_params) + result = evaluator_service.process_evaluator_invitation( evaluator_invitation_params[:email], evaluator_invitation_params @@ -20,12 +22,11 @@ def create if result[:success] redirect_to phase_evaluators_path(@phase), notice: result[:message] - else - flash.now[:alert] = result[:message] - @evaluator_invitations = @phase.evaluator_invitations - @existing_evaluators = @phase.evaluators - render :index + return end + + handle_failed_invitation(result) + render :index, status: :unprocessable_entity end def destroy @@ -64,7 +65,18 @@ def evaluator_service def evaluator_invitation_params params.require(:evaluator_invitation).permit( - :first_name, :last_name, :email, :challenge_id, :phase_id, :last_invite_sent + :full_name, :email, :challenge_id, :phase_id, :last_invite_sent ) end + + def handle_failed_invitation(result) + @evaluator_invitations = @phase.evaluator_invitations + @existing_evaluators = @phase.evaluators + + if result[:evaluator_invitation].present? + @evaluator_invitation = result[:evaluator_invitation] + else + @evaluator_invitation.errors.add(:base, result[:message]) + end + end end diff --git a/app/javascript/controllers/evaluation_form_controller.js b/app/javascript/controllers/evaluation_form_controller.js index 0113cfef..59e298ed 100644 --- a/app/javascript/controllers/evaluation_form_controller.js +++ b/app/javascript/controllers/evaluation_form_controller.js @@ -69,46 +69,4 @@ export default class extends Controller { ); accordions.forEach((content) => content.removeAttribute("hidden")); } - - validatePresence(e) { - const target = e.target; - const formGroup = target.closest(".usa-form-group"); - const fieldName = target.dataset.fieldName || target.id; - - const label = this.findLabel(target, formGroup); - - if (!target.value) { - this.addErrorClasses(target, label); - this.updateErrorMessage(fieldName, "can't be blank"); - } else { - this.removeErrorClasses(target, label); - this.updateErrorMessage(fieldName, ""); - } - } - - findLabel(target, formGroup) { - const isSelect = - target.tagName === "SELECT" || - target.classList.contains("usa-combo-box__input"); - const isRadio = target.type === "radio"; - - const labelId = isSelect ? target.name : target.id; - const labelQuery = isRadio ? "legend" : `label[for="${labelId}"]`; - - return formGroup.querySelector(labelQuery); - } - - addErrorClasses(target, label) { - target.classList.add("border-secondary"); - if (label) label.classList.add("text-secondary"); - } - - removeErrorClasses(target, label) { - target.classList.remove("border-secondary"); - if (label) label.classList.remove("text-secondary"); - } - - updateErrorMessage(field, message) { - document.getElementById(field + "_error").innerHTML = message; - } } diff --git a/app/javascript/controllers/form_validation_controller.js b/app/javascript/controllers/form_validation_controller.js new file mode 100644 index 00000000..4e0d7a73 --- /dev/null +++ b/app/javascript/controllers/form_validation_controller.js @@ -0,0 +1,46 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="form-validation" +export default class extends Controller { + validatePresence(e) { + const target = e.target; + const formGroup = target.closest(".usa-form-group"); + const fieldName = target.dataset.fieldName || target.id; + const label = this.findLabel(target, formGroup); + + if (!target.value) { + this.addErrorClasses(target, label); + this.updateErrorMessage(fieldName, "can't be blank"); + } else { + this.removeErrorClasses(target, label); + this.updateErrorMessage(fieldName, ""); + } + } + + findLabel(target, formGroup) { + const isSelect = target.tagName === "SELECT" || target.classList.contains("usa-combo-box__input"); + const isRadio = target.type === "radio"; + + const labelId = isSelect ? target.name : target.id; + const labelQuery = isRadio ? "legend" : `label[for="${labelId}"]`; + + return formGroup.querySelector(labelQuery); + } + + addErrorClasses(target, label) { + target.classList.add("border-secondary"); + if (label) label.classList.add("text-secondary"); + } + + removeErrorClasses(target, label) { + target.classList.remove("border-secondary"); + if (label) label.classList.remove("text-secondary"); + } + + updateErrorMessage(field, message) { + const errorElement = document.getElementById(field + "_error"); + if (errorElement) { + errorElement.innerHTML = message; + } + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 7e86587e..6e928fed 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -13,6 +13,9 @@ application.register("evaluation-criteria", EvaluationCriteriaController); import EvaluationFormController from "./evaluation_form_controller"; application.register("evaluation-form", EvaluationFormController); +import FormValidationController from "./form_validation_controller"; +application.register("form-validation", FormValidationController); + import HotdogController from "./hotdog_controller"; application.register("hotdog", HotdogController); diff --git a/app/models/evaluator_invitation.rb b/app/models/evaluator_invitation.rb index b22ab9c1..0b4633c3 100644 --- a/app/models/evaluator_invitation.rb +++ b/app/models/evaluator_invitation.rb @@ -24,4 +24,14 @@ class EvaluatorInvitation < ApplicationRecord validates :last_invite_sent, presence: true validates :email, uniqueness: { scope: [:challenge_id, :phase_id] } + + def full_name=(name) + names = name.to_s.strip.split(/\s+/, 2) + self.first_name = names[0] + self.last_name = names[1] + end + + def full_name + "#{first_name} #{last_name}".strip + end end diff --git a/app/services/evaluator_management_service.rb b/app/services/evaluator_management_service.rb index 30bd8fe5..64f66c8b 100644 --- a/app/services/evaluator_management_service.rb +++ b/app/services/evaluator_management_service.rb @@ -8,6 +8,7 @@ def initialize(challenge, phase) end def process_evaluator_invitation(email, invitation_params) + @invitation_params = invitation_params user = User.find_by(email:) user ? add_existing_user_as_evaluator(user) : handle_invitation(email, invitation_params) end @@ -44,6 +45,24 @@ def resend_invitation(invitation) private + def update_name_for_existing_user(user) + names = @invitation_params[:full_name].to_s.strip.split(/\s+/, 2) + if names.length < 2 + return { + success: false, + message: "First and last name are required" + } + end + + first_name, last_name = names + user.update( + first_name:, + last_name: + ) + + { success: true } + end + def add_existing_user_as_evaluator(user) if @phase.evaluators.include?(user) return { @@ -61,6 +80,9 @@ def add_existing_user_as_evaluator(user) } end + updated_name = update_name_for_existing_user(user) + return updated_name unless updated_name[:success] + cpe = ChallengePhasesEvaluator.find_or_create_by(challenge: @challenge, phase: @phase, user:) if cpe.persisted? @@ -101,7 +123,8 @@ def create_new_invitation(invitation_params) else { success: false, - message: invitation.errors.full_messages.join(", ") + message: invitation.errors.full_messages.join(", "), + evaluator_invitation: invitation } 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 406c6a8e..34966098 100644 --- a/app/views/evaluation_forms/_evaluation_criterion_fields.html.erb +++ b/app/views/evaluation_forms/_evaluation_criterion_fields.html.erb @@ -32,7 +32,7 @@ disabled: is_template || form_disabled, data: { "evaluation-criteria-target": "titleField", - "action": "evaluation-form#validatePresence focusout->evaluation-form#validatePresence" + "action": "form-validation#validatePresence focusout->form-validation#validatePresence" } %> @@ -55,7 +55,7 @@ disabled: is_template || form_disabled, data: { "evaluation-criteria-target": "descriptionField", - "action": "evaluation-form#validatePresence focusout->evaluation-form#validatePresence" + "action": "form-validation#validatePresence focusout->form-validation#validatePresence" } %> You can enter up to 1000 characters @@ -83,7 +83,7 @@ "evaluation-criteria-target": "pointsOrWeightField", action: " input->evaluation-criteria#checkPointsOrWeightMax blur->evaluation-criteria#checkPointsOrWeightMax - evaluation-form#validatePresence focusout->evaluation-form#validatePresence + form-validation#validatePresence focusout->form-validation#validatePresence " } %> @@ -111,7 +111,7 @@ disabled: is_template || form_disabled, data: { "evaluation-criteria-target": "scoringTypeRadio", - action: "click->evaluation-criteria#toggleScoringType evaluation-form#validatePresence", + action: "click->evaluation-criteria#toggleScoringType form-validation#validatePresence", field_name: criteria_field_id(f, "scoring_type", is_template) } %> @@ -187,7 +187,7 @@ value: f.object.option_labels[index.to_s], disabled: disabledOptionLabel || form_disabled, data: { - "action": "evaluation-form#validatePresence focusout->evaluation-form#validatePresence", + "action": "form-validation#validatePresence focusout->form-validation#validatePresence", "evaluation-criteria-target": "optionLabelInput" } %> diff --git a/app/views/evaluation_forms/_form.html.erb b/app/views/evaluation_forms/_form.html.erb index 41440bc7..a4355e46 100644 --- a/app/views/evaluation_forms/_form.html.erb +++ b/app/views/evaluation_forms/_form.html.erb @@ -1,4 +1,4 @@ -<%= form_with(model: evaluation_form, data: { controller: "evaluation-form modal" }) do |form| %> +<%= form_with(model: evaluation_form, data: { controller: "evaluation-form modal form-validation" }) do |form| %> <% if evaluation_form.errors.any? %>
- <%= form.text_area :instructions, class: "usa-textarea usa-character-count__field #{input_error_class(form, :instructions)}", maxlength: 3000, rows: "7", data: {"action": "evaluation-form#validatePresence focusout->evaluation-form#validatePresence"}, disabled: disabled%> + <%= form.text_area :instructions, class: "usa-textarea usa-character-count__field #{input_error_class(form, :instructions)}", maxlength: 3000, rows: "7", data: {"action": "form-validation#validatePresence focusout->form-validation#validatePresence"}, disabled: disabled%> You can enter up to 3000 characters
@@ -117,7 +117,7 @@ type="radio" name="evaluation_form[scale_type]" value="point" - data-action="click->evaluation-form#updateMaxPoints evaluation-form#validatePresence" + data-action="click->evaluation-form#updateMaxPoints form-validation#validatePresence" data-field-name="evaluation_form_scale_type" <%= 'checked' if evaluation_form.point_scoring? %> <%= 'disabled' if disabled %> @@ -131,7 +131,7 @@ type="radio" name="evaluation_form[scale_type]" value="weight" - data-action="click->evaluation-form#updateMaxPoints evaluation-form#validatePresence" + data-action="click->evaluation-form#updateMaxPoints form-validation#validatePresence" data-field-name="evaluation_form_scale_type" <%= 'checked' if evaluation_form.weighted_scoring? %> <%= 'disabled' if disabled %> @@ -208,7 +208,7 @@ name="evaluation_form[closing_date]" aria-labelledby="evaluation_form_closing_date" aria-describedby="evaluation_form_closing_date_hint" - data-action="evaluation-form#validatePresence focusout->evaluation-form#validatePresence" + data-action="form-validation#validatePresence focusout->form-validation#validatePresence" /> diff --git a/app/views/evaluator_submission_assignments/index.html.erb b/app/views/evaluator_submission_assignments/index.html.erb index 6bbd9e3f..2f1d4f67 100644 --- a/app/views/evaluator_submission_assignments/index.html.erb +++ b/app/views/evaluator_submission_assignments/index.html.erb @@ -5,7 +5,7 @@ <%= render 'shared/back_link', path: phase_evaluators_path(@phase) %> -

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

+

<%= challenge_phase_title(@challenge, @phase) %>

View submissions assigned to an evalutor.

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

diff --git a/app/views/evaluators/index.html.erb b/app/views/evaluators/index.html.erb index db59a1a3..f9e5076e 100644 --- a/app/views/evaluators/index.html.erb +++ b/app/views/evaluators/index.html.erb @@ -1,40 +1,74 @@
- <%= render 'shared/back_link', path: phases_path(@phase) %> + <%= render 'shared/back_link', path: phases_path %> -

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

+

<%= challenge_phase_title(@challenge, @phase) %>

Create and manage a list of evaluators for the challenge.

Add Evaluators

Evaluators will not be assigned to submissions until you assign in the submission detail view.

- <%= form_with(model: EvaluatorInvitation.new, url: phase_evaluators_path(@phase), method: :post, local: true) do |form| %> + <%= form_with(model: @evaluator_invitation || EvaluatorInvitation.new, url: phase_evaluators_path(@phase), method: :post, local: true, data: { controller: "form-validation modal" }, html: { novalidate: true }) do |form| %> <%= form.hidden_field :challenge_id, value: @challenge.id %> <%= form.hidden_field :phase_id, value: @phase.id %> <%= form.hidden_field :last_invite_sent, value: Time.current %> + + <% if form.object.errors.any? %> +
+ +
+ <% end %> +
- <%= form.label :first_name, class: "usa-label font-sans-md" do %> - First Name* - <% end %> -
Add evaluator's first name.
- <%= form.text_field :first_name, class: "usa-input", required: true, autocomplete: "given-name", style: "border: 1.5px solid #565c65;" %> -
-
- <%= form.label :last_name, class: "usa-label font-sans-md" do %> - Last Name* - <% end %> -
Add evaluator's last name.
- <%= form.text_field :last_name, class: "usa-input", required: true, autocomplete: "family-name", style: "border: 1.5px solid #565c65;" %> +
+ <%= form.label :full_name, class: "usa-label font-sans-md #{label_error_class(form, :first_name)}" do %> + First & Last Name* + <% end %> +
+ <%= form.text_field :full_name, + class: "usa-input #{input_error_class(form, :last_name)}", + required: true, + autocomplete: "name", + style: "border: 1.5px solid #565c65;", + data: { + action: "form-validation#validatePresence focusout->form-validation#validatePresence" + }, + value: form.object.full_name + %> + <%= inline_error(form, :last_name) %>
+
- <%= form.label :email, class: "usa-label font-sans-md" do %> - Email Address* - <% end %> -
Add a single email address for the individual.
- <%= form.email_field :email, class: "usa-input", required: true, autocomplete: "email", style: "border: 1.5px solid #565c65;" %> +
+ <%= form.label :email, class: "usa-label font-sans-md #{label_error_class(form, :email)}" do %> + Email Address* + <% end %> +
+ <%= form.email_field :email, + class: "usa-input #{input_error_class(form, :email)}", + required: true, + autocomplete: "email", + style: "border: 1.5px solid #565c65;", + data: { + action: "form-validation#validatePresence focusout->form-validation#validatePresence" + } + %> + <%= inline_error(form, :email) %>
+ <%= form.button type: 'submit', class: "usa-button margin-top-5 margin-bottom-3" do %> - <%= image_tag('images/usa-icons/person.svg', class: "usa-icon--size-3 icon-white", alt: "Add evaluator") %> + <%= image_tag('images/usa-icons/person.svg', class: "usa-icon--size-3 icon-white", alt: "") %> Add to evaluator list - <% end %> + <% end %> <% end %>

Evaluator List

diff --git a/spec/requests/evaluators_spec.rb b/spec/requests/evaluators_spec.rb index 59b6f7b8..cbf4f1c0 100644 --- a/spec/requests/evaluators_spec.rb +++ b/spec/requests/evaluators_spec.rb @@ -190,7 +190,7 @@ } expect(response).to render_template(:index) - expect(flash[:alert]).to eq('User does not have a valid evaluator role.') + expect(response.body).to include('User does not have a valid evaluator role.') end end end diff --git a/spec/services/evaluator_management_service_spec.rb b/spec/services/evaluator_management_service_spec.rb index 82faca8c..0a087989 100644 --- a/spec/services/evaluator_management_service_spec.rb +++ b/spec/services/evaluator_management_service_spec.rb @@ -11,7 +11,13 @@ let(:evaluator) { create(:user, role: 'evaluator') } it 'adds the user as an evaluator if not already added' do - result = service.process_evaluator_invitation(evaluator.email, {}) + result = service.process_evaluator_invitation( + evaluator.email, + { + email: evaluator.email, + full_name: 'Santos Bickford' + } + ) expect(result[:success]).to be true expect(result[:message]).to include('has been added as an evaluator') expect(ChallengePhasesEvaluator.where(challenge: challenge, phase: phase, user: evaluator).count).to eq(1) @@ -31,6 +37,26 @@ expect(result[:success]).to be false expect(result[:message]).to include('does not have a valid evaluator role') end + + it 'requires full name when adding an existing user' do + result = service.process_evaluator_invitation(evaluator.email, { email: evaluator.email }) + expect(result[:success]).to be false + expect(result[:message]).to eq('First and last name are required') + end + + it 'updates user name when adding as evaluator' do + result = service.process_evaluator_invitation( + evaluator.email, + { + email: evaluator.email, + full_name: 'Santos Bickford' + } + ) + expect(result[:success]).to be true + evaluator.reload + expect(evaluator.first_name).to eq('Santos') + expect(evaluator.last_name).to eq('Bickford') + end end context 'with a new user' do diff --git a/spec/system/evaluation_form_spec.rb b/spec/system/evaluation_form_spec.rb index fad7fa4d..ebb9f7cc 100644 --- a/spec/system/evaluation_form_spec.rb +++ b/spec/system/evaluation_form_spec.rb @@ -375,7 +375,7 @@ visit edit_evaluation_form_path(closed_evaluation_form) # Add expectation in spec to satisfy rubocop - expect(page).to have_css("form[data-controller='evaluation-form modal']") + expect(page).to have_css("form[data-controller='evaluation-form modal form-validation']") check_all_non_hidden_inputs_disabled_except_end_date end end @@ -794,7 +794,7 @@ def rebalance_criteria_weights # Checks that all non hidden or end date fields are disabled def check_all_non_hidden_inputs_disabled_except_end_date - within("form[data-controller='evaluation-form modal']") do + within("form[data-controller='evaluation-form modal form-validation']") do all("input:not([type='hidden']), textarea, select").each do |field| if field[:id] == "evaluation_form_closing_date" expect(field).not_to be_disabled, "Expected #{field[:id]} to not be disabled"