diff --git a/app/events/decidim/initiatives/answer_initiative_event.rb b/app/events/decidim/initiatives/answer_initiative_event.rb
new file mode 100644
index 0000000000..69940dec2e
--- /dev/null
+++ b/app/events/decidim/initiatives/answer_initiative_event.rb
@@ -0,0 +1,8 @@
+# frozen-string_literal: true
+
+module Decidim
+ module Initiatives
+ class AnswerInitiativeEvent < Decidim::Events::SimpleEvent
+ end
+ end
+end
diff --git a/config/application.rb b/config/application.rb
index 0872b7afb3..ac2b3c6f06 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -47,6 +47,8 @@ class Application < Rails::Application
require "extends/controllers/decidim/devise/sessions_controller_extends"
require "extends/controllers/decidim/editor_images_controller_extends"
require "extends/services/decidim/iframe_disabler_extends"
+ require "extends/helpers/decidim/icon_helper_extends"
+ require "extends/commands/decidim/initiatives/admin/update_initiative_answer_extends"
Decidim::GraphiQL::Rails.config.tap do |config|
config.initial_query = "{\n deployment {\n version\n branch\n remote\n upToDate\n currentCommit\n latestCommit\n locallyModified\n }\n}".html_safe
diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml
index befbb25417..89d9ee47b4 100644
--- a/config/i18n-tasks.yml
+++ b/config/i18n-tasks.yml
@@ -127,4 +127,6 @@ ignore_unused:
- decidim.proposals.collaborative_drafts.new.*
- decidim.admin.menu.admin_accountability
- decidim.anonymous_user
+ - decidim.events.initiatives.initiative_answered.*
- decidim.initiatives.pages.home.highlighted_initiatives.*
+
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 25bdb8f15d..543840c3ce 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -48,6 +48,12 @@ en:
email_outro: You have received this notification because you are participating in "%{participatory_space_title}"
email_subject: Your vote is still pending in %{participatory_space_title}
notification_title: The vote on budget %{resource_title} is still waiting for your confirmation in %{participatory_space_title}
+ initiatives:
+ initiative_answered:
+ email_intro: The initiative "%{resource_title}" has been answered.
+ email_outro: You have received this notification because you are following the initiative "%{resource_title}".
+ email_subject: Initiative "%{resource_title}" has been answered
+ notification_title: The initiative %{resource_title} has been answered.
users:
user_officialized:
email_intro: Participant %{name} (%{nickname}) has been officialized.
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index 92b7b6c56e..93f277f432 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -50,6 +50,12 @@ fr:
email_outro: Vous avez reçu cette notification parce que vous avez commencé à voter sur la concertation "%{participatory_space_title}"
email_subject: Votre vote est toujours en attente sur la concertation %{participatory_space_title}
notification_title: Votre vote pour le budget %{resource_title} attend d'être finalisé sur la concertation %{participatory_space_title}
+ initiatives:
+ initiative_answered:
+ email_intro: La pétition "%{resource_title}" a reçu une réponse.
+ email_outro: Vous avez reçu cette notification parce que vous suivez la pétition "%{resource_title}".
+ email_subject: La pétition "%{resource_title}" a reçu une réponse.
+ notification_title: La pétition %{resource_title} a reçu une réponse.
users:
user_officialized:
email_intro: Le participant %{name} (%{nickname}) a été officialisé.
diff --git a/lib/extends/commands/decidim/initiatives/admin/update_initiative_answer_extends.rb b/lib/extends/commands/decidim/initiatives/admin/update_initiative_answer_extends.rb
new file mode 100644
index 0000000000..aced25d9b7
--- /dev/null
+++ b/lib/extends/commands/decidim/initiatives/admin/update_initiative_answer_extends.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module UpdateInitiativeAnswerExtends
+ def call
+ return broadcast(:invalid) if form.invalid?
+
+ @initiative = Decidim.traceability.update!(
+ initiative,
+ current_user,
+ attributes
+ )
+ notify_initiative_is_extended if @notify_extended
+ notify_initiative_is_answered if @notify_answered
+ broadcast(:ok, initiative)
+ rescue ActiveRecord::RecordInvalid
+ broadcast(:invalid, initiative)
+ end
+
+ private
+
+ def attributes
+ attrs = {
+ answer: form.answer,
+ answer_url: form.answer_url
+ }
+
+ attrs[:answered_at] = Time.current if form.answer.present?
+
+ if form.signature_dates_required?
+ attrs[:signature_start_date] = form.signature_start_date
+ attrs[:signature_end_date] = form.signature_end_date
+
+ if initiative.published? && form.signature_end_date != initiative.signature_end_date &&
+ form.signature_end_date > initiative.signature_end_date
+ @notify_extended = true
+ end
+ end
+
+ @notify_answered = form.answer != initiative.answer && !form.answer.values.all?(&:blank?)
+
+ attrs
+ end
+
+ def notify_initiative_is_answered
+ Decidim::EventsManager.publish(
+ event: "decidim.events.initiatives.initiative_answered",
+ event_class: Decidim::Initiatives::AnswerInitiativeEvent,
+ resource: initiative,
+ followers: initiative.followers
+ )
+ end
+end
+
+Decidim::Initiatives::Admin::UpdateInitiativeAnswer.class_eval do
+ prepend UpdateInitiativeAnswerExtends
+end
diff --git a/lib/extends/helpers/decidim/icon_helper_extends.rb b/lib/extends/helpers/decidim/icon_helper_extends.rb
new file mode 100644
index 0000000000..b66490303c
--- /dev/null
+++ b/lib/extends/helpers/decidim/icon_helper_extends.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module IconHelperExtends
+ def resource_icon(resource, options = {})
+ if resource.instance_of?(Decidim::Initiative)
+ icon "initiatives", options
+ elsif resource.instance_of?(Decidim::Comments::Comment)
+ icon "comment-square", options
+ elsif resource.respond_to?(:component) && resource.component
+ component_icon(resource.component, options)
+ elsif resource.respond_to?(:manifest) && resource.manifest
+ manifest_icon(resource.manifest, options)
+ elsif resource.is_a?(Decidim::User)
+ icon "person", options
+ else
+ icon "bell", options
+ end
+ end
+end
+
+Decidim::IconHelper.module_eval do
+ prepend(IconHelperExtends)
+end
diff --git a/spec/commands/decidim/initiatives/admin/update_initiative_answer_spec.rb b/spec/commands/decidim/initiatives/admin/update_initiative_answer_spec.rb
new file mode 100644
index 0000000000..81f87c8820
--- /dev/null
+++ b/spec/commands/decidim/initiatives/admin/update_initiative_answer_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+module Decidim
+ module Initiatives
+ module Admin
+ describe UpdateInitiativeAnswer do
+ let(:form_klass) { Decidim::Initiatives::Admin::InitiativeAnswerForm }
+
+ context "when valid data" do
+ it_behaves_like "update an initiative answer" do
+ context "when the user is an admin" do
+ let!(:current_user) { create(:user, :admin, organization: initiative.organization) }
+ let!(:follower) { create(:user, organization: organization) }
+ let!(:follow) { create(:follow, followable: initiative, user: follower) }
+
+ it "notifies the followers for extension and answer" do
+ expect(Decidim::EventsManager)
+ .to receive(:publish)
+ .with(
+ event: "decidim.events.initiatives.initiative_extended",
+ event_class: Decidim::Initiatives::ExtendInitiativeEvent,
+ resource: initiative,
+ followers: [follower]
+ )
+ .ordered
+ expect(Decidim::EventsManager)
+ .to receive(:publish)
+ .with(
+ event: "decidim.events.initiatives.initiative_answered",
+ event_class: Decidim::Initiatives::AnswerInitiativeEvent,
+ resource: initiative,
+ followers: [follower]
+ )
+ .ordered
+
+ command.call
+ end
+
+ context "when the signature end time is not modified" do
+ let(:signature_end_date) { initiative.signature_end_date }
+
+ it "doesn't notify the followers" do
+ expect(Decidim::EventsManager).not_to receive(:publish).with(
+ event: "decidim.events.initiatives.initiative_extended",
+ event_class: Decidim::Initiatives::ExtendInitiativeEvent,
+ resource: initiative,
+ followers: [follower]
+ )
+
+ command.call
+ end
+ end
+ end
+ end
+ end
+
+ context "when validation failure" do
+ let(:organization) { create(:organization) }
+ let!(:initiative) { create(:initiative, organization: organization) }
+ let!(:form) do
+ form_klass
+ .from_model(initiative)
+ .with_context(current_organization: organization, initiative: initiative)
+ end
+
+ let(:command) { described_class.new(initiative, form, initiative.author) }
+
+ it "broadcasts invalid" do
+ expect(initiative).to receive(:valid?)
+ .at_least(:once)
+ .and_return(false)
+ expect { command.call }.to broadcast :invalid
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/factories.rb b/spec/factories.rb
index 16258e4b03..3a7bdad7e9 100644
--- a/spec/factories.rb
+++ b/spec/factories.rb
@@ -12,3 +12,4 @@
require "decidim/verifications/test/factories"
require "decidim/forms/test/factories"
require "decidim/surveys/test/factories"
+require "decidim/initiatives/test/factories"
diff --git a/spec/factory_bot_spec.rb b/spec/factory_bot_spec.rb
deleted file mode 100644
index d478a75b7f..0000000000
--- a/spec/factory_bot_spec.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-
-require "spec_helper"
-
-describe FactoryBot, processing_uploads_for: Decidim::AttachmentUploader do
- it "has 100% valid factories" do
- expect { described_class.lint(traits: true) }.not_to raise_error
- end
-end
diff --git a/spec/helpers/icon_helper_spec.rb b/spec/helpers/icon_helper_spec.rb
new file mode 100644
index 0000000000..6596b11947
--- /dev/null
+++ b/spec/helpers/icon_helper_spec.rb
@@ -0,0 +1,131 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+module Decidim
+ describe IconHelper do
+ describe "#component_icon" do
+ let(:component) do
+ create(:component, manifest_name: :dummy)
+ end
+
+ describe "when the component has no icon" do
+ before do
+ allow(component.manifest).to receive(:icon).and_return(nil)
+ end
+
+ it "returns a fallback" do
+ result = helper.component_icon(component)
+ expect(result).to include("question-mark")
+ end
+ end
+
+ describe "when the component has icon" do
+ it "returns the icon" do
+ result = helper.component_icon(component)
+ expect(result).to eq <<~SVG.strip
+
+ SVG
+ end
+
+ context "with role attribute specified" do
+ it "implements role attribute" do
+ result = helper.component_icon(component, role: "img")
+ expect(result).to eq <<~SVG.strip
+
+ SVG
+ end
+ end
+
+ context "with no role attribute specified" do
+ it "doesn't implement role attribute" do
+ result = helper.component_icon(component)
+ expect(result).to eq <<~SVG.strip
+
+ SVG
+ end
+ end
+ end
+
+ describe "resource_icon" do
+ let(:result) { helper.resource_icon(resource) }
+
+ context "when it has a component" do
+ let(:resource) { build :dummy_resource }
+
+ it "renders the component icon" do
+ expect(helper).to receive(:component_icon).with(resource.component, {})
+
+ result
+ end
+ end
+
+ context "when it has a manifest" do
+ let(:resource) { build(:component, manifest_name: :dummy) }
+
+ it "renders the manifest icon" do
+ expect(helper).to receive(:manifest_icon).with(resource.manifest, {})
+
+ result
+ end
+ end
+
+ context "when it is a user" do
+ let(:resource) { build :user }
+
+ it "renders a person icon" do
+ expect(result).to include("svg#icon-person")
+ end
+ end
+
+ context "when the resource component and manifest are nil" do
+ let(:resource) { build :dummy_resource }
+
+ before do
+ allow(resource).to receive(:component).and_return(nil)
+ end
+
+ it "renders a generic icon" do
+ expect(result).to include("svg#icon-bell")
+ end
+ end
+
+ context "when the manifest icon is nil" do
+ let(:resource) { build(:component, manifest_name: :dummy) }
+
+ before do
+ allow(resource.manifest).to receive(:icon).and_return(nil)
+ end
+
+ it "renders a generic icon" do
+ expect(result).to include("svg#icon-question-mark")
+ end
+ end
+
+ context "when the resource is a comment" do
+ let(:resource) { build :comment }
+
+ it "renders a comment icon" do
+ expect(result).to include("svg#icon-comment-square")
+ end
+ end
+
+ context "when the resource is an initiative" do
+ let(:resource) { build :initiative }
+
+ it "renders an initiative icon" do
+ expect(result).to include("svg#icon-initiatives")
+ end
+ end
+
+ context "and in other cases" do
+ let(:resource) { "Something" }
+
+ it "renders a generic icon" do
+ expect(result).to include("svg#icon-bell")
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/shared/update_initiative_answer_example.rb b/spec/shared/update_initiative_answer_example.rb
new file mode 100644
index 0000000000..5eab077786
--- /dev/null
+++ b/spec/shared/update_initiative_answer_example.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+shared_examples "update an initiative answer" do
+ let(:organization) { create(:organization) }
+ let(:initiative) { create(:initiative, organization: organization, state: state) }
+ let(:form) do
+ form_klass.from_params(
+ form_params
+ ).with_context(
+ current_organization: organization,
+ initiative: initiative
+ )
+ end
+ let(:signature_end_date) { Date.current + 500.days }
+ let(:state) { "published" }
+ let(:form_params) do
+ {
+ signature_start_date: Date.current + 10.days,
+ signature_end_date: signature_end_date,
+ answer: { en: "Measured answer" },
+ answer_url: "http://decidim.org"
+ }
+ end
+ let(:administrator) { create(:user, :admin, organization: organization) }
+ let(:current_user) { administrator }
+ let(:command) { described_class.new(initiative, form, current_user) }
+
+ describe "call" do
+ describe "when the form is not valid" do
+ before do
+ allow(form).to receive(:invalid?).and_return(true)
+ end
+
+ it "broadcasts invalid" do
+ expect { command.call }.to broadcast(:invalid)
+ end
+
+ it "doesn't updates the initiative" do
+ command.call
+
+ form_params.each do |key, value|
+ expect(initiative[key]).not_to eq(value)
+ end
+ end
+ end
+
+ describe "when the form is valid" do
+ it "broadcasts ok" do
+ expect { command.call }.to broadcast(:ok)
+ end
+
+ it "updates the initiative" do
+ command.call
+ initiative.reload
+
+ expect(initiative.answer["en"]).to eq(form_params[:answer][:en])
+ expect(initiative.answer_url).to eq(form_params[:answer_url])
+ end
+
+ context "when initiative is not published" do
+ let(:state) { "validating" }
+
+ it "voting interval remains unchanged" do
+ command.call
+ initiative.reload
+
+ [:signature_start_date, :signature_end_date].each do |key|
+ expect(initiative[key]).not_to eq(form_params[key])
+ end
+ end
+ end
+
+ context "when initiative is published" do
+ it "voting interval is updated" do
+ command.call
+ initiative.reload
+
+ [:signature_start_date, :signature_end_date].each do |key|
+ expect(initiative[key]).to eq(form_params[key])
+ end
+ end
+ end
+ end
+ end
+end