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.number_field :points_or_weight, + id: criteria_field_id(f, "points_or_weight", is_template), + name: criteria_field_name(f, "points_or_weight", is_template), + class: "usa-input flex-1 margin-top-0 margin-right-2 font-sans-lg", + min: 1, + step: 1, + placeholder: "Add criteria points/weight here", + required: true, + disabled: is_template || form_disabled, + data: {"evaluation-criteria-target": "pointsOrWeightField"} + %> + +
+ <%= f.label :points_or_weight, "Criteria Points/Weight", + for: criteria_field_id(f, "points_or_weight", is_template), + class: "text-bold" + %> + + * + + <%= image_tag "images/usa-icons/help_outline.svg", + width: 16, height: 16, alt: "Help for criteria title", + class: "flex-0" + %> +
+
+ +
+ <%= f.label :scoring_type, "Scoring Type", for: criteria_field_id(f, "scoring_type", is_template), class: "font-sans-lg text-bold" %> + * + <%= image_tag "images/usa-icons/help_outline.svg", width: 16, height: 16, alt: "Help for criteria title" %> + + <% scoring_types = { "numeric" => "Numeric score / points", "rating" => "Rating scale", "binary" => "Binary scale" } %> +
+ <% scoring_types.each do |value, label| %> +
+ <%= f.radio_button :scoring_type, value, + id: criteria_field_id(f, "scoring_type_#{value}", is_template), + name: criteria_field_name(f, :scoring_type, is_template), + class: "usa-radio__input scoring-type-radio", + checked: f.object.scoring_type == value, + required: true, + disabled: is_template || form_disabled, + data: { + "evaluation-criteria-target": "scoringTypeRadio", + action: "click->evaluation-criteria#toggleScoringType" + } + %> + + <%= label_tag criteria_field_id(f, "scoring_type_#{value}", is_template), label, class: "usa-radio__label" %> +
+ <% end %> +
+
+ +
+ <%= 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:

+ + +
+ <% 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 %> +
+ +
+ + <%= 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 @@
  • - - -