Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[296] Evaluation Form Validation - Part 2 #327

Merged
merged 15 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/assets/stylesheets/application.sass.scss
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ dialog::backdrop {
#add-criteria-button.usa-button {
background-color: #4d8055;
}

.usa-combo-box .border-secondary + input {
border: 1px solid red;
}
4 changes: 3 additions & 1 deletion app/controllers/evaluation_forms_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

# Controller for evaluation forms CRUD actions.
class EvaluationFormsController < ApplicationController
helper FormHelper
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this isn't necessary, the helpers are automatically included on every controller


before_action -> { authorize_user('challenge_manager') }
before_action :set_evaluation_form, only: %i[show edit update destroy]
before_action :set_evaluation_forms, only: %i[index]
Expand Down Expand Up @@ -99,7 +101,7 @@ def set_available_phases
def evaluation_form_params
permitted = params.require(:evaluation_form).
permit(:title, :instructions, :phase_id, :status, :comments_required,
:weighted_scoring, :publication_date, :closing_date, :challenge_id,
:scale_type, :publication_date, :closing_date, :challenge_id,
evaluation_criteria_attributes: [
:id, :title, :description, :points_or_weight, :scoring_type,
:option_range_start, :option_range_end, :_destroy,
Expand Down
5 changes: 0 additions & 5 deletions app/helpers/evaluation_forms_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,6 @@ def evaluation_period(evaluation_form)
"#{start_date} - #{end_date}"
end

def inline_error(evaluation_form, field)
error = evaluation_form.errors[field].present? ? evaluation_form.errors[field].first : ""
tag.span(error, class: "text-secondary font-body-2xs", id: "evaluation_form_#{field}_error")
end

def criteria_field_id(form, attribute, is_template)
prefix = "evaluation_form_evaluation_criteria_attributes"

Expand Down
22 changes: 22 additions & 0 deletions app/helpers/form_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

# Helpers for rendering various form elements and errors
module FormHelper
def label_error_class(form, field)
object = form.object
object.errors[field].present? ? "text-secondary" : ""
end

def input_error_class(form, field)
object = form.object
object.errors[field].present? ? "border-secondary" : ""
end

def inline_error(form, field)
object = form.object
field_id = (form.object_name + "_#{field}_error").gsub(/[\[\]]/, "_").squeeze('_')
error = object.errors[field].present? ? object.errors[field].join(", ") : ""

tag.span(error, class: "text-secondary font-body-2xs", id: field_id)
end
end
25 changes: 24 additions & 1 deletion app/javascript/controllers/evaluation_criteria_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export default class extends Controller {
this.replacePlaceholders(newCriteria);
this.enableInputs(newCriteria);

this.collapseAllCriteria();
this.expandCriterion(newCriteria);

this.criteriaListTarget.appendChild(newCriteria);

this.updateCriteriaTitles();
Expand All @@ -40,6 +43,26 @@ export default class extends Controller {
this.updateCriteriaTitles();
}

collapseAllCriteria() {
const accordionButtons = this.element.querySelectorAll(
".usa-accordion__button"
);
const accordions = this.element.querySelectorAll(".usa-accordion__content");

accordionButtons.forEach((button) =>
button.setAttribute("aria-expanded", false)
);
accordions.forEach((content) => content.setAttribute("hidden", ""));
}

expandCriterion(criterion) {
const accordionButton = criterion.querySelector(".usa-accordion__button");
const accordionContent = criterion.querySelector(".usa-accordion__content");

if (accordionButton) accordionButton.setAttribute("aria-expanded", true);
if (accordionContent) accordionContent.removeAttribute("hidden");
}

toggleScoringType(event) {
const row = event.target.closest(".criteria-row");
const scoringType = row.querySelector(".scoring-type-radio:checked").value;
Expand Down Expand Up @@ -171,7 +194,7 @@ export default class extends Controller {
.querySelectorAll(".criteria-option-label-row")
.forEach((labelRow, index) => {
labelRow.style.display =
index >= start && index <= end ? "flex" : "none";
index >= start && index <= end ? "block" : "none";
const input = labelRow.querySelector("input");
input.disabled = index < start || index > end;
});
Expand Down
45 changes: 36 additions & 9 deletions app/javascript/controllers/evaluation_form_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,10 @@ export default class extends Controller {
"data-min-date",
`${year}-${month}-${day}`
);

this.updateErrorMessage("evaluation_form_challenge_id", "");
this.updateErrorMessage("evaluation_form_phase_id", "");
} else {
this.updateErrorMessage("evaluation_form_challenge_id", "can't be blank");
this.challengeIDTarget.value = null;
this.phaseIDTarget.value = null;

this.startDateTarget.innerHTML = "mm/dd/yyyy";
}
}
Expand Down Expand Up @@ -72,15 +71,43 @@ export default class extends Controller {
}

validatePresence(e) {
if (!e.target.value) {
e.target.classList.add("border-secondary");
this.updateErrorMessage(e.target.id, "can't be blank");
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 {
e.target.classList.remove("border-secondary");
this.updateErrorMessage(e.target.id, "");
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;
}
Expand Down
15 changes: 15 additions & 0 deletions app/models/evaluation_criterion.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,19 @@ class EvaluationCriterion < ApplicationRecord
validates :title, length: { maximum: 150 }
validates :description, length: { maximum: 1000 }
validates :points_or_weight, numericality: { only_integer: true }
validates :scoring_type, presence: true

validate :validate_option_labels_not_blank, if: -> { rating? || binary? }

private

def validate_option_labels_not_blank
return unless option_labels.is_a?(Hash)

option_labels.each do |key, value|
if value.blank?
errors.add("option_labels_#{key}", "can't be blank")
end
end
end
end
46 changes: 40 additions & 6 deletions app/models/evaluation_form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
# title :string not null
# instructions :string not null
# comments_required :boolean default(FALSE)
# weighted_scoring :boolean default(FALSE)
# scale_type :string not null
# closing_date :date not null
# challenge_id :bigint not null
# created_at :datetime not null
Expand All @@ -17,41 +17,75 @@
#
class EvaluationForm < ApplicationRecord
belongs_to :challenge
belongs_to :phase
belongs_to :phase, optional: true # Disables default must exist error message
has_many :evaluation_criteria, lambda {
order(:created_at)
}, class_name: 'EvaluationCriterion', dependent: :destroy, inverse_of: :evaluation_form
accepts_nested_attributes_for :evaluation_criteria, allow_destroy: true

enum :scale_type, { point: "point", weight: "weight" }, type: "string"

scope :by_user, lambda { |user|
joins(challenge: :challenge_manager_users).
where(challenge_manager_users: { id: user.id })
}

validates :title, presence: true, length: { maximum: 150 }
validates :instructions, presence: true
validates :scale_type, presence: true
validates :closing_date, presence: true

# Adds custom error message for phase presence failure instead of default from above
validates :phase, presence: { message: I18n.t("evaluation_form.phase.presence_error") }
validates :phase_id, uniqueness: true

validate :criteria_weights_must_sum_to_one_hundred
validate :validate_unique_criteria_titles

def weighted_scoring?
scale_type == "weight"
end

def point_scoring?
scale_type == "point"
end

private

def validate_unique_criteria_titles
titles = evaluation_criteria.reject(&:marked_for_destruction?).map(&:title)
duplicate_titles = find_duplicate_titles

return unless titles.uniq.length != titles.length
add_criteria_title_errors(duplicate_titles) unless duplicate_titles.empty?
end

def find_duplicate_titles
current_criteria_titles = evaluation_criteria.reject(&:marked_for_destruction?).map(&:title)
current_criteria_titles.select { |title| current_criteria_titles.count(title) > 1 }.uniq
end

def add_criteria_title_errors(duplicate_titles)
evaluation_criteria.reject(&:marked_for_destruction?).each do |criterion|
if duplicate_titles.include?(criterion.title)
criterion.errors.add(:title, I18n.t("evaluation_criteria.duplicate_title_error"))
end
end
errors.add(:base, I18n.t("evaluation_criterion_unique_title_in_form_error"))
end

def criteria_weights_must_sum_to_one_hundred
total_weight = evaluation_criteria.reject(&:marked_for_destruction?).sum(&:points_or_weight)
return unless weighted_scoring? && total_criteria_weight != 100

return unless weighted_scoring? && total_weight != 100
add_weight_errors
end

def total_criteria_weight
evaluation_criteria.reject(&:marked_for_destruction?).sum { |criteria| criteria.points_or_weight.to_i }
end

def add_weight_errors
evaluation_criteria.reject(&:marked_for_destruction?).each do |criteria|
criteria.errors.add("points_or_weight", I18n.t("evaluation_criteria.must_sum_to_100_error"))
end
errors.add(:base, I18n.t("evaluation_form_criteria_weight_total_error"))
end
end
Loading
Loading