diff --git a/app/helpers/decidim/proposals/external_proposal_helper.rb b/app/helpers/decidim/proposals/external_proposal_helper.rb
index bb1abd0..602df84 100644
--- a/app/helpers/decidim/proposals/external_proposal_helper.rb
+++ b/app/helpers/decidim/proposals/external_proposal_helper.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+require "uri"
+
module Decidim
module Proposals
module ExternalProposalHelper
@@ -42,6 +44,11 @@ def external_css_style(state)
""
end
end
+
+ def display_host(url)
+ uri = URI.parse(url)
+ uri.host
+ end
end
end
end
diff --git a/app/packs/src/decidim/dataspace/component_edit_form.js b/app/packs/src/decidim/dataspace/component_edit_form.js
index c890da8..e4db964 100644
--- a/app/packs/src/decidim/dataspace/component_edit_form.js
+++ b/app/packs/src/decidim/dataspace/component_edit_form.js
@@ -3,24 +3,54 @@ document.addEventListener("DOMContentLoaded", function(){
const urlDiv = document.querySelector("div.integration_url_container")
const localeDiv = document.querySelector("div.preferred_locale_container")
const inputUrl = document.querySelector("input[name='component[settings][integration_url]']")
- inputUrl.setAttribute("placeholder", "https://platform.com")
+ inputUrl.setAttribute("placeholder", "https://platform.com, https://example.com")
if(integrationCheck){
if(integrationCheck.checked){
- urlDiv.style.display = "block"
- localeDiv.style.display = "block"
+ urlDiv.style.display = "block";
+ localeDiv.style.display = "block";
} else {
- urlDiv.style.display = "none"
- localeDiv.style.display = "none"
+ urlDiv.style.display = "none";
+ localeDiv.style.display = "none";
}
integrationCheck.addEventListener('change', function(){
if (this.checked) {
- urlDiv.style.display = "block"
- localeDiv.style.display = "block"
+ urlDiv.style.display = "block";
+ localeDiv.style.display = "block";
} else {
- urlDiv.style.display = "none"
- localeDiv.style.display = "none"
+ urlDiv.style.display = "none";
+ localeDiv.style.display = "none";
}
})
}
+ // check validity of urls when input looses focus
+ inputUrl.addEventListener("blur", checkUrl)
+ function checkUrl(event){
+ const values = event.target.value;
+ const errors = [];
+ values.split(",").forEach(function(value){
+ try {
+ // if value is not valid, it will throw a TypeError
+ const url = new URL(value);
+ } catch(error){
+ errors.push(error);
+ }
+ })
+ if(errors.length !== 0 && inputUrl.parentNode.lastChild === inputUrl){
+ // create p
+ const elem = document.createElement('p');
+ // create content
+ const newContent = document.createTextNode("There is an invalid url");
+ // add content to p
+ elem.appendChild(newContent);
+ // add style and class to p
+ elem.style.color = "red";
+ elem.classList.add('url_input_error');
+ // insert p after input
+ inputUrl.after(elem);
+ } else if(errors.length === 0 && inputUrl.parentNode.lastChild !== inputUrl){
+ const elem = document.querySelector('p.url_input_error');
+ inputUrl.parentNode.removeChild(elem);
+ }
+ }
})
diff --git a/app/views/decidim/proposals/proposals/_external_proposal.html.erb b/app/views/decidim/proposals/proposals/_external_proposal.html.erb
index 76cf14e..1273ecd 100644
--- a/app/views/decidim/proposals/proposals/_external_proposal.html.erb
+++ b/app/views/decidim/proposals/proposals/_external_proposal.html.erb
@@ -1,9 +1,9 @@
<% if card_size == :g %>
- <%= link_to external_proposal_proposals_path(external_proposal["reference"]), class: "card__grid-external", id: external_proposal["reference"] do %>
+ <%= link_to external_proposal_proposals_path(external_proposal["reference"], url: external_proposal["source"]), class: "card__grid-external", id: external_proposal["reference"] do %>
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 006504b..8bb978c 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -7,8 +7,8 @@ en:
proposals:
settings:
global:
- add_integration: Add an integration
- integration_url: Url platform to integrate
+ add_integration: Add integrations
+ integration_url: Url platforms to integrate (separated by a comma)
preferred_locale: Preferred language
preferred_locale_options:
ca: ca
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index 87f8f3f..ea7aba8 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -7,8 +7,8 @@ fr:
proposals:
settings:
global:
- add_integration: Ajouter une integration
- integration_url: Url de la plateforme à intégrer
+ add_integration: Ajouter des integrations
+ integration_url: Url des plateformes à intégrer (séparées par une virgule)
preferred_locale: Langue préférée
preferred_locale_options:
ca: ca
diff --git a/lib/extends/controllers/decidim/proposals/proposals_controller_extends.rb b/lib/extends/controllers/decidim/proposals/proposals_controller_extends.rb
index 49ec062..292b783 100644
--- a/lib/extends/controllers/decidim/proposals/proposals_controller_extends.rb
+++ b/lib/extends/controllers/decidim/proposals/proposals_controller_extends.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
require "active_support/concern"
+require "uri"
module ProposalsControllerExtends
extend ActiveSupport::Concern
@@ -16,10 +17,9 @@ def index
.order(position: :asc)
render "decidim/proposals/proposals/participatory_texts/participatory_text"
else
- if component_settings.add_integration && component_settings.integration_url.present? && data
+ if component_settings.add_integration && component_settings.integration_url.present? && data.present?
external_proposals = data["contributions"]
- @platform = component_settings.integration_url.split("//")[1]
@authors = data["authors"]
proposals = search.result
proposals = reorder(proposals.includes(:component, :coauthorships, :attachments))
@@ -42,12 +42,16 @@ def index
end
def external_proposal
- @external_proposal = GetDataFromApi.contribution(component_settings.integration_url, params[:reference], component_settings.preferred_locale || "en", "true")
+ uri = URI.parse(params[:url])
+ url = "#{uri.scheme}://#{uri.host}"
+ url += ":3000" if uri.host == "localhost"
+
+ @external_proposal = GetDataFromApi.contribution(url, params[:reference], component_settings.preferred_locale || "en", "true")
return if @external_proposal.nil?
@comments = @external_proposal["children"]
@parent_comments = @comments.select { |comment| comment["parent"] == @external_proposal["reference"] } if @comments
- @authors = GetDataFromApi.authors(component_settings.integration_url, component_settings.preferred_locale || "en")
+ @authors = GetDataFromApi.authors(url, component_settings.preferred_locale || "en")
.select { |author| @external_proposal["authors"].include?(author["reference"]) }
.map { |author| author["name"] }.join(", ")
end
@@ -66,7 +70,25 @@ def voted_proposals
end
def data
- @data ||= GetDataFromApi.data(component_settings.integration_url, component_settings.preferred_locale || "en").presence
+ @data ||= compile_data
+ end
+
+ def compile_data
+ data = {}
+ component_settings.integration_url.split(", ").each do |url|
+ url = url.strip
+ datum = GetDataFromApi.data(url, component_settings.preferred_locale || "en").presence
+ if datum
+ if data.has_key?("contributions")
+ data["contributions"].concat(datum["contributions"])
+ data["authors"].concat(datum["authors"])
+ else
+ data["contributions"] = datum["contributions"]
+ data["authors"] = datum["authors"]
+ end
+ end
+ end
+ data
end
def define_proposals_and_external_proposals(proposals, external_proposals, current_page, per_page)
diff --git a/spec/controllers/decidim/proposals/proposals_controller_spec.rb b/spec/controllers/decidim/proposals/proposals_controller_spec.rb
index 0349b86..4f6076e 100644
--- a/spec/controllers/decidim/proposals/proposals_controller_spec.rb
+++ b/spec/controllers/decidim/proposals/proposals_controller_spec.rb
@@ -111,28 +111,44 @@ module Proposals
}
end
- before do
- component.update!(settings: { add_integration: true, integration_url: "http://example.org", preferred_locale: "en" })
- allow(GetDataFromApi).to receive(:data).and_return(json)
+ context "and there is one url in integration url" do
+ before do
+ component.update!(settings: { add_integration: true, integration_url: "http://example.org", preferred_locale: "en" })
+ allow(GetDataFromApi).to receive(:data).and_return(json)
+ end
+
+ it "sorts proposals by search defaults and define external_proposals and other variables" do
+ get :index
+ expect(response).to have_http_status(:ok)
+ expect(subject).to render_template(:index)
+ expect(assigns(:proposals).order_values).to eq [Decidim::Proposals::Proposal.arel_table[Decidim::Proposals::Proposal.primary_key] * Arel.sql("RANDOM()")]
+ expect(assigns(:proposals).order_values.map(&:to_sql)).to eq ["\"decidim_proposals_proposals\".\"id\" * RANDOM()"]
+ expect(assigns(:authors).count).to eq 2
+ expect(assigns(:authors).first[:reference]).to eq "JD-MEET-2025-09-6"
+ expect(assigns(:authors).last[:reference]).to eq "JD-MEET-2025-09-23"
+ expect(assigns(:total_count)).to eq 4
+ expect(assigns(:current_page)).to eq 1
+ expect(assigns(:total_pages)).to eq 1
+ expect(assigns(:proposals).count).to eq 2
+ expect(assigns(:external_proposals).count).to eq 2
+ expect(assigns(:external_proposals).first[:reference]).to eq "JD-PROP-2025-09-1"
+ expect(assigns(:external_proposals).last[:reference]).to eq "JD-PROP-2025-09-20"
+ end
end
- it "sorts proposals by search defaults and define external_proposals and other variables" do
- get :index
- expect(response).to have_http_status(:ok)
- expect(subject).to render_template(:index)
- expect(assigns(:proposals).order_values).to eq [Decidim::Proposals::Proposal.arel_table[Decidim::Proposals::Proposal.primary_key] * Arel.sql("RANDOM()")]
- expect(assigns(:proposals).order_values.map(&:to_sql)).to eq ["\"decidim_proposals_proposals\".\"id\" * RANDOM()"]
- expect(assigns(:platform)).to eq "example.org"
- expect(assigns(:authors).count).to eq 2
- expect(assigns(:authors).first[:reference]).to eq "JD-MEET-2025-09-6"
- expect(assigns(:authors).last[:reference]).to eq "JD-MEET-2025-09-23"
- expect(assigns(:total_count)).to eq 4
- expect(assigns(:current_page)).to eq 1
- expect(assigns(:total_pages)).to eq 1
- expect(assigns(:proposals).count).to eq 2
- expect(assigns(:external_proposals).count).to eq 2
- expect(assigns(:external_proposals).first[:reference]).to eq "JD-PROP-2025-09-1"
- expect(assigns(:external_proposals).last[:reference]).to eq "JD-PROP-2025-09-20"
+ context "and there are 2 urls in integration_url" do
+ before do
+ component.update!(settings: { add_integration: true, integration_url: "http://example.org, http://example.org,", preferred_locale: "en" })
+ allow(GetDataFromApi).to receive(:data).and_return(json)
+ end
+
+ it "returns 4 external proposals and 4 authors" do
+ get :index
+ expect(response).to have_http_status(:ok)
+ expect(subject).to render_template(:index)
+ expect(assigns(:external_proposals).count).to eq 4
+ expect(assigns(:authors).count).to eq 4
+ end
end
end
@@ -264,7 +280,7 @@ module Proposals
end
it "displays external_proposal view and sets variables" do
- get :external_proposal, params: { reference: "JD-PROP-2025-09-1", param: :reference }
+ get :external_proposal, params: { reference: "JD-PROP-2025-09-1", param: :reference, url: "http://example.org" }
expect(response).to have_http_status(:ok)
expect(subject).to render_template(:external_proposal)
expect(assigns(:external_proposal)).to eq json_contrib
diff --git a/spec/helpers/external_proposal_helper_spec.rb b/spec/helpers/external_proposal_helper_spec.rb
index 11e5eaf..c47404e 100644
--- a/spec/helpers/external_proposal_helper_spec.rb
+++ b/spec/helpers/external_proposal_helper_spec.rb
@@ -70,6 +70,14 @@ module Proposals
end
end
end
+
+ describe "display_host" do
+ let(:url) { "http://localhost:3000" }
+
+ it "returns the host" do
+ expect(helper.display_host(url)).to eq("localhost")
+ end
+ end
end
end
end
diff --git a/spec/system/admin_adds_integrations_on_proposals_spec.rb b/spec/system/admin_adds_integrations_on_proposals_spec.rb
new file mode 100644
index 0000000..2ab14b4
--- /dev/null
+++ b/spec/system/admin_adds_integrations_on_proposals_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+# rubocop:disable RSpec/DescribeClass
+describe "Admin adds integrations on proposals component" do
+ # rubocop:enable RSpec/DescribeClass
+ include_context "with a component"
+ let!(:manifest_name) { "proposals" }
+ let(:participatory_process) { create(:participatory_process, organization:) }
+ let!(:component) { create(:proposal_component, participatory_space: participatory_process) }
+ let(:admin) { create(:user, :admin, :confirmed, organization:) }
+
+ context "when editing the proposals component" do
+ before do
+ switch_to_host(organization.host)
+ login_as admin, scope: :user
+ visit decidim_admin_participatory_processes.components_path(participatory_process)
+ within ".component-#{component.id}" do
+ find("a[title='Configure']").click
+ end
+ end
+
+ context "and adding valid urls" do
+ it "can adds multiple integrations and updates component" do
+ # check add integration displays 2 divs
+ check I18n.t("decidim.components.proposals.settings.global.add_integration")
+ expect(page).to have_css("div.integration_url_container")
+ expect(page).to have_css("div.preferred_locale_container")
+ # provide valid urls, no error message displayed
+ fill_in "component[settings][integration_url]", with: "http://example.com, http://localhost:3000"
+ expect(page).to have_no_css("p.url_input_error")
+ select("fr", from: "component[settings][preferred_locale]")
+ # update component succesfully
+ click_link_or_button "Update"
+ expect(page).to have_content("The component was updated successfully.")
+ end
+ end
+
+ context "and adding invalid url" do
+ it "gets an error message" do
+ check I18n.t("decidim.components.proposals.settings.global.add_integration")
+ # provide invalid url
+ fill_in "component[settings][integration_url]", with: "http://localhost:3000, example.com"
+ # error message displayed when input looses focus
+ find_by_id("component_settings_preferred_locale").click
+ sleep(1)
+ expect(page).to have_css("p.url_input_error")
+ # providing a good url removes the error
+ fill_in "component[settings][integration_url]", with: "http://localhost:3000, http://example.com"
+ # error message removed when input looses focus
+ find_by_id("component_settings_preferred_locale").click
+ sleep(1)
+ expect(page).to have_no_css("p.url_input_error")
+ end
+ end
+ end
+end
diff --git a/spec/system/external_proposal_spec.rb b/spec/system/external_proposal_spec.rb
index b8a2966..fd3869a 100644
--- a/spec/system/external_proposal_spec.rb
+++ b/spec/system/external_proposal_spec.rb
@@ -36,7 +36,7 @@
end
before do
- component.update!(settings: { add_integration: true, integration_url: "http://example.org", preferred_locale: "en" })
+ component.update!(settings: { add_integration: true, integration_url: "http://localhost:3000", preferred_locale: "en" })
allow(GetDataFromApi).to receive(:contribution).and_return(json_contrib)
allow(GetDataFromApi).to receive(:authors).and_return(authors)
visit_external_proposal
@@ -165,6 +165,6 @@ def decidim_proposals
end
def visit_external_proposal
- visit decidim_proposals.external_proposal_proposals_path("JD-PROP-2025-09-1")
+ visit decidim_proposals.external_proposal_proposals_path("JD-PROP-2025-09-1", url: "http://localhost:3000/processes/satisfaction-hope/f/7/proposals/1")
end
end
diff --git a/spec/system/proposals_index_spec.rb b/spec/system/proposals_index_spec.rb
index 0553b76..7236c38 100644
--- a/spec/system/proposals_index_spec.rb
+++ b/spec/system/proposals_index_spec.rb
@@ -122,42 +122,64 @@
before do
create(:proposal_component, manifest:, participatory_space: participatory_process)
create_list(:proposal, 3, component:)
- component.update!(settings: { add_integration: true, integration_url: "http://example.org", preferred_locale: "en" })
allow(GetDataFromApi).to receive(:data).and_return(json)
end
- it "lists the proposals ordered randomly by default and the external proposals at the end" do
- visit_component
- # 5 cards
- expect(page).to have_css("a[class='card__list']", count: 5)
- # 3 proposals
- expect(page).to have_css("[id^='proposals__proposal']", count: 3)
- # 2 external proposals
- expect(page).to have_css("[id='JD-PROP-2025-09-1']", count: 1)
- expect(page).to have_css("[id='JD-PROP-2025-09-20']", count: 1)
- end
-
- context "and there are a lot of proposals" do
+ context "and there is one url in integration_url" do
before do
- # Decidim::Paginable::OPTIONS.first is 25
- create_list(:proposal, Decidim::Paginable::OPTIONS.first, component:)
- # we have already create 3 proposals, so we will have a total of 28 proposals
+ component.update!(settings: { add_integration: true, integration_url: "http://example.org", preferred_locale: "en" })
end
- it "paginates them with proposals first and external proposals at the end" do
+ it "lists the proposals ordered randomly by default and the external proposals at the end" do
visit_component
- # only proposals on first page
- expect(page).to have_css("a[class='card__list']", count: Decidim::Paginable::OPTIONS.first)
- expect(page).to have_css("[id^='proposals__proposal']", count: Decidim::Paginable::OPTIONS.first)
-
- click_on "Next"
- # proposals and external proposals on second page
+ # 5 cards
expect(page).to have_css("a[class='card__list']", count: 5)
- expect(page).to have_css("[data-pages] [data-page][aria-current='page']", text: "2")
+ # 3 proposals
expect(page).to have_css("[id^='proposals__proposal']", count: 3)
+ # 2 external proposals
expect(page).to have_css("[id='JD-PROP-2025-09-1']", count: 1)
expect(page).to have_css("[id='JD-PROP-2025-09-20']", count: 1)
end
+
+ context "and there are a lot of proposals" do
+ before do
+ # Decidim::Paginable::OPTIONS.first is 25
+ create_list(:proposal, Decidim::Paginable::OPTIONS.first, component:)
+ # we have already create 3 proposals, so we will have a total of 28 proposals
+ end
+
+ it "paginates them with proposals first and external proposals at the end" do
+ visit_component
+ # only proposals on first page
+ expect(page).to have_css("a[class='card__list']", count: Decidim::Paginable::OPTIONS.first)
+ expect(page).to have_css("[id^='proposals__proposal']", count: Decidim::Paginable::OPTIONS.first)
+
+ click_on "Next"
+ # proposals and external proposals on second page
+ expect(page).to have_css("a[class='card__list']", count: 5)
+ expect(page).to have_css("[data-pages] [data-page][aria-current='page']", text: "2")
+ expect(page).to have_css("[id^='proposals__proposal']", count: 3)
+ expect(page).to have_css("[id='JD-PROP-2025-09-1']", count: 1)
+ expect(page).to have_css("[id='JD-PROP-2025-09-20']", count: 1)
+ end
+ end
+ end
+
+ context "and there are 2 urls in integration_url" do
+ before do
+ component.update!(settings: { add_integration: true, integration_url: "http://example.org, http://example.org", preferred_locale: "en" })
+ end
+
+ it "returns the double amount of external proposals" do
+ visit_component
+ # 7 cards
+ expect(page).to have_css("a[class='card__list']", count: 7)
+ # 3 proposals
+ expect(page).to have_css("[id^='proposals__proposal']", count: 3)
+ # 4 external proposals
+ expect(page).to have_css("[id='JD-PROP-2025-09-1']", count: 2)
+ expect(page).to have_css("[id='JD-PROP-2025-09-20']", count: 2)
+ end
end
end
end