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 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 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** diff --git a/app/controllers/decidim/dataspace/api/v1/authors_controller.rb b/app/controllers/decidim/dataspace/api/v1/authors_controller.rb index 3320e80..0b2d3db 100644 --- a/app/controllers/decidim/dataspace/api/v1/authors_controller.rb +++ b/app/controllers/decidim/dataspace/api/v1/authors_controller.rb @@ -6,28 +6,21 @@ 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 + preferred_locale = params[:preferred_locale].presence || "en" + authors = Author.from_proposals(preferred_locale) + return resource_not_found("Authors") if authors.blank? - def show - render json: render_author(@author), status: :ok + render json: authors, 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) + 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 553a3c6..5c9a3f0 100644 --- a/app/controllers/decidim/dataspace/api/v1/containers_controller.rb +++ b/app/controllers/decidim/dataspace/api/v1/containers_controller.rb @@ -6,34 +6,21 @@ 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 + preferred_locale = params["preferred_locale"] || "en" + containers = Container.from_proposals(preferred_locale) + return resource_not_found("Containers") if containers.blank? - def show - render json: render_container(@container), status: :ok + render json: containers, 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) - + 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 c0f599b..e299e51 100644 --- a/app/controllers/decidim/dataspace/api/v1/contributions_controller.rb +++ b/app/controllers/decidim/dataspace/api/v1/contributions_controller.rb @@ -1,42 +1,32 @@ # frozen_string_literal: true +require "uri" + module Decidim module Dataspace 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) } } + preferred_locale = params[:preferred_locale].presence || "en" + 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 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) + ref = CGI.unescape(params[:reference]) + preferred_locale = params[:preferred_locale].presence || "en" + 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/controllers/decidim/dataspace/api/v1/data_controller.rb b/app/controllers/decidim/dataspace/api/v1/data_controller.rb index dd1ba0c..4fc8954 100644 --- a/app/controllers/decidim/dataspace/api/v1/data_controller.rb +++ b/app/controllers/decidim/dataspace/api/v1/data_controller.rb @@ -4,53 +4,14 @@ module Decidim module Dataspace class Api::V1::DataController < Api::V1::BaseController def index + locale = params[:preferred_locale].presence || "en" 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(locale), + contributions: Contribution.from_proposals(locale), + authors: Author.from_proposals(locale) }, 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 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/models/decidim/dataspace/author.rb b/app/models/decidim/dataspace/author.rb index ee8632d..3390cda 100644 --- a/app/models/decidim/dataspace/author.rb +++ b/app/models/decidim/dataspace/author.rb @@ -11,6 +11,65 @@ class Author < Decidim::Dataspace::Interoperable # rubocop:enable Rails/HasAndBelongsToMany delegate :reference, :source, :created_at, :updated_at, :deleted_at, to: :interoperable + + 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) + + authors = proposals.all.map do |proposal| + proposal.authors.map do |author| + Author.display_author(author, locale) + end + 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) + 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" + 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" + 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 } + end + + def self.meeting_author(author, locale) + { reference: author.reference, + name: author.title[locale], + 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 diff --git a/app/models/decidim/dataspace/container.rb b/app/models/decidim/dataspace/container.rb index 034af02..90baeff 100644 --- a/app/models/decidim/dataspace/container.rb +++ b/app/models/decidim/dataspace/container.rb @@ -11,6 +11,54 @@ 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(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) + + containers = proposals.map do |proposal| + Container.from_proposal(proposal, locale) + end + containers.uniq { |hash| hash[:reference] } + end + + 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[locale], + "description": container.description[locale], + "metadata": {}, + "created_at": container.created_at, + "updated_at": container.updated_at, + "deleted_at": nil + } + end + + 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[locale], + "description": container.description[locale], + "metadata": {}, + "created_at": container.created_at, + "updated_at": container.updated_at, + "deleted_at": nil + } + end end end end diff --git a/app/models/decidim/dataspace/contribution.rb b/app/models/decidim/dataspace/contribution.rb index 75e4b1b..cfd63f3 100644 --- a/app/models/decidim/dataspace/contribution.rb +++ b/app/models/decidim/dataspace/contribution.rb @@ -17,6 +17,149 @@ class Contribution < Decidim::Dataspace::Interoperable delegate :reference, :source, :metadata, :created_at, :updated_at, :deleted_at, to: :interoperable + # 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 + .includes(:component) + locale = "en" + 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] }, + 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 + }, 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}" }, + 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 + } + end + + # get detailed comments of a proposal + def self.comments(proposal, locale) + proposal.comments.map do |comment| + component = comment.component + { + 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: 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 + + # 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 + + # 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] || proposal.title["en"], + content: proposal.body[locale] || proposal.body["en"], + 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 + } + 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) + 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 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..c890da8 --- /dev/null +++ b/app/packs/src/decidim/dataspace/component_edit_form.js @@ -0,0 +1,26 @@ +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" + } + }) + } +}) 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/services/get_data_from_api.rb b/app/services/get_data_from_api.rb new file mode 100644 index 0000000..8fb7965 --- /dev/null +++ b/app/services/get_data_from_api.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "net/http" +require "uri" + +class GetDataFromApi + 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) + rescue StandardError + nil + end + end + + 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) + rescue StandardError + nil + end + end + + 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) + rescue StandardError + nil + end + end + + 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) + rescue StandardError + nil + end + end + + 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}&with_comments=#{with_comments}") + begin + result = Net::HTTP.get(uri) + JSON.parse(result) + rescue StandardError + nil + end + end + + 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) + rescue StandardError + nil + end + end + + 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) + rescue StandardError + nil + end + end +end 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| %> +
<%= l(comment["created_at"].to_datetime, format: :decidim_short) %>
+<%= comment["content"] %>
+<%= t('.view_from', platform: @platform) %>
+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 %> +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 + { + "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", preferred_locale: "en" }) + allow(GetDataFromApi).to receive(:data).and_return(json) + end + + it "sorts proposals by search defaults and define external_proposals and other variables" do + get :index + expect(response).to have_http_status(:ok) + expect(subject).to render_template(:index) + expect(assigns(:proposals).order_values).to eq [Decidim::Proposals::Proposal.arel_table[Decidim::Proposals::Proposal.primary_key] * Arel.sql("RANDOM()")] + expect(assigns(:proposals).order_values.map(&:to_sql)).to eq ["\"decidim_proposals_proposals\".\"id\" * RANDOM()"] + expect(assigns(: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" + 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 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) } + + 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/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/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 diff --git a/spec/models/author_spec.rb b/spec/models/author_spec.rb index e936f63..fd04748 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("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 + + 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, "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 + + 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, "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 + + 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, "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 end end end diff --git a/spec/models/container_spec.rb b/spec/models/container_spec.rb index 48dbee1..64677c8 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("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 + + 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, "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 + + 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, "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 end end diff --git a/spec/models/contribution_spec.rb b/spec/models/contribution_spec.rb index 10e5ee0..9a71191 100644 --- a/spec/models/contribution_spec.rb +++ b/spec/models/contribution_spec.rb @@ -110,6 +110,81 @@ module Dataspace expect { contribution.destroy }.to change(Decidim::Dataspace::Interoperable, :count).by(-1) end end + + 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:) } + + 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 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]) + # 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 + + 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 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]) + # 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 end end 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..0af159f --- /dev/null +++ b/spec/services/get_data_from_api_service_spec.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require "spec_helper" +require "net/http" +require "uri" + +RSpec.describe "GetDataFromApi" do + context "when testing contributions" do + let(:url) { "http://example.com" } + + context "when testing list" do + 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://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://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 } + 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, "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 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://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 + 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, "en") + 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 +end diff --git a/spec/system/external_proposal_spec.rb b/spec/system/external_proposal_spec.rb new file mode 100644 index 0000000..b8a2966 --- /dev/null +++ b/spec/system/external_proposal_spec.rb @@ -0,0 +1,170 @@ +# 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, + "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 + } + 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"]) + # 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 + + 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 + } + ], + "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 + } + 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"]) + # 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 + 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 diff --git a/spec/system/proposals_index_spec.rb b/spec/system/proposals_index_spec.rb new file mode 100644 index 0000000..0553b76 --- /dev/null +++ b/spec/system/proposals_index_spec.rb @@ -0,0 +1,164 @@ +# 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(: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 + { + "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", preferred_locale: "en" }) + allow(GetDataFromApi).to receive(:data).and_return(json) + end + + it "lists the proposals ordered randomly by default and the external proposals at the end" do + visit_component + # 5 cards + expect(page).to have_css("a[class='card__list']", count: 5) + # 3 proposals + expect(page).to have_css("[id^='proposals__proposal']", count: 3) + # 2 external proposals + expect(page).to have_css("[id='JD-PROP-2025-09-1']", count: 1) + expect(page).to have_css("[id='JD-PROP-2025-09-20']", count: 1) + end + + context "and there are a lot of proposals" do + 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