Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def update_question(question_form, index)
body: question_form.body,
description: question_form.description,
question_type: question_form.question_type,
max_choices: question_form.max_choices,
position: index
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ def show
def update
enforce_permission_to(:create, :vote, election:)

response_ids = Array(params.dig(:response, question.id.to_s)).compact

if question.max_choices.present? && response_ids.size > question.max_choices
flash.now[:alert] = t("votes.question.max_choices_exceeded", scope: "decidim.elections", max: question.max_choices)
render :show
return
end

votes_buffer[question.id.to_s] = params.dig(:response, question.id.to_s)
redirect_to next_vote_step_path
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class QuestionForm < Decidim::Form

attribute :question_type, String, default: "multiple_option"
attribute :response_options, Array[Decidim::Elections::Admin::ResponseOptionForm]
attribute :max_choices, Integer
attribute :deleted, Boolean, default: false

translatable_attribute :body, String
Expand All @@ -18,6 +19,7 @@ class QuestionForm < Decidim::Form
validates :body, translatable_presence: true
validates :question_type, inclusion: { in: Decidim::Elections::Question.question_types }, if: :editable?
validates :response_options, presence: true, if: :editable?
validates :max_choices, numericality: { only_integer: true, greater_than: 1, less_than_or_equal_to: ->(form) { form.number_of_options } }, allow_blank: true

def election
@election ||= context[:election]
Expand All @@ -32,6 +34,10 @@ def to_param
def editable?
@editable ||= id.blank? || Decidim::Elections::Question.exists?(id:)
end

def number_of_options
response_options.size
end
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ def component_name

def question_title(question, tag = :h3, **options)
content_tag(tag, **options) do
translated_attribute(question.body)
title = translated_attribute(question.body)
if question.max_choices.present? && question.question_type == "multiple_option"
title += " (#{t("decidim.elections.votes.question.max_choices", count: question.max_choices)})"
end
title.html_safe
end
end

Expand Down
1 change: 1 addition & 0 deletions decidim-elections/app/models/decidim/elections/question.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def self.question_types
end

def max_votable_options
return max_choices if max_choices.present? && question_type == "multiple_option"
return response_options.size if question_type == "multiple_option"

1
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import "src/decidim/elections/elections.js"
import "src/decidim/elections/waiting_room.js"
import "src/decidim/elections/live_results_update.js";

Expand Down
32 changes: 32 additions & 0 deletions decidim-elections/app/packs/src/decidim/elections/elections.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
document.addEventListener("turbo:load", () => {
const responseContainers = document.querySelectorAll(".response[data-max-choices]");
if (!responseContainers.length) {
return;
}

responseContainers.forEach((container) => {
const maxChoices = parseInt(container.dataset.maxChoices, 10);
if (!maxChoices) {
return;
}

const checkboxes = container.querySelectorAll("input[type=checkbox]");
const alertElement = container.querySelector(".max-choices-alert");

const checkLimit = () => {
const checkedCount = container.querySelectorAll("input[type=checkbox]:checked").length;

if (checkedCount > maxChoices) {
alertElement.style.display = "block";
} else {
alertElement.style.display = "none";
}
};

checkboxes.forEach((checkbox) => {
checkbox.addEventListener("change", checkLimit);
});

checkLimit();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,17 @@
</div>
<% end %>
</div>

<div class="row column questionnaire-question-max-choices">
<%=
form.select(
:max_choices,
(2..question.number_of_options),
{ include_blank: t("any", scope: "decidim.forms.admin.questionnaires.question") },
disabled: !editable
)
%>
</div>
</div>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
<%= form_with url: url_for(action: :show, id: question), method: :patch, local: true do %>
<%= question_title(question, :h1, class: "h4 mb-8") %>

<div class="question__response-options mt-4 flex flex-col gap-4">
<div class="question__response-options mt-4 flex flex-col gap-4 response" data-max-choices="<%= question.max_choices if question.max_choices.presence %>">
<% question.response_options.each do |option| %>
<label class="rounded-lg border border-gray p-4">
<label class="rounded-lg border border-gray p-4 js-check-box-collection">
<%= render "decidim/elections/votes/responses/#{question.question_type}", option: %>
</label>
<% end %>

<% if question.max_choices.presence %>
<small class="form-error max-choices-alert" style="display:none;"><%= t("max_choices_alert", scope: "decidim.elections.votes.question") %></small>
<% end %>
</div>

<div class="vote-navigation flex justify-between mt-8">
Expand Down
5 changes: 5 additions & 0 deletions decidim-elections/config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
en:
activemodel:
attributes:
elections_question:
max_choices: Maximum number of choices
token_csv:
file: File
remove_all: Remove all current census data
Expand Down Expand Up @@ -303,6 +305,9 @@ en:
question:
back: Back
cast_vote: Cast vote
max_choices: 'Max choices: %{count}'
max_choices_alert: You have selected too many options. Please deselect some to continue.
max_choices_exceeded: You cannot select more than %{max} options. Please go back and adjust your selection.
next: Next
receipt:
description: Yo can vote again at any time while the voting period is open. Your previous vote will be overwritten by the new one.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class AddMaxChoicesToDecidimElectionsQuestions < ActiveRecord::Migration[7.2]
def change
add_column :decidim_elections_questions, :max_choices, :integer
end
end
10 changes: 7 additions & 3 deletions decidim-elections/lib/decidim/elections/test/factories.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@
end

trait :with_questions do
after :create do |election, _evaluator|
create_list(:election_question, 2, :with_response_options, :voting_enabled, election:)
after :create do |election, evaluator|
create_list(:election_question, 2, :with_response_options, :voting_enabled, election:, skip_injection: evaluator.skip_injection)
end
end

Expand All @@ -95,8 +95,12 @@
end

factory :election_question, class: "Decidim::Elections::Question" do
transient do
skip_injection { false }
end

association :election
body { generate_localized_title(:question_body) }
body { generate_localized_title(:question_body, skip_injection:) }
description { generate_localized_description(:question_description) }
question_type { "multiple_option" }
sequence(:position) { |n| n }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,67 @@ module Admin
end
end

context "when updating max_choices" do
let(:update_max_choices_params) do
{
"questions" => [
{
"id" => first_question.id,
"body" => first_question.body,
"description" => first_question.description,
"question_type" => "multiple_option",
"max_choices" => 2,
"response_options" => [
{ "id" => first_question_first_option.id, "body" => first_question_first_option.body },
{ "id" => first_question_second_option.id, "body" => first_question_second_option.body }
]
}
]
}
end

let(:form) { Decidim::Elections::Admin::QuestionsForm.from_params(update_max_choices_params).with_context(context_params) }
let(:command) { described_class.new(form, election) }

it "updates max_choices field" do
command.call
updated = election.reload.questions.find_by(id: first_question.id)
expect(updated.max_choices).to eq(2)
end
end

context "when adding a new question with max_choices" do
let(:add_with_max_choices_params) do
{
"questions" => [
{
"body" => { en: "Q with max choices" },
"description" => { en: "Description" },
"question_type" => "multiple_option",
"max_choices" => 3,
"response_options" => [
{ "body" => { en: "Option 1" } },
{ "body" => { en: "Option 2" } },
{ "body" => { en: "Option 3" } },
{ "body" => { en: "Option 4" } }
]
}
]
}
end

let(:form) { Decidim::Elections::Admin::QuestionsForm.from_params(add_with_max_choices_params).with_context(context_params) }
let(:command) { described_class.new(form, election) }

it "creates a new question with max_choices" do
expect { command.call }
.to change { election.reload.questions.count }.by(1)
new_question = election.reload.questions.last
expect(new_question.max_choices).to eq(3)
expect(new_question.response_options.size).to eq(4)
end
end

context "when updating, deleting, and adding at once" do
let(:combo_params) do
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,56 @@ module Elections
expect(session[:votes_buffer]).to eq({ question.id.to_s => nil, second_question.id.to_s => nil })
expect(response).to redirect_to(confirm_election_votes_path)
end

context "when question has max_choices limit" do
let!(:question_with_limit) do
create(:election_question, :voting_enabled, election:, question_type: "multiple_option", max_choices: 2)
end
let!(:option1) { create(:election_response_option, question: question_with_limit) }
let!(:option2) { create(:election_response_option, question: question_with_limit) }
let!(:option3) { create(:election_response_option, question: question_with_limit) }

it "rejects vote when exceeding max_choices" do
patch :update, params: params.merge(
id: question_with_limit.id,
response: {
question_with_limit.id.to_s => [option1.id, option2.id, option3.id]
}
)

expect(response).to have_http_status(:ok)
expect(flash[:alert]).to match(/cannot select more than 2/)
expect(subject).to render_template(:show)
end

it "accepts vote when within max_choices limit" do
session[:votes_buffer] = { question.id.to_s => nil, second_question.id.to_s => nil }

patch :update, params: params.merge(
id: question_with_limit.id,
response: {
question_with_limit.id.to_s => [option1.id, option2.id]
}
)

expect(session[:votes_buffer][question_with_limit.id.to_s]).to eq([option1.id.to_s, option2.id.to_s])
expect(response).to redirect_to(confirm_election_votes_path)
end

it "accepts vote with less than max_choices" do
session[:votes_buffer] = { question.id.to_s => nil, second_question.id.to_s => nil }

patch :update, params: params.merge(
id: question_with_limit.id,
response: {
question_with_limit.id.to_s => [option1.id]
}
)

expect(session[:votes_buffer][question_with_limit.id.to_s]).to eq([option1.id.to_s])
expect(response).to redirect_to(confirm_election_votes_path)
end
end
end
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,72 @@ module Admin
it { is_expected.not_to be_valid }
end

describe "max_choices validation" do
let(:attributes) do
{
body_en: body_en,
description_en: description_en,
question_type: question_type,
response_options: response_options,
max_choices: max_choices
}
end

context "when max_choices is valid" do
let(:max_choices) { 2 }

it { is_expected.to be_valid }
end

context "when max_choices is greater than number of options" do
let(:max_choices) { 10 }

it { is_expected.not_to be_valid }

it "adds an error on max_choices" do
subject.valid?
expect(subject.errors[:max_choices]).not_to be_empty
end
end

context "when max_choices is 1" do
let(:max_choices) { 1 }

it { is_expected.not_to be_valid }

it "adds an error on max_choices" do
subject.valid?
expect(subject.errors[:max_choices]).not_to be_empty
end
end

context "when max_choices is nil" do
let(:max_choices) { nil }

it { is_expected.to be_valid }
end

context "when max_choices is blank string" do
let(:max_choices) { "" }

it { is_expected.to be_valid }
end
end

describe "#number_of_options" do
it "returns the count of response options" do
expect(subject.number_of_options).to eq(2)
end

context "when there are no response options" do
let(:response_options) { {} }

it "returns 0" do
expect(subject.number_of_options).to eq(0)
end
end
end

it_behaves_like "form to param", default_id: "questionnaire-question-id"
end
end
Expand Down
Loading
Loading