diff --git a/Gemfile.lock b/Gemfile.lock
index d5a0a023..3592b0c6 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -131,7 +131,7 @@ GEM
erubi (1.13.0)
factory_bot (6.5.0)
activesupport (>= 5.0.0)
- faker (3.4.2)
+ faker (3.5.1)
i18n (>= 1.8.11, < 2)
faraday (2.12.0)
faraday-net_http (>= 2.0, < 3.4)
@@ -173,7 +173,7 @@ GEM
method_source (1.1.0)
mini_mime (1.1.5)
minitest (5.25.1)
- msgpack (1.7.2)
+ msgpack (1.7.3)
net-http (0.4.1)
uri
net-imap (0.5.0)
@@ -266,13 +266,13 @@ GEM
regexp_parser (2.9.2)
reline (0.5.10)
io-console (~> 0.5)
- rexml (3.3.8)
- rspec-core (3.13.1)
+ rexml (3.3.9)
+ rspec-core (3.13.2)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.3)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
- rspec-mocks (3.13.1)
+ rspec-mocks (3.13.2)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (7.0.1)
@@ -303,14 +303,14 @@ GEM
rubocop-performance (1.22.1)
rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
- rubocop-rails (2.26.2)
+ rubocop-rails (2.27.0)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.52.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rake (0.6.0)
rubocop (~> 1.0)
- rubocop-rspec (3.1.0)
+ rubocop-rspec (3.2.0)
rubocop (~> 1.61)
ruby-progressbar (1.13.0)
rubyzip (2.3.2)
diff --git a/app/assets/builds/.keep b/app/assets/builds/.keep
deleted file mode 100644
index e69de29b..00000000
diff --git a/app/assets/stylesheets/application.sass.scss b/app/assets/stylesheets/application.sass.scss
index fa5d1ded..1cf4bba4 100644
--- a/app/assets/stylesheets/application.sass.scss
+++ b/app/assets/stylesheets/application.sass.scss
@@ -9,3 +9,7 @@ dialog::backdrop {
background-color: black;
border-color: black;
}
+
+#add-criteria-button.usa-button {
+ background-color: #4d8055;
+}
diff --git a/app/controllers/evaluation_forms_controller.rb b/app/controllers/evaluation_forms_controller.rb
index a1044101..d8c5fed0 100644
--- a/app/controllers/evaluation_forms_controller.rb
+++ b/app/controllers/evaluation_forms_controller.rb
@@ -26,7 +26,7 @@ def create
respond_to do |format|
if @evaluation_form.save
format.html do
- redirect_to evaluation_forms_url, notice: I18n.t("evaluation_form_saved")
+ redirect_to evaluation_forms_confirmation_path, notice: I18n.t("evaluation_form_saved")
end
format.json { render :show, status: :created, location: @evaluation_form }
else
@@ -41,7 +41,7 @@ def update
respond_to do |format|
if @evaluation_form.update(evaluation_form_params)
format.html do
- redirect_to evaluation_forms_url, notice: I18n.t("evaluation_form_saved")
+ redirect_to evaluation_forms_confirmation_path, notice: I18n.t("evaluation_form_saved")
end
format.json { render :show, status: :ok, location: @evaluation_form }
else
@@ -61,6 +61,9 @@ def destroy
end
end
+ # GET /evaluation_forms/confirmation
+ def confirmation; end
+
private
# Use callbacks to share common setup or constraints between actions.
@@ -76,7 +79,12 @@ def set_evaluation_forms
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)
+ :weighted_scoring, :publication_date, :closing_date, :challenge_id,
+ evaluation_criteria_attributes: [
+ :id, :title, :description, :points_or_weight, :scoring_type,
+ :option_range_start, :option_range_end, :_destroy,
+ { option_labels: {} }
+ ])
closing_date = parse_closing_date(permitted[:closing_date])
closing_date ? permitted.merge({ closing_date: }) : permitted
end
diff --git a/app/helpers/evaluation_forms_helper.rb b/app/helpers/evaluation_forms_helper.rb
index 2e28f5aa..ed34b279 100644
--- a/app/helpers/evaluation_forms_helper.rb
+++ b/app/helpers/evaluation_forms_helper.rb
@@ -14,6 +14,26 @@ def inline_error(evaluation_form, field)
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"
+
+ if is_template
+ "#{prefix}_NEW_CRITERIA_#{attribute}"
+ else
+ "#{prefix}_#{form.options[:child_index]}_#{attribute}"
+ end
+ end
+
+ def criteria_field_name(form, attribute, is_template)
+ prefix = "evaluation_form[evaluation_criteria_attributes]"
+
+ if is_template
+ "#{prefix}[NEW_CRITERIA][#{attribute}]"
+ else
+ "#{prefix}[#{form.options[:child_index]}][#{attribute}]"
+ end
+ end
+
def eval_form_disabled?(evaluation_form)
evaluation_form.valid? && evaluation_form.phase.end_date < Time.zone.today
end
diff --git a/app/javascript/application.js b/app/javascript/application.js
index 25c2e08d..6ec9251b 100644
--- a/app/javascript/application.js
+++ b/app/javascript/application.js
@@ -1 +1 @@
-import "./controllers"
+import "./controllers";
diff --git a/app/javascript/controllers/evaluation_criteria_controller.js b/app/javascript/controllers/evaluation_criteria_controller.js
new file mode 100644
index 00000000..5bc183a1
--- /dev/null
+++ b/app/javascript/controllers/evaluation_criteria_controller.js
@@ -0,0 +1,205 @@
+// app/javascript/controllers/evaluation_criteria_controller.js
+import { Controller } from "@hotwired/stimulus";
+
+export default class extends Controller {
+ static targets = ["criteriaList", "template", "criteriaRow"];
+
+ connect() {
+ this.counter = this.criteriaRowTargets.length;
+ }
+
+ addCriteria() {
+ this.counter++;
+ const newCriteria = this.templateTarget.cloneNode(true);
+
+ this.replacePlaceholders(newCriteria);
+ this.enableInputs(newCriteria);
+
+ this.criteriaListTarget.appendChild(newCriteria);
+
+ this.updateCriteriaTitles();
+ }
+
+ removeCriteria(event) {
+ const row = event.target.closest(".criteria-row");
+ const destroyField = row.querySelector(".destroy-evaluation-criteria");
+
+ if (destroyField) {
+ row.style.display = "none";
+ destroyField.value = "true";
+ this.disableInputs(row);
+ } else {
+ row.remove();
+ }
+
+ if (this.visibleRows().length == 0) {
+ return this.addCriteria();
+ }
+
+ this.updateCriteriaTitles();
+ }
+
+ toggleScoringType(event) {
+ const row = event.target.closest(".criteria-row");
+ const scoringType = row.querySelector(".scoring-type-radio:checked").value;
+
+ this.updateScoringOptions(row, scoringType);
+ }
+
+ toggleOptionRange(event) {
+ const row = event.target.closest(".criteria-row");
+ const start = row.querySelector(
+ ".option-range-select.option-range-start"
+ ).value;
+ const end = row.querySelector(
+ ".option-range-select.option-range-end"
+ ).value;
+
+ this.toggleOptionLabels(row, start, end);
+ }
+
+ replacePlaceholders(newCriteria) {
+ newCriteria.setAttribute("data-evaluation-criteria-target", "criteriaRow");
+ newCriteria.style.display = "block";
+ newCriteria.removeAttribute("id");
+
+ let accordionButton = newCriteria.querySelector(".usa-accordion__button");
+ let accordionContent = newCriteria.querySelector(".usa-accordion__content");
+
+ let accordionId = accordionContent
+ .getAttribute("id")
+ .replace("NEW_CRITERIA", this.counter);
+
+ accordionButton.setAttribute("aria-controls", accordionId);
+ accordionContent.setAttribute("id", accordionId);
+
+ newCriteria.querySelectorAll("[id]").forEach((el) => {
+ el.id = el.id.replace("NEW_CRITERIA", this.counter);
+ });
+
+ newCriteria.querySelectorAll("[name]").forEach((el) => {
+ el.name = el.name.replace("NEW_CRITERIA", this.counter);
+ });
+
+ newCriteria.querySelectorAll("label").forEach((label) => {
+ label.setAttribute(
+ "for",
+ label.getAttribute("for").replace("NEW_CRITERIA", this.counter)
+ );
+ });
+ }
+
+ updateCriteriaTitles() {
+ this.visibleRows().forEach((row, index) => {
+ const numberElement = row.querySelector(".criteria-number");
+ numberElement.textContent = index + 1;
+ });
+ }
+
+ updateScoringOptions(row, scoringType) {
+ const options = {
+ scaleOptions: row.querySelector(".criteria-scale-options"),
+ binaryOptions: row.querySelector(".criteria-binary-options"),
+ ratingOptions: row.querySelector(".criteria-rating-options"),
+ scaleOptionLabels: row.querySelector(".criteria-scale-option-labels"),
+ };
+
+ switch (scoringType) {
+ case "binary":
+ this.showBinaryOptions(options);
+ this.toggleOptionLabels(row, 0, 1);
+ break;
+ case "rating":
+ this.showRatingOptions(row, options);
+ break;
+ default:
+ this.hideAllOptions(options);
+ break;
+ }
+ }
+
+ showBinaryOptions(options) {
+ options.scaleOptions.style.display = "block";
+ options.binaryOptions.style.display = "block";
+ options.ratingOptions.style.display = "none";
+ this.enableInputs(options.binaryOptions);
+ this.disableInputs(options.ratingOptions);
+ }
+
+ showRatingOptions(row, options) {
+ options.scaleOptions.style.display = "block";
+ options.binaryOptions.style.display = "none";
+ options.ratingOptions.style.display = "block";
+ this.enableInputs(options.ratingOptions);
+ this.disableInputs(options.binaryOptions);
+ const start = parseInt(
+ row.querySelector(".option-range-select.option-range-start").value
+ );
+ const end = parseInt(
+ row.querySelector(".option-range-select.option-range-end").value
+ );
+ this.toggleOptionLabels(row, start, end);
+ }
+
+ hideAllOptions(options) {
+ options.scaleOptions.style.display = "none";
+ options.binaryOptions.style.display = "none";
+ options.ratingOptions.style.display = "none";
+ this.disableInputs(options.binaryOptions);
+ this.disableInputs(options.ratingOptions);
+ this.disableInputs(options.scaleOptionLabels);
+ }
+
+ toggleOptionLabels(row, start, end) {
+ row
+ .querySelectorAll(".criteria-option-label-row")
+ .forEach((labelRow, index) => {
+ labelRow.style.display =
+ index >= start && index <= end ? "flex" : "none";
+ const input = labelRow.querySelector("input");
+ input.disabled = index < start || index > end;
+ });
+ }
+
+ disableInputs(container) {
+ container.querySelectorAll("input, select, textarea").forEach((input) => {
+ if (input.type != "hidden") {
+ input.disabled = true;
+ }
+ });
+ }
+
+ enableInputs(container) {
+ container.querySelectorAll("input, select, textarea").forEach((input) => {
+ input.disabled = false;
+ });
+ }
+
+ visibleRows() {
+ return this.criteriaRowTargets.filter(
+ (row) => row.style.display !== "none"
+ );
+ }
+
+ validateInputs(event) {
+ const section = document.getElementById(
+ event.target
+ .closest(".usa-accordion__button")
+ .getAttribute("aria-controls")
+ );
+
+ if (this.checkRequiredFields(section)) {
+ return true;
+ } else {
+ event.preventDefault();
+ event.stopPropagation();
+ return false;
+ }
+ }
+
+ checkRequiredFields(section) {
+ return Array.from(section.querySelectorAll("[required]")).every((field) =>
+ field.reportValidity()
+ );
+ }
+}
diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js
index b752d4cc..677fbf1f 100644
--- a/app/javascript/controllers/index.js
+++ b/app/javascript/controllers/index.js
@@ -2,8 +2,9 @@
// Run that command whenever you add a new controller or create them with
// ./bin/rails generate stimulus controllerName
-import { application } from "./application"
-
-import EvaluationFormController from "./evaluation_form_controller"
-application.register("evaluation-form", EvaluationFormController)
+import { application } from "./application";
+import EvaluationFormController from "./evaluation_form_controller";
+import EvaluationCriteriaController from "./evaluation_criteria_controller";
+application.register("evaluation-form", EvaluationFormController);
+application.register("evaluation-criteria", EvaluationCriteriaController);
diff --git a/app/models/evaluation_criterion.rb b/app/models/evaluation_criterion.rb
index e6716122..922fb813 100644
--- a/app/models/evaluation_criterion.rb
+++ b/app/models/evaluation_criterion.rb
@@ -29,12 +29,12 @@ class EvaluationCriterion < ApplicationRecord
enum :scoring_type, { numeric: 0, rating: 1, binary: 2 }
attribute :option_range_start, :integer
attribute :option_range_end, :integer
- attribute :option_labels, :json, default: -> { [] }
+ attribute :option_labels, :json, default: -> { {} }
attribute :evaluation_form_id, :integer
# Validations
validates :title, :description, :points_or_weight, presence: true
+ validates :title, length: { maximum: 150 }
+ validates :description, length: { maximum: 1000 }
validates :points_or_weight, numericality: { only_integer: true }
- validates :title,
- uniqueness: { scope: :evaluation_form_id, message: I18n.t("evaluation_criterion_unique_title_in_form") }
end
diff --git a/app/models/evaluation_form.rb b/app/models/evaluation_form.rb
index 49a4a7be..01e544b0 100644
--- a/app/models/evaluation_form.rb
+++ b/app/models/evaluation_form.rb
@@ -18,7 +18,10 @@
class EvaluationForm < ApplicationRecord
belongs_to :challenge
belongs_to :phase
- has_many :evaluation_criteria, dependent: :destroy, class_name: "EvaluationCriterion"
+ 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
scope :by_user, lambda { |user|
joins(challenge: :challenge_manager_users).
@@ -28,4 +31,23 @@ class EvaluationForm < ApplicationRecord
validates :title, presence: true, length: { maximum: 150 }
validates :instructions, presence: true
validates :closing_date, presence: true
+
+ validate :criteria_weights_must_sum_to_one_hundred, if: :weighted_scoring?
+ validate :validate_unique_criteria_titles
+
+ def validate_unique_criteria_titles
+ titles = evaluation_criteria.reject(&:marked_for_destruction?).map(&:title)
+
+ return unless titles.uniq.length != titles.length
+
+ 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 total_weight != 100
+
+ errors.add(:base, I18n.t("evaluation_form_criteria_weight_total_error"))
+ end
end
diff --git a/app/views/evaluation_forms/_evaluation_criterion_fields.html.erb b/app/views/evaluation_forms/_evaluation_criterion_fields.html.erb
new file mode 100644
index 00000000..793bf1ff
--- /dev/null
+++ b/app/views/evaluation_forms/_evaluation_criterion_fields.html.erb
@@ -0,0 +1,185 @@
+
+
+
+
+" class="usa-accordion__content" data-evaluation-criteria-target="accordionContent">
+ <% if f.object.persisted? %>
+ <%= f.hidden_field :id, value: f.object.id %>
+ <%= f.hidden_field :_destroy, class: "destroy-evaluation-criteria", data: {"evaluation-criteria-target": "destroyField"} %>
+ <% end %>
+
+
+ <%= f.label :title, "Criteria title", for: criteria_field_id(f, "title", is_template), class: "text-bold" %>
+ *
+ <%= image_tag "images/usa-icons/help_outline.svg", width: 16, height: 16, alt: "Help for criteria title" %>
+
+ <%= f.text_field :title,
+ id: criteria_field_id(f, "title", is_template),
+ name: criteria_field_name(f, "title", is_template),
+ class: "usa-input",
+ placeholder: "Add criteria title here",
+ maxlength: 150,
+ required: true,
+ disabled: is_template || form_disabled,
+ data: {"evaluation-criteria-target": "titleField"}
+ %>
+
+
+
+
+ <%= f.label :description, "Criteria description", for: criteria_field_id(f, "description", is_template), class: "text-bold" %>
+ *
+ <%= image_tag "images/usa-icons/help_outline.svg", width: 16, height: 16, alt: "Help for criteria title" %>
+
+ <%= f.text_area :description,
+ id: criteria_field_id(f, "description", is_template),
+ name: criteria_field_name(f, "description", is_template),
+ class: "usa-textarea usa-character-count__field",
+ placeholder: "Add criteria description here",
+ maxlength: 1000,
+ required: true,
+ disabled: is_template || form_disabled,
+ data: {"evaluation-criteria-target": "descriptionField"}
+ %>
+
+
You can enter up to 1000 characters
+
+
+
+
+
+
+
+ <%= f.label :rating_scale_options, "Rating Scale Options", for: criteria_field_id(f, "rating_scale_options", is_template), class: "text-bold" %>
+
+
+ <%= f.hidden_field :option_range_start,
+ id: criteria_field_id(f, "option_range_start", is_template),
+ name: criteria_field_name(f, "option_range_start", is_template),
+ class: "option-range-start",
+ disabled: !f.object.binary? || form_disabled,
+ value: 0
+ %>
+ <%= f.hidden_field :option_range_end,
+ id: criteria_field_id(f, "option_range_end", is_template),
+ name: criteria_field_name(f, "option_range_end", is_template),
+ class: "option-range-end",
+ disabled: !f.object.binary? || form_disabled,
+ value: 1,
+ data: {"evaluation-criteria-target": "hiddenOptionRangeEnd"}
+ %>
+
+
+
+
+ <%= f.select :option_range_start, options_for_select((0..1).to_a, f.object.option_range_start), {},
+ id: criteria_field_id(f, "option_range_start", is_template),
+ name: criteria_field_name(f, "option_range_start", is_template),
+ class: "usa-select margin-0 height-auto width-auto font-sans-md text-bold option-range-select option-range-start",
+ disabled: !f.object.rating? || form_disabled,
+ include_blank: true,
+ data: { action: "change->evaluation-criteria#toggleOptionRange" }
+ %>
+ to
+ <%= f.select :option_range_end, options_for_select((2..10).to_a, f.object.option_range_end), {},
+ id: criteria_field_id(f, "option_range_end", is_template),
+ name: criteria_field_name(f, "option_range_end", is_template),
+ class: "usa-select margin-0 height-auto width-auto font-sans-md text-bold option-range-select option-range-end",
+ disabled: !f.object.rating? || form_disabled,
+ include_blank: true,
+ data: { action: "change->evaluation-criteria#toggleOptionRange" }
+ %>
+
+
+
+
+ <% (0..10).each do |index| %>
+ <% disabledOptionLabel = is_template || f.object.numeric? || ((f.object.option_range_start && index < f.object.option_range_start) || (f.object.option_range_end && index > f.object.option_range_end)) %>
+
+
+ <%= label_tag criteria_field_id(f, "option_labels", is_template) ++ "_#{index}", index, class: "font-sans-lg text-bold text-base-light" %>
+
+ <%= f.text_field "option_labels",
+ id: criteria_field_id(f, "option_labels", is_template) ++ "_#{index}",
+ name: criteria_field_name(f, "option_labels", is_template) ++ "[#{index}]",
+ class: "usa-input margin-0",
+ value: f.object.option_labels[index.to_s],
+ required: true,
+ disabled: disabledOptionLabel || form_disabled,
+ data: {"evaluation-criteria-target": "optionLabelInput"}
+ %>
+
+ <% end %>
+
+
+
+ <% if !form_disabled %>
+
+ <%= button_tag type: "button", class: "delete-criteria-button usa-button usa-button--unstyled", title: "Delete criteria", data: {action: "click->evaluation-criteria#removeCriteria"} do %>
+ Remove criteria
+ <% end %>
+
+ <% end %>
+
diff --git a/app/views/evaluation_forms/_form.html.erb b/app/views/evaluation_forms/_form.html.erb
index c8ffc401..c5ec96b7 100644
--- a/app/views/evaluation_forms/_form.html.erb
+++ b/app/views/evaluation_forms/_form.html.erb
@@ -1,4 +1,15 @@
<%= form_with(model: evaluation_form, data: { controller: "evaluation-form" }) do |form| %>
+ <% if evaluation_form.errors.any? %>
+
+
<%= pluralize(evaluation_form.errors.count, "error") %> prohibited this evaluation form from being saved:
+
+
+ <% evaluation_form.errors.each do |error| %>
+ - <%= error.full_message %>
+ <% end %>
+
+
+ <% end %>
<%
if evaluation_form.challenge && evaluation_form.phase
challenge = evaluation_form.challenge
@@ -80,6 +91,43 @@
+
+
+
+ <% if evaluation_form.evaluation_criteria.empty? %>
+ <%= form.fields_for :evaluation_criteria, EvaluationCriterion.new do |ec| %>
+
+ <%= render partial: 'evaluation_criterion_fields', locals: { f: ec, is_template: false, form_disabled: false } %>
+
+ <% end %>
+ <% else %>
+ <%= form.fields_for :evaluation_criteria, include_id: false do |ec| %>
+
" data-evaluation-criteria-target="criteriaRow">
+ <%= render partial: 'evaluation_criterion_fields', locals: { f: ec, is_template: false, form_disabled: disabled } %>
+
+ <% end %>
+ <% end %>
+
+
+ <% if !disabled %>
+
+
+ <%= form.fields_for :evaluation_criteria, EvaluationCriterion.new do |ec| %>
+ <%= render partial: 'evaluation_criterion_fields', locals: { f: ec, is_template: true, form_disabled: disabled } %>
+ <% end %>
+
+
+
+ <%= button_tag type: "button", id: "add-criteria-button", class: "usa-button width-full display-flex flex-column", title: "Add another criteria", data: {action: "click->evaluation-criteria#addCriteria"} do %>
+ <%= image_tag(
+ "images/usa-icons/add_circle.svg",
+ class: "usa-icon icon-white circle-4 margin-bottom-1",
+ alt: "Add criteria plus icon"
+ )%>
+
Add another criteria
+ <% end %>
+ <% end %>
+
Evaluation Period
@@ -109,9 +157,6 @@
-
-
-