From 5b9c65da3dae250976b0b0e49ac4b1a6ba81260e Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 12 Sep 2025 14:52:17 +0200 Subject: [PATCH 01/33] feat: get authors from application proposals --- .../dataspace/api/v1/authors_controller.rb | 21 ++------ app/models/decidim/dataspace/author.rb | 49 +++++++++++++++++++ 2 files changed, 54 insertions(+), 16 deletions(-) diff --git a/app/controllers/decidim/dataspace/api/v1/authors_controller.rb b/app/controllers/decidim/dataspace/api/v1/authors_controller.rb index 3320e80..5849cc5 100644 --- a/app/controllers/decidim/dataspace/api/v1/authors_controller.rb +++ b/app/controllers/decidim/dataspace/api/v1/authors_controller.rb @@ -6,28 +6,17 @@ class Api::V1::AuthorsController < Api::V1::BaseController before_action :set_author, only: :show def index - render json: { authors: Decidim::Dataspace::Author.all.map { |author| render_author(author) } }, status: :ok - end + return resource_not_found("Authors") if Author.from_proposals.blank? - def show - render json: render_author(@author), status: :ok + render json: { authors: Author.from_proposals }, status: :ok end - private - - def render_author(author) - { - reference: author.reference, - name: author.name, - source: author.source - } + def show + render json: @author, status: :ok end def set_author - interoperable = Decidim::Dataspace::Interoperable.find_by(reference: params[:reference]) - return resource_not_found("Author") unless interoperable - - @author = Decidim::Dataspace::Author.find_by(interoperable_id: interoperable.id) + @author = Author.proposal_author(params[:reference]) return resource_not_found("Author") unless @author @author diff --git a/app/models/decidim/dataspace/author.rb b/app/models/decidim/dataspace/author.rb index ee8632d..638b222 100644 --- a/app/models/decidim/dataspace/author.rb +++ b/app/models/decidim/dataspace/author.rb @@ -11,6 +11,55 @@ class Author < Decidim::Dataspace::Interoperable # rubocop:enable Rails/HasAndBelongsToMany delegate :reference, :source, :created_at, :updated_at, :deleted_at, to: :interoperable + + def self.from_proposals + proposals = Decidim::Proposals::Proposal.all.map { |proposal| + proposal.authors.map do |author| + if author.instance_of?(Decidim::Organization) + Author.organization_author(author) + elsif author.instance_of?(Decidim::Meetings::Meeting) + Author.meeting_author(author) + elsif author.instance_of?(Decidim::User) || author.instance_of?(Decidim::UserGroup) + Author.user_or_group_author(author) + end + end + } + proposals.flatten.compact.uniq { |hash| hash[:reference] } + end + + def self.proposal_author(reference) + if Decidim::User.find_by(name: reference) || Decidim::UserGroup.find_by(name: reference) + author = Decidim::User.find_by(name: reference) || Decidim::UserGroup.find_by(name: reference) + return Author.user_or_group_author(author) + elsif Decidim::Organization.find_by(reference_prefix: reference) + author = Decidim::Organization.find_by(reference_prefix: reference) + return Author.organization_author(author) + elsif Decidim::Meetings::Meeting.find_by(reference: reference) + author = Decidim::Meetings::Meeting.find_by(reference: reference) + return Author.meeting_author(author) + end + end + + def self.organization_author(author) + { reference: author.reference_prefix, + name: author.name["en"], + source: author.official_url + } + end + + def self.meeting_author(author) + { reference: author.reference, + name: author.title["en"], + source: Decidim::ResourceLocatorPresenter.new(author).url + } + end + + def self.user_or_group_author(author) + { reference: author.name, + name: author.name, + source: author.personal_url + } + end end end end From 7a68c827d4a32acd85fedee344cf9f7d616974a7 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 12 Sep 2025 14:53:19 +0200 Subject: [PATCH 02/33] feat: get containers from application proposals --- .../dataspace/api/v1/containers_controller.rb | 27 +++----------- app/models/decidim/dataspace/container.rb | 37 +++++++++++++++++++ 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/app/controllers/decidim/dataspace/api/v1/containers_controller.rb b/app/controllers/decidim/dataspace/api/v1/containers_controller.rb index 553a3c6..8cb8c52 100644 --- a/app/controllers/decidim/dataspace/api/v1/containers_controller.rb +++ b/app/controllers/decidim/dataspace/api/v1/containers_controller.rb @@ -6,34 +6,17 @@ class Api::V1::ContainersController < Api::V1::BaseController before_action :set_container, only: :show def index - render json: { containers: Decidim::Dataspace::Container.all.map { |container| render_container(container) } }, status: :ok - end + return resource_not_found("Containers") if Container.from_proposals.blank? - def show - render json: render_container(@container), status: :ok + render json: { containers: Container.from_proposals }, status: :ok end - private - - def render_container(container) - { - "reference": container.reference, - "source": container.source, - "name": container.name, - "description": container.description, - "metadata": container.metadata, - "created_at": container.created_at, - "updated_at": container.updated_at, - "deleted_at": container.deleted_at - } + def show + render json: @container, status: :ok end def set_container - interoperable = Decidim::Dataspace::Interoperable.find_by(reference: params[:reference]) - return resource_not_found("Container") unless interoperable - - @container = Decidim::Dataspace::Container.find_by(interoperable_id: interoperable.id) - + @container = Container.from_params(params[:reference]) return resource_not_found("Container") unless @container @container diff --git a/app/models/decidim/dataspace/container.rb b/app/models/decidim/dataspace/container.rb index 034af02..d676588 100644 --- a/app/models/decidim/dataspace/container.rb +++ b/app/models/decidim/dataspace/container.rb @@ -11,6 +11,43 @@ class Container < Decidim::Dataspace::Interoperable has_many :contributions, dependent: :destroy, class_name: "Decidim::Dataspace::Contribution" delegate :reference, :source, :metadata, :created_at, :updated_at, :deleted_at, to: :interoperable + + def self.from_proposals + Decidim::Proposals::Proposal.all.map do |proposal| + Container.from_proposal(proposal) + end.uniq { |hash| hash[:reference] } + end + + def self.from_params(params) + container = Decidim::Assembly.find_by(reference: params).presence || Decidim::ParticipatoryProcess.find_by(reference: params).presence + return nil unless container + + { + "reference": container.reference, + "source": Decidim::ResourceLocatorPresenter.new(container).url, + "name": container.title["en"], + "description": container.description["en"], + "metadata": {}, + "created_at": container.created_at, + "updated_at": container.updated_at, + "deleted_at": nil + } + end + + def self.from_proposal(proposal) + container = proposal.component.participatory_space_type.constantize.find(proposal.component.participatory_space_id) + + { + "reference": container.reference, + "source": Decidim::ResourceLocatorPresenter.new(container).url, + "name": container.title["en"], + "description": container.description["en"], + "metadata": {}, + "created_at": container.created_at, + "updated_at": container.updated_at, + "deleted_at": nil + } + end end end end From 1117dbe386bd3bce8a70248503b3af108380f872 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 12 Sep 2025 14:54:01 +0200 Subject: [PATCH 03/33] feat: get contributions from application proposals --- .../api/v1/contributions_controller.rb | 28 ++--------- app/models/decidim/dataspace/contribution.rb | 47 +++++++++++++++++++ 2 files changed, 52 insertions(+), 23 deletions(-) diff --git a/app/controllers/decidim/dataspace/api/v1/contributions_controller.rb b/app/controllers/decidim/dataspace/api/v1/contributions_controller.rb index c0f599b..010602b 100644 --- a/app/controllers/decidim/dataspace/api/v1/contributions_controller.rb +++ b/app/controllers/decidim/dataspace/api/v1/contributions_controller.rb @@ -6,37 +6,19 @@ class Api::V1::ContributionsController < Api::V1::BaseController before_action :set_contribution, only: :show def index - render json: { contributions: Decidim::Dataspace::Contribution.all.map { |contribution| render_contribution(contribution) } } + return resource_not_found("Contributions") if Contribution.from_proposals.blank? + + render json: { contributions: Contribution.from_proposals }, status: :ok end def show - render json: render_contribution(@contribution) + render json: @contribution, status: :ok end private - def render_contribution(contribution) - { - reference: contribution.reference, - source: contribution.source, - container: contribution.container.reference, - locale: contribution.locale, - title: contribution.title, - content: contribution.content, - authors: contribution.authors&.map(&:reference), - metadata: contribution.metadata, - parent: contribution.parent&.reference, - created_at: contribution.created_at, - updated_at: contribution.updated_at, - deleted_at: contribution.deleted_at - } - end - def set_contribution - interoperable = Decidim::Dataspace::Interoperable.find_by(reference: params[:reference]) - return resource_not_found("Contribution") unless interoperable - - @contribution = Decidim::Dataspace::Contribution.find_by(interoperable_id: interoperable.id) + @contribution = Contribution.proposal(params[:reference]) return resource_not_found("Contribution") unless @contribution @contribution diff --git a/app/models/decidim/dataspace/contribution.rb b/app/models/decidim/dataspace/contribution.rb index 75e4b1b..7e2b1a3 100644 --- a/app/models/decidim/dataspace/contribution.rb +++ b/app/models/decidim/dataspace/contribution.rb @@ -17,6 +17,53 @@ class Contribution < Decidim::Dataspace::Interoperable delegate :reference, :source, :metadata, :created_at, :updated_at, :deleted_at, to: :interoperable + def self.from_proposals + Decidim::Proposals::Proposal.includes(:component).all.map do |proposal| + { + reference: proposal.reference, + source: Decidim::ResourceLocatorPresenter.new(proposal).url, + container: proposal.component.participatory_space_type.constantize.find(proposal.component.participatory_space_id).reference, + locale: "en", + title: proposal.title["en"], + content: proposal.body["en"], + authors: Contribution.authors(proposal), + created_at: proposal.created_at, + updated_at: proposal.updated_at, + deleted_at: nil # does not exist in proposal + } + end + end + + def self.proposal(params_reference) + proposal = Decidim::Proposals::Proposal.find_by(reference: params_reference) + return nil unless proposal + + { + reference: proposal.reference, + source: Decidim::ResourceLocatorPresenter.new(proposal).url, + container: proposal.component.participatory_space_type.constantize.find(proposal.component.participatory_space_id).reference, + locale: "en", + title: proposal.title["en"], + content: proposal.body["en"], + authors: Contribution.authors(proposal), + created_at: proposal.created_at, + updated_at: proposal.updated_at, + deleted_at: nil + } + end + + def self.authors(proposal) + proposal.authors.map do |author| + if author.instance_of?(Decidim::User) || author.instance_of?(Decidim::UserGroup) + author.name + elsif author.instance_of?(Decidim::Organization) + author.reference_prefix + elsif author.instance_of?(Decidim::Meetings::Meeting) + author.reference + end + end + end + private def title_or_content From bff77031358148f7bb021aff020b05088b70bee6 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 12 Sep 2025 14:54:33 +0200 Subject: [PATCH 04/33] feat: get data from application proposals --- .../dataspace/api/v1/data_controller.rb | 46 ++----------------- 1 file changed, 3 insertions(+), 43 deletions(-) diff --git a/app/controllers/decidim/dataspace/api/v1/data_controller.rb b/app/controllers/decidim/dataspace/api/v1/data_controller.rb index dd1ba0c..f3be15a 100644 --- a/app/controllers/decidim/dataspace/api/v1/data_controller.rb +++ b/app/controllers/decidim/dataspace/api/v1/data_controller.rb @@ -6,51 +6,11 @@ class Api::V1::DataController < Api::V1::BaseController def index render json: { - containers: Decidim::Dataspace::Container.all.map { |container| render_container(container) }, - contributions: Decidim::Dataspace::Contribution.all.map { |contribution| render_contribution(contribution) }, - authors: Decidim::Dataspace::Author.all.map { |author| render_author(author) } + containers: Container.from_proposals, + contributions: Contribution.from_proposals, + authors: Author.from_proposals }, status: :ok end - - private - - def render_container(container) - { - "reference": container.reference, - "source": container.source, - "name": container.name, - "description": container.description, - "metadata": container.metadata, - "created_at": container.created_at, - "updated_at": container.updated_at, - "deleted_at": container.deleted_at - } - end - - def render_contribution(contribution) - { - "reference": contribution.reference, - "source": contribution.source, - "container": contribution.container.reference, - "locale": contribution.locale, - "title": contribution.title, - "content": contribution.content, - "authors": contribution.authors&.map(&:reference), - "metadata": contribution.metadata, - "parent": contribution.parent&.reference, - "created_at": contribution.created_at, - "updated_at": contribution.updated_at, - "deleted_at": contribution.deleted_at - } - end - - def render_author(author) - { - reference: author.reference, - name: author.name, - source: author.source - } - end end end end From a3fbcd02ad626d92705803ed9803cc165cc1b716 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 12 Sep 2025 14:55:05 +0200 Subject: [PATCH 05/33] test: update model and controller tests --- .../api/v1/authors_controller_spec.rb | 69 ++++++++++++------- .../api/v1/containers_controller_spec.rb | 49 +++++-------- .../api/v1/contributions_controller_spec.rb | 46 +++++-------- .../dataspace/api/v1/data_controller_spec.rb | 33 ++------- spec/factories.rb | 1 + spec/models/author_spec.rb | 54 +++++++++++++++ spec/models/container_spec.rb | 38 ++++++++++ spec/models/contribution_spec.rb | 28 ++++++++ 8 files changed, 206 insertions(+), 112 deletions(-) diff --git a/spec/controllers/decidim/dataspace/api/v1/authors_controller_spec.rb b/spec/controllers/decidim/dataspace/api/v1/authors_controller_spec.rb index 16f240d..c5e6d1d 100644 --- a/spec/controllers/decidim/dataspace/api/v1/authors_controller_spec.rb +++ b/spec/controllers/decidim/dataspace/api/v1/authors_controller_spec.rb @@ -7,57 +7,80 @@ describe "index" do context "when there are authors" do - let!(:author) { create(:author_one) } - let!(:author_two) { create(:author_two) } - - before do - get :index - end + let(:component) { create(:proposal_component) } + let!(:proposal) { create(:proposal, :participant_author, component:) } + let!(:proposal_two) { create(:proposal, :official_meeting, component:) } + let!(:proposal_three) { create(:proposal, :official, component:) } it "is a success and returns json" do + get :index expect(response).to have_http_status(:ok) expect(response.content_type).to include("application/json") expect { response.parsed_body }.not_to raise_error end it "returns all authors" do - expect(response.parsed_body).to eq({ "authors" => [{ "reference" => author.reference, - "name" => author.name, - "source" => author.source }, - { "reference" => author_two.reference, - "name" => author_two.name, - "source" => author_two.source }] }) + get :index + expect(response.parsed_body["authors"].size).to eq(3) end end context "when there are no authors" do - it "is a success and returns json without authors" do + it "is a not_found and returns json without authors" do get :index - expect(response).to have_http_status(:ok) + expect(response).to have_http_status(:not_found) expect(response.content_type).to include("application/json") - expect(response.parsed_body["authors"].size).to eq(0) + expect(response.parsed_body).to eq({ "error" => "Authors not found" }) end end end describe "show" do context "when author exists" do - let!(:author) { create(:author_one) } + let(:component) { create(:proposal_component) } + let!(:proposal) { create(:proposal, :participant_author, component:) } it "is a success and returns json" do - get :show, params: { reference: "A01" } + get :show, params: { reference: proposal.authors.first.name } expect(response).to have_http_status(:ok) expect(response.content_type).to include("application/json") expect { response.parsed_body }.not_to raise_error end - it "returns the author" do - # "A01" is the reference of author - get :show, params: { reference: "A01" } - expect(response.parsed_body).to eq({ "reference" => author.reference, - "name" => author.name, - "source" => author.source }) + context "and author is a user" do + it "returns the author" do + author = proposal.authors.first + get :show, params: { reference: author.name } + expect(response.parsed_body).to eq({ "reference" => author.name, + "name" => author.name, + "source" => author.personal_url }) + end end + + context "and author is a official" do + let!(:proposal) { create(:proposal, :official, component:) } + + it "returns the author" do + author = proposal.authors.first + get :show, params: { reference: author.reference_prefix } + expect(response.parsed_body).to eq({ "reference" => author.reference_prefix, + "name" => author.name["en"], + "source" => author.official_url }) + end + end + + context "and author is a meeting" do + let!(:proposal) { create(:proposal, :official_meeting, component:) } + + it "returns the author" do + author = proposal.authors.first + get :show, params: { reference: author.reference } + expect(response.parsed_body).to eq({ "reference" => author.reference, + "name" => author.title["en"], + "source" => Decidim::ResourceLocatorPresenter.new(author).url }) + end + end + end context "when author does not exist" do diff --git a/spec/controllers/decidim/dataspace/api/v1/containers_controller_spec.rb b/spec/controllers/decidim/dataspace/api/v1/containers_controller_spec.rb index 17ffc45..9cba14f 100644 --- a/spec/controllers/decidim/dataspace/api/v1/containers_controller_spec.rb +++ b/spec/controllers/decidim/dataspace/api/v1/containers_controller_spec.rb @@ -7,8 +7,9 @@ describe "index" do context "when there are containers" do - let!(:container) { create(:container) } - let!(:container_two) { create(:container, reference: "B02", source: "https://example-container.com/") } + let(:component) { create(:proposal_component) } + let!(:proposal) { create(:proposal, component:) } + let!(:proposal_two) { create(:proposal, component:) } before do get :index @@ -21,41 +22,29 @@ end it "returns all containers" do - expect(response.parsed_body).to eq({ "containers" => [{ "reference" => container.reference, - "source" => container.source, - "name" => container.name, - "description" => container.description, - "metadata" => container.metadata, - "created_at" => container.created_at.as_json, - "updated_at" => container.updated_at.as_json, - "deleted_at" => container.deleted_at }, # deleted_at is nil - { "reference" => container_two.reference, - "source" => container_two.source, - "name" => container_two.name, - "description" => container_two.description, - "metadata" => container_two.metadata, - "created_at" => container_two.created_at.as_json, - "updated_at" => container_two.updated_at.as_json, - "deleted_at" => container_two.deleted_at }] }) + # proposals are created from participatory_process + expect(response.parsed_body["containers"].size).to eq(1) end end context "when there are no containers" do - it "is a success and returns json without containers" do + it "is a not_found and returns json without authors" do get :index - expect(response).to have_http_status(:ok) + expect(response).to have_http_status(:not_found) expect(response.content_type).to include("application/json") - expect(response.parsed_body["containers"].size).to eq(0) + expect(response.parsed_body).to eq({ "error" => "Containers not found" }) end end end describe "show" do context "when container exists" do - let!(:container) { create(:container) } + let(:component) { create(:proposal_component) } + let(:proposal) { create(:proposal, component:) } + let!(:container) { proposal.component.participatory_space_type.constantize.find(proposal.component.participatory_space_id) } before do - get :show, params: { reference: "B01" } + get :show, params: { reference: container.reference } end it "is a success and returns json" do @@ -65,16 +54,10 @@ end it "returns the container" do - expect(response.parsed_body).to eq({ - "reference" => container.reference, - "source" => container.source, - "name" => container.name, - "description" => container.description, - "metadata" => container.metadata, - "created_at" => container.created_at.as_json, - "updated_at" => container.updated_at.as_json, - "deleted_at" => container.deleted_at - }) + expect(response.parsed_body["reference"]).to eq(container.reference) + expect(response.parsed_body["source"]).to eq(Decidim::ResourceLocatorPresenter.new(container).url) + expect(response.parsed_body["name"]).to eq(container.title["en"]) + expect(response.parsed_body["description"]).to eq(container.description["en"]) end end diff --git a/spec/controllers/decidim/dataspace/api/v1/contributions_controller_spec.rb b/spec/controllers/decidim/dataspace/api/v1/contributions_controller_spec.rb index 5246dcf..04ab7bc 100644 --- a/spec/controllers/decidim/dataspace/api/v1/contributions_controller_spec.rb +++ b/spec/controllers/decidim/dataspace/api/v1/contributions_controller_spec.rb @@ -7,65 +7,51 @@ describe "index" do context "when there are contributions" do - let(:contribution) { create(:contribution, :with_two_authors) } - let!(:parent_contrib) { create(:parent_contrib, container: contribution.container) } + let(:component) { create(:proposal_component) } + let!(:proposal) { create(:proposal, component:) } + let!(:proposal_two) { create(:proposal, component:) } + let!(:proposal_three) { create(:proposal, component:) } it "is a success and returns json with contributions" do get :index expect(response).to have_http_status(:ok) expect(response.content_type).to include("application/json") expect { response.parsed_body }.not_to raise_error - expect(response.parsed_body["contributions"].size).to eq(2) + expect(response.parsed_body["contributions"].size).to eq(3) end end context "when there are no contributions" do - it "is a success and returns json with empty contributions" do + it "is a not_found and returns json without authors" do get :index - expect(response).to have_http_status(:ok) + expect(response).to have_http_status(:not_found) expect(response.content_type).to include("application/json") - expect { response.parsed_body }.not_to raise_error - expect(response.parsed_body["contributions"].size).to eq(0) + expect(response.parsed_body).to eq({ "error" => "Contributions not found" }) end end end describe "show" do - let(:contribution) { create(:contribution, :with_two_authors) } - let!(:parent_contrib) { create(:parent_contrib, container: contribution.container) } + let(:component) { create(:proposal_component) } + let!(:proposal) { create(:proposal, component:) } context "when contribution exists" do before do - # rubocop:disable Rails/SkipsModelValidations - contribution.update_column("parent_id", parent_contrib.id) - # rubocop:enable Rails/SkipsModelValidations - get :show, params: { reference: "C01" } + get :show, params: { reference: proposal.reference } end it "is a success and returns json" do - get :show, params: { reference: "C01" } expect(response).to have_http_status(:ok) expect(response.content_type).to include("application/json") expect { response.parsed_body }.not_to raise_error end it "returns the contribution" do - expect(response.parsed_body).to eq({ - "reference" => contribution.reference, - "source" => contribution.source, - "container" => contribution.container.reference, - "locale" => contribution.locale, - "title" => contribution.title, - "content" => contribution.content, - "authors" => contribution.authors&.map(&:reference), - "metadata" => contribution.metadata, - "parent" => contribution.parent&.reference, - "created_at" => contribution.created_at.as_json, - "updated_at" => contribution.updated_at.as_json, - "deleted_at" => contribution.deleted_at - }) - expect(response.parsed_body["authors"]).to eq(%w(A01 A02)) - expect(response.parsed_body["parent"]).to eq("C02") + expect(response.parsed_body["reference"]).to eq(proposal.reference) + expect(response.parsed_body["source"]).to eq(Decidim::ResourceLocatorPresenter.new(proposal).url) + expect(response.parsed_body["container"]).to eq(proposal.component.participatory_space_type.constantize.find(proposal.component.participatory_space_id).reference) + expect(response.parsed_body["title"]).to eq(proposal.title["en"]) + expect(response.parsed_body["content"]).to eq(proposal.body["en"]) end end diff --git a/spec/controllers/decidim/dataspace/api/v1/data_controller_spec.rb b/spec/controllers/decidim/dataspace/api/v1/data_controller_spec.rb index 463de38..255a10a 100644 --- a/spec/controllers/decidim/dataspace/api/v1/data_controller_spec.rb +++ b/spec/controllers/decidim/dataspace/api/v1/data_controller_spec.rb @@ -5,9 +5,10 @@ describe Decidim::Dataspace::Api::V1::DataController do routes { Decidim::Dataspace::Engine.routes } - let!(:author) { create(:author_one) } - let(:container) { create(:container) } - let!(:contribution) { create(:contribution, container:) } + let(:component) { create(:proposal_component) } + let!(:proposal) { create(:proposal, :participant_author, component:) } + let!(:proposal_two) { create(:proposal, :official_meeting, component:) } + let!(:proposal_three) { create(:proposal, :official, component:) } describe "index" do before do @@ -21,29 +22,9 @@ end it "returns all data" do - expect(response.parsed_body).to eq({ "containers" => [{ "reference" => container.reference, - "source" => container.source, - "name" => container.name, - "description" => container.description, - "metadata" => container.metadata, - "created_at" => container.created_at.as_json, - "updated_at" => container.updated_at.as_json, - "deleted_at" => container.deleted_at }], - "contributions" => [{ "reference" => contribution.reference, - "source" => contribution.source, - "container" => contribution.container.reference, - "locale" => contribution.locale, - "title" => contribution.title, - "content" => contribution.content, - "authors" => contribution.authors&.map(&:reference), - "metadata" => contribution.metadata, - "parent" => contribution.parent&.reference, - "created_at" => contribution.created_at.as_json, - "updated_at" => contribution.updated_at.as_json, - "deleted_at" => contribution.deleted_at }], - "authors" => [{ "reference" => author.reference, - "name" => author.name, - "source" => author.source }] }) + expect(response.parsed_body["contributions"].size).to eq(3) + expect(response.parsed_body["authors"].size).to eq(3) + expect(response.parsed_body["containers"].size).to eq(1) end end end diff --git a/spec/factories.rb b/spec/factories.rb index 9607959..b607669 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -1,3 +1,4 @@ # frozen_string_literal: true require "decidim/dataspace/test/factories" +require "decidim/proposals/test/factories" diff --git a/spec/models/author_spec.rb b/spec/models/author_spec.rb index e936f63..935d768 100644 --- a/spec/models/author_spec.rb +++ b/spec/models/author_spec.rb @@ -45,6 +45,60 @@ module Dataspace expect { author.destroy }.to change(Decidim::Dataspace::Interoperable, :count).by(-1) end end + + context "when using from_proposals method" do + let(:component) { create(:proposal_component) } + let!(:proposal) { create(:proposal, :participant_author, component:) } + let!(:proposal_two) { create(:proposal, :official_meeting, component:) } + let!(:proposal_three) { create(:proposal, :official, component:) } + + it "returns an array with 3 hash elements" do + expect(Author.from_proposals.class).to eq(Array) + expect(Author.from_proposals.size).to eq(3) + expect(Author.from_proposals.first.class).to eq(Hash) + end + end + + context "when using proposal_author method" do + context "and user as author" do + let(:component) { create(:proposal_component) } + let(:proposal) { create(:proposal, :participant_author, component:) } + let!(:author) { proposal.authors.first } + + it "returns a hash with author name as reference" do + # for user author, the reference is the name + expect(Author.proposal_author(author.name).class).to eq(Hash) + expect(Author.proposal_author(author.name).size).to eq(3) + expect(Author.proposal_author(author.name)[:reference]).to eq(proposal.authors.first.name) + end + end + + context "and official as author" do + let(:component) { create(:proposal_component) } + let(:proposal) { create(:proposal, :official, component:) } + let!(:author) { proposal.authors.first } + + it "returns a hash with organization reference_prefix as reference" do + # for official author, the reference is the reference_prefix + expect(Author.proposal_author(author.reference_prefix).class).to eq(Hash) + expect(Author.proposal_author(author.reference_prefix).size).to eq(3) + expect(Author.proposal_author(author.reference_prefix)[:reference]).to eq(proposal.authors.first.reference_prefix) + end + end + + context "and official meeting as author" do + let(:component) { create(:proposal_component) } + let(:proposal) { create(:proposal, :official_meeting, component:) } + let!(:author) { proposal.authors.first } + + it "returns a hash with organization reference_prefix as reference" do + # for official_meeting author, the reference is the reference + expect(Author.proposal_author(author.reference).class).to eq(Hash) + expect(Author.proposal_author(author.reference).size).to eq(3) + expect(Author.proposal_author(author.reference)[:reference]).to eq(proposal.authors.first.reference) + end + end + end end end end diff --git a/spec/models/container_spec.rb b/spec/models/container_spec.rb index 48dbee1..d3c6c5e 100644 --- a/spec/models/container_spec.rb +++ b/spec/models/container_spec.rb @@ -66,6 +66,44 @@ module Dataspace expect { container.destroy }.to change(Decidim::Dataspace::Interoperable, :count).by(-1) end end + + context "when using from_proposals method" do + let(:component) { create(:proposal_component) } + let!(:proposal) { create(:proposal,component:) } + let!(:proposal_two) { create(:proposal, component:) } + let!(:proposal_three) { create(:proposal, component:) } + + it "returns an array with 1 hash element" do + expect(Container.from_proposals.class).to eq(Array) + expect(Container.from_proposals.size).to eq(1) # the 3 proposals have the same container + expect(Container.from_proposals.first.class).to eq(Hash) + end + end + + context "when using from_params method" do + let(:component) { create(:proposal_component) } + let!(:proposal) { create(:proposal, component:) } + # proposal container is a process + let!(:container) { Decidim::ParticipatoryProcess.find(component.participatory_space_id) } + + it "returns a hash with container reference as reference" do + expect(Container.from_params(container.reference).class).to eq(Hash) + expect(Container.from_params(container.reference).size).to eq(8) + expect(Container.from_params(container.reference)[:reference]).to eq(container.reference) + end + end + + context "when using from_proposal method" do + let(:component) { create(:proposal_component) } + let!(:proposal) { create(:proposal, component:) } + let!(:container) { Decidim::ParticipatoryProcess.find(component.participatory_space_id) } + + it "returns a hash with container reference as reference" do + expect(Container.from_proposal(proposal).class).to eq(Hash) + expect(Container.from_proposal(proposal).size).to eq(8) + expect(Container.from_proposal(proposal)[:reference]).to eq(container.reference) + end + end end end end diff --git a/spec/models/contribution_spec.rb b/spec/models/contribution_spec.rb index 10e5ee0..88a81f8 100644 --- a/spec/models/contribution_spec.rb +++ b/spec/models/contribution_spec.rb @@ -110,6 +110,34 @@ module Dataspace expect { contribution.destroy }.to change(Decidim::Dataspace::Interoperable, :count).by(-1) end end + + context "when using self.from_proposals method" do + let(:component) { create(:proposal_component) } + let!(:proposal) { create(:proposal, component:) } + let!(:proposal_two) { create(:proposal, component:) } + let!(:proposal_three) { create(:proposal, component:) } + + it "returns an array with 3 hash elements" do + method_call = Contribution.from_proposals + expect(method_call.class).to eq(Array) + expect(method_call.size).to eq(3) + expect(method_call.first.class).to eq(Hash) + end + end + + context "when using self.proposal method" do + let(:component) { create(:proposal_component) } + let!(:proposal) { create(:proposal, :participant_author, component:) } + + it "returns an array with 1 hash element" do + method_call = Contribution.proposal(proposal.reference) + expect(method_call.class).to eq(Hash) + expect(method_call.size).to eq(10) + expect(method_call[:reference]).to eq(proposal.reference) + # reference for user author is name + expect(method_call[:authors]).to eq([proposal.authors.first.name]) + end + end end end end From 785fb260e97f3ce5c3f296a05d45f7fb4953d90c Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 3 Oct 2025 11:06:22 +0200 Subject: [PATCH 06/33] feat: filter retrieved proposals --- app/models/decidim/dataspace/contribution.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/models/decidim/dataspace/contribution.rb b/app/models/decidim/dataspace/contribution.rb index 7e2b1a3..9861de1 100644 --- a/app/models/decidim/dataspace/contribution.rb +++ b/app/models/decidim/dataspace/contribution.rb @@ -18,7 +18,10 @@ class Contribution < Decidim::Dataspace::Interoperable delegate :reference, :source, :metadata, :created_at, :updated_at, :deleted_at, to: :interoperable def self.from_proposals - Decidim::Proposals::Proposal.includes(:component).all.map do |proposal| + Decidim::Proposals::Proposal.published + .not_hidden + .only_amendables + .includes(:component).all.map do |proposal| { reference: proposal.reference, source: Decidim::ResourceLocatorPresenter.new(proposal).url, From 09db7238900b709c4674853c3e8627443b24895e Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 3 Oct 2025 11:07:38 +0200 Subject: [PATCH 07/33] feat: add service to get data from api --- app/services/get_data_from_api.rb | 77 +++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 app/services/get_data_from_api.rb diff --git a/app/services/get_data_from_api.rb b/app/services/get_data_from_api.rb new file mode 100644 index 0000000..3982e47 --- /dev/null +++ b/app/services/get_data_from_api.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "net/http" +require "uri" + +class GetDataFromApi + + def self.data(url) + uri = URI(url + "/api/v1/data") + begin + result = Net::HTTP.get(uri) + JSON.parse(result) + rescue StandardError + nil + end + end + + def self.contributions(url) + uri = URI(url + "/api/v1/data/contributions") + begin + result = Net::HTTP.get(uri) + JSON.parse(result) + rescue StandardError + nil + end + end + + def self.containers(url) + uri = URI(url + "/api/v1/data/containers") + begin + result = Net::HTTP.get(uri) + JSON.parse(result) + rescue StandardError + nil + end + end + + def self.authors(url) + uri = URI(url + "/api/v1/data/authors") + begin + result = Net::HTTP.get(uri) + JSON.parse(result) + rescue StandardError + nil + end + end + + def self.contribution(url, ref) + uri = URI(url + "/api/v1/data/contributions/#{ref}") + begin + result = Net::HTTP.get(uri) + JSON.parse(result) + rescue StandardError + nil + end + end + + def self.container(url, ref) + uri = URI(url + "/api/v1/data/containers/#{ref}") + begin + result = Net::HTTP.get(uri) + JSON.parse(result) + rescue StandardError + nil + end + end + + def self.author(url, ref) + uri = URI(url + "/api/v1/data/authors/#{ref}") + begin + result = Net::HTTP.get(uri) + JSON.parse(result) + rescue StandardError + nil + end + end +end From 25c54c8b0f309139ff8cc23247426a5942ef4295 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 3 Oct 2025 11:08:02 +0200 Subject: [PATCH 08/33] test: add tests for service --- .../get_data_from_api_service_spec.rb | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 spec/services/get_data_from_api_service_spec.rb diff --git a/spec/services/get_data_from_api_service_spec.rb b/spec/services/get_data_from_api_service_spec.rb new file mode 100644 index 0000000..267663b --- /dev/null +++ b/spec/services/get_data_from_api_service_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "spec_helper" +require "net/http" +require "uri" + +RSpec.describe GetDataFromApi, type: :model do + context "when testing contributions" do + let(:url) { "http://localhost:3000" } + + context "when testing list" do + let(:uri) { URI("http://localhost:3000/api/v1/data/contributions") } + let(:json) do + { "contributions" => [{ "reference" => "JD-PROP-2025-09-1", + "source" => "http://localhost:3000/processes/satisfaction-hope/f/7/proposals/1", + "container" => "JD-PART-2025-09-1", + "locale" => "en", + "title" => "Test one", + "content" => "Debitis repellat provident", + "authors" => ["JD-MEET-2025-09-6"], + "created_at" => "2025-09-11T10:20:21.222Z", + "updated_at" => "2025-09-11T10:21:56.604Z", + "deleted_at" => nil }, + { "reference" => "JD-PROP-2025-09-20", + "source" => "http://localhost:3000/assemblies/smile-trivial/f/25/proposals/20", + "container" => "JD-ASSE-2025-09-1", + "locale" => "en", + "title" => "Test two", + "content" => "Non et vel", + "authors" => ["JD-MEET-2025-09-23"], + "created_at" => "2025-09-11T10:43:23.743Z", + "updated_at" => "2025-09-11T10:43:27.147Z", + "deleted_at" => nil }] }.to_json + end + + before do + allow(Net::HTTP).to receive(:get).with(uri).and_return(json) + end + + it "returns json containing a list of contributions" do + response = GetDataFromApi.contributions(url) + expect(response.class).to eq Hash + expect(response["contributions"].size).to eq(2) + expect(response["contributions"][0]["title"]).to eq("Test one") + end + end + + context "when testing show" do + let(:uri) { URI("http://localhost:3000/api/v1/data/contributions/JD-PROP-2025-09-1") } + let(:json) do + { "reference" => "JD-PROP-2025-09-1", + "source" => "http://localhost:3000/processes/satisfaction-hope/f/7/proposals/1", + "container" => "JD-PART-2025-09-1", + "locale" => "en", + "title" => "Test one", + "content" => "Debitis repellat provident", + "authors" => ["JD-MEET-2025-09-6"], + "created_at" => "2025-09-11T10:20:21.222Z", + "updated_at" => "2025-09-11T10:21:56.604Z", + "deleted_at" => nil }.to_json + end + + let(:ref) { "JD-PROP-2025-09-1" } + + before do + allow(Net::HTTP).to receive(:get).with(uri).and_return(json) + end + + it "returns json containing one contribution" do + response = GetDataFromApi.contribution(url, ref) + expect(response.class).to eq Hash + expect(response["reference"]).to eq("JD-PROP-2025-09-1") + expect(response["title"]).to eq("Test one") + end + end + end +end From e1e1325b0515e6bcd07186a7f635dd141ef641d2 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 3 Oct 2025 11:09:06 +0200 Subject: [PATCH 09/33] feat: add proposals index extends to display external proposals --- lib/decidim/dataspace/engine.rb | 6 ++ .../proposals/proposals_controller_extends.rb | 85 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 lib/extends/controllers/decidim/proposals/proposals_controller_extends.rb diff --git a/lib/decidim/dataspace/engine.rb b/lib/decidim/dataspace/engine.rb index 2963139..6c63122 100644 --- a/lib/decidim/dataspace/engine.rb +++ b/lib/decidim/dataspace/engine.rb @@ -27,6 +27,12 @@ class Engine < ::Rails::Engine end end + initializer "dataspace-extends" do + config.after_initialize do + require "extends/controllers/decidim/proposals/proposals_controller_extends" + end + end + initializer "dataspace.mount_routes" do Decidim::Core::Engine.routes do mount Decidim::Dataspace::Engine => "/" diff --git a/lib/extends/controllers/decidim/proposals/proposals_controller_extends.rb b/lib/extends/controllers/decidim/proposals/proposals_controller_extends.rb new file mode 100644 index 0000000..211dc26 --- /dev/null +++ b/lib/extends/controllers/decidim/proposals/proposals_controller_extends.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "active_support/concern" + +module ProposalsControllerExtends + extend ActiveSupport::Concern + + included do + def index + if component_settings.participatory_texts_enabled? + @proposals = Decidim::Proposals::Proposal.where(component: current_component) + .published + .not_hidden + .only_amendables + .includes(:category, :scope, :attachments, :coauthorships) + .order(position: :asc) + render "decidim/proposals/proposals/participatory_texts/participatory_text" + else + if component_settings.add_integration && + component_settings.integration_url.present? && + external_proposals ||= GetDataFromApi.contributions(component_settings.integration_url).presence + + proposals = search.result + proposals = reorder(proposals.includes(:component, :coauthorships, :attachments)) + external_proposals = external_proposals["contributions"] + @total_count = proposals.size + external_proposals.size + @current_page = params[:page].to_i + @current_page = 1 if @current_page < 1 + @total_pages = (@total_count.to_f / per_page).ceil + @proposals, @external_proposals = define_proposals_and_external_proposals(proposals, external_proposals, @current_page, per_page) + # Create a pagination object for view + @pagination = create_pagination_object(@total_count, @current_page, per_page) + else + @proposals = search.result + @proposals = reorder(@proposals) + @proposals = paginate(@proposals) + @proposals = @proposals.includes(:component, :coauthorships, :attachments) + end + + @voted_proposals = voted_proposals + end + end + + private + + def voted_proposals + if current_user + Decidim::Proposals::ProposalVote.where( + author: current_user, + proposal: @proposals.pluck(:id) + ).pluck(:decidim_proposal_id) + else + [] + end + end + + def define_proposals_and_external_proposals(proposals, external_proposals, current_page, per_page) + @proposals = [] + @external_proposals = [] + offset = (current_page - 1) * per_page + total_proposals = proposals.size + if offset < total_proposals + # Only proposals on page + proposals_to_show = [per_page, total_proposals - offset].min + @proposals = proposals.offset(offset).limit(proposals_to_show) + + # proposals + external_proposals + remaining_slots = per_page - proposals_to_show + @external_proposals = external_proposals[0, remaining_slots] || [] if remaining_slots.positive? + else + # Only external_proposals + external_offset = offset - total_proposals + @external_proposals = external_proposals[external_offset, per_page] || [] + end + [@proposals, @external_proposals] + end + + def create_pagination_object(total_count, current_page, per_page) + Kaminari.paginate_array([], total_count:) + .page(current_page).per(per_page) + end + end +end + +Decidim::Proposals::ProposalsController.include(ProposalsControllerExtends) From 03b7f3f0ef181f5f50694257f91179b7041e501d Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 3 Oct 2025 11:10:29 +0200 Subject: [PATCH 10/33] feat: add new sttings to proposal component --- lib/decidim/dataspace/admin_engine.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/decidim/dataspace/admin_engine.rb b/lib/decidim/dataspace/admin_engine.rb index c7e16b1..37b913c 100644 --- a/lib/decidim/dataspace/admin_engine.rb +++ b/lib/decidim/dataspace/admin_engine.rb @@ -19,6 +19,14 @@ class AdminEngine < ::Rails::Engine # root to: "dataspace#index" end + initializer "decidim-dataspace.add_proposal_component_settings" do + manifest = Decidim.find_component_manifest("proposals") + manifest.settings(:global) do |settings| + settings.attribute :add_integration, type: :boolean, default: false + settings.attribute :integration_url, type: :string + end + end + def load_seed nil end From 64ed913b8351e9193a35ae8ef1994fadbd58b2b4 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 3 Oct 2025 11:12:01 +0200 Subject: [PATCH 11/33] feat: add views and js file --- app/packs/entrypoints/decidim_dataspace.js | 2 + .../decidim/dataspace/component_edit_form.js | 18 +++++ .../components/_settings_fields.html.erb | 14 ++++ .../proposals/_external_proposal.html.erb | 20 +++++ .../proposals/proposals/_proposals.html.erb | 41 +++++++++++ .../proposals/proposals/index.html.erb | 73 +++++++++++++++++++ 6 files changed, 168 insertions(+) create mode 100644 app/packs/src/decidim/dataspace/component_edit_form.js create mode 100644 app/views/decidim/admin/components/_settings_fields.html.erb create mode 100644 app/views/decidim/proposals/proposals/_external_proposal.html.erb create mode 100644 app/views/decidim/proposals/proposals/_proposals.html.erb create mode 100644 app/views/decidim/proposals/proposals/index.html.erb diff --git a/app/packs/entrypoints/decidim_dataspace.js b/app/packs/entrypoints/decidim_dataspace.js index a516e90..2177ce5 100644 --- a/app/packs/entrypoints/decidim_dataspace.js +++ b/app/packs/entrypoints/decidim_dataspace.js @@ -1,2 +1,4 @@ // Images require.context("../images", true) + +import "src/decidim/dataspace/component_edit_form" diff --git a/app/packs/src/decidim/dataspace/component_edit_form.js b/app/packs/src/decidim/dataspace/component_edit_form.js new file mode 100644 index 0000000..3f24cdd --- /dev/null +++ b/app/packs/src/decidim/dataspace/component_edit_form.js @@ -0,0 +1,18 @@ +document.addEventListener("DOMContentLoaded", function(){ + const integrationCheck = document.querySelector('input#component_settings_add_integration') + const urlDiv = document.querySelector("div.integration_url_container") + if(integrationCheck){ + if(integrationCheck.checked){ + urlDiv.style.display = "block" + } else { + urlDiv.style.display = "none" + } + integrationCheck.addEventListener('change', function(){ + if (this.checked) { + urlDiv.style.display = "block" + } else { + urlDiv.style.display = "none" + } + }) + } +}) diff --git a/app/views/decidim/admin/components/_settings_fields.html.erb b/app/views/decidim/admin/components/_settings_fields.html.erb new file mode 100644 index 0000000..2fc5009 --- /dev/null +++ b/app/views/decidim/admin/components/_settings_fields.html.erb @@ -0,0 +1,14 @@ +<% manifest.settings(settings_name).attributes.each do |field_name, settings_attribute| %> +
+ <%= settings_attribute_input( + form, + settings_attribute, + field_name, + "decidim.components.#{manifest.name}.settings.#{settings_name}", + tabs_prefix:, + readonly: settings_attribute.readonly?(component: @component), + editor: settings_attribute.editor?(component: @component) + ) %> +
+<% end %> +<%= append_javascript_pack_tag "decidim_dataspace.js" %> diff --git a/app/views/decidim/proposals/proposals/_external_proposal.html.erb b/app/views/decidim/proposals/proposals/_external_proposal.html.erb new file mode 100644 index 0000000..38952d7 --- /dev/null +++ b/app/views/decidim/proposals/proposals/_external_proposal.html.erb @@ -0,0 +1,20 @@ +<% if card_size == :g %> + <%= link_to external_proposal["source"], class: "card__grid", id: external_proposal["reference"] do %> +
+ <%= external_icon "media/images/placeholder-card-g.svg", class: "card__placeholder-g" %> +
+
+ <%= content_tag :h4, external_proposal["title"], class: "h4 text-secondary" %> +

Proposal from external platform

+
+ <% end %> +<% else %> + <%= link_to external_proposal["source"], class: "card__list", id: external_proposal["reference"] do %> + <%= content_tag(:div, class: "card__list-content") do %> + <%= content_tag :h4, class: "h4 card__list-title" do %> + <%= external_proposal["title"] %> + <% end %> +

Proposal from external platform

+ <% end %> + <% end %> +<% end %> diff --git a/app/views/decidim/proposals/proposals/_proposals.html.erb b/app/views/decidim/proposals/proposals/_proposals.html.erb new file mode 100644 index 0000000..620dd22 --- /dev/null +++ b/app/views/decidim/proposals/proposals/_proposals.html.erb @@ -0,0 +1,41 @@ +<% if params.dig("filter", "with_availability").present? && params["filter"]["with_availability"] == "withdrawn" %> + <%= cell("decidim/announcement", t("decidim.proposals.proposals.index.text_banner", + go_back_link: link_to(t("decidim.proposals.proposals.index.click_here"), proposals_path("filter[with_availability]" => nil)) + ).html_safe, callout_class: "warning [&>a]:text-secondary") %> +<% end %> + +<% if @proposals.empty? && @external_proposals.empty? %> + <%= cell("decidim/announcement", params[:filter].present? ? t(".empty_filters") : t(".empty")) %> +<% else %> +
+

<%= t("count", scope: "decidim.proposals.proposals.index", count: @total_count || @proposals.total_count ) %>

+ +
+ + <%= order_selector available_orders, i18n_scope: "decidim.proposals.proposals.orders" %> + +
+ <%= render partial: "proposal", collection: @proposals, as: :proposal, locals: { card_size: card_size_for_view_mode(@view_mode) } %> + <% if @external_proposals.present? %> + <%= render partial: "external_proposal", collection: @external_proposals, as: :external_proposal, locals: { card_size: card_size_for_view_mode(@view_mode) } %> + <% end %> +
+ <% if @total_pages && @total_pages > 1 %> + <% per_page = (params[:per_page] || Decidim::Paginable::OPTIONS.first).to_i %> +
+ <%= render partial: "decidim/shared/results_per_page", locals: { per_page: }, formats: [:html] %> + <%= paginate @pagination, window: 2, outer_window: 1, theme: "decidim" %> +
+ <% elsif !@total_pages %> + <%= decidim_paginate @proposals %> + <% end %> +<% end %> + +<% if params.dig("filter", "with_availability").present? && params["filter"]["with_availability"] == "withdrawn" %> + <%= link_to t("decidim.proposals.proposals.index.see_all"), proposals_path("filter[with_availability]" => nil), class: "button button__sm button__text-secondary" %> +<% else %> + <%= link_to t("decidim.proposals.proposals.index.see_all_withdrawn"), proposals_path(filter: { with_availability: "withdrawn", with_any_state: [] }), class: "button button__sm button__text-secondary" %> +<% end %> diff --git a/app/views/decidim/proposals/proposals/index.html.erb b/app/views/decidim/proposals/proposals/index.html.erb new file mode 100644 index 0000000..17ccb8b --- /dev/null +++ b/app/views/decidim/proposals/proposals/index.html.erb @@ -0,0 +1,73 @@ +<% add_decidim_meta_tags( + description: translated_attribute(current_participatory_space.short_description), + title: t("decidim.components.pagination.page_title", + component_name:, + current_page: @current_page || @proposals.current_page, + total_pages: @total_pages || @proposals.total_pages), + url: proposals_url, + resource: current_component) %> + +<% append_stylesheet_pack_tag "decidim_proposals", media: "all" %> +<% append_javascript_pack_tag "decidim_proposals" %> + +<% content_for :aside do %> +

<%= component_name %>

+ +
+ <% if current_settings.creation_enabled && current_component.participatory_space.can_participate?(current_user) %> + <%= action_authorized_link_to :create, new_proposal_path, class: "button button__xl button__secondary w-full", data: { "redirect_url" => new_proposal_path } do %> + <%= t("new_proposal", scope: "decidim.proposals.proposals.index") %> + <%= icon "add-line" %> + <% end %> + <% end %> + + <% if component_settings.collaborative_drafts_enabled? %> + <%= link_to collaborative_drafts_path, class: "button button__sm button__transparent-secondary" do %> + <%= t("collaborative_drafts_list", scope: "decidim.proposals.proposals.index") %> + <%= icon "edit-2-line" %> + <% end %> + <% end %> +
+ <% if @proposals.present? %> + <%= render layout: "decidim/shared/filters", locals: { filter_sections: , search_variable: :search_text_cont, skip_to_id: "proposals" } do %> + <%= hidden_field_tag :order, order, id: nil, class: "order_filter" %> + <% end %> + <% end %> +<% end %> + +<%= render layout: "layouts/decidim/shared/layout_two_col" do %> + + <% if Decidim::Map.available?(:geocoding, :dynamic) && component_settings.geocoding_enabled? %> +
+ <%= dynamic_map_for proposals_data_for_map(@proposals) do %> + + <% end %> +
+ <% end %> + + <%= render partial: "decidim/shared/component_announcement" %> + + <% if show_voting_rules? %> +
+ <%= render partial: "voting_rules" %> +
+ <% end %> + +
+ <%= render partial: "proposals" %> +
+ +<% end %> From 2e204bfa14ed31cc57ea5a6dfb534c65343fce84 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 3 Oct 2025 11:12:49 +0200 Subject: [PATCH 12/33] feat: add translations keys for new settings --- config/locales/en.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config/locales/en.yml b/config/locales/en.yml index 49fee51..1167f94 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -4,3 +4,9 @@ en: components: dataspace: name: Dataspace + proposals: + settings: + global: + add_integration: Add an integration + integration_url: Url platform to integrate + integration_url_text: "Provide the url, for instance https://www.platform.com" From 99e205fee8fc511ae6db678686db4fbe5ac70f6f Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 3 Oct 2025 11:13:39 +0200 Subject: [PATCH 13/33] test: add controller and system tests for proposals index --- .../proposals/proposals_controller_spec.rb | 419 ++++++++++++++++++ spec/system/proposals_index_spec.rb | 116 +++++ 2 files changed, 535 insertions(+) create mode 100644 spec/controllers/decidim/proposals/proposals_controller_spec.rb create mode 100644 spec/system/proposals_index_spec.rb diff --git a/spec/controllers/decidim/proposals/proposals_controller_spec.rb b/spec/controllers/decidim/proposals/proposals_controller_spec.rb new file mode 100644 index 0000000..801df77 --- /dev/null +++ b/spec/controllers/decidim/proposals/proposals_controller_spec.rb @@ -0,0 +1,419 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Proposals + describe ProposalsController do + routes { Decidim::Proposals::Engine.routes } + + let(:user) { create(:user, :confirmed, organization: component.organization) } + + let(:proposal_params) do + { + component_id: component.id + } + end + let(:params) { { proposal: proposal_params } } + + before do + request.env["decidim.current_organization"] = component.organization + request.env["decidim.current_participatory_space"] = component.participatory_space + request.env["decidim.current_component"] = component + stub_const("Decidim::Paginable::OPTIONS", [100]) + end + + describe "GET index" do + context "when participatory texts are disabled" do + let(:component) { create(:proposal_component, :with_geocoding_enabled) } + + context "and there are no externals proposals" do + it "sorts proposals by search defaults" 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()"] + end + end + + context "and there are externals proposals" do + let(:component) { create(:proposal_component) } + let!(:proposals) { create_list(:proposal, 2, component:) } + + let(:json) do + { "contributions" => [{ "reference" => "JD-PROP-2025-09-1", + "source" => "http://localhost:3000/processes/satisfaction-hope/f/7/proposals/1", + "container" => "JD-PART-2025-09-1", + "locale" => "en", + "title" => "Test one", + "content" => "Debitis repellat provident", + "authors" => ["JD-MEET-2025-09-6"], + "created_at" => "2025-09-11T10:20:21.222Z", + "updated_at" => "2025-09-11T10:21:56.604Z", + "deleted_at" => nil }, + { "reference" => "JD-PROP-2025-09-20", + "source" => "http://localhost:3000/assemblies/smile-trivial/f/25/proposals/20", + "container" => "JD-ASSE-2025-09-1", + "locale" => "en", + "title" => "Test two", + "content" => "Non et vel", + "authors" => ["JD-MEET-2025-09-23"], + "created_at" => "2025-09-11T10:43:23.743Z", + "updated_at" => "2025-09-11T10:43:27.147Z", + "deleted_at" => nil }] } + end + + before do + component.update!(settings: { add_integration: true, integration_url: "http://example.org", comments_enabled: false }) + allow(GetDataFromApi).to receive(:contributions).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(: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 "sets two different collections" do + geocoded_proposals = create_list(:proposal, 10, component:, latitude: 1.1, longitude: 2.2) + non_geocoded_proposals = create_list(:proposal, 2, component:, latitude: nil, longitude: nil) + + get :index + expect(response).to have_http_status(:ok) + expect(subject).to render_template(:index) + + expect(assigns(:proposals).count).to eq 12 + expect(assigns(:proposals)).to match_array(geocoded_proposals + non_geocoded_proposals) + end + end + + context "when participatory texts are enabled" do + let(:component) { create(:proposal_component, :with_participatory_texts_enabled) } + + it "sorts proposals by position" do + get :index + expect(response).to have_http_status(:ok) + expect(subject).to render_template(:participatory_text) + expect(assigns(:proposals).order_values.first.expr.name).to eq("position") + end + + context "when emendations exist" do + let!(:amendable) { create(:proposal, component:) } + let!(:emendation) { create(:proposal, component:) } + let!(:amendment) { create(:amendment, amendable:, emendation:, state: "accepted") } + + it "does not include emendations" do + get :index + expect(response).to have_http_status(:ok) + emendations = assigns(:proposals).select(&:emendation?) + expect(emendations).to be_empty + end + end + end + end + + describe "GET new" do + let(:component) { create(:proposal_component, :with_creation_enabled) } + + before { sign_in user } + + context "when NO draft proposals exist" do + it "renders the empty form" do + get(:new, params:) + expect(response).to have_http_status(:ok) + expect(subject).to render_template(:new) + end + end + + context "when draft proposals exist from other users" do + let!(:others_draft) { create(:proposal, :draft, component:) } + + it "renders the empty form" do + get(:new, params:) + expect(response).to have_http_status(:ok) + expect(subject).to render_template(:new) + end + end + end + + context "when user is not logged in" do + let(:component) { create(:proposal_component, :with_creation_enabled) } + + it "redirects to the login page" do + get(:new) + expect(response).to have_http_status(:found) + expect(response.body).to have_text("You are being redirected") + end + end + + describe "POST create" do + before { sign_in user } + + context "when creation is not enabled" do + let(:component) { create(:proposal_component) } + + it "raises an error" do + post(:create, params:) + + expect(flash[:alert]).not_to be_empty + end + end + + context "when creation is enabled" do + let(:component) { create(:proposal_component, :with_creation_enabled) } + let(:proposal_params) do + { + component_id: component.id, + title: "Lorem ipsum dolor sit amet, consectetur adipiscing elit", + body: "Ut sed dolor vitae purus volutpat venenatis. Donec sit amet sagittis sapien. Curabitur rhoncus ullamcorper feugiat. Aliquam et magna metus." + } + end + + it "creates a proposal" do + post(:create, params:) + + expect(flash[:notice]).not_to be_empty + expect(response).to have_http_status(:found) + end + end + end + + describe "PATCH update" do + let(:component) { create(:proposal_component, :with_creation_enabled, :with_attachments_allowed) } + let(:proposal) { create(:proposal, component:, users: [user]) } + let(:proposal_params) do + { + title: "Lorem ipsum dolor sit amet, consectetur adipiscing elit", + body: "Ut sed dolor vitae purus volutpat venenatis. Donec sit amet sagittis sapien. Curabitur rhoncus ullamcorper feugiat. Aliquam et magna metus." + } + end + let(:params) do + { + id: proposal.id, + proposal: proposal_params + } + end + + before { sign_in user } + + it "updates the proposal" do + patch(:update, params:) + + expect(flash[:notice]).not_to be_empty + expect(response).to have_http_status(:found) + end + + context "when the existing proposal has attachments and there are other errors on the form" do + include_context "with controller rendering the view" do + let(:proposal_params) do + { + title: "Short", + # When the proposal has existing photos or documents, their IDs + # will be sent as Strings in the form payload. + photos: proposal.photos.map { |a| a.id.to_s }, + documents: proposal.documents.map { |a| a.id.to_s } + } + end + let(:proposal) { create(:proposal, :with_photo, :with_document, component:, users: [user]) } + + it "displays the editing form with errors" do + patch(:update, params:) + + expect(flash[:alert]).not_to be_empty + expect(response).to have_http_status(:ok) + expect(subject).to render_template(:edit) + expect(response.body).to include("There was a problem saving") + end + end + end + end + + describe "access links from creating proposal steps" do + let!(:component) { create(:proposal_component, :with_creation_enabled) } + let!(:current_user) { create(:user, :confirmed, organization: component.organization) } + let!(:proposal_extra) { create(:proposal, :draft, component:, users: [current_user]) } + let!(:params) do + { + id: proposal_extra.id, + proposal: proposal_params + } + end + + before { sign_in user } + + context "when you try to preview a proposal created by another user" do + it "will not render the preview page" do + get(:preview, params:) + expect(subject).not_to render_template(:preview) + end + end + + context "when you try to publish a proposal created by another user" do + it "will not render the publish page" do + post(:publish, params:) + expect(subject).not_to render_template(:publish) + end + end + end + + describe "withdraw a proposal" do + let(:component) { create(:proposal_component, :with_creation_enabled) } + + before { sign_in user } + + context "when an authorized user is withdrawing a proposal" do + let(:proposal) { create(:proposal, component:, users: [user]) } + + it "withdraws the proposal" do + put :withdraw, params: params.merge(id: proposal.id) + + expect(flash[:notice]).to eq("Proposal successfully updated.") + expect(response).to have_http_status(:found) + proposal.reload + expect(proposal).to be_withdrawn + end + + context "and the proposal already has votes" do + let(:proposal) { create(:proposal, :with_votes, component:, users: [user]) } + + it "is not able to withdraw the proposal" do + put :withdraw, params: params.merge(id: proposal.id) + + expect(flash[:alert]).to eq("This proposal cannot be withdrawn because it already has votes.") + expect(response).to have_http_status(:found) + proposal.reload + expect(proposal).not_to be_withdrawn + end + end + end + + describe "when current user is NOT the author of the proposal" do + let(:current_user) { create(:user, :confirmed, organization: component.organization) } + let(:proposal) { create(:proposal, component:, users: [current_user]) } + + context "and the proposal has no votes" do + it "is not able to withdraw the proposal" do + expect(WithdrawProposal).not_to receive(:call) + + put :withdraw, params: params.merge(id: proposal.id) + + expect(flash[:alert]).to eq("You are not authorized to perform this action.") + expect(response).to have_http_status(:found) + proposal.reload + expect(proposal).not_to be_withdrawn + end + end + end + end + + describe "GET show" do + let!(:component) { create(:proposal_component, :with_amendments_enabled) } + let!(:amendable) { create(:proposal, component:) } + let!(:emendation) { create(:proposal, component:) } + let!(:amendment) { create(:amendment, amendable:, emendation:) } + let(:active_step_id) { component.participatory_space.active_step.id } + + context "when the proposal is an amendable" do + it "shows the proposal" do + get :show, params: params.merge(id: amendable.id) + expect(response).to have_http_status(:ok) + expect(subject).to render_template(:show) + end + + context "and the user is not logged in" do + it "shows the proposal" do + get :show, params: params.merge(id: amendable.id) + expect(response).to have_http_status(:ok) + expect(subject).to render_template(:show) + end + end + end + + context "when the proposal is an emendation" do + context "and amendments VISIBILITY is set to 'participants'" do + before do + component.update!(step_settings: { active_step_id => { amendments_visibility: "participants" } }) + end + + context "when the user is not logged in" do + it "redirects to 404" do + expect do + get :show, params: params.merge(id: emendation.id) + end.to raise_error(ActionController::RoutingError) + end + end + + context "when the user is logged in" do + before { sign_in user } + + context "and the user is the author of the emendation" do + let(:user) { amendment.amender } + + it "shows the proposal" do + get :show, params: params.merge(id: emendation.id) + expect(response).to have_http_status(:ok) + expect(subject).to render_template(:show) + end + end + + context "and is NOT the author of the emendation" do + it "redirects to 404" do + expect do + get :show, params: params.merge(id: emendation.id) + end.to raise_error(ActionController::RoutingError) + end + + context "when the user is an admin" do + let(:user) { create(:user, :admin, :confirmed, organization: component.organization) } + + it "shows the proposal" do + get :show, params: params.merge(id: emendation.id) + expect(response).to have_http_status(:ok) + expect(subject).to render_template(:show) + end + end + end + end + end + + context "and amendments VISIBILITY is set to 'all'" do + before do + component.update!(step_settings: { active_step_id => { amendments_visibility: "all" } }) + end + + context "when the user is not logged in" do + it "shows the proposal" do + get :show, params: params.merge(id: emendation.id) + expect(response).to have_http_status(:ok) + expect(subject).to render_template(:show) + end + end + + context "when the user is logged in" do + before { sign_in user } + + context "and is NOT the author of the emendation" do + it "shows the proposal" do + get :show, params: params.merge(id: emendation.id) + expect(response).to have_http_status(:ok) + expect(subject).to render_template(:show) + end + end + end + end + end + end + end + end +end diff --git a/spec/system/proposals_index_spec.rb b/spec/system/proposals_index_spec.rb new file mode 100644 index 0000000..8c942bb --- /dev/null +++ b/spec/system/proposals_index_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Proposals" do + include ActionView::Helpers::TextHelper + include_context "with a component" + let(:manifest_name) { "proposals" } + + let!(:category) { create(:category, participatory_space: participatory_process) } + let!(:scope) { create(:scope, organization:) } + let!(:user) { create(:user, :confirmed, organization:) } + let(:scoped_participatory_process) { create(:participatory_process, :with_steps, organization:, scope:) } + + let(:proposal_title) { translated(proposal.title) } + + context "when listing proposals in a participatory process" do + shared_examples_for "a random proposal ordering" do + let!(:lucky_proposal) { create(:proposal, component:) } + let!(:unlucky_proposal) { create(:proposal, component:) } + let!(:lucky_proposal_title) { translated(lucky_proposal.title) } + let!(:unlucky_proposal_title) { translated(unlucky_proposal.title) } + + it "lists the proposals ordered randomly by default" do + visit_component + + expect(page).to have_css("a", text: "Random") + expect(page).to have_css("[id^='proposals__proposal']", count: 2) + expect(page).to have_css("[id^='proposals__proposal']", text: lucky_proposal_title) + expect(page).to have_css("[id^='proposals__proposal']", text: unlucky_proposal_title) + expect(page).to have_author(lucky_proposal.creator_author.name) + end + end + + context "when there is no external proposals" do + it "lists all the proposals" do + create(:proposal_component, + manifest:, + participatory_space: participatory_process) + + create_list(:proposal, 3, component:) + + visit_component + # only proposals + expect(page).to have_css("a[class='card__list']", count: 3) + expect(page).to have_css("[id^='proposals__proposal']", count: 3) + end + end + + context "and there are external proposals" do + let(:json) do + { "contributions" => [{ "reference" => "JD-PROP-2025-09-1", + "source" => "http://localhost:3000/processes/satisfaction-hope/f/7/proposals/1", + "container" => "JD-PART-2025-09-1", + "locale" => "en", + "title" => "Test one", + "content" => "Debitis repellat provident", + "authors" => ["JD-MEET-2025-09-6"], + "created_at" => "2025-09-11T10:20:21.222Z", + "updated_at" => "2025-09-11T10:21:56.604Z", + "deleted_at" => nil }, + { "reference" => "JD-PROP-2025-09-20", + "source" => "http://localhost:3000/assemblies/smile-trivial/f/25/proposals/20", + "container" => "JD-ASSE-2025-09-1", + "locale" => "en", + "title" => "Test two", + "content" => "Non et vel", + "authors" => ["JD-MEET-2025-09-23"], + "created_at" => "2025-09-11T10:43:23.743Z", + "updated_at" => "2025-09-11T10:43:27.147Z", + "deleted_at" => nil }] } + end + + 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" }) + allow(GetDataFromApi).to receive(:contributions).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 + 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 + end +end From b67c5956ab105f2c1cb6f7d78d5ca785ab017f54 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 3 Oct 2025 11:45:44 +0200 Subject: [PATCH 14/33] ci: add imagemagick --- .github/workflows/ci_dataspace.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci_dataspace.yml b/.github/workflows/ci_dataspace.yml index 34ec421..ba53310 100644 --- a/.github/workflows/ci_dataspace.yml +++ b/.github/workflows/ci_dataspace.yml @@ -63,6 +63,10 @@ jobs: name: Create test app - run: mkdir -p ./spec/decidim_dummy_app/tmp/screenshots name: Create the screenshots folder + - run: | + sudo apt update + sudo apt install libu2f-udev imagemagick + name: install needed gems - uses: nanasess/setup-chromedriver@v2 - run: RAILS_ENV=test bundle exec rails shakapacker:compile name: Precompile assets From 4c4f2cc4a20071c59aa7adf045ae1ee304da48cd Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 17 Oct 2025 10:46:37 +0200 Subject: [PATCH 15/33] chore: update version of uri --- Gemfile | 1 + Gemfile.lock | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index 1a69455..7b75364 100644 --- a/Gemfile +++ b/Gemfile @@ -9,6 +9,7 @@ gem "decidim-dataspace", path: "." gem "bootsnap", "~> 1.4" gem "puma", ">= 6.3.1" +gem "uri", ">= 1.0.4" group :development, :test do gem "byebug", "~> 11.0", platform: :mri diff --git a/Gemfile.lock b/Gemfile.lock index 714dfcc..5211874 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -765,7 +765,7 @@ GEM uber (0.1.0) unicode-display_width (2.6.0) uniform_notifier (1.17.0) - uri (1.0.3) + uri (1.0.4) valid_email2 (4.0.6) activemodel (>= 3.2) mail (~> 2.5) @@ -826,6 +826,7 @@ DEPENDENCIES letter_opener_web (~> 2.0) listen (~> 3.1) puma (>= 6.3.1) + uri (>= 1.0.4) web-console (~> 4.2) RUBY VERSION From 7e3c3dcd9983447642119b984c22825fbb8b90e9 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 17 Oct 2025 10:58:21 +0200 Subject: [PATCH 16/33] feat: update service and api controllers for escaping ref and adding preferred locale --- .../dataspace/api/v1/authors_controller.rb | 10 ++++-- .../dataspace/api/v1/containers_controller.rb | 10 ++++-- .../api/v1/contributions_controller.rb | 11 +++++-- .../dataspace/api/v1/data_controller.rb | 7 +++-- app/services/get_data_from_api.rb | 31 ++++++++++--------- 5 files changed, 43 insertions(+), 26 deletions(-) diff --git a/app/controllers/decidim/dataspace/api/v1/authors_controller.rb b/app/controllers/decidim/dataspace/api/v1/authors_controller.rb index 5849cc5..0b2d3db 100644 --- a/app/controllers/decidim/dataspace/api/v1/authors_controller.rb +++ b/app/controllers/decidim/dataspace/api/v1/authors_controller.rb @@ -6,9 +6,11 @@ class Api::V1::AuthorsController < Api::V1::BaseController before_action :set_author, only: :show def index - return resource_not_found("Authors") if Author.from_proposals.blank? + preferred_locale = params[:preferred_locale].presence || "en" + authors = Author.from_proposals(preferred_locale) + return resource_not_found("Authors") if authors.blank? - render json: { authors: Author.from_proposals }, status: :ok + render json: authors, status: :ok end def show @@ -16,7 +18,9 @@ def show end def set_author - @author = Author.proposal_author(params[:reference]) + ref = CGI.unescape(params[:reference]) + preferred_locale = params[:preferred_locale].presence || "en" + @author = Author.proposal_author(ref, preferred_locale) return resource_not_found("Author") unless @author @author diff --git a/app/controllers/decidim/dataspace/api/v1/containers_controller.rb b/app/controllers/decidim/dataspace/api/v1/containers_controller.rb index 8cb8c52..5c9a3f0 100644 --- a/app/controllers/decidim/dataspace/api/v1/containers_controller.rb +++ b/app/controllers/decidim/dataspace/api/v1/containers_controller.rb @@ -6,9 +6,11 @@ class Api::V1::ContainersController < Api::V1::BaseController before_action :set_container, only: :show def index - return resource_not_found("Containers") if Container.from_proposals.blank? + preferred_locale = params["preferred_locale"] || "en" + containers = Container.from_proposals(preferred_locale) + return resource_not_found("Containers") if containers.blank? - render json: { containers: Container.from_proposals }, status: :ok + render json: containers, status: :ok end def show @@ -16,7 +18,9 @@ def show end def set_container - @container = Container.from_params(params[:reference]) + ref = CGI.unescape(params[:reference]) + preferred_locale = params["preferred_locale"] || "en" + @container = Container.from_params(ref, preferred_locale) return resource_not_found("Container") unless @container @container diff --git a/app/controllers/decidim/dataspace/api/v1/contributions_controller.rb b/app/controllers/decidim/dataspace/api/v1/contributions_controller.rb index 010602b..4ef2086 100644 --- a/app/controllers/decidim/dataspace/api/v1/contributions_controller.rb +++ b/app/controllers/decidim/dataspace/api/v1/contributions_controller.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require 'uri' module Decidim module Dataspace @@ -6,9 +7,11 @@ class Api::V1::ContributionsController < Api::V1::BaseController before_action :set_contribution, only: :show def index - return resource_not_found("Contributions") if Contribution.from_proposals.blank? + preferred_locale = params[:preferred_locale].presence || "en" + contributions = Contribution.from_proposals(preferred_locale) + return resource_not_found("Contributions") if contributions.blank? - render json: { contributions: Contribution.from_proposals }, status: :ok + render json: contributions, status: :ok end def show @@ -18,7 +21,9 @@ def show private def set_contribution - @contribution = Contribution.proposal(params[:reference]) + ref = CGI.unescape(params[:reference]) + preferred_locale = params[:preferred_locale].presence || "en" + @contribution = Contribution.proposal(ref, preferred_locale) return resource_not_found("Contribution") unless @contribution @contribution diff --git a/app/controllers/decidim/dataspace/api/v1/data_controller.rb b/app/controllers/decidim/dataspace/api/v1/data_controller.rb index f3be15a..4fc8954 100644 --- a/app/controllers/decidim/dataspace/api/v1/data_controller.rb +++ b/app/controllers/decidim/dataspace/api/v1/data_controller.rb @@ -4,11 +4,12 @@ module Decidim module Dataspace class Api::V1::DataController < Api::V1::BaseController def index + locale = params[:preferred_locale].presence || "en" render json: { - containers: Container.from_proposals, - contributions: Contribution.from_proposals, - authors: Author.from_proposals + containers: Container.from_proposals(locale), + contributions: Contribution.from_proposals(locale), + authors: Author.from_proposals(locale) }, status: :ok end end diff --git a/app/services/get_data_from_api.rb b/app/services/get_data_from_api.rb index 3982e47..1a195ef 100644 --- a/app/services/get_data_from_api.rb +++ b/app/services/get_data_from_api.rb @@ -5,8 +5,8 @@ class GetDataFromApi - def self.data(url) - uri = URI(url + "/api/v1/data") + def self.data(url, preferred_locale) + uri = URI(url + "/api/v1/data?preferred_locale=#{preferred_locale}") begin result = Net::HTTP.get(uri) JSON.parse(result) @@ -15,8 +15,8 @@ def self.data(url) end end - def self.contributions(url) - uri = URI(url + "/api/v1/data/contributions") + def self.contributions(url, preferred_locale) + uri = URI(url + "/api/v1/data/contributions?preferred_locale=#{preferred_locale}") begin result = Net::HTTP.get(uri) JSON.parse(result) @@ -25,8 +25,8 @@ def self.contributions(url) end end - def self.containers(url) - uri = URI(url + "/api/v1/data/containers") + def self.containers(url, preferred_locale) + uri = URI(url + "/api/v1/data/containers?preferred_locale=#{preferred_locale}") begin result = Net::HTTP.get(uri) JSON.parse(result) @@ -35,8 +35,8 @@ def self.containers(url) end end - def self.authors(url) - uri = URI(url + "/api/v1/data/authors") + def self.authors(url, preferred_locale) + uri = URI(url + "/api/v1/data/authors?preferred_locale=#{preferred_locale}") begin result = Net::HTTP.get(uri) JSON.parse(result) @@ -45,8 +45,9 @@ def self.authors(url) end end - def self.contribution(url, ref) - uri = URI(url + "/api/v1/data/contributions/#{ref}") + def self.contribution(url, ref, preferred_locale) + ref = CGI.escape(ref) + uri = URI(url + "/api/v1/data/contributions/#{ref}?preferred_locale=#{preferred_locale}") begin result = Net::HTTP.get(uri) JSON.parse(result) @@ -55,8 +56,9 @@ def self.contribution(url, ref) end end - def self.container(url, ref) - uri = URI(url + "/api/v1/data/containers/#{ref}") + def self.container(url, ref, preferred_locale) + ref = CGI.escape(ref) + uri = URI(url + "/api/v1/data/containers/#{ref}?preferred_locale=#{preferred_locale}") begin result = Net::HTTP.get(uri) JSON.parse(result) @@ -65,8 +67,9 @@ def self.container(url, ref) end end - def self.author(url, ref) - uri = URI(url + "/api/v1/data/authors/#{ref}") + def self.author(url, ref, preferred_locale) + ref = CGI.escape(ref) + uri = URI(url + "/api/v1/data/authors/#{ref}?preferred_locale=#{preferred_locale}") begin result = Net::HTTP.get(uri) JSON.parse(result) From 7b8c5da76c8519af18a68757f99a41e9463f3d5c Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 17 Oct 2025 10:59:24 +0200 Subject: [PATCH 17/33] feat: add new setting to choose preferred locale --- lib/decidim/dataspace/admin_engine.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/decidim/dataspace/admin_engine.rb b/lib/decidim/dataspace/admin_engine.rb index 37b913c..77e9efe 100644 --- a/lib/decidim/dataspace/admin_engine.rb +++ b/lib/decidim/dataspace/admin_engine.rb @@ -20,10 +20,13 @@ class AdminEngine < ::Rails::Engine end initializer "decidim-dataspace.add_proposal_component_settings" do + languages = Rails.application.secrets.dig(:decidim, :available_locales) + default_language = Rails.application.secrets.dig(:decidim, :default_locale) manifest = Decidim.find_component_manifest("proposals") manifest.settings(:global) do |settings| settings.attribute :add_integration, type: :boolean, default: false settings.attribute :integration_url, type: :string + settings.attribute :preferred_locale, type: :select, default: default_language, choices: languages end end From 1c9bbc3d3fcec86af5ca6492c8fb053b58a71110 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 17 Oct 2025 11:00:16 +0200 Subject: [PATCH 18/33] feat: update models for preferred locale --- app/models/decidim/dataspace/author.rb | 34 ++++++++++++-------- app/models/decidim/dataspace/container.rb | 31 +++++++++++------- app/models/decidim/dataspace/contribution.rb | 31 +++++++++++------- 3 files changed, 60 insertions(+), 36 deletions(-) diff --git a/app/models/decidim/dataspace/author.rb b/app/models/decidim/dataspace/author.rb index 638b222..a889bea 100644 --- a/app/models/decidim/dataspace/author.rb +++ b/app/models/decidim/dataspace/author.rb @@ -12,44 +12,52 @@ class Author < Decidim::Dataspace::Interoperable delegate :reference, :source, :created_at, :updated_at, :deleted_at, to: :interoperable - def self.from_proposals - proposals = Decidim::Proposals::Proposal.all.map { |proposal| + def self.from_proposals(preferred_locale) + proposals = Decidim::Proposals::Proposal.published + .not_hidden + .only_amendables + locale = "en" + available_locales = proposals.first&.organization&.available_locales + locale = preferred_locale if available_locales.present? && available_locales.include?(preferred_locale) + + proposals.all.map do |proposal| proposal.authors.map do |author| if author.instance_of?(Decidim::Organization) - Author.organization_author(author) + Author.organization_author(author, locale) elsif author.instance_of?(Decidim::Meetings::Meeting) - Author.meeting_author(author) + Author.meeting_author(author, locale) elsif author.instance_of?(Decidim::User) || author.instance_of?(Decidim::UserGroup) Author.user_or_group_author(author) end end - } - proposals.flatten.compact.uniq { |hash| hash[:reference] } + end.compact.flatten.uniq { |hash| hash[:reference] } end - def self.proposal_author(reference) + def self.proposal_author(reference, preferred_locale) if Decidim::User.find_by(name: reference) || Decidim::UserGroup.find_by(name: reference) author = Decidim::User.find_by(name: reference) || Decidim::UserGroup.find_by(name: reference) return Author.user_or_group_author(author) elsif Decidim::Organization.find_by(reference_prefix: reference) author = Decidim::Organization.find_by(reference_prefix: reference) - return Author.organization_author(author) + locale = author.available_locales.include?(preferred_locale) ? preferred_locale : "en" + return Author.organization_author(author, locale) elsif Decidim::Meetings::Meeting.find_by(reference: reference) author = Decidim::Meetings::Meeting.find_by(reference: reference) - return Author.meeting_author(author) + locale = author.organization.available_locales.include?(preferred_locale) ? preferred_locale : "en" + return Author.meeting_author(author, locale) end end - def self.organization_author(author) + def self.organization_author(author, locale) { reference: author.reference_prefix, - name: author.name["en"], + name: author.name[locale], source: author.official_url } end - def self.meeting_author(author) + def self.meeting_author(author, locale) { reference: author.reference, - name: author.title["en"], + name: author.title[locale], source: Decidim::ResourceLocatorPresenter.new(author).url } end diff --git a/app/models/decidim/dataspace/container.rb b/app/models/decidim/dataspace/container.rb index d676588..f7c1f5c 100644 --- a/app/models/decidim/dataspace/container.rb +++ b/app/models/decidim/dataspace/container.rb @@ -12,21 +12,29 @@ class Container < Decidim::Dataspace::Interoperable delegate :reference, :source, :metadata, :created_at, :updated_at, :deleted_at, to: :interoperable - def self.from_proposals - Decidim::Proposals::Proposal.all.map do |proposal| - Container.from_proposal(proposal) + def self.from_proposals(preferred_locale) + proposals = Decidim::Proposals::Proposal.published + .not_hidden + .only_amendables + locale = "en" + available_locales = proposals.first&.organization&.available_locales + locale = preferred_locale if available_locales.present? && available_locales.include?(preferred_locale) + + proposals.map do |proposal| + Container.from_proposal(proposal, locale) end.uniq { |hash| hash[:reference] } end - def self.from_params(params) - container = Decidim::Assembly.find_by(reference: params).presence || Decidim::ParticipatoryProcess.find_by(reference: params).presence + def self.from_params(ref, preferred_locale) + container = Decidim::Assembly.find_by(reference: ref).presence || Decidim::ParticipatoryProcess.find_by(reference: ref).presence return nil unless container + locale = container.organization.available_locales.include?(preferred_locale) ? preferred_locale : "en" { "reference": container.reference, "source": Decidim::ResourceLocatorPresenter.new(container).url, - "name": container.title["en"], - "description": container.description["en"], + "name": container.title[locale], + "description": container.description[locale], "metadata": {}, "created_at": container.created_at, "updated_at": container.updated_at, @@ -34,14 +42,15 @@ def self.from_params(params) } end - def self.from_proposal(proposal) - container = proposal.component.participatory_space_type.constantize.find(proposal.component.participatory_space_id) + def self.from_proposal(proposal, locale) + component = proposal.component + container = component.participatory_space_type.constantize.find(component.participatory_space_id) { "reference": container.reference, "source": Decidim::ResourceLocatorPresenter.new(container).url, - "name": container.title["en"], - "description": container.description["en"], + "name": container.title[locale], + "description": container.description[locale], "metadata": {}, "created_at": container.created_at, "updated_at": container.updated_at, diff --git a/app/models/decidim/dataspace/contribution.rb b/app/models/decidim/dataspace/contribution.rb index 9861de1..0d9c944 100644 --- a/app/models/decidim/dataspace/contribution.rb +++ b/app/models/decidim/dataspace/contribution.rb @@ -17,18 +17,23 @@ class Contribution < Decidim::Dataspace::Interoperable delegate :reference, :source, :metadata, :created_at, :updated_at, :deleted_at, to: :interoperable - def self.from_proposals - Decidim::Proposals::Proposal.published - .not_hidden - .only_amendables - .includes(:component).all.map do |proposal| + def self.from_proposals(preferred_locale) + proposals = Decidim::Proposals::Proposal.published + .not_hidden + .only_amendables + .includes(:component) + locale = "en" + available_locales = proposals.first&.organization&.available_locales + locale = preferred_locale if available_locales.present? && available_locales.include?(preferred_locale) + + proposals.all.map do |proposal| { reference: proposal.reference, source: Decidim::ResourceLocatorPresenter.new(proposal).url, container: proposal.component.participatory_space_type.constantize.find(proposal.component.participatory_space_id).reference, - locale: "en", - title: proposal.title["en"], - content: proposal.body["en"], + locale: locale, + title: proposal.title[locale], + content: proposal.body[locale], authors: Contribution.authors(proposal), created_at: proposal.created_at, updated_at: proposal.updated_at, @@ -37,17 +42,19 @@ def self.from_proposals end end - def self.proposal(params_reference) + def self.proposal(params_reference, preferred_locale) proposal = Decidim::Proposals::Proposal.find_by(reference: params_reference) return nil unless proposal + available_locales = proposal.organization.available_locales + locale = available_locales.include?(preferred_locale) ? preferred_locale : "en" { reference: proposal.reference, source: Decidim::ResourceLocatorPresenter.new(proposal).url, container: proposal.component.participatory_space_type.constantize.find(proposal.component.participatory_space_id).reference, - locale: "en", - title: proposal.title["en"], - content: proposal.body["en"], + locale: locale, + title: proposal.title[locale], + content: proposal.body[locale], authors: Contribution.authors(proposal), created_at: proposal.created_at, updated_at: proposal.updated_at, From 6dc6f7c5bf911ac9a4e4180b3bb032541528d930 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 17 Oct 2025 11:01:16 +0200 Subject: [PATCH 19/33] feat: update en file and add fr file --- config/locales/en.yml | 20 ++++++++++++++++++++ config/locales/fr.yml | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 config/locales/fr.yml diff --git a/config/locales/en.yml b/config/locales/en.yml index 1167f94..9e5b981 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -10,3 +10,23 @@ en: add_integration: Add an integration integration_url: Url platform to integrate integration_url_text: "Provide the url, for instance https://www.platform.com" + preferred_locale: Preferred language + preferred_locale_options: + ca: ca + cs: cs + de: de + en: en + es: es + eu: eu + fi: fi + fr: fr + it: it + ja: ja + nl: nl + pl: pl + pt: pt + ro: ro + proposals: + proposals: + external_proposal: + view_from: View from platform %{platform} diff --git a/config/locales/fr.yml b/config/locales/fr.yml new file mode 100644 index 0000000..b9be02f --- /dev/null +++ b/config/locales/fr.yml @@ -0,0 +1,32 @@ +--- +fr: + decidim: + components: + dataspace: + name: Dataspace + proposals: + settings: + global: + add_integration: Ajouter une integration + integration_url: Url de la plateforme à intégrer + integration_url_text: "Fournissez l'url, par exemple https://www.platform.com" + preferred_locale: Langue préférée + preferred_locale_options: + ca: ca + cs: cs + de: de + en: en + es: es + eu: eu + fi: fi + fr: fr + it: it + ja: ja + nl: nl + pl: pl + pt: pt + ro: ro + proposals: + proposals: + external_proposal: + view_from: Vue de la plateforme %{platform} From e61bd4a2e0f2f21453fc05ce7a2a0646d07c6698 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 17 Oct 2025 11:02:18 +0200 Subject: [PATCH 20/33] feat: update external_proposal view and add style --- .../decidim/dataspace/dataspace.scss | 43 +++++++++++++++++++ .../proposals/_external_proposal.html.erb | 10 ++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/app/packs/stylesheets/decidim/dataspace/dataspace.scss b/app/packs/stylesheets/decidim/dataspace/dataspace.scss index d770790..18438b5 100644 --- a/app/packs/stylesheets/decidim/dataspace/dataspace.scss +++ b/app/packs/stylesheets/decidim/dataspace/dataspace.scss @@ -1 +1,44 @@ /* css for decidim_dataspace */ + +.card__grid-external { + display: flex; + flex-direction: column; + border-radius: 0.25rem; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 0 0 #0000; + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); + --tw-ring-opacity: 1; + --tw-ring-color: rgb(116 81 235 / var(--tw-ring-opacity, 1)); + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} +.card__grid-external:hover { + cursor: pointer; + --tw-ring-opacity: 1; + --tw-ring-color: rgb(var(--tertiary-rgb) / var(--tw-ring-opacity, 1)); +} +.card__grid-img-text { + width: fit-content; + padding: 0 10px; + color: white; + z-index: 10; + position: relative; + bottom: 90%; + background-color: rgb(116 81 235 / var(--tw-ring-opacity, 1)); +} +.card__grid-external:hover .card__grid-img-text { + cursor: pointer; + --tw-ring-opacity: 1; + background-color: rgb(var(--tertiary-rgb) / var(--tw-ring-opacity, 1)); +} +.card__grid-author { + display:flex; + align-items: center; + margin-bottom: 0.75rem; + margin-left: 1rem; +} +p.author_name{ + margin-left: 0.75rem; +} diff --git a/app/views/decidim/proposals/proposals/_external_proposal.html.erb b/app/views/decidim/proposals/proposals/_external_proposal.html.erb index 38952d7..090da1b 100644 --- a/app/views/decidim/proposals/proposals/_external_proposal.html.erb +++ b/app/views/decidim/proposals/proposals/_external_proposal.html.erb @@ -1,11 +1,17 @@ <% if card_size == :g %> - <%= link_to external_proposal["source"], class: "card__grid", id: external_proposal["reference"] do %> + <%= link_to external_proposal["source"], class: "card__grid-external", id: external_proposal["reference"] do %>
<%= external_icon "media/images/placeholder-card-g.svg", class: "card__placeholder-g" %> +

<%= t('.view_from', platform: @platform) %>

<%= content_tag :h4, external_proposal["title"], class: "h4 text-secondary" %> -

Proposal from external platform

+
+
+
+ default avatar"> +
+

<%= @authors.select{|author| external_proposal["authors"].include?(author["reference"])}.map{|author| author["name"]}.join(', ') %>

<% end %> <% else %> From 118c9f0593f1e5500104637d4eebe48352c82f7b Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 17 Oct 2025 11:02:45 +0200 Subject: [PATCH 21/33] feat: update proposal controller --- .../decidim/proposals/proposals_controller_extends.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/extends/controllers/decidim/proposals/proposals_controller_extends.rb b/lib/extends/controllers/decidim/proposals/proposals_controller_extends.rb index 211dc26..d56cdf8 100644 --- a/lib/extends/controllers/decidim/proposals/proposals_controller_extends.rb +++ b/lib/extends/controllers/decidim/proposals/proposals_controller_extends.rb @@ -18,11 +18,13 @@ def index else if component_settings.add_integration && component_settings.integration_url.present? && - external_proposals ||= GetDataFromApi.contributions(component_settings.integration_url).presence + data ||= GetDataFromApi.data(component_settings.integration_url, component_settings.preferred_locale || "en").presence + external_proposals ||= data["contributions"] + @platform ||= component_settings.integration_url.split("//")[1] + @authors ||= data["authors"] proposals = search.result proposals = reorder(proposals.includes(:component, :coauthorships, :attachments)) - external_proposals = external_proposals["contributions"] @total_count = proposals.size + external_proposals.size @current_page = params[:page].to_i @current_page = 1 if @current_page < 1 From 82d67c9c7c6086984bb9a9124be59dae0793c0ab Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 17 Oct 2025 11:03:11 +0200 Subject: [PATCH 22/33] test: update tests --- .../api/v1/authors_controller_spec.rb | 6 +- .../api/v1/containers_controller_spec.rb | 6 +- .../api/v1/contributions_controller_spec.rb | 2 +- .../proposals/proposals_controller_spec.rb | 99 ++++++++++++++----- spec/models/author_spec.rb | 24 ++--- spec/models/container_spec.rb | 20 ++-- spec/models/contribution_spec.rb | 4 +- .../get_data_from_api_service_spec.rb | 60 +++++------ spec/system/proposals_index_spec.rb | 92 ++++++++++++----- 9 files changed, 208 insertions(+), 105 deletions(-) diff --git a/spec/controllers/decidim/dataspace/api/v1/authors_controller_spec.rb b/spec/controllers/decidim/dataspace/api/v1/authors_controller_spec.rb index c5e6d1d..06a2cc0 100644 --- a/spec/controllers/decidim/dataspace/api/v1/authors_controller_spec.rb +++ b/spec/controllers/decidim/dataspace/api/v1/authors_controller_spec.rb @@ -6,7 +6,7 @@ routes { Decidim::Dataspace::Engine.routes } describe "index" do - context "when there are authors" do + context "when there are proposals and authors" do let(:component) { create(:proposal_component) } let!(:proposal) { create(:proposal, :participant_author, component:) } let!(:proposal_two) { create(:proposal, :official_meeting, component:) } @@ -21,11 +21,11 @@ it "returns all authors" do get :index - expect(response.parsed_body["authors"].size).to eq(3) + expect(response.parsed_body.size).to eq(3) end end - context "when there are no authors" do + context "when there are no proposals and so no authors" do it "is a not_found and returns json without authors" do get :index expect(response).to have_http_status(:not_found) diff --git a/spec/controllers/decidim/dataspace/api/v1/containers_controller_spec.rb b/spec/controllers/decidim/dataspace/api/v1/containers_controller_spec.rb index 9cba14f..fb4f4dd 100644 --- a/spec/controllers/decidim/dataspace/api/v1/containers_controller_spec.rb +++ b/spec/controllers/decidim/dataspace/api/v1/containers_controller_spec.rb @@ -6,7 +6,7 @@ routes { Decidim::Dataspace::Engine.routes } describe "index" do - context "when there are containers" do + context "when there are proposals and containers" do let(:component) { create(:proposal_component) } let!(:proposal) { create(:proposal, component:) } let!(:proposal_two) { create(:proposal, component:) } @@ -23,11 +23,11 @@ it "returns all containers" do # proposals are created from participatory_process - expect(response.parsed_body["containers"].size).to eq(1) + expect(response.parsed_body.size).to eq(1) end end - context "when there are no containers" do + context "when there are no proposals and so no containers" do it "is a not_found and returns json without authors" do get :index expect(response).to have_http_status(:not_found) diff --git a/spec/controllers/decidim/dataspace/api/v1/contributions_controller_spec.rb b/spec/controllers/decidim/dataspace/api/v1/contributions_controller_spec.rb index 04ab7bc..9c7b760 100644 --- a/spec/controllers/decidim/dataspace/api/v1/contributions_controller_spec.rb +++ b/spec/controllers/decidim/dataspace/api/v1/contributions_controller_spec.rb @@ -17,7 +17,7 @@ expect(response).to have_http_status(:ok) expect(response.content_type).to include("application/json") expect { response.parsed_body }.not_to raise_error - expect(response.parsed_body["contributions"].size).to eq(3) + expect(response.parsed_body.size).to eq(3) end end diff --git a/spec/controllers/decidim/proposals/proposals_controller_spec.rb b/spec/controllers/decidim/proposals/proposals_controller_spec.rb index 801df77..7caa2c0 100644 --- a/spec/controllers/decidim/proposals/proposals_controller_spec.rb +++ b/spec/controllers/decidim/proposals/proposals_controller_spec.rb @@ -40,33 +40,80 @@ module Proposals context "and there are externals proposals" do let(:component) { create(:proposal_component) } let!(:proposals) { create_list(:proposal, 2, component:) } + let(:contrib_one) do + { "reference": "JD-PROP-2025-09-1", + "source": "http://localhost:3000/processes/satisfaction-hope/f/7/proposals/1", + "container": "JD-PART-2025-09-1", + "locale": "en", + "title": "Test one", + "content": "Debitis repellat provident", + "authors": ["JD-MEET-2025-09-6"], + "created_at": "2025-09-11T10:20:21.222Z", + "updated_at": "2025-09-11T10:21:56.604Z", + "deleted_at": nil } + end + let(:contrib_two) do + { "reference": "JD-PROP-2025-09-20", + "source": "http://localhost:3000/assemblies/smile-trivial/f/25/proposals/20", + "container": "JD-ASSE-2025-09-1", + "locale": "en", + "title": "Test two", + "content": "Non et vel", + "authors": ["JD-MEET-2025-09-23"], + "created_at": "2025-09-11T10:43:23.743Z", + "updated_at": "2025-09-11T10:43:27.147Z", + "deleted_at": nil } + end + let(:container_one) do + { + "reference": "JD-PART-2025-09-1", + "source": "http://localhost:3000/processes/satisfaction-hope", + "name": "Cupiditate natus dignissimos saepe ut.", + "description": "

Voluptas recusandae est. Nesciunt excepturi corrupti. Qui natus eligendi.

", + "metadata": {}, + "created_at": "2025-09-11T10:14:58.111Z", + "updated_at": "2025-09-11T10:14:58.126Z", + "deleted_at": nil + } + end + let(:container_two) do + { + "reference": "JD-ASSE-2025-09-1", + "source": "http://localhost:3000/assemblies/smile-trivial", + "name": "Molestiae aut corporis quas et.", + "description": "

Ratione autem repellendus. Error voluptatem ipsam. Ut dicta velit.

", + "metadata": {}, + "created_at": "2025-09-11T10:38:07.682Z", + "updated_at": "2025-09-11T10:38:07.682Z", + "deleted_at": nil + } + end + let(:author_one) do + { + "reference": "JD-MEET-2025-09-6", + "name": "Animi voluptatum.", + "source": "http://localhost:3000/processes/satisfaction-hope/f/5/meetings/6" + } + end + let(:author_two) do + { + "reference": "JD-MEET-2025-09-23", + "name": "Et natus.", + "source": "http://localhost:3000/assemblies/smile-trivial/f/23/meetings/23" + } + end let(:json) do - { "contributions" => [{ "reference" => "JD-PROP-2025-09-1", - "source" => "http://localhost:3000/processes/satisfaction-hope/f/7/proposals/1", - "container" => "JD-PART-2025-09-1", - "locale" => "en", - "title" => "Test one", - "content" => "Debitis repellat provident", - "authors" => ["JD-MEET-2025-09-6"], - "created_at" => "2025-09-11T10:20:21.222Z", - "updated_at" => "2025-09-11T10:21:56.604Z", - "deleted_at" => nil }, - { "reference" => "JD-PROP-2025-09-20", - "source" => "http://localhost:3000/assemblies/smile-trivial/f/25/proposals/20", - "container" => "JD-ASSE-2025-09-1", - "locale" => "en", - "title" => "Test two", - "content" => "Non et vel", - "authors" => ["JD-MEET-2025-09-23"], - "created_at" => "2025-09-11T10:43:23.743Z", - "updated_at" => "2025-09-11T10:43:27.147Z", - "deleted_at" => nil }] } + { + "containers" => [container_one, container_two], + "contributions" => [contrib_one, contrib_two], + "authors" => [author_one, author_two] + } end before do - component.update!(settings: { add_integration: true, integration_url: "http://example.org", comments_enabled: false }) - allow(GetDataFromApi).to receive(:contributions).and_return(json) + 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 @@ -75,13 +122,17 @@ module Proposals 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" + 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 diff --git a/spec/models/author_spec.rb b/spec/models/author_spec.rb index 935d768..fd04748 100644 --- a/spec/models/author_spec.rb +++ b/spec/models/author_spec.rb @@ -53,9 +53,9 @@ module Dataspace let!(:proposal_three) { create(:proposal, :official, component:) } it "returns an array with 3 hash elements" do - expect(Author.from_proposals.class).to eq(Array) - expect(Author.from_proposals.size).to eq(3) - expect(Author.from_proposals.first.class).to eq(Hash) + expect(Author.from_proposals("en").class).to eq(Array) + expect(Author.from_proposals("en").size).to eq(3) + expect(Author.from_proposals("en").first.class).to eq(Hash) end end @@ -67,9 +67,9 @@ module Dataspace it "returns a hash with author name as reference" do # for user author, the reference is the name - expect(Author.proposal_author(author.name).class).to eq(Hash) - expect(Author.proposal_author(author.name).size).to eq(3) - expect(Author.proposal_author(author.name)[:reference]).to eq(proposal.authors.first.name) + expect(Author.proposal_author(author.name, "en").class).to eq(Hash) + expect(Author.proposal_author(author.name, "en").size).to eq(3) + expect(Author.proposal_author(author.name, "en")[:reference]).to eq(proposal.authors.first.name) end end @@ -80,9 +80,9 @@ module Dataspace it "returns a hash with organization reference_prefix as reference" do # for official author, the reference is the reference_prefix - expect(Author.proposal_author(author.reference_prefix).class).to eq(Hash) - expect(Author.proposal_author(author.reference_prefix).size).to eq(3) - expect(Author.proposal_author(author.reference_prefix)[:reference]).to eq(proposal.authors.first.reference_prefix) + expect(Author.proposal_author(author.reference_prefix, "en").class).to eq(Hash) + expect(Author.proposal_author(author.reference_prefix, "en").size).to eq(3) + expect(Author.proposal_author(author.reference_prefix, "en")[:reference]).to eq(proposal.authors.first.reference_prefix) end end @@ -93,9 +93,9 @@ module Dataspace it "returns a hash with organization reference_prefix as reference" do # for official_meeting author, the reference is the reference - expect(Author.proposal_author(author.reference).class).to eq(Hash) - expect(Author.proposal_author(author.reference).size).to eq(3) - expect(Author.proposal_author(author.reference)[:reference]).to eq(proposal.authors.first.reference) + expect(Author.proposal_author(author.reference, "en").class).to eq(Hash) + expect(Author.proposal_author(author.reference, "en").size).to eq(3) + expect(Author.proposal_author(author.reference, "en")[:reference]).to eq(proposal.authors.first.reference) end end end diff --git a/spec/models/container_spec.rb b/spec/models/container_spec.rb index d3c6c5e..64677c8 100644 --- a/spec/models/container_spec.rb +++ b/spec/models/container_spec.rb @@ -69,14 +69,14 @@ module Dataspace context "when using from_proposals method" do let(:component) { create(:proposal_component) } - let!(:proposal) { create(:proposal,component:) } + let!(:proposal) { create(:proposal, component:) } let!(:proposal_two) { create(:proposal, component:) } let!(:proposal_three) { create(:proposal, component:) } it "returns an array with 1 hash element" do - expect(Container.from_proposals.class).to eq(Array) - expect(Container.from_proposals.size).to eq(1) # the 3 proposals have the same container - expect(Container.from_proposals.first.class).to eq(Hash) + expect(Container.from_proposals("en").class).to eq(Array) + expect(Container.from_proposals("en").size).to eq(1) # the 3 proposals have the same container + expect(Container.from_proposals("en").first.class).to eq(Hash) end end @@ -87,9 +87,9 @@ module Dataspace let!(:container) { Decidim::ParticipatoryProcess.find(component.participatory_space_id) } it "returns a hash with container reference as reference" do - expect(Container.from_params(container.reference).class).to eq(Hash) - expect(Container.from_params(container.reference).size).to eq(8) - expect(Container.from_params(container.reference)[:reference]).to eq(container.reference) + expect(Container.from_params(container.reference, "en").class).to eq(Hash) + expect(Container.from_params(container.reference, "en").size).to eq(8) + expect(Container.from_params(container.reference, "en")[:reference]).to eq(container.reference) end end @@ -99,9 +99,9 @@ module Dataspace let!(:container) { Decidim::ParticipatoryProcess.find(component.participatory_space_id) } it "returns a hash with container reference as reference" do - expect(Container.from_proposal(proposal).class).to eq(Hash) - expect(Container.from_proposal(proposal).size).to eq(8) - expect(Container.from_proposal(proposal)[:reference]).to eq(container.reference) + expect(Container.from_proposal(proposal, "en").class).to eq(Hash) + expect(Container.from_proposal(proposal, "en").size).to eq(8) + expect(Container.from_proposal(proposal, "en")[:reference]).to eq(container.reference) end end end diff --git a/spec/models/contribution_spec.rb b/spec/models/contribution_spec.rb index 88a81f8..3f493b4 100644 --- a/spec/models/contribution_spec.rb +++ b/spec/models/contribution_spec.rb @@ -118,7 +118,7 @@ module Dataspace let!(:proposal_three) { create(:proposal, component:) } it "returns an array with 3 hash elements" do - method_call = Contribution.from_proposals + method_call = Contribution.from_proposals("en") expect(method_call.class).to eq(Array) expect(method_call.size).to eq(3) expect(method_call.first.class).to eq(Hash) @@ -130,7 +130,7 @@ module Dataspace let!(:proposal) { create(:proposal, :participant_author, component:) } it "returns an array with 1 hash element" do - method_call = Contribution.proposal(proposal.reference) + method_call = Contribution.proposal(proposal.reference, "en") expect(method_call.class).to eq(Hash) expect(method_call.size).to eq(10) expect(method_call[:reference]).to eq(proposal.reference) diff --git a/spec/services/get_data_from_api_service_spec.rb b/spec/services/get_data_from_api_service_spec.rb index 267663b..9e1bc22 100644 --- a/spec/services/get_data_from_api_service_spec.rb +++ b/spec/services/get_data_from_api_service_spec.rb @@ -9,44 +9,48 @@ let(:url) { "http://localhost:3000" } context "when testing list" do - let(:uri) { URI("http://localhost:3000/api/v1/data/contributions") } - let(:json) do - { "contributions" => [{ "reference" => "JD-PROP-2025-09-1", - "source" => "http://localhost:3000/processes/satisfaction-hope/f/7/proposals/1", - "container" => "JD-PART-2025-09-1", - "locale" => "en", - "title" => "Test one", - "content" => "Debitis repellat provident", - "authors" => ["JD-MEET-2025-09-6"], - "created_at" => "2025-09-11T10:20:21.222Z", - "updated_at" => "2025-09-11T10:21:56.604Z", - "deleted_at" => nil }, - { "reference" => "JD-PROP-2025-09-20", - "source" => "http://localhost:3000/assemblies/smile-trivial/f/25/proposals/20", - "container" => "JD-ASSE-2025-09-1", - "locale" => "en", - "title" => "Test two", - "content" => "Non et vel", - "authors" => ["JD-MEET-2025-09-23"], - "created_at" => "2025-09-11T10:43:23.743Z", - "updated_at" => "2025-09-11T10:43:27.147Z", - "deleted_at" => nil }] }.to_json + let(:uri) { URI("http://localhost:3000/api/v1/data/contributions?preferred_locale=en") } + let(:contrib_one) do + { "reference" => "JD-PROP-2025-09-1", + "source" => "http://localhost:3000/processes/satisfaction-hope/f/7/proposals/1", + "container" => "JD-PART-2025-09-1", + "locale" => "en", + "title" => "Test one", + "content" => "Debitis repellat provident", + "authors" => ["JD-MEET-2025-09-6"], + "created_at" => "2025-09-11T10:20:21.222Z", + "updated_at" => "2025-09-11T10:21:56.604Z", + "deleted_at" => nil } end + let(:contrib_two) do + { "reference" => "JD-PROP-2025-09-20", + "source" => "http://localhost:3000/assemblies/smile-trivial/f/25/proposals/20", + "container" => "JD-ASSE-2025-09-1", + "locale" => "en", + "title" => "Test two", + "content" => "Non et vel", + "authors" => ["JD-MEET-2025-09-23"], + "created_at" => "2025-09-11T10:43:23.743Z", + "updated_at" => "2025-09-11T10:43:27.147Z", + "deleted_at" => nil } + end + + let(:json) { [contrib_one, contrib_two].to_json } before do allow(Net::HTTP).to receive(:get).with(uri).and_return(json) end it "returns json containing a list of contributions" do - response = GetDataFromApi.contributions(url) - expect(response.class).to eq Hash - expect(response["contributions"].size).to eq(2) - expect(response["contributions"][0]["title"]).to eq("Test one") + response = GetDataFromApi.contributions(url, "en") + expect(response.class).to eq Array + expect(response.size).to eq(2) + expect(response[0]["title"]).to eq("Test one") end end context "when testing show" do - let(:uri) { URI("http://localhost:3000/api/v1/data/contributions/JD-PROP-2025-09-1") } + let(:uri) { URI("http://localhost:3000/api/v1/data/contributions/JD-PROP-2025-09-1?preferred_locale=en") } let(:json) do { "reference" => "JD-PROP-2025-09-1", "source" => "http://localhost:3000/processes/satisfaction-hope/f/7/proposals/1", @@ -67,7 +71,7 @@ end it "returns json containing one contribution" do - response = GetDataFromApi.contribution(url, ref) + response = GetDataFromApi.contribution(url, ref, "en") expect(response.class).to eq Hash expect(response["reference"]).to eq("JD-PROP-2025-09-1") expect(response["title"]).to eq("Test one") diff --git a/spec/system/proposals_index_spec.rb b/spec/system/proposals_index_spec.rb index 8c942bb..0553b76 100644 --- a/spec/system/proposals_index_spec.rb +++ b/spec/system/proposals_index_spec.rb @@ -48,34 +48,82 @@ end context "and there are external proposals" do + let(:contrib_one) do + { "reference" => "JD-PROP-2025-09-1", + "source" => "http://localhost:3000/processes/satisfaction-hope/f/7/proposals/1", + "container" => "JD-PART-2025-09-1", + "locale" => "en", + "title" => "Test one", + "content" => "Debitis repellat provident", + "authors" => ["JD-MEET-2025-09-6"], + "created_at" => "2025-09-11T10:20:21.222Z", + "updated_at" => "2025-09-11T10:21:56.604Z", + "deleted_at" => nil } + end + let(:contrib_two) do + { "reference" => "JD-PROP-2025-09-20", + "source" => "http://localhost:3000/assemblies/smile-trivial/f/25/proposals/20", + "container" => "JD-ASSE-2025-09-1", + "locale" => "en", + "title" => "Test two", + "content" => "Non et vel", + "authors" => ["JD-MEET-2025-09-23"], + "created_at" => "2025-09-11T10:43:23.743Z", + "updated_at" => "2025-09-11T10:43:27.147Z", + "deleted_at" => nil } + end + let(:container_one) do + { + "reference": "JD-PART-2025-09-1", + "source": "http://localhost:3000/processes/satisfaction-hope", + "name": "Cupiditate natus dignissimos saepe ut.", + "description": "

Voluptas recusandae est. Nesciunt excepturi corrupti. Qui natus eligendi.

", + "metadata": {}, + "created_at": "2025-09-11T10:14:58.111Z", + "updated_at": "2025-09-11T10:14:58.126Z", + "deleted_at": nil + } + end + let(:container_two) do + { + "reference": "JD-ASSE-2025-09-1", + "source": "http://localhost:3000/assemblies/smile-trivial", + "name": "Molestiae aut corporis quas et.", + "description": "

Ratione autem repellendus. Error voluptatem ipsam. Ut dicta velit.

", + "metadata": {}, + "created_at": "2025-09-11T10:38:07.682Z", + "updated_at": "2025-09-11T10:38:07.682Z", + "deleted_at": nil + } + end + let(:author_one) do + { + "reference": "JD-MEET-2025-09-6", + "name": "Animi voluptatum.", + "source": "http://localhost:3000/processes/satisfaction-hope/f/5/meetings/6" + } + end + let(:author_two) do + { + "reference": "JD-MEET-2025-09-23", + "name": "Et natus.", + "source": "http://localhost:3000/assemblies/smile-trivial/f/23/meetings/23" + } + end + let(:json) do - { "contributions" => [{ "reference" => "JD-PROP-2025-09-1", - "source" => "http://localhost:3000/processes/satisfaction-hope/f/7/proposals/1", - "container" => "JD-PART-2025-09-1", - "locale" => "en", - "title" => "Test one", - "content" => "Debitis repellat provident", - "authors" => ["JD-MEET-2025-09-6"], - "created_at" => "2025-09-11T10:20:21.222Z", - "updated_at" => "2025-09-11T10:21:56.604Z", - "deleted_at" => nil }, - { "reference" => "JD-PROP-2025-09-20", - "source" => "http://localhost:3000/assemblies/smile-trivial/f/25/proposals/20", - "container" => "JD-ASSE-2025-09-1", - "locale" => "en", - "title" => "Test two", - "content" => "Non et vel", - "authors" => ["JD-MEET-2025-09-23"], - "created_at" => "2025-09-11T10:43:23.743Z", - "updated_at" => "2025-09-11T10:43:27.147Z", - "deleted_at" => nil }] } + { + "containers" => [container_one, container_two], + "contributions" => [contrib_one, contrib_two], + "authors" => [author_one, author_two] + } end 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" }) - allow(GetDataFromApi).to receive(:contributions).and_return(json) + 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 From b8a6cbe578e3ed163fb2dbff72efee0075c82388 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 17 Oct 2025 11:25:19 +0200 Subject: [PATCH 23/33] refactor: with rubocop --- .../api/v1/contributions_controller.rb | 3 +- app/models/decidim/dataspace/author.rb | 42 ++++++++++--------- app/models/decidim/dataspace/container.rb | 6 ++- app/models/decidim/dataspace/contribution.rb | 10 +++-- app/services/get_data_from_api.rb | 1 - .../proposals/proposals_controller_extends.rb | 6 +-- .../api/v1/authors_controller_spec.rb | 1 - .../proposals/proposals_controller_spec.rb | 6 +-- 8 files changed, 40 insertions(+), 35 deletions(-) diff --git a/app/controllers/decidim/dataspace/api/v1/contributions_controller.rb b/app/controllers/decidim/dataspace/api/v1/contributions_controller.rb index 4ef2086..0c1a072 100644 --- a/app/controllers/decidim/dataspace/api/v1/contributions_controller.rb +++ b/app/controllers/decidim/dataspace/api/v1/contributions_controller.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true -require 'uri' + +require "uri" module Decidim module Dataspace diff --git a/app/models/decidim/dataspace/author.rb b/app/models/decidim/dataspace/author.rb index a889bea..3390cda 100644 --- a/app/models/decidim/dataspace/author.rb +++ b/app/models/decidim/dataspace/author.rb @@ -20,53 +20,55 @@ def self.from_proposals(preferred_locale) available_locales = proposals.first&.organization&.available_locales locale = preferred_locale if available_locales.present? && available_locales.include?(preferred_locale) - proposals.all.map do |proposal| + authors = proposals.all.map do |proposal| proposal.authors.map do |author| - if author.instance_of?(Decidim::Organization) - Author.organization_author(author, locale) - elsif author.instance_of?(Decidim::Meetings::Meeting) - Author.meeting_author(author, locale) - elsif author.instance_of?(Decidim::User) || author.instance_of?(Decidim::UserGroup) - Author.user_or_group_author(author) - end + Author.display_author(author, locale) end - end.compact.flatten.uniq { |hash| hash[:reference] } + end + authors.compact.flatten.uniq { |hash| hash[:reference] } end def self.proposal_author(reference, preferred_locale) if Decidim::User.find_by(name: reference) || Decidim::UserGroup.find_by(name: reference) author = Decidim::User.find_by(name: reference) || Decidim::UserGroup.find_by(name: reference) - return Author.user_or_group_author(author) + Author.user_or_group_author(author) elsif Decidim::Organization.find_by(reference_prefix: reference) author = Decidim::Organization.find_by(reference_prefix: reference) locale = author.available_locales.include?(preferred_locale) ? preferred_locale : "en" - return Author.organization_author(author, locale) - elsif Decidim::Meetings::Meeting.find_by(reference: reference) - author = Decidim::Meetings::Meeting.find_by(reference: reference) + Author.organization_author(author, locale) + elsif Decidim::Meetings::Meeting.find_by(reference:) + author = Decidim::Meetings::Meeting.find_by(reference:) locale = author.organization.available_locales.include?(preferred_locale) ? preferred_locale : "en" - return Author.meeting_author(author, locale) + Author.meeting_author(author, locale) + end + end + + def self.display_author(author, locale) + if author.instance_of?(Decidim::Organization) + Author.organization_author(author, locale) + elsif author.instance_of?(Decidim::Meetings::Meeting) + Author.meeting_author(author, locale) + elsif author.instance_of?(Decidim::User) || author.instance_of?(Decidim::UserGroup) + Author.user_or_group_author(author) end end def self.organization_author(author, locale) { reference: author.reference_prefix, name: author.name[locale], - source: author.official_url - } + source: author.official_url } end def self.meeting_author(author, locale) { reference: author.reference, name: author.title[locale], - source: Decidim::ResourceLocatorPresenter.new(author).url - } + source: Decidim::ResourceLocatorPresenter.new(author).url } end def self.user_or_group_author(author) { reference: author.name, name: author.name, - source: author.personal_url - } + source: author.personal_url } end end end diff --git a/app/models/decidim/dataspace/container.rb b/app/models/decidim/dataspace/container.rb index f7c1f5c..90baeff 100644 --- a/app/models/decidim/dataspace/container.rb +++ b/app/models/decidim/dataspace/container.rb @@ -16,13 +16,15 @@ def self.from_proposals(preferred_locale) proposals = Decidim::Proposals::Proposal.published .not_hidden .only_amendables + .includes([:component]) locale = "en" available_locales = proposals.first&.organization&.available_locales locale = preferred_locale if available_locales.present? && available_locales.include?(preferred_locale) - proposals.map do |proposal| + containers = proposals.map do |proposal| Container.from_proposal(proposal, locale) - end.uniq { |hash| hash[:reference] } + end + containers.uniq { |hash| hash[:reference] } end def self.from_params(ref, preferred_locale) diff --git a/app/models/decidim/dataspace/contribution.rb b/app/models/decidim/dataspace/contribution.rb index 0d9c944..06796b1 100644 --- a/app/models/decidim/dataspace/contribution.rb +++ b/app/models/decidim/dataspace/contribution.rb @@ -27,11 +27,12 @@ def self.from_proposals(preferred_locale) locale = preferred_locale if available_locales.present? && available_locales.include?(preferred_locale) proposals.all.map do |proposal| + component = proposal.component { reference: proposal.reference, source: Decidim::ResourceLocatorPresenter.new(proposal).url, - container: proposal.component.participatory_space_type.constantize.find(proposal.component.participatory_space_id).reference, - locale: locale, + container: component.participatory_space_type.constantize.find(component.participatory_space_id).reference, + locale:, title: proposal.title[locale], content: proposal.body[locale], authors: Contribution.authors(proposal), @@ -48,11 +49,12 @@ def self.proposal(params_reference, preferred_locale) available_locales = proposal.organization.available_locales locale = available_locales.include?(preferred_locale) ? preferred_locale : "en" + component = proposal.component { reference: proposal.reference, source: Decidim::ResourceLocatorPresenter.new(proposal).url, - container: proposal.component.participatory_space_type.constantize.find(proposal.component.participatory_space_id).reference, - locale: locale, + container: component.participatory_space_type.constantize.find(component.participatory_space_id).reference, + locale:, title: proposal.title[locale], content: proposal.body[locale], authors: Contribution.authors(proposal), diff --git a/app/services/get_data_from_api.rb b/app/services/get_data_from_api.rb index 1a195ef..7beb4e0 100644 --- a/app/services/get_data_from_api.rb +++ b/app/services/get_data_from_api.rb @@ -4,7 +4,6 @@ require "uri" class GetDataFromApi - def self.data(url, preferred_locale) uri = URI(url + "/api/v1/data?preferred_locale=#{preferred_locale}") begin diff --git a/lib/extends/controllers/decidim/proposals/proposals_controller_extends.rb b/lib/extends/controllers/decidim/proposals/proposals_controller_extends.rb index d56cdf8..7356271 100644 --- a/lib/extends/controllers/decidim/proposals/proposals_controller_extends.rb +++ b/lib/extends/controllers/decidim/proposals/proposals_controller_extends.rb @@ -20,9 +20,9 @@ def index component_settings.integration_url.present? && data ||= GetDataFromApi.data(component_settings.integration_url, component_settings.preferred_locale || "en").presence - external_proposals ||= data["contributions"] - @platform ||= component_settings.integration_url.split("//")[1] - @authors ||= data["authors"] + external_proposals = data["contributions"] + @platform = component_settings.integration_url.split("//")[1] + @authors = data["authors"] proposals = search.result proposals = reorder(proposals.includes(:component, :coauthorships, :attachments)) @total_count = proposals.size + external_proposals.size diff --git a/spec/controllers/decidim/dataspace/api/v1/authors_controller_spec.rb b/spec/controllers/decidim/dataspace/api/v1/authors_controller_spec.rb index 06a2cc0..b74311b 100644 --- a/spec/controllers/decidim/dataspace/api/v1/authors_controller_spec.rb +++ b/spec/controllers/decidim/dataspace/api/v1/authors_controller_spec.rb @@ -80,7 +80,6 @@ "source" => Decidim::ResourceLocatorPresenter.new(author).url }) end end - end context "when author does not exist" do diff --git a/spec/controllers/decidim/proposals/proposals_controller_spec.rb b/spec/controllers/decidim/proposals/proposals_controller_spec.rb index 7caa2c0..5f2a36a 100644 --- a/spec/controllers/decidim/proposals/proposals_controller_spec.rb +++ b/spec/controllers/decidim/proposals/proposals_controller_spec.rb @@ -53,9 +53,9 @@ module Proposals "deleted_at": nil } end let(:contrib_two) do - { "reference": "JD-PROP-2025-09-20", - "source": "http://localhost:3000/assemblies/smile-trivial/f/25/proposals/20", - "container": "JD-ASSE-2025-09-1", + { "reference": "JD-PROP-2025-09-20", + "source": "http://localhost:3000/assemblies/smile-trivial/f/25/proposals/20", + "container": "JD-ASSE-2025-09-1", "locale": "en", "title": "Test two", "content": "Non et vel", From 79a6a1e0578708b95508461cfe0e32e82eda28f2 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 17 Oct 2025 11:54:42 +0200 Subject: [PATCH 24/33] feat: show/hide preferred locale and add placeholder for url --- app/packs/src/decidim/dataspace/component_edit_form.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/packs/src/decidim/dataspace/component_edit_form.js b/app/packs/src/decidim/dataspace/component_edit_form.js index 3f24cdd..c890da8 100644 --- a/app/packs/src/decidim/dataspace/component_edit_form.js +++ b/app/packs/src/decidim/dataspace/component_edit_form.js @@ -1,17 +1,25 @@ document.addEventListener("DOMContentLoaded", function(){ const integrationCheck = document.querySelector('input#component_settings_add_integration') 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") + if(integrationCheck){ if(integrationCheck.checked){ urlDiv.style.display = "block" + localeDiv.style.display = "block" } else { urlDiv.style.display = "none" + localeDiv.style.display = "none" } integrationCheck.addEventListener('change', function(){ if (this.checked) { urlDiv.style.display = "block" + localeDiv.style.display = "block" } else { urlDiv.style.display = "none" + localeDiv.style.display = "none" } }) } From b40f512242bc39793a8738393f718b77ae6c2cd2 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 17 Oct 2025 11:55:23 +0200 Subject: [PATCH 25/33] fix: remove unused key in locales files --- config/locales/en.yml | 1 - config/locales/fr.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index 9e5b981..006504b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -9,7 +9,6 @@ en: global: add_integration: Add an integration integration_url: Url platform to integrate - integration_url_text: "Provide the url, for instance https://www.platform.com" preferred_locale: Preferred language preferred_locale_options: ca: ca diff --git a/config/locales/fr.yml b/config/locales/fr.yml index b9be02f..87f8f3f 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -9,7 +9,6 @@ fr: global: add_integration: Ajouter une integration integration_url: Url de la plateforme à intégrer - integration_url_text: "Fournissez l'url, par exemple https://www.platform.com" preferred_locale: Langue préférée preferred_locale_options: ca: ca From 563fee2a834e0f51355a90e1c18c27d8961df455 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Tue, 28 Oct 2025 17:12:14 +0100 Subject: [PATCH 26/33] feat: add external_proposal show and add comments --- .../api/v1/contributions_controller.rb | 6 +- app/models/decidim/dataspace/contribution.rb | 111 +++++++++++++++--- app/services/get_data_from_api.rb | 4 +- lib/decidim/dataspace/engine.rb | 12 ++ .../proposals/proposals_controller_extends.rb | 19 ++- 5 files changed, 130 insertions(+), 22 deletions(-) diff --git a/app/controllers/decidim/dataspace/api/v1/contributions_controller.rb b/app/controllers/decidim/dataspace/api/v1/contributions_controller.rb index 0c1a072..e299e51 100644 --- a/app/controllers/decidim/dataspace/api/v1/contributions_controller.rb +++ b/app/controllers/decidim/dataspace/api/v1/contributions_controller.rb @@ -9,7 +9,8 @@ class Api::V1::ContributionsController < Api::V1::BaseController def index preferred_locale = params[:preferred_locale].presence || "en" - contributions = Contribution.from_proposals(preferred_locale) + with_comments = params[:with_comments].presence + contributions = Contribution.from_proposals(preferred_locale, with_comments) return resource_not_found("Contributions") if contributions.blank? render json: contributions, status: :ok @@ -24,7 +25,8 @@ def show def set_contribution ref = CGI.unescape(params[:reference]) preferred_locale = params[:preferred_locale].presence || "en" - @contribution = Contribution.proposal(ref, preferred_locale) + with_comments = params[:with_comments].presence + @contribution = Contribution.proposal(ref, preferred_locale, with_comments) return resource_not_found("Contribution") unless @contribution @contribution diff --git a/app/models/decidim/dataspace/contribution.rb b/app/models/decidim/dataspace/contribution.rb index 06796b1..ea16b8b 100644 --- a/app/models/decidim/dataspace/contribution.rb +++ b/app/models/decidim/dataspace/contribution.rb @@ -17,7 +17,8 @@ class Contribution < Decidim::Dataspace::Interoperable delegate :reference, :source, :metadata, :created_at, :updated_at, :deleted_at, to: :interoperable - def self.from_proposals(preferred_locale) + # get all the proposals with or without comments + def self.from_proposals(preferred_locale, with_comments = "false") proposals = Decidim::Proposals::Proposal.published .not_hidden .only_amendables @@ -26,44 +27,124 @@ def self.from_proposals(preferred_locale) available_locales = proposals.first&.organization&.available_locales locale = preferred_locale if available_locales.present? && available_locales.include?(preferred_locale) + return Contribution.proposals_with_comments(proposals, locale) if with_comments == "true" + + proposals.all.map do |proposal| + Contribution.proposal_without_comment(proposal, locale) + end + end + + # get one proposal with or without detailed comments + def self.proposal(params_reference, preferred_locale, with_comments = "false") + proposal = Decidim::Proposals::Proposal.find_by(reference: params_reference) + return nil unless proposal + + available_locales = proposal.organization.available_locales + locale = available_locales.include?(preferred_locale) ? preferred_locale : "en" + + return Contribution.proposal_with_comments(proposal, locale) if with_comments == "true" + + Contribution.proposal_without_comment(proposal, locale) + end + + # get proposals with comments + def self.proposals_with_comments(proposals, locale) proposals.all.map do |proposal| component = proposal.component + comments = Contribution.comments(proposal, locale) + [ + { + reference: proposal.reference, + source: Decidim::ResourceLocatorPresenter.new(proposal).url, + container: component.participatory_space_type.constantize.find(component.participatory_space_id).reference, + locale:, + title: proposal.title[locale] || proposal.title["en"], + content: proposal.body[locale] || proposal.body["en"], + authors: Contribution.authors(proposal), + parent: nil, + children: comments.map { |comment| comment[:reference] }, + created_at: proposal.created_at, + updated_at: proposal.updated_at, + deleted_at: nil # does not exist in proposal + }, comments.flatten + ] + end.flatten + end + + # get proposal without detailed comments + def self.proposal_without_comment(proposal, locale) + component = proposal.component + { + reference: proposal.reference, + source: Decidim::ResourceLocatorPresenter.new(proposal).url, + container: component.participatory_space_type.constantize.find(component.participatory_space_id).reference, + locale:, + title: proposal.title[locale] || proposal.title["en"], + content: proposal.body[locale] || proposal.body["en"], + authors: Contribution.authors(proposal), + parent: nil, + children: proposal.comments.map { |comment| "#{proposal.reference}-#{comment.id}" }, + created_at: proposal.created_at, + updated_at: proposal.updated_at, + deleted_at: nil # does not exist in proposal + } + end + + # get detailed comments of a proposal + def self.comments(proposal, locale) + proposal.comments.map do |comment| + component = comment.component { - reference: proposal.reference, + reference: "#{proposal.reference}-#{comment.id}", source: Decidim::ResourceLocatorPresenter.new(proposal).url, container: component.participatory_space_type.constantize.find(component.participatory_space_id).reference, locale:, - title: proposal.title[locale], - content: proposal.body[locale], - authors: Contribution.authors(proposal), - created_at: proposal.created_at, - updated_at: proposal.updated_at, - deleted_at: nil # does not exist in proposal + title: nil, + content: comment.body[locale] || comment.body["en"], + authors: comment.author.name, + parent: Contribution.parent(comment, proposal), + children: Contribution.children(comment, proposal), + metadata: { depth: comment.depth }, + created_at: comment.created_at, + updated_at: comment.updated_at, + deleted_at: comment.deleted_at } end end - def self.proposal(params_reference, preferred_locale) - proposal = Decidim::Proposals::Proposal.find_by(reference: params_reference) - return nil unless proposal + # get parent of a comment + def self.parent(comment, proposal) + comment.decidim_commentable_type == "Decidim::Comments::Comment" ? "#{proposal.reference}-#{comment.decidim_commentable_id}" : proposal.reference + end - available_locales = proposal.organization.available_locales - locale = available_locales.include?(preferred_locale) ? preferred_locale : "en" + # get children of a comment + def self.children(comment, proposal) + children = Decidim::Comments::Comment.where(decidim_commentable_type: "Decidim::Comments::Comment", decidim_commentable_id: comment.id) + return nil if children.blank? + + children.map { |child| "#{proposal.reference}-#{child.id}" } + end + + # get one proposal with detailed comments + def self.proposal_with_comments(proposal, locale) component = proposal.component { reference: proposal.reference, source: Decidim::ResourceLocatorPresenter.new(proposal).url, container: component.participatory_space_type.constantize.find(component.participatory_space_id).reference, locale:, - title: proposal.title[locale], - content: proposal.body[locale], + title: proposal.title[locale] || proposal.title["en"], + content: proposal.body[locale] || proposal.body["en"], authors: Contribution.authors(proposal), + parent: nil, + children: Contribution.comments(proposal, locale), created_at: proposal.created_at, updated_at: proposal.updated_at, deleted_at: nil } end + # get authors of a proposal def self.authors(proposal) proposal.authors.map do |author| if author.instance_of?(Decidim::User) || author.instance_of?(Decidim::UserGroup) diff --git a/app/services/get_data_from_api.rb b/app/services/get_data_from_api.rb index 7beb4e0..8fb7965 100644 --- a/app/services/get_data_from_api.rb +++ b/app/services/get_data_from_api.rb @@ -44,9 +44,9 @@ def self.authors(url, preferred_locale) end end - def self.contribution(url, ref, preferred_locale) + def self.contribution(url, ref, preferred_locale, with_comments = "false") ref = CGI.escape(ref) - uri = URI(url + "/api/v1/data/contributions/#{ref}?preferred_locale=#{preferred_locale}") + uri = URI(url + "/api/v1/data/contributions/#{ref}?preferred_locale=#{preferred_locale}&with_comments=#{with_comments}") begin result = Net::HTTP.get(uri) JSON.parse(result) diff --git a/lib/decidim/dataspace/engine.rb b/lib/decidim/dataspace/engine.rb index 6c63122..a26b97c 100644 --- a/lib/decidim/dataspace/engine.rb +++ b/lib/decidim/dataspace/engine.rb @@ -27,6 +27,18 @@ class Engine < ::Rails::Engine end end + initializer "decidim_dataspace.mount_routes", before: :add_routing_paths do + # Mount the engine routes to Decidim::Core::Engine because otherwise + # they would not get mounted properly. + Decidim::Proposals::Engine.routes.append do + resources :proposals do + collection do + get "external_proposal/:reference", to: "proposals#external_proposal", param: :reference, as: :external_proposal + end + end + end + end + initializer "dataspace-extends" do config.after_initialize do require "extends/controllers/decidim/proposals/proposals_controller_extends" diff --git a/lib/extends/controllers/decidim/proposals/proposals_controller_extends.rb b/lib/extends/controllers/decidim/proposals/proposals_controller_extends.rb index 7356271..49ec062 100644 --- a/lib/extends/controllers/decidim/proposals/proposals_controller_extends.rb +++ b/lib/extends/controllers/decidim/proposals/proposals_controller_extends.rb @@ -16,9 +16,7 @@ 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 ||= GetDataFromApi.data(component_settings.integration_url, component_settings.preferred_locale || "en").presence + if component_settings.add_integration && component_settings.integration_url.present? && data external_proposals = data["contributions"] @platform = component_settings.integration_url.split("//")[1] @@ -43,6 +41,17 @@ def index end end + def external_proposal + @external_proposal = GetDataFromApi.contribution(component_settings.integration_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") + .select { |author| @external_proposal["authors"].include?(author["reference"]) } + .map { |author| author["name"] }.join(", ") + end + private def voted_proposals @@ -56,6 +65,10 @@ def voted_proposals end end + def data + @data ||= GetDataFromApi.data(component_settings.integration_url, component_settings.preferred_locale || "en").presence + end + def define_proposals_and_external_proposals(proposals, external_proposals, current_page, per_page) @proposals = [] @external_proposals = [] From bab5670b0b4890b60fc9bfb799572bf32b147b3d Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Tue, 28 Oct 2025 17:13:47 +0100 Subject: [PATCH 27/33] feat: add view for extenral_proposal and new partials for comments --- .../proposals/proposals/_comment.html.erb | 18 ++++++++ .../proposals/_comment_replies.html.erb | 3 ++ .../proposals/_comment_thread.html.erb | 3 ++ .../proposals/proposals/_comments.html.erb | 21 ++++++++++ .../proposals/_external_proposal.html.erb | 17 +++++--- .../proposals/external_proposal.html.erb | 42 +++++++++++++++++++ 6 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 app/views/decidim/proposals/proposals/_comment.html.erb create mode 100644 app/views/decidim/proposals/proposals/_comment_replies.html.erb create mode 100644 app/views/decidim/proposals/proposals/_comment_thread.html.erb create mode 100644 app/views/decidim/proposals/proposals/_comments.html.erb create mode 100644 app/views/decidim/proposals/proposals/external_proposal.html.erb diff --git a/app/views/decidim/proposals/proposals/_comment.html.erb b/app/views/decidim/proposals/proposals/_comment.html.erb new file mode 100644 index 0000000..70dea20 --- /dev/null +++ b/app/views/decidim/proposals/proposals/_comment.html.erb @@ -0,0 +1,18 @@ +<%= content_tag :div, id: "comment_#{comment_ref}", class: "comment relative", role: "comment", data: { comment_id: comment[:reference], parent: comment[:parent] }, root_depth: comment["metadata"]["depth"] do %> +
+
+ default avatar"> +
+

<%= comment["authors"] %>

+

<%= l(comment["created_at"].to_datetime, format: :decidim_short) %>

+
+

<%= comment["content"] %>

+
+
"> + <% if comment["children"].present? %> + <%= render partial: "comment_replies", locals: { comment_replies: comments.select{|comm| comment["children"].include?(comm["reference"]) }, comments: comments } %> + <% end %> +
+
+<% end %> + diff --git a/app/views/decidim/proposals/proposals/_comment_replies.html.erb b/app/views/decidim/proposals/proposals/_comment_replies.html.erb new file mode 100644 index 0000000..bb25d67 --- /dev/null +++ b/app/views/decidim/proposals/proposals/_comment_replies.html.erb @@ -0,0 +1,3 @@ +<% comment_replies.each do |reply| %> + <%= render partial: "comment", locals: { comment: reply, comment_ref: reply["reference"], comments: comments } %> +<% end %> diff --git a/app/views/decidim/proposals/proposals/_comment_thread.html.erb b/app/views/decidim/proposals/proposals/_comment_thread.html.erb new file mode 100644 index 0000000..97e9aa0 --- /dev/null +++ b/app/views/decidim/proposals/proposals/_comment_thread.html.erb @@ -0,0 +1,3 @@ +
"> + <%= render partial: "comment", locals: { comment: comment, comment_ref: comment["reference"], comments: comments } %> +
diff --git a/app/views/decidim/proposals/proposals/_comments.html.erb b/app/views/decidim/proposals/proposals/_comments.html.erb new file mode 100644 index 0000000..2380fcf --- /dev/null +++ b/app/views/decidim/proposals/proposals/_comments.html.erb @@ -0,0 +1,21 @@ +
+
+
+

+ <% if comments.count <= 1 %> + <%= t("decidim.components.comments.comment_details_title") %> + <% else %> + <%= icon "chat-1-line", class: "fill-tertiary w-6 h-6 inline-block align-middle" %> + + <%= t("decidim.components.comments.title", count: comments.count) %> + + <% end %> +

+
+
+ <% parent_comments.each do |parent_comment| %> + <%= render partial: "comment_thread", locals: { comment: parent_comment, comments: comments } %> + <% end %> +
+
+
diff --git a/app/views/decidim/proposals/proposals/_external_proposal.html.erb b/app/views/decidim/proposals/proposals/_external_proposal.html.erb index 090da1b..f6311cd 100644 --- a/app/views/decidim/proposals/proposals/_external_proposal.html.erb +++ b/app/views/decidim/proposals/proposals/_external_proposal.html.erb @@ -1,5 +1,6 @@ <% if card_size == :g %> - <%= link_to external_proposal["source"], class: "card__grid-external", id: external_proposal["reference"] do %> + + <%= link_to external_proposal_proposals_path(external_proposal["reference"]), class: "card__grid-external", id: external_proposal["reference"] do %>
<%= external_icon "media/images/placeholder-card-g.svg", class: "card__placeholder-g" %>

<%= t('.view_from', platform: @platform) %>

@@ -7,11 +8,17 @@
<%= content_tag :h4, external_proposal["title"], class: "h4 text-secondary" %>
-
-
- default avatar"> + <% end %> <% else %> diff --git a/app/views/decidim/proposals/proposals/external_proposal.html.erb b/app/views/decidim/proposals/proposals/external_proposal.html.erb new file mode 100644 index 0000000..cce3a50 --- /dev/null +++ b/app/views/decidim/proposals/proposals/external_proposal.html.erb @@ -0,0 +1,42 @@ +<% add_decidim_meta_tags({ + description: @external_proposal["content"], + title: @external_proposal["title"], + url: @external_proposal["source"] + }) %> + +<% append_stylesheet_pack_tag "decidim_proposals", media: "all" %> +<% append_javascript_pack_tag "decidim_proposals" %> + +<%= render layout: "layouts/decidim/shared/layout_item", locals: { back_path: component_settings.participatory_texts_enabled? ? main_component_path(current_component) : proposals_path } do %> + +
+ <%= render partial: "voting_rules" %> +

<%= @external_proposal["title"] %>

+
+
+ default avatar"> +
+

<%= @authors %>

+
+
+ +
+
+ <%= decidim_sanitize_editor(@external_proposal["content"]) %> +
+
+ + <%#= render partial: "actions" %> + +
+ <%= cell "decidim/comments_button", nil %> +
+ <% if @comments.present? %> + <% append_stylesheet_pack_tag "decidim_comments" %> + <% append_javascript_pack_tag "decidim_comments", defer: false %> + <%= content_tag :div, id: "comments-for-#{@external_proposal[:reference]}", class: "mt-8" do %> + <%= render partial: "comments", locals: { parent_comments: @parent_comments, comments: @comments } %> + <% end %> + <% end %> +<% end %> + From 5e1b258564e6ffc14c5184e41ce6743509907453 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Tue, 28 Oct 2025 17:15:06 +0100 Subject: [PATCH 28/33] test: add tests for external_proposal and update tests with comments --- .../api/v1/contributions_controller_spec.rb | 89 +++++++--- .../proposals/proposals_controller_spec.rb | 100 ++++++++++++ spec/models/contribution_spec.rb | 73 +++++++-- .../get_data_from_api_service_spec.rb | 94 ++++++++++- spec/system/external_proposal_spec.rb | 152 ++++++++++++++++++ 5 files changed, 467 insertions(+), 41 deletions(-) create mode 100644 spec/system/external_proposal_spec.rb diff --git a/spec/controllers/decidim/dataspace/api/v1/contributions_controller_spec.rb b/spec/controllers/decidim/dataspace/api/v1/contributions_controller_spec.rb index 9c7b760..fedfbe8 100644 --- a/spec/controllers/decidim/dataspace/api/v1/contributions_controller_spec.rb +++ b/spec/controllers/decidim/dataspace/api/v1/contributions_controller_spec.rb @@ -6,18 +6,34 @@ routes { Decidim::Dataspace::Engine.routes } describe "index" do - context "when there are contributions" do + context "when there are contributions with comments" do let(:component) { create(:proposal_component) } let!(:proposal) { create(:proposal, component:) } + let!(:comment_one) { create(:comment, commentable: proposal) } + let!(:comment_two) { create(:comment, commentable: proposal) } let!(:proposal_two) { create(:proposal, component:) } let!(:proposal_three) { create(:proposal, component:) } - it "is a success and returns json with contributions" do - get :index - expect(response).to have_http_status(:ok) - expect(response.content_type).to include("application/json") - expect { response.parsed_body }.not_to raise_error - expect(response.parsed_body.size).to eq(3) + context "and with_comments is false" do + it "is a success and returns json with only proposals as contributions" do + get :index + expect(response).to have_http_status(:ok) + expect(response.content_type).to include("application/json") + expect { response.parsed_body }.not_to raise_error + # only proposals are rendered + expect(response.parsed_body.size).to eq(3) + end + end + + context "and with_comments is true" do + it "is a success and returns json with proposals and comments as contributions" do + get :index, params: { with_comments: "true" } + expect(response).to have_http_status(:ok) + expect(response.content_type).to include("application/json") + expect { response.parsed_body }.not_to raise_error + # proposals + comments are rendered + expect(response.parsed_body.size).to eq(5) + end end end @@ -34,24 +50,57 @@ describe "show" do let(:component) { create(:proposal_component) } let!(:proposal) { create(:proposal, component:) } + let!(:comment_one) { create(:comment, commentable: proposal) } + let!(:comment_two) { create(:comment, commentable: proposal) } context "when contribution exists" do - before do - get :show, params: { reference: proposal.reference } - end + context "and with comments is false" do + before do + get :show, params: { reference: proposal.reference } + end - it "is a success and returns json" do - expect(response).to have_http_status(:ok) - expect(response.content_type).to include("application/json") - expect { response.parsed_body }.not_to raise_error + it "is a success and returns json" do + expect(response).to have_http_status(:ok) + expect(response.content_type).to include("application/json") + expect { response.parsed_body }.not_to raise_error + end + + it "returns the contribution with no detailed comments" do + expect(response.parsed_body["reference"]).to eq(proposal.reference) + expect(response.parsed_body["source"]).to eq(Decidim::ResourceLocatorPresenter.new(proposal).url) + expect(response.parsed_body["container"]).to eq(proposal.component.participatory_space_type.constantize.find(proposal.component.participatory_space_id).reference) + expect(response.parsed_body["title"]).to eq(proposal.title["en"]) + expect(response.parsed_body["content"]).to eq(proposal.body["en"]) + # 2 comments without details + expect(response.parsed_body["children"].size).to eq(2) + expect(response.parsed_body["children"].first.class).to eq(String) + expect(response.parsed_body["children"].first).to eq("#{proposal["reference"]}-#{comment_one.id}") + end end - it "returns the contribution" do - expect(response.parsed_body["reference"]).to eq(proposal.reference) - expect(response.parsed_body["source"]).to eq(Decidim::ResourceLocatorPresenter.new(proposal).url) - expect(response.parsed_body["container"]).to eq(proposal.component.participatory_space_type.constantize.find(proposal.component.participatory_space_id).reference) - expect(response.parsed_body["title"]).to eq(proposal.title["en"]) - expect(response.parsed_body["content"]).to eq(proposal.body["en"]) + context "and with comments is true" do + before do + get :show, params: { reference: proposal.reference, with_comments: "true" } + end + + it "is a success and returns json" do + expect(response).to have_http_status(:ok) + expect(response.content_type).to include("application/json") + expect { response.parsed_body }.not_to raise_error + end + + it "returns the contribution with detailed comments" do + expect(response.parsed_body["reference"]).to eq(proposal.reference) + expect(response.parsed_body["source"]).to eq(Decidim::ResourceLocatorPresenter.new(proposal).url) + expect(response.parsed_body["container"]).to eq(proposal.component.participatory_space_type.constantize.find(proposal.component.participatory_space_id).reference) + expect(response.parsed_body["title"]).to eq(proposal.title["en"]) + expect(response.parsed_body["content"]).to eq(proposal.body["en"]) + # 2 comments with details + expect(response.parsed_body["children"].size).to eq(2) + expect(response.parsed_body["children"].first.class).to eq(Hash) + expect(response.parsed_body["children"].first["reference"]).to eq("#{proposal["reference"]}-#{comment_one.id}") + expect(response.parsed_body["children"].first["content"]).to eq(comment_one.body["en"]) + end end end diff --git a/spec/controllers/decidim/proposals/proposals_controller_spec.rb b/spec/controllers/decidim/proposals/proposals_controller_spec.rb index 5f2a36a..0349b86 100644 --- a/spec/controllers/decidim/proposals/proposals_controller_spec.rb +++ b/spec/controllers/decidim/proposals/proposals_controller_spec.rb @@ -174,6 +174,106 @@ module Proposals end end + describe "GET external_proposal" do + let(:component) { create(:proposal_component) } + let(:json_contrib) do + { + "reference" => "JD-PROP-2025-09-1", + "source" => "http://localhost:3000/processes/satisfaction-hope/f/7/proposals/1", + "container" => "JD-PART-2025-09-1", + "locale" => "en", + "title" => "Quia sapiente.", + "content" => "Debitis repellat provident. Earum dolorem eaque. Aut quia officiis.\nAsperiores cupiditate accusantium. Esse rerum quia. Atque et distinctio.", + "authors" => [ + "JD-MEET-2025-09-23" + ], + "parent" => nil, + "children" => [ + { + "reference" => "JD-PROP-2025-09-1-249", + "source" => "http://localhost:3000/processes/satisfaction-hope/f/7/proposals/1", + "container" => "JD-PART-2025-09-1", + "locale" => "en", + "title" => nil, + "content" => "Cumque hic quia veniam et dolores aliquam commodi laudantium omnis expedita enim natus et beatae quidem dolores architecto repudiandae rem a corporis impedit rerum fugit neque eos dicta deserunt consequatur numquam magnam voluptate inventore omnis aut porro nemo voluptas sit quia saepe aut provident accusantium voluptatem illum nam quaerat molestiae.", + "authors" => "Kautzer-Mayer", + "parent" => "JD-PROP-2025-09-1", + "children" => [ + "JD-PROP-2025-09-1-250" + ], + "metadata" => { + "depth" => 0 + }, + "created_at" => "2025-09-11T10:20:23.609Z", + "updated_at" => "2025-09-11T10:20:23.609Z", + "deleted_at" => nil + }, + { + "reference" => "JD-PROP-2025-09-1-250", + "source" => "http://localhost:3000/processes/satisfaction-hope/f/7/proposals/1", + "container" => "JD-PART-2025-09-1", + "locale" => "en", + "title" => nil, + "content" => "Voluptatem illum sit eius eligendi omnis dolore qui alias et occaecati eos ipsum blanditiis unde fugit minus est quia excepturi eos ut nam iste molestias cupiditate et vel repellat quidem qui non est porro commodi quia mollitia reiciendis odit rem voluptas tempora autem et sequi quos provident accusantium fugiat accusamus.", + "authors" => "Aldo Davis", + "parent" => "JD-PROP-2025-09-1-249", + "children" => nil, + "metadata" => { + "depth" => 1 + }, + "created_at" => "2025-09-11T10:20:24.655Z", + "updated_at" => "2025-09-11T10:20:24.655Z", + "deleted_at" => nil + } + ], + "created_at" => "2025-09-11T10:20:21.222Z", + "updated_at" => "2025-09-11T10:21:56.604Z", + "deleted_at" => nil + } + end + + let(:authors) do + [ + { + "reference" => "JD-MEET-2025-09-23", + "name" => "Et natus.", + "source" => "http://localhost:3000/assemblies/smile-trivial/f/23/meetings/23" + }, + { + "reference" => "Aldo Davis", + "name" => "Aldo Davis", + "source" => nil + }, + { + "reference" => "Kautzer-Mayer", + "name" => "Kautzer-Mayer", + "source" => nil + }, + { + "reference" => "JD", + "name" => "Gislason LLC", + "source" => nil + } + ] + end + + before do + component.update!(settings: { add_integration: true, integration_url: "http://example.org", preferred_locale: "en" }) + allow(GetDataFromApi).to receive(:contribution).and_return(json_contrib) + allow(GetDataFromApi).to receive(:authors).and_return(authors) + end + + it "displays external_proposal view and sets variables" do + get :external_proposal, params: { reference: "JD-PROP-2025-09-1", param: :reference } + expect(response).to have_http_status(:ok) + expect(subject).to render_template(:external_proposal) + expect(assigns(:external_proposal)).to eq json_contrib + expect(assigns(:comments)).to eq json_contrib["children"] + expect(assigns(:parent_comments)).to eq(json_contrib["children"].select { |comment| comment["parent"] == json_contrib["reference"] }) + expect(assigns(:authors)).to eq "Et natus." + end + end + describe "GET new" do let(:component) { create(:proposal_component, :with_creation_enabled) } diff --git a/spec/models/contribution_spec.rb b/spec/models/contribution_spec.rb index 3f493b4..8a0ab30 100644 --- a/spec/models/contribution_spec.rb +++ b/spec/models/contribution_spec.rb @@ -111,31 +111,78 @@ module Dataspace end end - context "when using self.from_proposals method" do + context "when using self.from_proposals method with 3 proposals and 2 comments" do let(:component) { create(:proposal_component) } let!(:proposal) { create(:proposal, component:) } + let!(:comment_one) { create(:comment, commentable: proposal) } + let!(:comment_two) { create(:comment, commentable: proposal) } let!(:proposal_two) { create(:proposal, component:) } let!(:proposal_three) { create(:proposal, component:) } - it "returns an array with 3 hash elements" do - method_call = Contribution.from_proposals("en") - expect(method_call.class).to eq(Array) - expect(method_call.size).to eq(3) - expect(method_call.first.class).to eq(Hash) + context "and with_comments is false" do + it "returns an array with 3 hash proposals elements" do + method_call = Contribution.from_proposals("en") + expect(method_call.class).to eq(Array) + expect(method_call.size).to eq(3) + expect(method_call.first.class).to eq(Hash) + expect(method_call.first[:children].size).to eq(2) + end + end + + context "and with_comments is true" do + it "returns an array with 5 proposals+comments hash elements" do + method_call = Contribution.from_proposals("en", "true") + expect(method_call.class).to eq(Array) + expect(method_call.size).to eq(5) + expect(method_call.first.class).to eq(Hash) + # first is proposal and has 2 children + expect(method_call.first[:children].size).to eq(2) + # second will be the first comment of proposal + expect(method_call.second[:reference]).to eq("#{method_call.first[:reference]}-#{comment_one.id}") + # last is proposal_three and has no children + expect(method_call.last[:children]).to eq([]) + end end end context "when using self.proposal method" do let(:component) { create(:proposal_component) } let!(:proposal) { create(:proposal, :participant_author, component:) } + let!(:comment_one) { create(:comment, commentable: proposal) } + let!(:comment_two) { create(:comment, commentable: proposal) } + + context "and with_comments is false" do + it "returns an array with 1 hash element and no detailed comments in children key" do + method_call = Contribution.proposal(proposal.reference, "en") + expect(method_call.class).to eq(Hash) + # we have 12 keys in the returned hash + expect(method_call.size).to eq(12) + expect(method_call[:reference]).to eq(proposal.reference) + # reference for user author is name + expect(method_call[:authors]).to eq([proposal.authors.first.name]) + # proposal has 2 comments + expect(method_call[:children].size).to eq(2) + # comments are not detailed + expect(method_call[:children].first.class).to eq(String) + expect(method_call[:children].first).to eq("#{method_call[:reference]}-#{comment_one.id}") + end + end - it "returns an array with 1 hash element" do - method_call = Contribution.proposal(proposal.reference, "en") - expect(method_call.class).to eq(Hash) - expect(method_call.size).to eq(10) - expect(method_call[:reference]).to eq(proposal.reference) - # reference for user author is name - expect(method_call[:authors]).to eq([proposal.authors.first.name]) + context "and with_comments is true" do + it "returns an array with 1 hash element and detailed comments in children key" do + method_call = Contribution.proposal(proposal.reference, "en", "true") + expect(method_call.class).to eq(Hash) + # we have 12 keys in the returned hash + expect(method_call.size).to eq(12) + expect(method_call[:reference]).to eq(proposal.reference) + # reference for user author is name + expect(method_call[:authors]).to eq([proposal.authors.first.name]) + # proposal has 2 comments + expect(method_call[:children].size).to eq(2) + # comments are detailed + expect(method_call[:children].first.class).to eq(Hash) + expect(method_call[:children].first[:reference]).to eq("#{method_call[:reference]}-#{comment_one.id}") + end end end end diff --git a/spec/services/get_data_from_api_service_spec.rb b/spec/services/get_data_from_api_service_spec.rb index 9e1bc22..0af159f 100644 --- a/spec/services/get_data_from_api_service_spec.rb +++ b/spec/services/get_data_from_api_service_spec.rb @@ -4,32 +4,36 @@ require "net/http" require "uri" -RSpec.describe GetDataFromApi, type: :model do +RSpec.describe "GetDataFromApi" do context "when testing contributions" do - let(:url) { "http://localhost:3000" } + let(:url) { "http://example.com" } context "when testing list" do - let(:uri) { URI("http://localhost:3000/api/v1/data/contributions?preferred_locale=en") } + let(:uri) { URI("http://example.com/api/v1/data/contributions?preferred_locale=en") } let(:contrib_one) do { "reference" => "JD-PROP-2025-09-1", - "source" => "http://localhost:3000/processes/satisfaction-hope/f/7/proposals/1", + "source" => "http://example.com/processes/satisfaction-hope/f/7/proposals/1", "container" => "JD-PART-2025-09-1", "locale" => "en", "title" => "Test one", "content" => "Debitis repellat provident", "authors" => ["JD-MEET-2025-09-6"], + "parent" => nil, + "children" => [], "created_at" => "2025-09-11T10:20:21.222Z", "updated_at" => "2025-09-11T10:21:56.604Z", "deleted_at" => nil } end let(:contrib_two) do { "reference" => "JD-PROP-2025-09-20", - "source" => "http://localhost:3000/assemblies/smile-trivial/f/25/proposals/20", + "source" => "http://example.com/assemblies/smile-trivial/f/25/proposals/20", "container" => "JD-ASSE-2025-09-1", "locale" => "en", "title" => "Test two", "content" => "Non et vel", "authors" => ["JD-MEET-2025-09-23"], + "parent" => nil, + "children" => [], "created_at" => "2025-09-11T10:43:23.743Z", "updated_at" => "2025-09-11T10:43:27.147Z", "deleted_at" => nil } @@ -49,16 +53,18 @@ end end - context "when testing show" do - let(:uri) { URI("http://localhost:3000/api/v1/data/contributions/JD-PROP-2025-09-1?preferred_locale=en") } + context "when testing show with with_comments false" do + let(:uri) { URI("http://example.com/api/v1/data/contributions/JD-PROP-2025-09-1?preferred_locale=en&with_comments=false") } let(:json) do { "reference" => "JD-PROP-2025-09-1", - "source" => "http://localhost:3000/processes/satisfaction-hope/f/7/proposals/1", + "source" => "http://example.com/processes/satisfaction-hope/f/7/proposals/1", "container" => "JD-PART-2025-09-1", "locale" => "en", "title" => "Test one", "content" => "Debitis repellat provident", "authors" => ["JD-MEET-2025-09-6"], + "parent" => nil, + "children" => %w(JD-PART-2025-09-1-249 JD-PART-2025-09-1-250), "created_at" => "2025-09-11T10:20:21.222Z", "updated_at" => "2025-09-11T10:21:56.604Z", "deleted_at" => nil }.to_json @@ -75,6 +81,78 @@ expect(response.class).to eq Hash expect(response["reference"]).to eq("JD-PROP-2025-09-1") expect(response["title"]).to eq("Test one") + expect(response["children"]).to eq(["JD-PART-2025-09-1-249", "JD-PART-2025-09-1-250"]) + end + end + + context "when testing show with with_comments true" do + let(:uri) { URI("http://example.com/api/v1/data/contributions/JD-PROP-2025-09-1?preferred_locale=en&with_comments=true") } + let(:json) do + { "reference" => "JD-PROP-2025-09-1", + "source" => "http://example.com/processes/satisfaction-hope/f/7/proposals/1", + "container" => "JD-PART-2025-09-1", + "locale" => "en", + "title" => "Test one", + "content" => "Debitis repellat provident", + "authors" => ["JD-MEET-2025-09-6"], + "parent" => nil, + "children" => [ + { + "reference" => "JD-PROP-2025-09-1-249", + "source" => "http://example.com/processes/satisfaction-hope/f/7/proposals/1", + "container" => "JD-PART-2025-09-1", + "locale" => "en", + "title" => nil, + "content" => "Cumque hic quia veniam et dolores aliquam commodi laudantium omnis expedita enim natus et beatae quidem dolores architecto repudiandae rem a corporis impedit rerum fugit neque eos dicta deserunt consequatur numquam magnam voluptate inventore omnis aut porro nemo voluptas sit quia saepe aut provident accusantium voluptatem illum nam quaerat molestiae.", + "authors" => "Kautzer-Mayer", + "parent" => "JD-PROP-2025-09-1", + "children" => [ + "JD-PROP-2025-09-1-250" + ], + "metadata" => { + "depth" => 0 + }, + "created_at" => "2025-09-11T10:20:23.609Z", + "updated_at" => "2025-09-11T10:20:23.609Z", + "deleted_at" => nil + }, + { + "reference" => "JD-PROP-2025-09-1-250", + "source" => "http://example.com/processes/satisfaction-hope/f/7/proposals/1", + "container" => "JD-PART-2025-09-1", + "locale" => "en", + "title" => nil, + "content" => "Voluptatem illum sit eius eligendi omnis dolore qui alias et occaecati eos ipsum blanditiis unde fugit minus est quia excepturi eos ut nam iste molestias cupiditate et vel repellat quidem qui non est porro commodi quia mollitia reiciendis odit rem voluptas tempora autem et sequi quos provident accusantium fugiat accusamus.", + "authors" => "Aldo Davis", + "parent" => "JD-PROP-2025-09-1-249", + "children" => nil, + "metadata" => { + "depth" => 1 + }, + "created_at" => "2025-09-11T10:20:24.655Z", + "updated_at" => "2025-09-11T10:20:24.655Z", + "deleted_at" => nil + } + ], + "created_at" => "2025-09-11T10:20:21.222Z", + "updated_at" => "2025-09-11T10:21:56.604Z", + "deleted_at" => nil }.to_json + end + + let(:ref) { "JD-PROP-2025-09-1" } + + before do + allow(Net::HTTP).to receive(:get).with(uri).and_return(json) + end + + it "returns json containing one contribution and its detailed comments" do + response = GetDataFromApi.contribution(url, ref, "en", "true") + expect(response.class).to eq Hash + expect(response["reference"]).to eq("JD-PROP-2025-09-1") + expect(response["title"]).to eq("Test one") + expect(response["children"].class).to eq(Array) + expect(response["children"].first.class).to eq(Hash) + expect(response["children"].first["reference"]).to eq("JD-PROP-2025-09-1-249") end end end diff --git a/spec/system/external_proposal_spec.rb b/spec/system/external_proposal_spec.rb new file mode 100644 index 0000000..7a424b0 --- /dev/null +++ b/spec/system/external_proposal_spec.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +require "spec_helper" + +# rubocop:disable RSpec/DescribeClass +describe "Proposals external proposal" 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(:authors) do + [ + { + "reference" => "JD-MEET-2025-09-23", + "name" => "Et natus.", + "source" => "http://localhost:3000/assemblies/smile-trivial/f/23/meetings/23" + }, + { + "reference" => "Aldo Davis", + "name" => "Aldo Davis", + "source" => nil + }, + { + "reference" => "Kautzer-Mayer", + "name" => "Kautzer-Mayer", + "source" => nil + }, + { + "reference" => "JD", + "name" => "Gislason LLC", + "source" => nil + } + ] + end + + before do + component.update!(settings: { add_integration: true, integration_url: "http://example.org", preferred_locale: "en" }) + allow(GetDataFromApi).to receive(:contribution).and_return(json_contrib) + allow(GetDataFromApi).to receive(:authors).and_return(authors) + visit_external_proposal + end + + context "when the external proposal has no comments" do + let(:json_contrib) do + { + "reference" => "JD-PROP-2025-09-1", + "source" => "http://localhost:3000/processes/satisfaction-hope/f/7/proposals/1", + "container" => "JD-PART-2025-09-1", + "locale" => "en", + "title" => "Quia sapiente.", + "content" => "Debitis repellat provident. Earum dolorem eaque. Aut quia officiis.", + "authors" => [ + "Aldo Davis" + ], + "parent" => nil, + "children" => nil, + "created_at" => "2025-09-11T10:20:21.222Z", + "updated_at" => "2025-09-11T10:21:56.604Z", + "deleted_at" => nil + } + end + + it "displays only external proposal" do + expect(page).to have_css("h1", text: "Quia sapiente.") + expect(page).to have_css("p.author_name", text: "Aldo Davis") + expect(page).to have_css("div.rich-text-display", text: json_contrib["content"]) + expect(page).to have_no_css("div#comments") + end + end + + context "when the external proposal has comments" do + let(:json_contrib) do + { + "reference" => "JD-PROP-2025-09-1", + "source" => "http://localhost:3000/processes/satisfaction-hope/f/7/proposals/1", + "container" => "JD-PART-2025-09-1", + "locale" => "en", + "title" => "Quia sapiente.", + "content" => "Debitis repellat provident. Earum dolorem eaque. Aut quia officiis.", + "authors" => [ + "Aldo Davis" + ], + "parent" => nil, + "children" => [ + { + "reference" => "JD-PROP-2025-09-1-249", + "source" => "http://localhost:3000/processes/satisfaction-hope/f/7/proposals/1", + "container" => "JD-PART-2025-09-1", + "locale" => "en", + "title" => nil, + "content" => "Cumque hic quia veniam et dolores aliquam commodi laudantium omnis expedita enim natus et beatae quidem dolores architecto repudiandae rem a corporis impedit rerum fugit neque eos dicta deserunt consequatur numquam magnam voluptate inventore omnis aut porro nemo voluptas sit quia saepe aut provident accusantium voluptatem illum nam quaerat molestiae.", + "authors" => "Kautzer-Mayer", + "parent" => "JD-PROP-2025-09-1", + "children" => [ + "JD-PROP-2025-09-1-250" + ], + "metadata" => { + "depth" => 0 + }, + "created_at" => "2025-09-11T10:20:23.609Z", + "updated_at" => "2025-09-11T10:20:23.609Z", + "deleted_at" => nil + }, + { + "reference" => "JD-PROP-2025-09-1-250", + "source" => "http://localhost:3000/processes/satisfaction-hope/f/7/proposals/1", + "container" => "JD-PART-2025-09-1", + "locale" => "en", + "title" => nil, + "content" => "Voluptatem illum sit eius eligendi omnis dolore qui alias et occaecati eos ipsum blanditiis unde fugit minus est quia excepturi eos ut nam iste molestias cupiditate et vel repellat quidem qui non est porro commodi quia mollitia reiciendis odit rem voluptas tempora autem et sequi quos provident accusantium fugiat accusamus.", + "authors" => "Aldo Davis", + "parent" => "JD-PROP-2025-09-1-249", + "children" => nil, + "metadata" => { + "depth" => 1 + }, + "created_at" => "2025-09-11T10:20:24.655Z", + "updated_at" => "2025-09-11T10:20:24.655Z", + "deleted_at" => nil + } + ], + "created_at" => "2025-09-11T10:20:21.222Z", + "updated_at" => "2025-09-11T10:21:56.604Z", + "deleted_at" => nil + } + end + + it "displays external proposal with its comments" do + expect(page).to have_css("h1", text: "Quia sapiente.") + expect(page).to have_css("p.author_name", text: "Aldo Davis") + expect(page).to have_css("div.rich-text-display", text: json_contrib["content"]) + expect(page).to have_css("div#comments") + expect(page).to have_css("span.comments-count", text: "2 comments") + within "div.comment-thread" do + expect(page).to have_css("div#comment_JD-PROP-2025-09-1-249") + expect(page).to have_css("div#accordion-JD-PROP-2025-09-1-249") + end + end + end + + private + + def decidim_proposals + Decidim::EngineRouter.main_proxy(component) + end + + def visit_external_proposal + visit decidim_proposals.external_proposal_proposals_path("JD-PROP-2025-09-1") + end +end From 4b4cd22872b80483dbc2cb05789c06a7a8e8350f Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Tue, 28 Oct 2025 17:15:34 +0100 Subject: [PATCH 29/33] chore: update readme --- README.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 038a60e..1f0a3ec 100644 --- a/README.md +++ b/README.md @@ -25,19 +25,25 @@ bundle exec rake db:migrate ## API endpoints + Retrieve all data from the data space\ -GET "/data" +GET "api/v1/data" + Retrieve all containers from the data space\ -GET "/data/containers" +GET "api/v1/data/containers" + Retrieve a container using its reference\ -GET "/data/containers/:reference" +GET "api/v1/data/containers/:reference" + Retrieve all contributions from the data space\ -GET "/data/contributions" +GET "api/v1/data/contributions" + Retrieve a contribution using its reference\ -GET "/data/contributions/:reference" +GET "api/v1/data/contributions/:reference" + Retrieve all authors from the data space\ -GET "/data/authors" +GET "api/v1/data/authors" + Retrieve an author using its reference\ -GET "/data/authors/:reference" +GET "api/v1/data/authors/:reference" + +Please note that for the 2 endpoints related to contribution, you can add 2 query params ++ "preferred_locale=fr" to get the data with your favorite language (default is "en") ++ "with_comments=true" (default is false) + + for contributions endpoint, it will give you proposals and comments (the default is only proposals) + + for contribution endpoint, it will give you a proposal with detailed comments as children Please note that the reference is the last part of the URL and **needs to be URL encoded** From 80359fc4b95da8ff3c1016b6c2938bd6f085b775 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Wed, 29 Oct 2025 15:01:51 +0100 Subject: [PATCH 30/33] feat: add proposal state in metadata --- app/models/decidim/dataspace/contribution.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/models/decidim/dataspace/contribution.rb b/app/models/decidim/dataspace/contribution.rb index ea16b8b..cfd63f3 100644 --- a/app/models/decidim/dataspace/contribution.rb +++ b/app/models/decidim/dataspace/contribution.rb @@ -63,6 +63,7 @@ def self.proposals_with_comments(proposals, locale) authors: Contribution.authors(proposal), parent: nil, children: comments.map { |comment| comment[:reference] }, + metadata: { state: { withdrawn: proposal.withdrawn?, emendation: proposal.emendation?, state: proposal.state } }, created_at: proposal.created_at, updated_at: proposal.updated_at, deleted_at: nil # does not exist in proposal @@ -84,6 +85,7 @@ def self.proposal_without_comment(proposal, locale) authors: Contribution.authors(proposal), parent: nil, children: proposal.comments.map { |comment| "#{proposal.reference}-#{comment.id}" }, + metadata: { state: { withdrawn: proposal.withdrawn?, emendation: proposal.emendation?, state: proposal.state } }, created_at: proposal.created_at, updated_at: proposal.updated_at, deleted_at: nil # does not exist in proposal @@ -138,6 +140,7 @@ def self.proposal_with_comments(proposal, locale) authors: Contribution.authors(proposal), parent: nil, children: Contribution.comments(proposal, locale), + metadata: { state: { withdrawn: proposal.withdrawn?, emendation: proposal.emendation?, state: proposal.state } }, created_at: proposal.created_at, updated_at: proposal.updated_at, deleted_at: nil From 8e9b577ff4bb1d7d7d38986be8980182be07bed4 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Wed, 29 Oct 2025 15:02:43 +0100 Subject: [PATCH 31/33] feat: add helper and update views to display state --- .../proposals/external_proposal_helper.rb | 47 +++++++++++++++++++ .../proposals/_external_proposal.html.erb | 7 ++- .../proposals/external_proposal.html.erb | 3 ++ lib/decidim/dataspace/engine.rb | 9 ++++ 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 app/helpers/decidim/proposals/external_proposal_helper.rb diff --git a/app/helpers/decidim/proposals/external_proposal_helper.rb b/app/helpers/decidim/proposals/external_proposal_helper.rb new file mode 100644 index 0000000..bb1abd0 --- /dev/null +++ b/app/helpers/decidim/proposals/external_proposal_helper.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Decidim + module Proposals + module ExternalProposalHelper + def external_state_item(state) + return if state["state"].blank? + + if state["withdrawn"] == true + content_tag(:span, humanize_proposal_state(:withdrawn), class: "label alert") + elsif state["emendation"] == true + content_tag(:span, humanize_proposal_state(state["state"].capitalize), class: "label #{external_state_class(state)}") + else + content_tag(:span, translated_attribute(state["state"].capitalize), class: "label", style: external_css_style(state)) + end + end + + def external_state_class(state) + return "alert" if state["withdrawn"] == "true" + + case state["state"] + when "accepted" + "success" + when "rejected" + "alert" + when "evaluating" + "warning" + else + "muted" + end + end + + def external_css_style(state) + case state["state"] + when "accepted" + "background-color: #E3FCE9; color: #15602C; border-color: #15602C;" + when "rejected" + "background-color: #FFEBE9; color: #D1242F; border-color: #D1242F;" + when "evaluating" + "background-color: #FFF1E5; color: #BC4C00; border-color: #BC4C00;" + else + "" + end + end + end + end +end diff --git a/app/views/decidim/proposals/proposals/_external_proposal.html.erb b/app/views/decidim/proposals/proposals/_external_proposal.html.erb index f6311cd..76cf14e 100644 --- a/app/views/decidim/proposals/proposals/_external_proposal.html.erb +++ b/app/views/decidim/proposals/proposals/_external_proposal.html.erb @@ -6,7 +6,12 @@

<%= t('.view_from', platform: @platform) %>

- <%= content_tag :h4, external_proposal["title"], class: "h4 text-secondary" %> +
+ <%= content_tag :h4, external_proposal["title"], class: "h4 text-secondary" %> + <% if external_proposal["metadata"]["state"]["state"].present? %> + <%= external_state_item(external_proposal["metadata"]["state"]) %> + <% end %> +
diff --git a/lib/decidim/dataspace/engine.rb b/lib/decidim/dataspace/engine.rb index a26b97c..9691118 100644 --- a/lib/decidim/dataspace/engine.rb +++ b/lib/decidim/dataspace/engine.rb @@ -45,6 +45,15 @@ class Engine < ::Rails::Engine end end + initializer "decidim_dataspace.add_customizations" do + config.to_prepare do + # Helper + Decidim::Proposals::ProposalsHelper.class_eval do + include Decidim::Proposals::ExternalProposalHelper + end + end + end + initializer "dataspace.mount_routes" do Decidim::Core::Engine.routes do mount Decidim::Dataspace::Engine => "/" From 9f368df96c369a86fc18d01b0d9acc46c9e5f1ba Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Wed, 29 Oct 2025 15:03:40 +0100 Subject: [PATCH 32/33] test: add tests for helper --- spec/helpers/external_proposal_helper_spec.rb | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 spec/helpers/external_proposal_helper_spec.rb diff --git a/spec/helpers/external_proposal_helper_spec.rb b/spec/helpers/external_proposal_helper_spec.rb new file mode 100644 index 0000000..11e5eaf --- /dev/null +++ b/spec/helpers/external_proposal_helper_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Proposals + describe ExternalProposalHelper do + let!(:state) { { "withdrawn": false, "emendation": false, "state": "accepted" } } + + describe "external_state_item" do + context "when state is blank" do + let(:state) { {} } + + it "returns nil" do + expect(helper.external_state_item(state)).to be_nil + end + end + + context "when state is not blank" do + context "and withdrawn is true" do + let(:state) { { "withdrawn" => true, "emendation" => false, "state" => "accepted" } } + + before do + allow(helper).to receive(:humanize_proposal_state).and_return(I18n.t(:withdrawn, scope: "decidim.proposals.answers", default: :not_answered)) + end + + it "returns a span" do + expect(helper.external_state_item(state)).to eq('Withdrawn') + end + end + + context "and withdrawn is false" do + before do + allow(helper).to receive(:humanize_proposal_state).and_return(I18n.t(state["state"], scope: "decidim.proposals.answers", default: :not_answered)) + end + + context "and emendation is true" do + let(:state) { { "withdrawn" => false, "emendation" => true, "state" => "accepted" } } + + it "returns a span" do + expect(helper.external_state_item(state)).to eq('Accepted') + end + end + + context "and emendation is false" do + context "and state is accepted" do + let(:state) { { "withdrawn" => false, "emendation" => false, "state" => "accepted" } } + + it "returns a span" do + expect(helper.external_state_item(state)).to eq('Accepted') + end + end + + context "and state is rejected" do + let(:state) { { "withdrawn" => false, "emendation" => false, "state" => "rejected" } } + + it "returns a span" do + expect(helper.external_state_item(state)).to eq('Rejected') + end + end + + context "and state is evaluating" do + let(:state) { { "withdrawn" => false, "emendation" => false, "state" => "evaluating" } } + + it "returns a span" do + expect(helper.external_state_item(state)).to eq('Evaluating') + end + end + end + end + end + end + end + end +end From c4fa3b1fda35a864935005e642b25c2b42578d28 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Wed, 29 Oct 2025 16:31:15 +0100 Subject: [PATCH 33/33] test: update tests --- spec/models/contribution_spec.rb | 8 ++++---- spec/system/external_proposal_spec.rb | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/spec/models/contribution_spec.rb b/spec/models/contribution_spec.rb index 8a0ab30..9a71191 100644 --- a/spec/models/contribution_spec.rb +++ b/spec/models/contribution_spec.rb @@ -155,8 +155,8 @@ module Dataspace it "returns an array with 1 hash element and no detailed comments in children key" do method_call = Contribution.proposal(proposal.reference, "en") expect(method_call.class).to eq(Hash) - # we have 12 keys in the returned hash - expect(method_call.size).to eq(12) + # we have 13 keys in the returned hash + expect(method_call.size).to eq(13) expect(method_call[:reference]).to eq(proposal.reference) # reference for user author is name expect(method_call[:authors]).to eq([proposal.authors.first.name]) @@ -172,8 +172,8 @@ module Dataspace it "returns an array with 1 hash element and detailed comments in children key" do method_call = Contribution.proposal(proposal.reference, "en", "true") expect(method_call.class).to eq(Hash) - # we have 12 keys in the returned hash - expect(method_call.size).to eq(12) + # we have 13 keys in the returned hash + expect(method_call.size).to eq(13) expect(method_call[:reference]).to eq(proposal.reference) # reference for user author is name expect(method_call[:authors]).to eq([proposal.authors.first.name]) diff --git a/spec/system/external_proposal_spec.rb b/spec/system/external_proposal_spec.rb index 7a424b0..b8a2966 100644 --- a/spec/system/external_proposal_spec.rb +++ b/spec/system/external_proposal_spec.rb @@ -56,6 +56,13 @@ ], "parent" => nil, "children" => nil, + "metadata" => { + "state" => { + "withdrawn" => false, + "emendation" => false, + "state" => "accepted" + } + }, "created_at" => "2025-09-11T10:20:21.222Z", "updated_at" => "2025-09-11T10:21:56.604Z", "deleted_at" => nil @@ -66,6 +73,9 @@ expect(page).to have_css("h1", text: "Quia sapiente.") expect(page).to have_css("p.author_name", text: "Aldo Davis") expect(page).to have_css("div.rich-text-display", text: json_contrib["content"]) + # check status is displayed + expect(page).to have_content("Accepted") + # check there is no comments expect(page).to have_no_css("div#comments") end end @@ -121,6 +131,13 @@ "deleted_at" => nil } ], + "metadata" => { + "state" => { + "withdrawn" => false, + "emendation" => false, + "state" => nil + } + }, "created_at" => "2025-09-11T10:20:21.222Z", "updated_at" => "2025-09-11T10:21:56.604Z", "deleted_at" => nil @@ -131,6 +148,7 @@ expect(page).to have_css("h1", text: "Quia sapiente.") expect(page).to have_css("p.author_name", text: "Aldo Davis") expect(page).to have_css("div.rich-text-display", text: json_contrib["content"]) + # check comments are displayed expect(page).to have_css("div#comments") expect(page).to have_css("span.comments-count", text: "2 comments") within "div.comment-thread" do