diff --git a/decidim-admin/app/helpers/decidim/admin/settings_helper.rb b/decidim-admin/app/helpers/decidim/admin/settings_helper.rb index 025d27d26ba6f..34c22e27515ab 100644 --- a/decidim-admin/app/helpers/decidim/admin/settings_helper.rb +++ b/decidim-admin/app/helpers/decidim/admin/settings_helper.rb @@ -16,7 +16,8 @@ module SettingsHelper select: :select_field, scope: :scope_field, enum: :collection_radio_buttons, - time: :datetime_field + time: :datetime_field, + integer_with_units: :integer_with_units }.freeze # Renders a form field that matches a settings attribute's type. @@ -58,6 +59,8 @@ def settings_attribute_input(form, attribute, name, i18n_scope, options = {}) render_select_form_field(form, attribute, name, i18n_scope, options) elsif form_method == :scope_field scopes_select_field(form, name) + elsif form_method == :integer_with_units + integer_with_units(form, attribute, name, i18n_scope, options) else form.send(form_method, name, options) end @@ -165,6 +168,33 @@ def build_enum_choices(name, i18n_scope, choices) [t("#{name}_choices.#{choice}", scope: i18n_scope), choice] end end + + # Renders a form field that includes an integer input and a select dropdown for units. + # + # @param form (see #settings_attribute_input) + # @param attribute [Decidim::SettingsManifest::Attribute] The attribute to be rendered + # @param name (see #settings_attribute_input) + # @param i18n_scope (see #settings_attribute_input) + # @param options (see #settings_attribute_input) + # @option options [String] :label The label text for the field + # @return [ActiveSupport::SafeBuffer] Rendered form field + def integer_with_units(form, attribute, name, i18n_scope, options) + value = form.object.send(name) + + number_value = value[0].to_i + unit_value = value[1].to_s + + number_field_html = form.number_field(name, options.merge(label: false, + value: number_value, + name: "#{form.field_name(name)}[0]", + style: "flex: 0 0 25%;")) + select_field_html = form.select(name, + attribute.build_units.map { |unit| [t("#{name}_units.#{unit}", scope: i18n_scope), unit] }, + { label: false, value: unit_value }, + { name: "#{form.field_name(name)}[1]", style: "flex: 1 1 75%;" }) + + content_tag(:label, options[:label]) + content_tag(:div, number_field_html + select_field_html, class: "flex space-x-2 items-center") + end end end end diff --git a/decidim-admin/app/packs/src/decidim/admin/proposal_infinite_edit.js b/decidim-admin/app/packs/src/decidim/admin/proposal_infinite_edit.js index 836c3ad02378a..d0cc27c2a48fd 100644 --- a/decidim-admin/app/packs/src/decidim/admin/proposal_infinite_edit.js +++ b/decidim-admin/app/packs/src/decidim/admin/proposal_infinite_edit.js @@ -1,10 +1,7 @@ $(() => { - const $limitedTimeLabel = $("label[for='component_settings_proposal_edit_time_limited']") - const $limitedTimeRadioButton = $("#component_settings_proposal_edit_time_limited") - const $infiniteTimeRadioButton = $("#component_settings_proposal_edit_time_infinite") - const $editTimeContainer = $(".proposal_edit_before_minutes_container") - - $editTimeContainer.detach().appendTo($limitedTimeLabel) + const $limitedTimeRadioButton = $("#component_settings_proposal_edit_time_limited"); + const $infiniteTimeRadioButton = $("#component_settings_proposal_edit_time_infinite"); + const $editTimeContainer = $(".edit_time_container"); if ($infiniteTimeRadioButton.is(":checked")) { $editTimeContainer.hide(); diff --git a/decidim-core/lib/decidim/attributes.rb b/decidim-core/lib/decidim/attributes.rb index e6aa97ad99862..52251aad77f91 100644 --- a/decidim-core/lib/decidim/attributes.rb +++ b/decidim-core/lib/decidim/attributes.rb @@ -12,6 +12,7 @@ module Attributes autoload :Model, "decidim/attributes/model" autoload :Symbol, "decidim/attributes/symbol" autoload :Integer, "decidim/attributes/integer" + autoload :IntegerWithUnits, "decidim/attributes/integer_with_units" # Base types ActiveModel::Type.register(:array, Decidim::Attributes::Array) @@ -28,6 +29,7 @@ module Attributes ActiveModel::Type.register(:"decidim/attributes/localized_date", Decidim::Attributes::LocalizedDate) ActiveModel::Type.register(:"decidim/attributes/clean_string", Decidim::Attributes::CleanString) ActiveModel::Type.register(:"decidim/attributes/blob", Decidim::Attributes::Blob) + ActiveModel::Type.register(:"decidim/attributes/integer_with_units", Decidim::Attributes::IntegerWithUnits) ActiveModel::Type.register(:integer, Decidim::Attributes::Integer) end diff --git a/decidim-core/lib/decidim/attributes/integer_with_units.rb b/decidim-core/lib/decidim/attributes/integer_with_units.rb new file mode 100644 index 0000000000000..952e6952984e4 --- /dev/null +++ b/decidim-core/lib/decidim/attributes/integer_with_units.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Decidim + module Attributes + # Custom attributes value to represent an Integer with units. + class IntegerWithUnits < ActiveModel::Type::Value + def type + :"decidim/attributes/integer_with_units" + end + + def cast(value) + return nil if value.nil? + + case value + when ::Hash + [value["0"].to_i.abs, value["1"].to_s] + when ::Array + return value if value.size != 2 + + [value[0].to_i.abs, value[1].to_s] + else + value + end + end + end + end +end diff --git a/decidim-core/lib/decidim/settings_manifest.rb b/decidim-core/lib/decidim/settings_manifest.rb index e14d9afac8bfe..f656eca0b4020 100644 --- a/decidim-core/lib/decidim/settings_manifest.rb +++ b/decidim-core/lib/decidim/settings_manifest.rb @@ -70,6 +70,8 @@ def manifest validates name, presence: true if attribute.required validates name, inclusion: { in: attribute.build_choices } if attribute.type == :enum end + + SettingsManifest.add_integer_with_units_validation(self, name, attribute) if attribute.type == :integer_with_units end end @@ -77,6 +79,20 @@ def manifest @schema end + def self.add_integer_with_units_validation(schema, name, attribute) + schema.class_eval do + validate do + value = send(name) + value = [value["0"].to_i, value["1"].to_s] if value.is_a?(::Hash) + + errors.add(name, :invalid) unless value.is_a?(::Array) && + value.size == 2 && + value[0].is_a?(Integer) && + attribute.build_units.include?(value[1]) + end + end + end + def required_attributes_for_authorization attributes.select { |_, attribute| attribute.required_for_authorization? } end @@ -91,6 +107,7 @@ class Attribute TYPES = { boolean: { klass: Boolean, default: false }, integer: { klass: Integer, default: 0 }, + integer_with_units: { klass: Decidim::Attributes::IntegerWithUnits, default: [5, "minutes"] }, string: { klass: String, default: nil }, float: { klass: Float, default: nil }, text: { klass: String, default: nil }, @@ -111,9 +128,21 @@ class Attribute attribute :required_for_authorization, Boolean, default: false attribute :readonly attribute :choices + attribute :units + attribute :item_classes, Array, default: [] attribute :include_blank, Boolean, default: false validates :type, inclusion: { in: TYPES.keys } + validate :validate_integer_with_units_structure + + def validate_integer_with_units_structure + return unless type == :integer_with_units + + errors.add(:default, :invalid) unless default_value.is_a?(::Array) && + default_value.size == 2 && + default_value[0].is_a?(Integer) && + build_units.include?(default_value[1]) + end def type_class TYPES[type][:klass] @@ -127,6 +156,10 @@ def build_choices choices.try(:call) || choices end + def build_units + units.try(:call) || units + end + def readonly?(context) readonly&.call(context) end diff --git a/decidim-core/spec/lib/attributes/integer_with_units_spec.rb b/decidim-core/spec/lib/attributes/integer_with_units_spec.rb new file mode 100644 index 0000000000000..f02b046614ed3 --- /dev/null +++ b/decidim-core/spec/lib/attributes/integer_with_units_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + describe Attributes::IntegerWithUnits do + describe "#cast" do + subject { described_class.new.cast(value) } + + describe "#cast" do + context "when given nil" do + let(:value) { nil } + + it "returns nil" do + expect(subject).to be_nil + end + end + + context "when given a Hash" do + let(:value) { { "0" => "5", "1" => "minutes" } } + + it "returns an array with the integer and the unit" do + expect(subject).to eq([5, "minutes"]) + end + + context "when the integer is negative" do + let(:value) { { "0" => "-5", "1" => "minutes" } } + + it "returns an array with the absolute value of the integer and the unit" do + expect(subject).to eq([5, "minutes"]) + end + end + end + + context "when given an Array" do + let(:value) { %w(5 minutes) } + + it "returns an array with the integer and the unit" do + expect(subject).to eq([5, "minutes"]) + end + + context "when the array size is not 2" do + let(:value) { ["5"] } + + it "returns the original value" do + expect(subject).to eq(["5"]) + end + end + + context "when the integer is negative" do + let(:value) { %w(-5 minutes) } + + it "returns an array with the absolute value of the integer and the unit" do + expect(subject).to eq([5, "minutes"]) + end + end + end + + context "when given other values" do + let(:value) { "some string" } + + it "returns the original value" do + expect(subject).to eq("some string") + end + end + end + end + end +end diff --git a/decidim-proposals/app/models/decidim/proposals/proposal.rb b/decidim-proposals/app/models/decidim/proposals/proposal.rb index c760e868e0d63..eeb3b4846d0de 100644 --- a/decidim-proposals/app/models/decidim/proposals/proposal.rb +++ b/decidim-proposals/app/models/decidim/proposals/proposal.rb @@ -476,8 +476,18 @@ def within_edit_time_limit? return true if draft? return true if component.settings.proposal_edit_time == "infinite" - limit = updated_at + component.settings.proposal_edit_before_minutes.minutes - Time.current < limit + time_value, time_unit = component.settings.edit_time + + limit_time = case time_unit + when "minutes" + updated_at + time_value.minutes + when "hours" + updated_at + time_value.hours + else + updated_at + time_value.days + end + + Time.current < limit_time end def process_amendment_state_change! diff --git a/decidim-proposals/config/locales/en.yml b/decidim-proposals/config/locales/en.yml index d84751cd718f9..ae6430e24a600 100644 --- a/decidim-proposals/config/locales/en.yml +++ b/decidim-proposals/config/locales/en.yml @@ -171,6 +171,11 @@ en: random: Random recent: Recent with_more_authors: With more authors + edit_time: Proposals can be edited by authors before this much time passes + edit_time_units: + days: Days + hours: Hours + minutes: Minutes geocoding_enabled: Geocoding enabled minimum_votes_per_user: Minimum votes per user new_proposal_body_template: New proposal body template @@ -180,11 +185,14 @@ en: participatory_texts_enabled: Participatory texts enabled participatory_texts_enabled_readonly: Cannot interact with this setting if there are existing proposals. Please, create a new `Proposals component` if you want to enable this feature or discard all imported proposals in the `Participatory Texts` menu if you want to disable it. proposal_answering_enabled: Proposal answering enabled - proposal_edit_before_minutes: Proposals can be edited by authors before this many minutes passes proposal_edit_time: Proposal editing proposal_edit_time_choices: infinite: Allow editing proposals for an infinite amount of time limited: Allow editing of proposals within a specific timeframe + proposal_edit_time_unit_options: + days: Days + hours: Hours + minutes: Minutes proposal_length: Maximum proposal body length proposal_limit: Proposal limit per participant proposal_wizard_step_1_help_text: Proposal wizard "Create" step help text diff --git a/decidim-proposals/lib/decidim/proposals/component.rb b/decidim-proposals/lib/decidim/proposals/component.rb index 427f795346491..7d218f7f9c272 100644 --- a/decidim-proposals/lib/decidim/proposals/component.rb +++ b/decidim-proposals/lib/decidim/proposals/component.rb @@ -37,8 +37,8 @@ settings.attribute :minimum_votes_per_user, type: :integer, default: 0, required: true settings.attribute :proposal_limit, type: :integer, default: 0, required: true settings.attribute :proposal_length, type: :integer, default: 500 - settings.attribute :proposal_edit_time, type: :enum, default: "limited", choices: -> { %w(limited infinite) } - settings.attribute :proposal_edit_before_minutes, type: :integer, default: 5, required: true + settings.attribute :proposal_edit_time, type: :enum, default: "limited", choices: -> { %w(infinite limited) } + settings.attribute :edit_time, type: :integer_with_units, default: [5, "minutes"], required: true, units: %w(minutes hours days) settings.attribute :threshold_per_proposal, type: :integer, default: 0, required: true settings.attribute :can_accumulate_votes_beyond_threshold, type: :boolean, default: false settings.attribute :proposal_answering_enabled, type: :boolean, default: true diff --git a/decidim-proposals/spec/lib/decidim/proposals/component_spec.rb b/decidim-proposals/spec/lib/decidim/proposals/component_spec.rb index 937d99f74db49..84586799e67c3 100644 --- a/decidim-proposals/spec/lib/decidim/proposals/component_spec.rb +++ b/decidim-proposals/spec/lib/decidim/proposals/component_spec.rb @@ -145,8 +145,8 @@ it_behaves_like "has mandatory config setting", :minimum_votes_per_user end - context "when proposal_edit_before_minutes is empty" do - it_behaves_like "has mandatory config setting", :proposal_edit_before_minutes + context "when proposal_edit_time is empty" do + it_behaves_like "has mandatory config setting", :edit_time end context "when comments_max_length is empty" do diff --git a/decidim-proposals/spec/system/proposal_editing_time_spec.rb b/decidim-proposals/spec/system/proposal_editing_time_spec.rb new file mode 100644 index 0000000000000..b9de82f8db782 --- /dev/null +++ b/decidim-proposals/spec/system/proposal_editing_time_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "editing a proposal" do + include_context "with a component" + let!(:author) { create(:user, :confirmed, organization: component.organization) } + let(:manifest_name) { "proposals" } + let!(:scope) { create(:scope, organization:) } + let(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let(:proposal) { create(:proposal, component:, users: [author]) } + let(:component) do + create(:proposal_component, + :with_creation_enabled, + manifest:, + organization:, + participatory_space: participatory_process, + settings: { + edit_time:, + proposal_edit_time: "limited" + }) + end + + before do + freeze_time + login_as(author, scope: :user) + travel_to(proposal.updated_at + check_time) + visit_component + click_on(translated(proposal.title)) + end + + context "when the edit time is within the limit" do + let!(:edit_time) { [10, "minutes"] } + let(:check_time) { 6.minutes } + + it "shows the edit button" do + expect(page).to have_link("Edit") + end + end + + context "when the edit time has passed" do + let(:edit_time) { [11, "minutes"] } + let(:check_time) { 12.minutes } + + it "does not show the edit button" do + expect(page).to have_no_link("Edit") + end + end + + context "when the edit time is set to hours" do + let(:edit_time) { [1, "hours"] } + let(:check_time) { 30.minutes } + + it "shows the edit button" do + expect(page).to have_link("Edit") + end + + context "and the time has passed" do + let(:check_time) { 2.hours } + + it "does not show the edit button" do + expect(page).to have_no_link("Edit") + end + end + end + + context "when the edit time is set to days" do + let(:edit_time) { [1, "days"] } + let(:check_time) { 12.hours } + + it "shows the edit button" do + expect(page).to have_link("Edit") + end + + context "and the time has passed" do + let(:check_time) { 2.days } + + it "does not show the edit button" do + expect(page).to have_no_link("Edit") + end + end + end +end