diff --git a/Gemfile.lock b/Gemfile.lock index 4bc13f4..7f20cc1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -301,14 +301,6 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - carrierwave (2.2.6) - activemodel (>= 5.0.0) - activesupport (>= 5.0.0) - addressable (~> 2.6) - image_processing (~> 1.1) - marcel (~> 1.0.0) - mini_mime (>= 0.1.3) - ssrf_filter (~> 1.0) cells (4.1.8) declarative-builder (~> 0.2.0) declarative-option (< 0.2.0) @@ -409,23 +401,13 @@ GEM activemodel (>= 3.2) mime-types (>= 1.0) flamegraph (0.9.5) - fog-aws (3.21.0) - fog-core (~> 2.1) - fog-json (~> 1.1) - fog-xml (~> 0.1) fog-core (2.6.0) builder excon (~> 1.0) formatador (>= 0.2, < 2.0) mime-types - fog-json (1.2.0) - fog-core - multi_json (~> 1.10) fog-local (0.8.0) fog-core (>= 1.27, < 3.0) - fog-xml (0.1.4) - fog-core - nokogiri (>= 1.5.11, < 2.0.0) formatador (1.1.0) foundation_rails_helper (4.0.1) actionpack (>= 4.1, < 7.1) @@ -532,7 +514,6 @@ GEM mini_mime (1.1.5) minitest (5.25.1) msgpack (1.7.5) - multi_json (1.15.0) multi_xml (0.7.1) bigdecimal (~> 3.1) net-http (0.5.0) @@ -813,7 +794,6 @@ GEM spring-watcher-listen (2.1.0) listen (>= 2.7, < 4.0) spring (>= 4) - ssrf_filter (1.1.2) stackprof (0.2.26) stringio (3.1.2) temple (0.10.3) @@ -892,7 +872,6 @@ DEPENDENCIES brakeman (~> 6.1) bullet byebug (~> 11.0) - carrierwave dalli decidim-accountability! decidim-admin! @@ -914,7 +893,6 @@ DEPENDENCIES decidim-verifications! dotenv-rails (~> 2.7) flamegraph - fog-aws letter_opener_web (~> 2.0) listen (~> 3.1) memory_profiler diff --git a/config/application.rb b/config/application.rb index b553bc9..82ececb 100644 --- a/config/application.rb +++ b/config/application.rb @@ -26,5 +26,8 @@ class Application < Rails::Application # # config.time_zone = "Central Time (US & Canada)" # config.eager_load_paths << Rails.root.join("extras") + config.after_initialize do # run after the initialization of the framework itself, engines, and all the application's initializers in config/initializers + require "extends/forms/decidim/proposals/proposal_form_extends" + end end end diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml new file mode 100644 index 0000000..90fd6ae --- /dev/null +++ b/config/i18n-tasks.yml @@ -0,0 +1,185 @@ +# i18n-tasks finds and manages missing and unused translations: https://github.com/glebm/i18n-tasks + +# The "main" locale. +base_locale: en +## All available locales are inferred from the data by default. Alternatively, specify them explicitly: +# locales: [es, fr] +## Reporting locale, default: en. Available: en, ru. +# internal_locale: en + +# Read and write translations. +data: + ## Translations are read from the file system. Supported format: YAML, JSON. + ## Provide a custom adapter: + # adapter: I18n::Tasks::Data::FileSystem + + # Locale files or `Dir.glob` patterns where translations are read from: + read: + ## Default: + # - config/locales/%{locale}.yml + ## More files: + # - config/locales/**/*.%{locale}.yml + + # Locale files to write new keys to, based on a list of key pattern => file rules. Matched from top to bottom: + # `i18n-tasks normalize -p` will force move the keys according to these rules + write: + ## For example, write devise and simple form keys to their respective files: + # - ['{devise, simple_form}.*', 'config/locales/\1.%{locale}.yml'] + ## Catch-all default: + # - config/locales/%{locale}.yml + + # External locale data (e.g. gems). + # This data is not considered unused and is never written to. + external: + ## Example (replace %#= with %=): + # - "<%#= %x[bundle info vagrant --path].chomp %>/templates/locales/%{locale}.yml" + + ## Specify the router (see Readme for details). Valid values: conservative_router, pattern_router, or a custom class. + # router: conservative_router + + yaml: + write: + # do not wrap lines at 80 characters + line_width: -1 + + ## Pretty-print JSON: + # json: + # write: + # indent: ' ' + # space: ' ' + # object_nl: "\n" + # array_nl: "\n" + +# Find translate calls +search: + ## Paths or `Find.find` patterns to search in: + # paths: + # - app/ + + ## Root directories for relative keys resolution. + # relative_roots: + # - app/controllers + # - app/helpers + # - app/mailers + # - app/presenters + # - app/views + + ## Directories where method names which should not be part of a relative key resolution. + # By default, if a relative translation is used inside a method, the name of the method will be considered part of the resolved key. + # Directories listed here will not consider the name of the method part of the resolved key + # + # relative_exclude_method_name_paths: + # - + + ## Files or `File.fnmatch` patterns to exclude from search. Some files are always excluded regardless of this setting: + ## *.jpg *.jpeg *.png *.gif *.svg *.ico *.eot *.otf *.ttf *.woff *.woff2 *.pdf *.css *.sass *.scss *.less + ## *.yml *.json *.zip *.tar.gz *.swf *.flv *.mp3 *.wav *.flac *.webm *.mp4 *.ogg *.opus *.webp *.map *.xlsx + exclude: + - app/assets/images + - app/assets/fonts + - app/assets/videos + - app/assets/builds + + ## Alternatively, the only files or `File.fnmatch patterns` to search in `paths`: + ## If specified, this settings takes priority over `exclude`, but `exclude` still applies. + # only: ["*.rb", "*.html.slim"] + + ## If `strict` is `false`, guess usages such as t("categories.#{category}.title"). The default is `true`. + # strict: true + + ## Allows adding ast_matchers for finding translations using the AST-scanners + ## The available matchers are: + ## - RailsModelMatcher + ## Matches ActiveRecord translations like + ## User.human_attribute_name(:email) and User.model_name.human + ## - DefaultI18nSubjectMatcher + ## Matches ActionMailer's default_i18n_subject method + ## + ## To implement your own, please see `I18n::Tasks::Scanners::AstMatchers::BaseMatcher`. + # ast_matchers: + # - 'I18n::Tasks::Scanners::AstMatchers::RailsModelMatcher' + # - 'I18n::Tasks::Scanners::AstMatchers::DefaultI18nSubjectMatcher' + + ## Multiple scanners can be used. Their results are merged. + ## The options specified above are passed down to each scanner. Per-scanner options can be specified as well. + ## See this example of a custom scanner: https://github.com/glebm/i18n-tasks/wiki/A-custom-scanner-example + +## Translation Services +# translation: +# # Google Translate +# # Get an API key and set billing info at https://code.google.com/apis/console to use Google Translate +# google_translate_api_key: "AbC-dEf5" +# # DeepL Pro Translate +# # Get an API key and subscription at https://www.deepl.com/pro to use DeepL Pro +# deepl_api_key: "48E92789-57A3-466A-9959-1A1A1A1A1A1A" +# # deepl_host: "https://api.deepl.com" +# # deepl_version: "v2" +# # deepl_glossary_ids: +# # - f28106eb-0e06-489e-82c6-8215d6f95089 +# # - 2c6415be-1852-4f54-9e1b-d800463496b4 +# # add additional options to the DeepL.translate call: https://www.deepl.com/docs-api/translate-text/translate-text/ +# deepl_options: +# formality: prefer_less +# # OpenAI +# openai_api_key: "sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" +# # openai_model: "gpt-3.5-turbo" # see https://platform.openai.com/docs/models +# # may contain `%{from}` and `%{to}`, which will be replaced by source and target locale codes, respectively (using `Kernel.format`) +# # openai_system_prompt: >- +# # You are a professional translator that translates content from the %{from} locale +# # to the %{to} locale in an i18n locale array. +# # +# # The array has a structured format and contains multiple strings. Your task is to translate +# # each of these strings and create a new array with the translated strings. +# # +# # HTML markups (enclosed in < and > characters) must not be changed under any circumstance. +# # Variables (starting with %%{ and ending with }) must not be changed under any circumstance. +# # +# # Keep in mind the context of all the strings for a more accurate translation. + +## Do not consider these keys missing: +ignore_missing: + - time.buttons.select + - decidim.admin.models.assembly.fields.promoted +# - 'errors.messages.{accepted,blank,invalid,too_short,too_long}' +# - '{devise,simple_form}.*' + +## Consider these keys used: +ignore_unused: + - decidim.admin.models.assembly.fields.promoted + - time.buttons.select + - decidim.components.proposals.settings.global.require_category + - decidim.components.proposals.settings.global.require_scope + +# - 'activerecord.attributes.*' +# - '{devise,kaminari,will_paginate}.*' +# - 'simple_form.{yes,no}' +# - 'simple_form.{placeholders,hints,labels}.*' +# - 'simple_form.{error_notification,required}.:' + +## Exclude these keys from the `i18n-tasks eq-base' report: +# ignore_eq_base: +# all: +# - common.ok +# fr,es: +# - common.brand + +## Exclude these keys from the `i18n-tasks check-consistent-interpolations` report: +# ignore_inconsistent_interpolations: +# - 'activerecord.attributes.*' + +## Ignore these keys completely: +# ignore: +# - kaminari.* + +## Sometimes, it isn't possible for i18n-tasks to match the key correctly, +## e.g. in case of a relative key defined in a helper method. +## In these cases you can use the built-in PatternMapper to map patterns to keys, e.g.: +# +# <%# I18n::Tasks.add_scanner 'I18n::Tasks::Scanners::PatternMapper', +# only: %w(*.html.haml *.html.slim), +# patterns: [['= title\b', '.page_title']] %> +# +# The PatternMapper can also match key literals via a special %{key} interpolation, e.g.: +# +# <%# I18n::Tasks.add_scanner 'I18n::Tasks::Scanners::PatternMapper', +# patterns: [['\bSpree\.t[( ]\s*%{key}', 'spree.%{key}']] %> diff --git a/config/initializers/decidim.rb b/config/initializers/decidim.rb index 1a90b5b..3f01ad8 100644 --- a/config/initializers/decidim.rb +++ b/config/initializers/decidim.rb @@ -428,6 +428,13 @@ Decidim::Proposals.configure do |config| config.participatory_space_highlighted_proposals_limit = Rails.application.secrets.dig(:decidim, :proposals, :participatory_space_highlighted_proposals_limit).presence || 4 config.process_group_highlighted_proposals_limit = Rails.application.secrets.dig(:decidim, :proposals, :process_group_highlighted_proposals_limit).presence || 3 + config.require_category = true # Default + config.require_scope = true # Default + end + + Decidim.find_component_manifest(:proposals).settings(:global) do |settings| + settings.attribute :require_category, type: :boolean, default: Decidim::Proposals.config.require_category + settings.attribute :require_scope, type: :boolean, default: Decidim::Proposals.config.require_scope end end diff --git a/config/locales/en.yml b/config/locales/en.yml index c81dd88..dace6dc 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,7 +1,14 @@ +--- en: decidim: admin: models: assembly: fields: - promoted: "Promoted" + promoted: Promoted + components: + proposals: + settings: + global: + require_category: Category is required + require_scope: Scope is required diff --git a/config/locales/fr.yml b/config/locales/fr.yml index c552ba5..1cdf5c1 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -1,4 +1,12 @@ +--- fr: + decidim: + components: + proposals: + settings: + global: + require_category: La catégorie est obligatoire + require_scope: Le secteur est obligatoire time: buttons: - select: Sélectionner \ No newline at end of file + select: Sélectionner diff --git a/lib/extends/forms/decidim/proposals/proposal_form_extends.rb b/lib/extends/forms/decidim/proposals/proposal_form_extends.rb new file mode 100644 index 0000000..e09d49b --- /dev/null +++ b/lib/extends/forms/decidim/proposals/proposal_form_extends.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "active_support/concern" + +module ProposalFormExtends + extend ActiveSupport::Concern + + included do + attribute :require_category, :boolean, default: Decidim::Proposals.config.require_category + attribute :require_scope, :boolean, default: Decidim::Proposals.config.require_scope + + validates :category_id, presence: true, if: ->(form) { form.require_category? } + validates :scope_id, presence: true, if: ->(form) { form.require_scope? } + validate :check_category, if: ->(form) { form.require_category? } + validate :check_scope, if: ->(form) { form.require_scope? } + + def categories_enabled? + categories&.any? + end + + def scopes_enabled? + current_component.scopes_enabled? && current_component.has_subscopes? + end + + def require_category? + current_component.settings.require_category && categories_enabled? + end + + def require_scope? + current_component.settings.require_scope && scopes_enabled? + end + + private + + def check_category + errors.add(:category, :blank) if category_id.blank? && require_category? + end + + def check_scope + errors.add(:scope, :blank) if scope_id.blank? && require_scope? + end + end +end + +Decidim::Proposals::ProposalForm.include(ProposalFormExtends) diff --git a/spec/factories.rb b/spec/factories.rb index 0e6d87b..dd73559 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -1,3 +1,4 @@ # frozen_string_literal: true require "decidim/core/test/factories" +require "decidim/proposals/test/factories" diff --git a/spec/forms/decidim/proposals/proposal_form_spec.rb b/spec/forms/decidim/proposals/proposal_form_spec.rb new file mode 100644 index 0000000..ed0bcfe --- /dev/null +++ b/spec/forms/decidim/proposals/proposal_form_spec.rb @@ -0,0 +1,336 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Proposals + describe ProposalForm do + subject { form } + + let(:organization) { create(:organization, available_locales: [:en]) } + let!(:participatory_space) { create(:participatory_process, :with_steps, organization:) } + let(:component) { create(:proposal_component, participatory_space:, settings:) } + let!(:category) { create(:category, participatory_space:) } + let(:title) { "More sidewalks and less roads!" } + let(:body) { "Everything would be better" } + let(:body_template) { nil } + let(:author) { create(:user, organization:) } + let(:user_group) { create(:user_group, :verified, users: [author], organization:) } + let(:user_group_id) { user_group.id } + let(:parent_scope) { create(:scope, organization:) } + let!(:scope) { create(:subscope, parent: parent_scope) } + let(:category_id) { category.try(:id) } + let(:scope_id) { scope.try(:id) } + let(:latitude) { 40.1234 } + let(:longitude) { 2.1234 } + let(:address) { nil } + let(:suggested_hashtags) { [] } + let(:attachment_params) { nil } + let(:meeting_as_author) { false } + + let(:params) do + { + title:, + body:, + body_template:, + author:, + category_id:, + scope_id:, + address:, + meeting_as_author:, + attachment: attachment_params, + suggested_hashtags: + } + end + + let(:form) do + described_class.from_params(params).with_context( + current_component: component, + current_organization: component.organization, + current_participatory_space: participatory_space + ) + end + + describe "whithout category and without scope" do + let!(:category) { nil } + let(:settings) { { scopes_enabled: false, require_category: true, require_scope: true } } + + context "when no category_id" do + let(:category_id) { nil } + + it { is_expected.to be_valid } + end + + context "when no scope_id" do + let(:scope_id) { nil } + + it { is_expected.to be_valid } + end + end + + describe "with category and scope" do + let!(:settings) { { scopes_enabled: true, require_category: true, require_scope: true } } + + describe "scope" do + let(:current_component) { component } + + it_behaves_like "a scopable resource" + end + + context "when everything is OK" do + it { is_expected.to be_valid } + end + + context "when there is no title" do + let(:title) { nil } + + it { is_expected.to be_invalid } + + it "only adds errors to this field" do + subject.valid? + expect(subject.errors.attribute_names).to eq [:title] + end + end + + context "when the title is too long" do + let(:title) { "A" * 200 } + + it { is_expected.to be_invalid } + end + + context "when the title is the minimum length" do + let(:title) { "Length is right" } + + it { is_expected.to be_valid } + end + + context "when the body is not etiquette-compliant" do + let(:body) { "A" } + + it { is_expected.to be_invalid } + end + + context "when there is no body" do + let(:body) { nil } + + it { is_expected.to be_invalid } + end + + context "when the body exceeds the permitted length" do + let(:component) { create(:proposal_component, :with_proposal_length, participatory_space:, proposal_length: allowed_length) } + let(:allowed_length) { 15 } + let(:body) { "A body longer than the permitted" } + + it { is_expected.to be_invalid } + + context "with carriage return characters that cause it to exceed" do + let(:allowed_length) { 80 } + let(:body) { "This text is just the correct length\r\nwith the carriage return characters removed" } + + it { is_expected.to be_valid } + end + end + + context "when there is a body template set" do + let(:body_template) { "This is the template" } + + it { is_expected.to be_valid } + + context "when the template and the body are the same" do + let(:body) { body_template } + + it { is_expected.to be_invalid } + end + end + + context "when no category_id" do + let(:category_id) { nil } + + it { is_expected.to be_invalid } + end + + context "when no scope_id" do + let(:scope_id) { nil } + + it { is_expected.to be_invalid } + end + + context "with invalid category_id" do + let(:category_id) { 987 } + + it { is_expected.to be_invalid } + end + + context "when geocoding is enabled" do + let(:component) { create(:proposal_component, :with_geocoding_enabled, participatory_space:) } + + context "when the address is not present" do + it "does not store the coordinates" do + expect(subject).to be_valid + expect(subject.address).to be_nil + expect(subject.latitude).to be_nil + expect(subject.longitude).to be_nil + end + end + + context "when the address is present" do + let(:address) { "Some address" } + + before do + stub_geocoding(address, [latitude, longitude]) + end + + it "validates the address and store its coordinates" do + expect(subject).to be_valid + expect(subject.latitude).to eq(latitude) + expect(subject.longitude).to eq(longitude) + end + end + + context "when latitude and longitude are manually set" do + context "when the has address checkbox is unchecked" do + it "is valid" do + expect(subject).to be_valid + expect(subject.latitude).to be_nil + expect(subject.longitude).to be_nil + end + end + + context "when the proposal is unchanged" do + let(:previous_proposal) { create(:proposal, address:, component:, decidim_scope_id: scope.id, category:) } + let(:title) { translated(previous_proposal.title) } + let(:body) { translated(previous_proposal.body) } + let(:params) do + { + id: previous_proposal.id, + title:, + body:, + author: previous_proposal.authors.first, + category_id: previous_proposal.category.id, + scope_id: previous_proposal.scope.id, + address:, + attachment: previous_proposal.try(:attachment_params), + latitude:, + longitude: + } + end + + it "is valid" do + expect(subject).to be_valid + expect(subject.latitude).to eq(latitude) + expect(subject.longitude).to eq(longitude) + end + end + end + end + + describe "category" do + subject { form.category } + + context "when the category exists" do + it { is_expected.to be_a(Decidim::Category) } + end + + context "when the category does not exist" do + let(:category_id) { 7654 } + + it { is_expected.to be_nil } + end + + context "when the category is from another process" do + let(:category_id) { create(:category).id } + + it { is_expected.to be_nil } + end + end + + it "properly maps category id from model" do + proposal = create(:proposal, component:, category:) + + expect(described_class.from_model(proposal).category_id).to eq(category_id) + end + + it "properly maps user group id from model" do + proposal = create(:proposal, component:, users: [author], user_groups: [user_group]) + + expect(described_class.from_model(proposal).user_group_id).to eq(user_group_id) + end + + context "when the attachment is present" do + let(:params) do + { + :title => title, + :body => body, + :author => author, + :category_id => category_id, + :scope_id => scope_id, + :address => address, + :meeting_as_author => meeting_as_author, + :suggested_hashtags => suggested_hashtags, + attachments_key => [Decidim::Dev.test_file("city.jpeg", "image/jpeg")] + } + end + let(:attachments_key) { :add_documents } + + it { is_expected.to be_valid } + + context "when the form has some errors" do + let(:title) { nil } + + it "adds an error to the `:attachment` field" do + expect(subject).not_to be_valid + expect(subject.errors.full_messages).to contain_exactly("Title cannot be blank", "Title is too short (under 15 characters)", "Add documents Needs to be reattached") + expect(subject.errors.attribute_names).to contain_exactly(:title, :add_documents) + end + end + end + + describe "#extra_hashtags" do + subject { form.extra_hashtags } + + let(:component) do + create( + :proposal_component, + :with_extra_hashtags, + participatory_space:, + suggested_hashtags: component_suggested_hashtags, + automatic_hashtags: component_automatic_hashtags + ) + end + let(:component_automatic_hashtags) { "" } + let(:component_suggested_hashtags) { "" } + + it { is_expected.to eq([]) } + + context "when there are auto hashtags" do + let(:component_automatic_hashtags) { "HashtagAuto1 HashtagAuto2" } + + it { is_expected.to eq(%w(HashtagAuto1 HashtagAuto2)) } + end + + context "when there are some suggested hashtags checked" do + let(:component_suggested_hashtags) { "HashtagSuggested1 HashtagSuggested2 HashtagSuggested3" } + let(:suggested_hashtags) { %w(HashtagSuggested1 HashtagSuggested2) } + + it { is_expected.to eq(%w(HashtagSuggested1 HashtagSuggested2)) } + end + + context "when there are invalid suggested hashtags checked" do + let(:component_suggested_hashtags) { "HashtagSuggested1 HashtagSuggested2" } + let(:suggested_hashtags) { %w(HashtagSuggested1 HashtagSuggested3) } + + it { is_expected.to eq(%w(HashtagSuggested1)) } + end + + context "when there are both suggested and auto hashtags" do + let(:component_automatic_hashtags) { "HashtagAuto1 HashtagAuto2" } + let(:component_suggested_hashtags) { "HashtagSuggested1 HashtagSuggested2" } + let(:suggested_hashtags) { %w(HashtagSuggested2) } + + it { is_expected.to eq(%w(HashtagAuto1 HashtagAuto2 HashtagSuggested2)) } + end + end + end + end + end +end diff --git a/spec/i18n_spec.rb b/spec/i18n_spec.rb new file mode 100644 index 0000000..2db6251 --- /dev/null +++ b/spec/i18n_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "i18n/tasks" + +RSpec.describe I18n do + let(:i18n) { I18n::Tasks::BaseTask.new } + let(:missing_keys) { i18n.missing_keys } + let(:unused_keys) { i18n.unused_keys } + let(:inconsistent_interpolations) { i18n.inconsistent_interpolations } + + it "does not have missing keys" do + expect(missing_keys).to be_empty, + "Missing #{missing_keys.leaves.count} i18n keys, run `i18n-tasks missing' to show them" + end + + it "does not have unused keys" do + expect(unused_keys).to be_empty, + "#{unused_keys.leaves.count} unused i18n keys, run `i18n-tasks unused' to show them" + end + + it "files are normalized" do + non_normalized = i18n.non_normalized_paths + error_message = "The following files need to be normalized:\n" \ + "#{non_normalized.map { |path| " #{path}" }.join("\n")}\n" \ + "Please run `i18n-tasks normalize' to fix" + expect(non_normalized).to be_empty, error_message + end + + it "does not have inconsistent interpolations" do + error_message = "#{inconsistent_interpolations.leaves.count} i18n keys have inconsistent interpolations.\n" \ + "Run `i18n-tasks check-consistent-interpolations' to show them" + expect(inconsistent_interpolations).to be_empty, error_message + end +end diff --git a/spec/system/create_proposal_spec.rb b/spec/system/create_proposal_spec.rb new file mode 100644 index 0000000..0ceaf51 --- /dev/null +++ b/spec/system/create_proposal_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "User creates proposal simply" do + let!(:organization) { create :organization, *organization_traits, available_locales: [:en] } + let!(:participatory_process) { create :participatory_process, :with_steps, organization: } + let(:manifest_name) { "proposals" } + let(:manifest) { Decidim.find_component_manifest(manifest_name) } + let!(:user) { create :user, :confirmed, organization: } + let(:settings) { nil } + let!(:component) do + create(:proposal_component, + :with_creation_enabled, + :with_attachments_allowed, + manifest:, + participatory_space: participatory_process, + settings:) + end + let(:organization_traits) { [] } + + let(:proposal_title) { Faker::Lorem.paragraph } + let(:proposal_body) { Faker::Lorem.paragraph } + + def visit_component + if organization_traits.include?(:secure_context) + switch_to_secure_context_host + else + switch_to_host(organization.host) + end + page.visit main_component_path(component) + end + + before do + login_as user, scope: :user + visit_component + end + + context "when category and scope are required," do + let(:settings) { { require_category: true, require_scope: true } } + + context "without any scopes or categories" do + before do + expect(Decidim::Scope.count).to eq(0) + expect(Decidim::Category.count).to eq(0) + end + + it "creates a new proposal without a category and scope" do + click_link_or_button "New proposal" + fill_in :proposal_title, with: proposal_title + fill_in :proposal_body, with: proposal_body + click_link_or_button "Continue" + click_link_or_button "Publish" + expect(page).to have_content("Proposal successfully published.") + expect(Decidim::Proposals::Proposal.last.title["en"]).to eq(proposal_title) + expect(Decidim::Proposals::Proposal.last.body["en"]).to eq(proposal_body) + end + end + + context "when scopes are enabled and there is subscope and category" do + before do + component.update(settings: { scopes_enabled: true, scope_id: parent_scope.id, attachments_allowed: true }) + end + + let(:parent_scope) { create(:scope, organization:) } + let!(:scope) { create(:subscope, parent: parent_scope) } + let!(:category) { create(:category, participatory_space: participatory_process) } + + it "doesnt create a new proposal without category and scope" do + click_link_or_button "New proposal" + fill_in :proposal_title, with: proposal_title + fill_in :proposal_body, with: proposal_body + click_link_or_button "Continue" + expect(page).to have_css(".form-error") + expect(page).to have_content("There is an error in this field") + end + + it "creates a new proposal with a category and scope" do + click_link_or_button "New proposal" + fill_in :proposal_title, with: proposal_title + fill_in :proposal_body, with: proposal_body + fill_category_and_scope(category, scope) + click_link_or_button "Continue" + click_link_or_button "Publish" + expect(page).to have_content("Proposal successfully published.") + expect(Decidim::Proposals::Proposal.last.category).to eq(category) + expect(Decidim::Proposals::Proposal.last.scope).to eq(scope) + end + + it "can be edited after creating a draft" do + click_link_or_button "New proposal" + fill_in :proposal_title, with: proposal_title + fill_in :proposal_body, with: proposal_body + fill_category_and_scope(category, scope) + click_link_or_button "Continue" + click_link_or_button "Modify the proposal" + fill_in :proposal_title, with: "This proposal is modified" + click_link_or_button "Preview" + expect(page).to have_content("This proposal is modified") + click_link_or_button "Publish" + expect(page).to have_content("Proposal successfully published.") + end + + context "when uploading a file", processing_uploads_for: Decidim::AttachmentUploader do + it "can add image" do + click_link_or_button "New proposal" + fill_in :proposal_title, with: proposal_title + fill_in :proposal_body, with: proposal_body + fill_category_and_scope(category, scope) + dynamically_attach_file(:proposal_documents, Decidim::Dev.asset("city.jpeg")) + click_link_or_button "Continue" + click_link_or_button "Publish" + expect(page).to have_content("Proposal successfully published.") + end + end + + context "when draft proposal exists for current users" do + let!(:draft) { create(:proposal, :draft, component:, users: [user]) } + + before do + click_link_or_button "New proposal" + path = "#{main_component_path(component)}/#{draft.id}/edit_draft?component_id=#{component.id}&question_slug=#{component.participatory_space.slug}" + expect(page).to have_current_path(path) + fill_category_and_scope(category, scope) + end + + it "can finish proposal" do + click_link_or_button "Preview" + click_link_or_button "Publish" + expect(page).to have_content("Proposal successfully published.") + end + end + end + end + + context "when category and scope arent required," do + let(:settings) { { require_category: false, require_scope: false } } + + it "creates a new proposal without category and scope" do + click_link_or_button "New proposal" + fill_in :proposal_title, with: proposal_title + fill_in :proposal_body, with: proposal_body + click_link_or_button "Continue" + click_link_or_button "Publish" + expect(page).to have_content("Proposal successfully published.") + end + end + + def fill_category_and_scope(category, scope) + select category.name["en"], from: :proposal_category_id + select scope.name["en"], from: :proposal_scope_id + end +end diff --git a/spec/system/edit_proposal_spec.rb b/spec/system/edit_proposal_spec.rb new file mode 100644 index 0000000..f426491 --- /dev/null +++ b/spec/system/edit_proposal_spec.rb @@ -0,0 +1,233 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "User edits proposals" do + include_context "with a component" + let!(:organization) { create :organization, available_locales: [:en] } + let!(:participatory_process) { create :participatory_process, :with_steps, organization: } + let(:manifest_name) { "proposals" } + let(:manifest) { Decidim.find_component_manifest(manifest_name) } + let!(:user) { create :user, :confirmed, organization: } + let(:settings) { nil } + let(:component) do + create(:proposal_component, + :with_creation_enabled, + :with_attachments_allowed, + manifest:, + participatory_space: participatory_process, + settings:) + end + let(:organization_traits) { [] } + + let(:proposal_title) { "This is my great proposal to change the world" } + let(:proposal_body) { "This is my great proposal to change the world" } + + def visit_component + if organization_traits&.include?(:secure_context) + switch_to_secure_context_host + else + switch_to_host(organization.host) + end + page.visit main_component_path(component) + end + + context "when user has proposal" do + let!(:proposal) { create(:proposal, users: [user], component:) } + let(:settings) { { require_category: false, require_scope: false, attachments_allowed: true } } + + before do + login_as user, scope: :user + visit_component + click_link_or_button translated(proposal.title) + click_link_or_button "Edit proposal" + fill_in :proposal_title, with: proposal_title + fill_in :proposal_body, with: proposal_body + end + + it "can be edited" do + click_link_or_button "Send" + expect(page).to have_content("Proposal successfully updated") + expect(Decidim::Proposals::Proposal.last.title["en"]).to eq(proposal_title) + expect(Decidim::Proposals::Proposal.last.body["en"]).to eq(proposal_body) + end + + context "when uploading a file", processing_uploads_for: Decidim::AttachmentUploader do + it "can add image" do + dynamically_attach_file(:proposal_documents, Decidim::Dev.asset("city.jpeg")) + click_link_or_button "Send" + expect(page).to have_content("Proposal successfully updated") + end + + it "can add images" do + dynamically_attach_file(:proposal_documents, Decidim::Dev.asset("city.jpeg")) + click_link_or_button "Send" + click_link_or_button "Edit proposal" + dynamically_attach_file(:proposal_documents, Decidim::Dev.asset("city2.jpeg"), remove_before: true) + click_link_or_button "Send" + expect(page).to have_content("Proposal successfully updated") + expect(Decidim::Proposals::Proposal.last.attachments.count).to eq(1) + end + + it "can add pdf document" do + dynamically_attach_file(:proposal_documents, Decidim::Dev.asset("Exampledocument.pdf")) + click_link_or_button "Send" + expect(page).to have_content("Proposal successfully updated") + end + end + + context "when proposal has attachment" do + let!(:proposal) { create(:proposal, users: [user], component:, body: proposal_body, title: proposal_title) } + let!(:attachment) { create(:attachment, title: { "en" => filename }, file:, attached_to: proposal, weight: 0) } + + context "when proposal has pdf attachment" do + let(:filename) { "Exampledocument.pdf" } + let(:file) { Decidim::Dev.test_file(filename, "application/pdf") } + + before do + login_as user, scope: :user + visit_component + click_link_or_button translated(proposal.title) + end + + it "can remove document attachment" do + click_link_or_button "Edit proposal" + + click_link_or_button "Edit documents" + within ".upload-modal" do + click_link_or_button "Remove" + click_link_or_button "Save" + end + + click_link_or_button "Send" + expect(page).to have_content("Proposal successfully updated.") + expect(page).to have_no_content("Documents ") + expect(page).to have_no_link(filename) + expect(Decidim::Proposals::Proposal.find(proposal.id).attachments).to be_empty + end + end + + context "when proposal has card image" do + let(:filename) { "city.jpeg" } + let(:file) { Decidim::Dev.test_file(filename, "image/jpeg") } + + before do + login_as user, scope: :user + + settings = component.settings + settings.comments_enabled = false + component.update(settings:) + + visit_component + click_link_or_button translated(proposal.title), match: :first + end + + it "can remove card image" do + click_link_or_button "Edit proposal" + + click_link_or_button "Edit documents" + within ".upload-modal" do + click_link_or_button "Remove" + click_link_or_button "Save" + end + + click_link_or_button "Send" + expect(page).to have_content("Proposal successfully updated.") + expect(page).to have_no_content("Images") + expect(page).to have_no_link(filename) + expect(Decidim::Proposals::Proposal.find(proposal.id).attachments).to be_empty + end + + it "can set new card image" do + click_link_or_button "Edit proposal" + dynamically_attach_file(:proposal_documents, Decidim::Dev.asset("city2.jpeg"), remove_before: true) + + click_link_or_button "Send" + expect(page).to have_content("Proposal successfully updated.") + expect(page).to have_content("Images") + + created_proposal = Decidim::Proposals::Proposal.find(proposal.id) + expect(created_proposal.attachments.count).to eq(1) + expect(created_proposal.photos.count).to eq(1) + expect(created_proposal.photos.first.title["en"]).to eq("city2.jpeg") + end + end + end + + context "when proposal has card image and document image" do + let!(:proposal) { create(:proposal, users: [user], component:) } + + let!(:card_image) { create(:attachment, title: { "en" => filename }, file:, attached_to: proposal, weight: 0) } + let(:filename) { "city.jpeg" } + let(:file) { Decidim::Dev.test_file(filename, "image/jpeg") } + + let!(:document) { create(:attachment, title: { "en" => filename2 }, file: file2, attached_to: proposal, weight: 1) } + let(:filename2) { "city2.jpeg" } + let(:file2) { Decidim::Dev.test_file(filename2, "image/jpeg") } + + before do + login_as user, scope: :user + visit_component + click_link_or_button translated(proposal.title), match: :first + end + + it "attachments are in different sections" do + click_link_or_button "Edit proposal" + page.execute_script "window.scrollBy(0,10000)" + expect(page).to have_css(".attachment-details[data-filename='#{filename}']") + expect(page).to have_css(".attachment-details[data-filename='#{filename2}']") + end + end + + context "and category and scope are required" do + let!(:settings) { { scopes_enabled: true, require_category: true, require_scope: true } } + let(:category) { create(:category, participatory_space: participatory_process) } + let!(:category_bis) { create(:category, participatory_space: participatory_process) } + let(:parent_scope) { create(:scope, organization:) } + let(:scope) { create(:subscope, parent: parent_scope) } + let(:proposal) { create(:proposal, users: [user], component:, body: proposal_body, title: proposal_title, decidim_scope_id: scope.id, category:) } + + before do + login_as user, scope: :user + visit_component + click_link_or_button translated(proposal.title), match: :first + end + + it "can edit proposal without changing category and scope" do + click_link_or_button "Edit proposal" + fill_in :proposal_body, with: "This is my new body" + click_link_or_button "Send" + expect(page).to have_content("Proposal successfully updated.") + expect(page).to have_content("This is my new body") + end + + it "can edit proposal by changing scope" do + click_link_or_button "Edit proposal" + select parent_scope.name["en"], from: :proposal_scope_id + click_link_or_button "Send" + expect(page).to have_content("Proposal successfully updated.") + end + + it "can edit proposal by changing category" do + click_link_or_button "Edit proposal" + select category_bis.name["en"], from: :proposal_category_id + click_link_or_button "Send" + expect(page).to have_content("Proposal successfully updated.") + end + + it "cannot edit proposal without a category" do + click_link_or_button "Edit proposal" + select "Please select a category", from: :proposal_category_id + click_link_or_button "Send" + expect(page).to have_content("There is an error in this field") + end + + it "cannot edit proposal without a scope" do + click_link_or_button "Edit proposal" + select "Select a scope", from: :proposal_scope_id + click_link_or_button "Send" + expect(page).to have_content("There is an error in this field") + end + end + end +end