diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 5178fcfe0647b..2e3c7fae82733 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -24,6 +24,7 @@ AGPL Ajuntament Ajuntamentde alabs +amd amendables AMR andreslucena @@ -866,6 +867,8 @@ tableize tagsinput tailwindcss tarekraafat +taxonomizable +taxonomization technopolitical templatable templateable diff --git a/.github/workflows/test_app.yml b/.github/workflows/test_app.yml index e9334e98c7664..e2c30e6d77cf8 100644 --- a/.github/workflows/test_app.yml +++ b/.github/workflows/test_app.yml @@ -38,6 +38,11 @@ on: required: false default: true type: boolean + chrome_version: + description: 'Chrome & Chromedriver version' + required: false + default: "126.0.6478.182" + type: string jobs: build_app: @@ -78,7 +83,14 @@ jobs: - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ inputs.ruby_version }} + - run: | + wget --no-verbose -O /tmp/chrome.deb https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${{inputs.chrome_version}}-1_amd64.deb + sudo dpkg -i /tmp/chrome.deb + rm /tmp/chrome.deb + name: Install Chrome version ${{inputs.chrome_version}} - uses: nanasess/setup-chromedriver@v2 + with: + chromedriver-version: ${{inputs.chrome_version}} - uses: actions/cache@v4 id: app-cache with: @@ -122,3 +134,4 @@ jobs: name: screenshots path: ./spec/decidim_dummy_app/tmp/screenshots if-no-files-found: ignore + overwrite: true diff --git a/Gemfile b/Gemfile index bb3a2a7ab0deb..722594c1ac409 100644 --- a/Gemfile +++ b/Gemfile @@ -26,7 +26,5 @@ end group :development do gem "letter_opener_web", "~> 2.0" gem "listen", "~> 3.1" - gem "spring", "~> 4.0" - gem "spring-watcher-listen", "~> 2.0" gem "web-console", "~> 4.2" end diff --git a/Gemfile.lock b/Gemfile.lock index d6c6710bc65a8..36735fa4519bb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -20,7 +20,6 @@ PATH decidim-sortitions (= 0.30.0.dev) decidim-surveys (= 0.30.0.dev) decidim-system (= 0.30.0.dev) - decidim-templates (= 0.30.0.dev) decidim-verifications (= 0.30.0.dev) decidim-accountability (0.30.0.dev) decidim-comments (= 0.30.0.dev) @@ -143,6 +142,8 @@ PATH selenium-webdriver (~> 4.9) simplecov (~> 0.22.0) simplecov-cobertura (~> 2.1.0) + spring (~> 4.0) + spring-watcher-listen (~> 2.0) w3c_rspec_validators (~> 0.3.0) webmock (~> 3.18) wisper-rspec (~> 1.0) @@ -190,7 +191,6 @@ PATH decidim-templates (0.30.0.dev) decidim-core (= 0.30.0.dev) decidim-forms (= 0.30.0.dev) - decidim-proposals (= 0.30.0.dev) decidim-verifications (0.30.0.dev) decidim-core (= 0.30.0.dev) @@ -810,8 +810,6 @@ DEPENDENCIES listen (~> 3.1) parallel_tests (~> 4.2) puma (>= 6.3.1) - spring (~> 4.0) - spring-watcher-listen (~> 2.0) web-console (~> 4.2) RUBY VERSION diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index b51cab7fb8ed7..29bef8190f3e4 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -17,6 +17,7 @@ gem "decidim-dev", github: "decidim/decidim" ```console sudo apt install p7zip # or the alternative installation process for your operating system. See "2.1. 7zip dependency introduction" +bundle remove spring spring-watcher-listen bundle update decidim bin/rails decidim:upgrade bin/rails db:migrate @@ -42,7 +43,19 @@ You can read more about this change on PR [#13185](https://github.com/decidim/de These are one time actions that need to be done after the code is updated in the production database. -### 3.1. [[TITLE OF THE ACTION]] +### 3.1. Remove spring and spring-watcher-listen from your Gemfile + +To simplify the upgrade process, we have decided to add `spring` and `spring-watcher-listener` as hard dependencies of `decidim-dev`. + +Before upgrading to this version, make sure you run in your console: + +```bash +bundle remove spring spring-watcher-listen +``` + +You can read more about this change on PR [#13235](https://github.com/decidim/decidim/pull/13235). + +### 3.2. [[TITLE OF THE ACTION]] You can read more about this change on PR [#XXXX](https://github.com/decidim/decidim/pull/XXXX). @@ -61,7 +74,19 @@ You can read more about this change on PR [#XXXX](https://github.com/decidim/dec ## 5. Changes in APIs -### 5.1. [[TITLE OF THE CHANGE]] +### 5.1. Decidim version number no longer disclosed through the GraphQL API by default + +In previous Decidim versions, you could request the running Decidim version through the following API query against the GraphQL API: + +```graphql +query { decidim { version } } +``` + +This no longer returns the running Decidim version by default and instead it will result to `null` being reported as the version number. + +If you would like to re-enable exposing the Decidim version number through the GraphQL API, you may do so by setting the `DECIDIM_API_DISCLOSE_SYSTEM_VERSION` environment variable to `true`. However, this is highly discouraged but may be required for some automation or integrations. + +### 5.2. [[TITLE OF THE CHANGE]] In order to [[REASONING (e.g. improve the maintenance of the code base)]] we have changed... diff --git a/decidim-admin/README.md b/decidim-admin/README.md index 30ab9341197cf..aee1d4636f2c6 100644 --- a/decidim-admin/README.md +++ b/decidim-admin/README.md @@ -42,19 +42,6 @@ There are some pages that exist by default and cannot be deleted since there are links to them inside the Decidim framework, see `Decidim::StaticPage` for the default list. -### Pager Configuration - -The number of results shown per page and per page range can be configured in the app `decidim.rb` initializer as follows: - -```ruby -Decidim::Admin.configure do |config| - config.per_page_range = [15, 50, 100] -end -``` - -* `Decidim::Admin.per_page_range.first` sets the `default_per_page` value for `Decidim::Admin` (in Kaminari) -* `Decidim::Admin.per_page_range.last` sets the `max_per_page` value for `Decidim::Admin` (in Kaminari) - ## Contributing See [Decidim](https://github.com/decidim/decidim). diff --git a/decidim-admin/app/commands/decidim/admin/create_taxonomy.rb b/decidim-admin/app/commands/decidim/admin/create_taxonomy.rb new file mode 100644 index 0000000000000..3eeb41db36f98 --- /dev/null +++ b/decidim-admin/app/commands/decidim/admin/create_taxonomy.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Decidim + module Admin + # A command with all the business logic to create a taxonomy. + # This command is called from the controller. + class CreateTaxonomy < Decidim::Commands::CreateResource + fetch_form_attributes :name, :organization, :parent_id + + protected + + def resource_class = Decidim::Taxonomy + + def extra_params + { + extra: { + parent_name: form.try(:parent).try(:name) + } + } + end + end + end +end diff --git a/decidim-admin/app/commands/decidim/admin/destroy_component.rb b/decidim-admin/app/commands/decidim/admin/destroy_component.rb index d73c1daa835e9..e83023b2ec9bc 100644 --- a/decidim-admin/app/commands/decidim/admin/destroy_component.rb +++ b/decidim-admin/app/commands/decidim/admin/destroy_component.rb @@ -7,6 +7,7 @@ class DestroyComponent < Decidim::Commands::DestroyResource private def run_before_hooks + Decidim::Reminder.where(component: resource).destroy_all resource.manifest.run_hooks(:before_destroy, resource) end diff --git a/decidim-admin/app/commands/decidim/admin/destroy_taxonomy.rb b/decidim-admin/app/commands/decidim/admin/destroy_taxonomy.rb new file mode 100644 index 0000000000000..6280d9fb798df --- /dev/null +++ b/decidim-admin/app/commands/decidim/admin/destroy_taxonomy.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Decidim + module Admin + # A command with all the business logic to destroy a taxonomy. + class DestroyTaxonomy < Decidim::Commands::DestroyResource + private + + def extra_params + { + extra: { + parent_name: resource.parent.try(:name) + } + } + end + end + end +end diff --git a/decidim-admin/app/commands/decidim/admin/reorder_taxonomies.rb b/decidim-admin/app/commands/decidim/admin/reorder_taxonomies.rb new file mode 100644 index 0000000000000..9a4d0fdd5ce6b --- /dev/null +++ b/decidim-admin/app/commands/decidim/admin/reorder_taxonomies.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Decidim + module Admin + # A command that reorders a collection of taxonomies + # the ones that might be missing. + class ReorderTaxonomies < Decidim::Command + # Public: Initializes the command. + # + # organization - the Organization where the content blocks reside + # order - an Array holding the order of IDs of published content blocks. + def initialize(organization, order, offset = 0) + @organization = organization + @order = order + @offset = offset + end + + # Executes the command. Broadcasts these events: + # + # - :ok when everything is valid. + # - :invalid if the data was not valid and we could not proceed. + # + # Returns nothing. + def call + return broadcast(:invalid) if order.blank? + return broadcast(:invalid) if collection.empty? + + reorder_steps + broadcast(:ok) + end + + private + + attr_reader :organization, :offset + + def reorder_steps + transaction do + reset_weights + collection.reload + set_new_weights + end + end + + def reset_weights + # rubocop:disable Rails/SkipsModelValidations + collection.where.not(weight: nil).where(id: order).update_all(weight: nil) + # rubocop:enable Rails/SkipsModelValidations + end + + def set_new_weights + data = order.each_with_index.inject({}) do |hash, (id, index)| + hash.update(id => index + 1 + offset) + end + + data.each do |id, weight| + item = collection.find_by(id:) + item.update!(weight:) if item.present? + end + end + + def order + return nil unless @order.is_a?(Array) && @order.present? + + @order + end + + def collection + @collection ||= Decidim::Taxonomy.where(organization:, parent_id: first_item.parent_id) + end + + def first_item + @first_item ||= Decidim::Taxonomy.where(organization:).find(order.first) + end + end + end +end diff --git a/decidim-admin/app/commands/decidim/admin/update_taxonomy.rb b/decidim-admin/app/commands/decidim/admin/update_taxonomy.rb new file mode 100644 index 0000000000000..a127d527f8e1c --- /dev/null +++ b/decidim-admin/app/commands/decidim/admin/update_taxonomy.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Decidim + module Admin + # A command to update a taxonomy. + class UpdateTaxonomy < Decidim::Commands::UpdateResource + fetch_form_attributes :name, :parent_id + + protected + + def extra_params + { + extra: { + parent_name: resource.parent.try(:name) + } + } + end + end + end +end diff --git a/decidim-admin/app/controllers/concerns/decidim/admin/filterable.rb b/decidim-admin/app/controllers/concerns/decidim/admin/filterable.rb index e04c659b8573d..d3412478782e7 100644 --- a/decidim-admin/app/controllers/concerns/decidim/admin/filterable.rb +++ b/decidim-admin/app/controllers/concerns/decidim/admin/filterable.rb @@ -9,7 +9,7 @@ module Filterable extend ActiveSupport::Concern included do - include Decidim::Admin::Paginable + include Decidim::Paginable include Decidim::TranslatableAttributes helper Decidim::Admin::FilterableHelper diff --git a/decidim-admin/app/controllers/concerns/decidim/admin/paginable.rb b/decidim-admin/app/controllers/concerns/decidim/admin/paginable.rb deleted file mode 100644 index ea1e082755622..0000000000000 --- a/decidim-admin/app/controllers/concerns/decidim/admin/paginable.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -require "active_support/concern" - -module Decidim - module Admin - module Paginable - # Common logic to paginate admin resources - extend ActiveSupport::Concern - - included do - include Decidim::Paginable - - def per_page - params[:per_page].present? ? params[:per_page].to_i : Decidim::Admin.per_page_range.first - end - end - end - end -end diff --git a/decidim-admin/app/controllers/concerns/decidim/admin/taxonomies/filterable.rb b/decidim-admin/app/controllers/concerns/decidim/admin/taxonomies/filterable.rb new file mode 100644 index 0000000000000..90f293777723b --- /dev/null +++ b/decidim-admin/app/controllers/concerns/decidim/admin/taxonomies/filterable.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "active_support/concern" + +module Decidim + module Admin + module Taxonomies + module Filterable + extend ActiveSupport::Concern + + included do + include Decidim::Admin::Filterable + + private + + def base_query + collection + end + + def search_field_predicate + :name_or_children_name_cont + end + end + end + end + end +end diff --git a/decidim-admin/app/controllers/concerns/decidim/admin/verification_conflicts/filterable.rb b/decidim-admin/app/controllers/concerns/decidim/admin/verification_conflicts/filterable.rb new file mode 100644 index 0000000000000..cb2937b676a8a --- /dev/null +++ b/decidim-admin/app/controllers/concerns/decidim/admin/verification_conflicts/filterable.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "active_support/concern" + +module Decidim + module Admin + module VerificationConflicts + module Filterable + extend ActiveSupport::Concern + + included do + include Decidim::Admin::Filterable + + private + + def base_query + collection + end + + def search_field_predicate + :current_user_name_or_current_user_nickname_or_current_user_email_cont + end + + def filters + [] + end + end + end + end + end +end diff --git a/decidim-admin/app/controllers/decidim/admin/application_controller.rb b/decidim-admin/app/controllers/decidim/admin/application_controller.rb index 49e4e064f3f9c..7c610b5f9d79c 100644 --- a/decidim-admin/app/controllers/decidim/admin/application_controller.rb +++ b/decidim-admin/app/controllers/decidim/admin/application_controller.rb @@ -27,7 +27,6 @@ class ApplicationController < ::DecidimController helper Decidim::Admin::IconWithTooltipHelper helper Decidim::Admin::MenuHelper helper Decidim::Admin::ScopesHelper - helper Decidim::Admin::Paginable::PerPageHelper helper Decidim::DecidimFormHelper helper Decidim::ReplaceButtonsHelper helper Decidim::ScopesHelper diff --git a/decidim-admin/app/controllers/decidim/admin/conflicts_controller.rb b/decidim-admin/app/controllers/decidim/admin/conflicts_controller.rb index a3c7881737bfc..5533813993cfa 100644 --- a/decidim-admin/app/controllers/decidim/admin/conflicts_controller.rb +++ b/decidim-admin/app/controllers/decidim/admin/conflicts_controller.rb @@ -3,18 +3,16 @@ module Decidim module Admin class ConflictsController < Decidim::Admin::ApplicationController + include Decidim::Admin::VerificationConflicts::Filterable + layout "decidim/admin/users" - helper_method :context_breadcrumb_items + helper_method :context_breadcrumb_items, :conflicts add_breadcrumb_item_from_menu :impersonate_menu def index enforce_permission_to :index, :impersonatable_user - - @conflicts = Decidim::Verifications::Conflict.joins(:current_user).where( - decidim_users: { decidim_organization_id: current_organization.id } - ) end def edit @@ -66,6 +64,16 @@ def impersonations_breadcrumb_item url: decidim_admin.impersonatable_users_path } end + + def collection + @collection ||= Decidim::Verifications::Conflict.joins(:current_user).where( + decidim_users: { decidim_organization_id: current_organization.id } + ) + end + + def conflicts + @conflicts ||= filtered_collection.order(created_at: :desc) + end end end end diff --git a/decidim-admin/app/controllers/decidim/admin/taxonomies_controller.rb b/decidim-admin/app/controllers/decidim/admin/taxonomies_controller.rb new file mode 100644 index 0000000000000..2be03d943c65c --- /dev/null +++ b/decidim-admin/app/controllers/decidim/admin/taxonomies_controller.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module Decidim + module Admin + class TaxonomiesController < Decidim::Admin::ApplicationController + include Decidim::Admin::Taxonomies::Filterable + + layout "decidim/admin/settings" + + add_breadcrumb_item_from_menu :admin_settings_menu + + helper_method :collection, :parent_options, :taxonomy + + before_action only: :edit do + redirect_to edit_taxonomy_path(taxonomy.parent) unless taxonomy && taxonomy.root? + end + + def index + @taxonomies = filtered_collection + end + + def new + enforce_permission_to :create, :taxonomy + + @form = form(Decidim::Admin::TaxonomyForm).instance + end + + def create + enforce_permission_to :create, :taxonomy + + @form = form(Decidim::Admin::TaxonomyForm).from_params(params) + CreateTaxonomy.call(@form) do + on(:ok) do + flash[:notice] = I18n.t("create.success", scope: "decidim.admin.taxonomies") + redirect_to taxonomies_path + end + + on(:invalid) do + flash.now[:alert] = I18n.t("create.invalid", scope: "decidim.admin.taxonomies") + render action: "new" + end + end + end + + def edit + enforce_permission_to(:update, :taxonomy, taxonomy:) + @form = form(Decidim::Admin::TaxonomyForm).from_model(taxonomy) + @taxonomies = filtered_collection + end + + def update + enforce_permission_to(:update, :taxonomy, taxonomy:) + @form = form(Decidim::Admin::TaxonomyForm).from_params(params) + + UpdateTaxonomy.call(@form, taxonomy) do + on(:ok) do + flash[:notice] = I18n.t("update.success", scope: "decidim.admin.taxonomies") + redirect_to taxonomies_path + end + + on(:invalid) do + flash.now[:alert] = I18n.t("update.invalid", scope: "decidim.admin.taxonomies") + render action: "edit" + end + end + end + + def destroy + enforce_permission_to(:destroy, :taxonomy, taxonomy:) + + DestroyTaxonomy.call(taxonomy, current_user) do + on(:ok) do + flash[:notice] = I18n.t("destroy.success", scope: "decidim.admin.taxonomies") + redirect_to taxonomies_path + end + + on(:invalid) do + flash.now[:alert] = I18n.t("destroy.invalid", scope: "decidim.admin.taxonomies") + redirect_to taxonomies_path + end + end + end + + def reorder + enforce_permission_to :update, :taxonomy + + ReorderTaxonomies.call(current_organization, params[:ids_order], page_offset) do + on(:ok) do + head :ok + end + + on(:invalid) do + head :bad_request + end + end + end + + private + + def collection + @collection ||= taxonomy ? taxonomy.children : root_taxonomies + end + + def root_taxonomies + @root_taxonomies ||= current_organization.taxonomies.where(parent_id: nil) + end + + def taxonomy + @taxonomy ||= current_organization.taxonomies.find_by(id: params[:id]) + end + end + end +end diff --git a/decidim-admin/app/controllers/decidim/admin/taxonomy_items_controller.rb b/decidim-admin/app/controllers/decidim/admin/taxonomy_items_controller.rb new file mode 100644 index 0000000000000..5a76cf2149956 --- /dev/null +++ b/decidim-admin/app/controllers/decidim/admin/taxonomy_items_controller.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module Decidim + module Admin + class TaxonomyItemsController < Decidim::Admin::ApplicationController + layout false + + helper_method :taxonomy, :taxonomy_item, :parent_options, :selected_parent_id + before_action do + if taxonomy_item && taxonomy_item.parent_ids.exclude?(taxonomy.id) + flash[:alert] = I18n.t("update.invalid", scope: "decidim.admin.taxonomies") + render plain: I18n.t("update.invalid", scope: "decidim.admin.taxonomies"), status: :unprocessable_entity + end + end + + def new + enforce_permission_to :create, :taxonomy_item + @form = form(Decidim::Admin::TaxonomyItemForm).instance + end + + def create + enforce_permission_to :create, :taxonomy_item + @form = form(Decidim::Admin::TaxonomyItemForm).from_params(params) + CreateTaxonomy.call(@form) do + on(:ok) do + flash[:notice] = I18n.t("create.success", scope: "decidim.admin.taxonomies") + redirect_to edit_taxonomy_path(taxonomy) + end + + on(:invalid) do + flash.now[:alert] = I18n.t("create.invalid", scope: "decidim.admin.taxonomies") + render action: "new" + end + end + end + + def edit + enforce_permission_to :update, :taxonomy_item, taxonomy: taxonomy_item + @form = form(Decidim::Admin::TaxonomyItemForm).from_model(taxonomy_item) + end + + def update + enforce_permission_to :update, :taxonomy_item, taxonomy: taxonomy_item + @form = form(Decidim::Admin::TaxonomyItemForm).from_params(params) + UpdateTaxonomy.call(@form, taxonomy_item) do + on(:ok) do + flash[:notice] = I18n.t("update.success", scope: "decidim.admin.taxonomies") + redirect_to edit_taxonomy_path(taxonomy) + end + + on(:invalid) do + flash.now[:alert] = I18n.t("update.invalid", scope: "decidim.admin.taxonomies") + render action: "edit" + end + end + end + + private + + def taxonomy + @taxonomy ||= Decidim::Taxonomy.find_by(organization: current_organization, id: params[:taxonomy_id]) + end + + def taxonomy_item + @taxonomy_item ||= Decidim::Taxonomy.find_by(organization: current_organization, id: params[:id]) + end + + def selected_parent_id + @selected_parent_id ||= taxonomy_item&.parent_id || taxonomy.id + end + + def parent_options + @parent_options ||= begin + options = [[I18n.t("new.none", scope: "decidim.admin.taxonomy_items"), taxonomy.id]] + taxonomy.children.each do |child| + next if child.id == taxonomy_item&.id + + options << [translated_attribute(child.name).to_s, child.id] + # add children to the list with indentation + child.children.each do |grandchild| + next if grandchild.id == taxonomy_item&.id + + options << ["   #{translated_attribute(grandchild.name)}".html_safe, grandchild.id] + end + end + options + end + end + end + end +end diff --git a/decidim-admin/app/forms/decidim/admin/taxonomy_form.rb b/decidim-admin/app/forms/decidim/admin/taxonomy_form.rb new file mode 100644 index 0000000000000..a092cd3428a19 --- /dev/null +++ b/decidim-admin/app/forms/decidim/admin/taxonomy_form.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Decidim + module Admin + # A form object to be used when creating or updating a taxonomy. + class TaxonomyForm < Decidim::Form + include Decidim::TranslatableAttributes + + mimic :taxonomy + + translatable_attribute :name, String + + validates :name, translatable_presence: true + + alias organization current_organization + + def parent_id = nil + end + end +end diff --git a/decidim-admin/app/forms/decidim/admin/taxonomy_item_form.rb b/decidim-admin/app/forms/decidim/admin/taxonomy_item_form.rb new file mode 100644 index 0000000000000..9b33372748bd6 --- /dev/null +++ b/decidim-admin/app/forms/decidim/admin/taxonomy_item_form.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Decidim + module Admin + # A form object to be used when creating or updating a taxonomy. + class TaxonomyItemForm < Decidim::Form + include Decidim::TranslatableAttributes + + mimic :taxonomy + + # we do not use "name" here to avoid collisions when using foundation tabs for multilingual fields tabs + # as this is used in a modal and the name identifier is used for the root taxonomy + translatable_attribute :item_name, String + attribute :parent_id, Integer + + validates :item_name, translatable_presence: true + validate :validate_parent_id_within_same_root_taxonomy + + alias name item_name + + def map_model(model) + self.item_name = model.name + end + + def self.from_params(params, additional_params = {}) + additional_params[:taxonomy] = {} + if params[:taxonomy] + params[:taxonomy].each do |key, value| + additional_params[:taxonomy][key[8..]] = value if key.start_with?("item_name_") + end + end + super + end + + def validate_parent_id_within_same_root_taxonomy + if parent + current_root_taxonomy = if parent.root? + parent + else + parent.root_taxonomy + end + + errors.add(:parent_id, :invalid) unless parent.root_taxonomy.id == current_root_taxonomy.id + else + errors.add(:parent_id, :invalid) + end + end + + def parent + @parent ||= Decidim::Taxonomy.find_by(id: parent_id) + end + end + end +end diff --git a/decidim-admin/app/helpers/decidim/admin/paginable/per_page_helper.rb b/decidim-admin/app/helpers/decidim/admin/paginable/per_page_helper.rb deleted file mode 100644 index bb9715bde28cf..0000000000000 --- a/decidim-admin/app/helpers/decidim/admin/paginable/per_page_helper.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Decidim - module Admin - module Paginable - # This module includes helpers the :per_page cell's option - module PerPageHelper - def per_page_options - OpenStruct.new( - per_page:, - per_page_range: Decidim::Admin.per_page_range - ) - end - - # Renders the pagination dropdown menu in the admin panel. - def admin_filters_pagination - cell("decidim/admin/results_per_page", per_page_options) - end - end - end - end -end diff --git a/decidim-admin/app/packs/src/decidim/admin/sortable.js b/decidim-admin/app/packs/src/decidim/admin/sortable.js index 9b66bc447ba37..fb67eb654b2a5 100644 --- a/decidim-admin/app/packs/src/decidim/admin/sortable.js +++ b/decidim-admin/app/packs/src/decidim/admin/sortable.js @@ -1,19 +1,31 @@ -import createSortList from "src/decidim/admin/sort_list.component" +import sortable from "html5sortable/dist/html5sortable.es"; -// Once in DOM -$(() => { - const selector = ".js-sortable" - const $sortable = $(selector) +/* + Initializes any element with the class js-sortable as a sortable list + User html5Sortable, with options available as data-draggable-options (see https://github.com/lukasoppermann/html5sortable) - $sortable.each((index, elem) => { - const item = (elem.id) - ? `#${elem.id}` - : selector + Event are dispatched on the element with the class js-sortable, so you can simply do: - createSortList(item, { - handle: "li", - forcePlaceholderSize: true, - placeholderClass: "sort-placeholder" - }) - }) -}) + document.querySelector('.js-sortable').addEventListener('sortupdate', (event) => { + console.log('The new order is:', event.target.children); + }); +*/ +window.addEventListener("DOMContentLoaded", () => { + const draggables = document.querySelectorAll(".js-sortable"); + + if (draggables) { + draggables.forEach((draggable) => { + let options = { + "forcePlaceholderSize": true + }; + ["items", "acceptFrom", "handle", "placeholderClass", "placeholder", "hoverClass"].forEach((option) => { + let dataOption = `draggable${option.charAt(0).toUpperCase() + option.slice(1)}`; + if (draggable.dataset[dataOption]) { + options[option] = draggable.dataset[dataOption]; + } + }); + // console.log("initialize sortable with options", options); + sortable(draggable, options); + }); + } +}); diff --git a/decidim-admin/app/packs/stylesheets/decidim/admin/_taxonomies.scss b/decidim-admin/app/packs/stylesheets/decidim/admin/_taxonomies.scss new file mode 100644 index 0000000000000..ece16340ab739 --- /dev/null +++ b/decidim-admin/app/packs/stylesheets/decidim/admin/_taxonomies.scss @@ -0,0 +1,74 @@ +.spinner-container { + &::before { + @apply absolute -mt-2 -ml-4 top-1/2 left-1/2; + } +} + +.draggable-taxonomy { + &.change-page { + @apply bg-gray-3 italic; + } + + .dragger { + @apply text-2xl cursor-ns-resize; + } + + td { + @apply align-top; + + &.js-drag-handle .dragger { + @apply mt-1; + } + } +} + +ul.taxonomy { + @apply flex; + + li { + @apply flex-none text-center w-20 mt-2 mb-2; + + &:first-child { + @apply flex-initial text-left flex-grow; + } + + &:nth-child(2), + &:last-child { + @apply w-36; + } + } + + .taxonomy-list__actions { + span { + @apply ml-1; + } + + svg { + @apply inline-block fill-secondary w-5 h-5; + } + + .action-space { + @apply w-5 h-5; + } + } +} + +[data-dialog]#item-form { + @apply bottom-0 sm:top-[5.5rem] top-[8.55rem]; + + [data-dialog-container] > :last-child { + grid-column: span 1 / span 1; + } +} + +#item-form-content { + @apply p-6 rounded-r-none py-0; + + h1 { + @apply mt-6 mx-0; + } + + .row { + @apply px-0; + } +} diff --git a/decidim-admin/app/packs/stylesheets/decidim/admin/application.scss b/decidim-admin/app/packs/stylesheets/decidim/admin/application.scss index 3a6dbd77ac3b4..ff8d63b8cb319 100755 --- a/decidim-admin/app/packs/stylesheets/decidim/admin/application.scss +++ b/decidim-admin/app/packs/stylesheets/decidim/admin/application.scss @@ -24,6 +24,7 @@ @import "stylesheets/decidim/admin/_logs.scss"; @import "stylesheets/decidim/admin/_filters.scss"; @import "stylesheets/decidim/admin/_table-list.scss"; +@import "stylesheets/decidim/admin/_taxonomies.scss"; @import "stylesheets/decidim/admin/_dropdown.scss"; @import "stylesheets/decidim/admin/_item_show.scss"; @import "stylesheets/decidim/admin/_item_edit.scss"; diff --git a/decidim-admin/app/permissions/decidim/admin/permissions.rb b/decidim-admin/app/permissions/decidim/admin/permissions.rb index 2ef73b4a793a0..296e97322c035 100644 --- a/decidim-admin/app/permissions/decidim/admin/permissions.rb +++ b/decidim-admin/app/permissions/decidim/admin/permissions.rb @@ -59,6 +59,12 @@ def permissions allow! if permission_action.subject == :help_sections allow! if permission_action.subject == :share_token allow! if permission_action.subject == :reminder + + if permission_action.subject == :taxonomy + permission_action.action == :destroy ? allow_destroy_taxonomy? : allow! + end + + allow! if permission_action.subject == :taxonomy_item end permission_action @@ -253,6 +259,14 @@ def admin_terms_accepted? def available_authorization_handlers? user.organization.available_authorization_handlers.any? end + + def allow_destroy_taxonomy? + return unless permission_action.action == :destroy + + taxonomy = context.fetch(:taxonomy, nil) + + toggle_allow(taxonomy&.removable?) + end end end end diff --git a/decidim-admin/app/views/decidim/admin/block_user/new.html.erb b/decidim-admin/app/views/decidim/admin/block_user/new.html.erb index e62f0e08d20f8..bd96f01d00b9c 100644 --- a/decidim-admin/app/views/decidim/admin/block_user/new.html.erb +++ b/decidim-admin/app/views/decidim/admin/block_user/new.html.erb @@ -19,7 +19,7 @@ <%= cell("decidim/announcement", t(".already_reported_html"), callout_class: "alert" ) %> <% end %> - <% if defined?(Decidim::Templates) %> + <% if Decidim.module_installed?(:templates) %> <%= render "decidim/templates/admin/block_user_templates/template_chooser", form: f %> <% end %> diff --git a/decidim-admin/app/views/decidim/admin/conflicts/index.html.erb b/decidim-admin/app/views/decidim/admin/conflicts/index.html.erb index ac81afc7dfcd1..16117766de80e 100644 --- a/decidim-admin/app/views/decidim/admin/conflicts/index.html.erb +++ b/decidim-admin/app/views/decidim/admin/conflicts/index.html.erb @@ -1,10 +1,32 @@ <% add_decidim_page_title(t("title", scope: "decidim.admin.conflicts")) %> -
-
-

- <%= t("title", scope: "decidim.admin.conflicts") %> -

+
+

+ <%= t("title", scope: "decidim.admin.conflicts") %> +

+
+ +
+ +
+ +
@@ -17,7 +39,7 @@ - <% @conflicts.each do |conflict| %> + <% conflicts.each do |conflict| %> @@ -30,3 +52,4 @@
<%= conflict.current_user.name %> <%= conflict.managed_user.name %>
+<%= decidim_paginate conflicts %> diff --git a/decidim-admin/app/views/decidim/admin/taxonomies/_filters.html.erb b/decidim-admin/app/views/decidim/admin/taxonomies/_filters.html.erb new file mode 100644 index 0000000000000..d635006f292d4 --- /dev/null +++ b/decidim-admin/app/views/decidim/admin/taxonomies/_filters.html.erb @@ -0,0 +1,19 @@ +
+ +
diff --git a/decidim-admin/app/views/decidim/admin/taxonomies/_form.html.erb b/decidim-admin/app/views/decidim/admin/taxonomies/_form.html.erb new file mode 100644 index 0000000000000..d715fd194cb11 --- /dev/null +++ b/decidim-admin/app/views/decidim/admin/taxonomies/_form.html.erb @@ -0,0 +1,5 @@ +
+
+ <%= form.translated :text_field, :name, autofocus: true, class: "js-hashtags", hashtaggable: true, aria: { label: :name } %> +
+
diff --git a/decidim-admin/app/views/decidim/admin/taxonomies/_row.html.erb b/decidim-admin/app/views/decidim/admin/taxonomies/_row.html.erb new file mode 100644 index 0000000000000..3b4facc02e743 --- /dev/null +++ b/decidim-admin/app/views/decidim/admin/taxonomies/_row.html.erb @@ -0,0 +1,33 @@ + + <%== icon("draggable", class: "dragger") %> + + + <% if with_children && taxonomy.children.any? %> +
+ <% taxonomy.children.each do |child| %> + <%= render partial: "decidim/admin/taxonomies/row_children", locals: { child:, class_name: "", grandchild: false } %> + + <% if child.children.any? %> +
+ <% child.children.each do |grandchild| %> + <%= render partial: "decidim/admin/taxonomies/row_children", locals: { child: grandchild, class_name: "ml-6", grandchild: true } %> + <% end %> +
+ <% end %> + <% end %> +
+ <% end %> + + diff --git a/decidim-admin/app/views/decidim/admin/taxonomies/_row_children.html.erb b/decidim-admin/app/views/decidim/admin/taxonomies/_row_children.html.erb new file mode 100644 index 0000000000000..27645aaebf29b --- /dev/null +++ b/decidim-admin/app/views/decidim/admin/taxonomies/_row_children.html.erb @@ -0,0 +1,8 @@ + diff --git a/decidim-admin/app/views/decidim/admin/taxonomies/_table.html.erb b/decidim-admin/app/views/decidim/admin/taxonomies/_table.html.erb new file mode 100644 index 0000000000000..0598010cee6c1 --- /dev/null +++ b/decidim-admin/app/views/decidim/admin/taxonomies/_table.html.erb @@ -0,0 +1,70 @@ +
+ + + + + + + + + <% if path = path_to_prev_page(collection) %> + + <% end %> + + <% collection.each do |taxonomy| %> + <%= render "row", taxonomy:, with_children: !taxonomy.root? %> + <% end %> + <% if path = path_to_next_page(collection) %> + + <% end %> + +
Move +
    +
  • <%= t("decidim.admin.taxonomies.name") %>
  • +
  • <%= taxonomy.blank? ? t("decidim.admin.taxonomies.amount") : t("decidim.admin.taxonomies.count") %>
  • +
  • <%= t("decidim.admin.taxonomies.actions") %>
  • +
+
<%= t(".to_prev_page") %>
<%= t(".to_next_page") %>
+
+ +<%= decidim_paginate collection %> + + diff --git a/decidim-admin/app/views/decidim/admin/taxonomies/_taxonomy_actions.html.erb b/decidim-admin/app/views/decidim/admin/taxonomies/_taxonomy_actions.html.erb new file mode 100644 index 0000000000000..fb5047608c536 --- /dev/null +++ b/decidim-admin/app/views/decidim/admin/taxonomies/_taxonomy_actions.html.erb @@ -0,0 +1,10 @@ +<% if allowed_to? :update, :taxonomy, taxonomy: taxonomy %> + <%= icon_link_to "pencil-line", taxonomy.root? ? edit_taxonomy_path(taxonomy) : edit_taxonomy_item_path(taxonomy_id: taxonomy.root_taxonomy.id, id: taxonomy.id), t("actions.edit", scope: "decidim.admin"), class: "action-icon--edit js-drawer-editor" %> +<% else %> + +<% end %> +<% if allowed_to? :destroy, :taxonomy, taxonomy: taxonomy %> + <%= icon_link_to "delete-bin-line", taxonomy, t("actions.destroy", scope: "decidim.admin"), class: "action-icon--remove", method: :delete, data: { confirm: t(".confirm_destroy", name: strip_tags(translated_attribute(taxonomy.name))) } %> +<% else %> + +<% end %> diff --git a/decidim-admin/app/views/decidim/admin/taxonomies/edit.html.erb b/decidim-admin/app/views/decidim/admin/taxonomies/edit.html.erb new file mode 100644 index 0000000000000..3116411d36a0c --- /dev/null +++ b/decidim-admin/app/views/decidim/admin/taxonomies/edit.html.erb @@ -0,0 +1,87 @@ +<% add_decidim_page_title(t(".title", taxonomy_name: translated_attribute(taxonomy.name))) %> + +
+

+ <%= t(".title", taxonomy_name: translated_attribute(taxonomy.name)) %> + <%= link_to :back, class: "button button__transparent-secondary button__sm" do %> + <%= t(".back") %> + <% end %> +

+
+ +
+ <%= decidim_form_for(@form, html: { class: "form-defaults form edit_taxonomy_" }) do |f| %> + <%= render partial: "form", object: f %> +
+
+ <%= f.submit t(".update"), class: "button button__sm button__secondary" %> +
+
+ <% end %> +
+ +
+

+ <%= t(".subtitle", taxonomy_name: translated_attribute(taxonomy.name)) %> + <%= link_to t(".new_item"), new_taxonomy_item_path(taxonomy), id: "new-item", class: "js-drawer-editor button button__sm button__secondary new" %> +

+ + <% if collection.any? %> + <%= render "filters" %> + + <% if @taxonomies.any? %> +

<%= t(".description") %>

+ <%= render "table", collection: @taxonomies %> + <% else %> +

<%= t("no_items_found", scope: "decidim.admin.taxonomies.index") %>

+ <% end %> + <% else %> +

<%= t(".no_items") %>

+ <% end %> +
+ +<%= decidim_drawer id: "item-form" do %> +
+<% end %> + + diff --git a/decidim-admin/app/views/decidim/admin/taxonomies/index.html.erb b/decidim-admin/app/views/decidim/admin/taxonomies/index.html.erb new file mode 100644 index 0000000000000..2a789b9b04421 --- /dev/null +++ b/decidim-admin/app/views/decidim/admin/taxonomies/index.html.erb @@ -0,0 +1,28 @@ +<% add_decidim_page_title(t("taxonomies", scope: "decidim.admin.titles")) %> + +
+
+
+

+ <%= t "decidim.admin.titles.taxonomies" %> + <% if allowed_to? :create, :taxonomy %> + <%= link_to t(".new_taxonomy"), new_taxonomy_path, class: "button button__sm button__secondary new" %> + <% end %> +

+

+ <%= t(".description") %> +

+
+
+ <% if collection.any? %> + <%= render "filters" %> + + <% if @taxonomies.any? %> + <%= render "table", collection: @taxonomies if @taxonomies %> + <% else %> +

<%= t(".no_items_found") %>

+ <% end %> + <% else %> +

<%= t("decidim.admin.taxonomies.no_taxonomies") %>

+ <% end %> +
diff --git a/decidim-admin/app/views/decidim/admin/taxonomies/new.html.erb b/decidim-admin/app/views/decidim/admin/taxonomies/new.html.erb new file mode 100644 index 0000000000000..8ced13697109f --- /dev/null +++ b/decidim-admin/app/views/decidim/admin/taxonomies/new.html.erb @@ -0,0 +1,16 @@ +<% add_decidim_page_title(t(".title")) %> +
+

+ <%= t ".title" %> +

+
+
+ <%= decidim_form_for(@form, url: taxonomies_path, html: { class: "form-defaults form new_taxonomy_" }) do |f| %> + <%= render partial: "form", object: f %> +
+
+ <%= f.submit t(".create"), class: "button button__sm button__secondary" %> +
+
+ <% end %> +
diff --git a/decidim-admin/app/views/decidim/admin/taxonomy_items/_form.html.erb b/decidim-admin/app/views/decidim/admin/taxonomy_items/_form.html.erb new file mode 100644 index 0000000000000..9a20b8b1fed84 --- /dev/null +++ b/decidim-admin/app/views/decidim/admin/taxonomy_items/_form.html.erb @@ -0,0 +1,8 @@ +<%= display_flash_messages %> + +
+ <%= form.translated :text_field, :item_name, autofocus: true, class: "js-hashtags", hashtaggable: true, aria: { label: :name } %> +
+
+ <%= form.select :parent_id, options_for_select(parent_options, selected: selected_parent_id), {}, class: "form-control" %> +
diff --git a/decidim-admin/app/views/decidim/admin/taxonomy_items/edit.html.erb b/decidim-admin/app/views/decidim/admin/taxonomy_items/edit.html.erb new file mode 100644 index 0000000000000..56332917d60e4 --- /dev/null +++ b/decidim-admin/app/views/decidim/admin/taxonomy_items/edit.html.erb @@ -0,0 +1,12 @@ +
+
+ <%= decidim_form_for(@form, url: taxonomy_item_path(taxonomy_id: taxonomy.id, id: taxonomy_item.id ), remote: true, html: { id: "taxonomy-item-form", class: "form-defaults form new_taxonomy_" }) do |f| %> +

+ <%= t ".title", taxonomy: translated_attribute(taxonomy.name) %> + <%= f.submit t(".update"), class: "button button__sm button__secondary" %> +

+ + <%= render partial: "form", object: f %> + <% end %> +
+
diff --git a/decidim-admin/app/views/decidim/admin/taxonomy_items/new.html.erb b/decidim-admin/app/views/decidim/admin/taxonomy_items/new.html.erb new file mode 100644 index 0000000000000..b2a4c43a91f36 --- /dev/null +++ b/decidim-admin/app/views/decidim/admin/taxonomy_items/new.html.erb @@ -0,0 +1,12 @@ +
+
+ <%= decidim_form_for(@form, url: taxonomy_items_path(taxonomy), remote: true, html: { id: "taxonomy-item-form", class: "form-defaults form new_taxonomy_" }) do |f| %> +

+ <%= t ".title", taxonomy: translated_attribute(taxonomy.name) %> + <%= f.submit t(".create"), class: "button button__sm button__secondary" %> +

+ + <%= render partial: "form", object: f %> + <% end %> +
+
diff --git a/decidim-admin/config/locales/ca.yml b/decidim-admin/config/locales/ca.yml index ac6734c438dce..cb9e40ca4c9ff 100644 --- a/decidim-admin/config/locales/ca.yml +++ b/decidim-admin/config/locales/ca.yml @@ -350,6 +350,8 @@ ca: conflicts: attempts: Intents 'false': 'No' + index: + text: Cercar per correu electrònic, nom o àlies. managed_user_name: Usuari gestionat solved: Resolt title: Conflictes de verificació diff --git a/decidim-admin/config/locales/de.yml b/decidim-admin/config/locales/de.yml index 3c4fb6e9db92c..128483a14792c 100644 --- a/decidim-admin/config/locales/de.yml +++ b/decidim-admin/config/locales/de.yml @@ -350,6 +350,8 @@ de: conflicts: attempts: Versuche 'false': 'Nein' + index: + text: Suche nach bestehender E-Mail, Name oder Spitzname des Teilnehmenden. managed_user_name: Verwalteter Benutzer solved: Gelöst title: Überprüfungskonflikte diff --git a/decidim-admin/config/locales/en.yml b/decidim-admin/config/locales/en.yml index b0cf812736638..cac5e6c6b3a4c 100644 --- a/decidim-admin/config/locales/en.yml +++ b/decidim-admin/config/locales/en.yml @@ -203,7 +203,7 @@ en: verify: Verify admin_terms_of_service: accept: - error: There was an error while accepting the admin terms of service. + error: There was a problem while accepting the admin terms of service. success: Great! You have accepted the admin terms of service. actions: accept: I agree with the terms @@ -351,6 +351,8 @@ en: conflicts: attempts: Attempts 'false': 'No' + index: + text: Search by current user email, name or nickname. managed_user_name: Managed User solved: Solved title: Verification conflicts @@ -365,7 +367,7 @@ en: user_name: User content_blocks: create: - error: There was an error while creating the content block. + error: There was a problem while creating the content block. success: Content block successfully created. destroy: error: There was a problem trying to delete this content block. @@ -468,6 +470,9 @@ en: pending: Pending rejected: Rejected verified: Verified + taxonomies: + taxonomy_id_eq: + label: Taxonomy forms: file_help: import: @@ -590,6 +595,7 @@ en: settings: Settings static_page_topics: Topics static_pages: Pages + taxonomies: Taxonomies user_groups: Groups users: Participants metrics: @@ -784,7 +790,7 @@ en: success: Newsletter updated successfully. Please review it before sending. officializations: block: - error: There was an error blocking the participant. + error: There was a problem blocking the participant. success: Participant successfully blocked. create: success: Participant successfully officialized. @@ -818,7 +824,7 @@ en: show: Show title: Show participant's email address unblock: - error: There was an error unblocking the participant. + error: There was a problem unblocking the participant. success: Participant successfully unblocked. organization: edit: @@ -1017,6 +1023,51 @@ en: update: error: There was a problem updating this page. success: Page updated successfully. + taxonomies: + actions: Actions + amount: Amount + count: Count + create: + invalid: There was a problem creating this taxonomy. + success: Taxonomy created successfully. + destroy: + invalid: There was a problem destroying this taxonomy. + success: Taxonomy successfully destroyed. + edit: + back: Back + description: The items in this taxonomy will be used to classify or filter resources, such as participatory spaces or components. Use drag and drop to reorder the list. + new_item: New item + no_items: There are currently no items in this taxonomy. Create a list of items here to classify or filter resources, such as participatory spaces or components. Items can be nested up to three levels. + subtitle: Items in "%{taxonomy_name}" + title: Edit taxonomy "%{taxonomy_name}" + update: Update + filters: + search_placeholder: Search + index: + description: A taxonomy allows admins categorizing and organizing content. For example, add a taxonomy to classify processes by geographical scope. + new_taxonomy: New taxonomy + no_items_found: No taxonomies found matching the search criteria. + name: Name + new: + create: Create taxonomy + title: New taxonomy + no_taxonomies: There are currently no taxonomies. Create a list of taxonomies here and defined items in each of them to classify or filter resources. + table: + to_next_page: Drag over for next page + to_prev_page: Drag over for previous page + taxonomy_actions: + confirm_destroy: Are you sure you want to delete %{name}? This will also delete all the children items. + update: + invalid: There was a problem updating this taxonomy. + success: Taxonomy updated successfully. + taxonomy_items: + edit: + title: Edit item in %{taxonomy} + update: Update item + new: + create: Create item + none: None + title: New item in %{taxonomy} titles: admin_log: Admin log area_types: Area types @@ -1037,6 +1088,7 @@ en: scope_types: Scope types scopes: Scopes statistics: Activity + taxonomies: Taxonomies user_groups: Groups users: Administrators user_group: diff --git a/decidim-admin/config/locales/es.yml b/decidim-admin/config/locales/es.yml index e91b9f6461af7..62d77115d4355 100644 --- a/decidim-admin/config/locales/es.yml +++ b/decidim-admin/config/locales/es.yml @@ -350,6 +350,8 @@ es: conflicts: attempts: Intentos 'false': 'No' + index: + text: Buscar por correo electrónico, nombre o alias. managed_user_name: Usuario gestionado solved: Resuelto title: Conflictos de verificación diff --git a/decidim-admin/config/locales/fr-CA.yml b/decidim-admin/config/locales/fr-CA.yml index de182311cf48c..50a052f64cee1 100644 --- a/decidim-admin/config/locales/fr-CA.yml +++ b/decidim-admin/config/locales/fr-CA.yml @@ -350,6 +350,8 @@ fr-CA: conflicts: attempts: Tentatives 'false': 'Non' + index: + text: Recherche par adresse e-mail, nom ou pseudonyme. managed_user_name: Utilisateur représenté solved: Résolu title: Conflits de vérification diff --git a/decidim-admin/config/locales/fr.yml b/decidim-admin/config/locales/fr.yml index 3db6d76ea9799..ee8d6e2a725a5 100644 --- a/decidim-admin/config/locales/fr.yml +++ b/decidim-admin/config/locales/fr.yml @@ -350,6 +350,8 @@ fr: conflicts: attempts: Tentatives 'false': 'Non' + index: + text: Recherche par adresse e-mail, nom ou pseudonyme. managed_user_name: Utilisateur représenté solved: Résolu title: Conflits de vérification diff --git a/decidim-admin/config/routes.rb b/decidim-admin/config/routes.rb index 03311899fe4a8..75dc6156ca15c 100644 --- a/decidim-admin/config/routes.rb +++ b/decidim-admin/config/routes.rb @@ -122,6 +122,11 @@ resources :conflicts, only: [:index, :edit, :update], controller: "conflicts" + resources :taxonomies, except: [:show] do + patch :reorder, on: :collection + resources :items, only: [:new, :create, :edit, :update], controller: "taxonomy_items" + end + root to: "dashboard#show" end end diff --git a/decidim-admin/lib/decidim/admin.rb b/decidim-admin/lib/decidim/admin.rb index 60edff72e7145..a7778ccba621b 100644 --- a/decidim-admin/lib/decidim/admin.rb +++ b/decidim-admin/lib/decidim/admin.rb @@ -17,21 +17,6 @@ module Admin include ActiveSupport::Configurable - # Public Setting that configures Kaminari configuration options - # https://github.com/kaminari/kaminari#general-configuration-options - - # Range of number of results per_page. Defaults to [15, 50, 100]. - # per_page_range.first sets the default number per page - # per_page_range.last sets the default max_per_page - config_accessor :per_page_range do - [15, 50, 100] - end - - Kaminari.configure do |config| - config.default_per_page = Decidim::Admin.per_page_range.first - config.max_per_page = Decidim::Admin.per_page_range.last - end - # Public: Stores an instance of ViewHooks def self.view_hooks @view_hooks ||= ViewHooks.new diff --git a/decidim-admin/lib/decidim/admin/menu.rb b/decidim-admin/lib/decidim/admin/menu.rb index 1abacacc56e32..c7942b4e358b7 100644 --- a/decidim-admin/lib/decidim/admin/menu.rb +++ b/decidim-admin/lib/decidim/admin/menu.rb @@ -163,11 +163,17 @@ def self.register_admin_settings_menu! icon_name: "home-gear-line", if: allowed_to?(:update, :organization, organization: current_organization) + menu.add_item :taxonomies, + I18n.t("menu.taxonomies", scope: "decidim.admin"), + decidim_admin.taxonomies_path, + icon_name: "price-tag-3-line", + position: 1.3 + menu.add_item :scopes, I18n.t("menu.scopes", scope: "decidim.admin"), decidim_admin.scopes_path, icon_name: "price-tag-3-line", - position: 1.3, + position: 1.4, if: allowed_to?(:read, :scope), active: [%w( decidim/admin/scopes diff --git a/decidim-admin/lib/decidim/admin/test/manage_paginated_collection_examples.rb b/decidim-admin/lib/decidim/admin/test/manage_paginated_collection_examples.rb index c7ea21248be81..6912eff355be7 100644 --- a/decidim-admin/lib/decidim/admin/test/manage_paginated_collection_examples.rb +++ b/decidim-admin/lib/decidim/admin/test/manage_paginated_collection_examples.rb @@ -12,13 +12,13 @@ end describe "Number of results per page" do - it "lists 15 resources per page by default" do - expect(page).to have_css(".table-list tbody tr", count: 15) + it "lists 25 resources per page by default" do + expect(page).to have_css(".table-list tbody tr", count: 25) end it "changes the number of results per page" do within "[data-pagination]" do - page.find("details", text: "15").click + page.find("details", text: "25").click click_on "50" end diff --git a/decidim-admin/spec/commands/decidim/admin/create_taxonomy_spec.rb b/decidim-admin/spec/commands/decidim/admin/create_taxonomy_spec.rb new file mode 100644 index 0000000000000..b55c029d62c51 --- /dev/null +++ b/decidim-admin/spec/commands/decidim/admin/create_taxonomy_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::Admin + describe CreateTaxonomy do + subject { described_class.new(form) } + + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, :confirmed, organization:) } + let(:name) { attributes_for(:taxonomy)[:name] } + let(:form) do + double( + invalid?: invalid, + name:, + organization:, + current_user: user, + parent_id: + ) + end + let(:invalid) { false } + let(:parent_id) { nil } + + context "when the form is not valid" do + let(:invalid) { true } + + it "is not valid" do + expect { subject.call }.to broadcast(:invalid) + end + end + + context "when the form is valid" do + it "creates the taxonomy" do + expect { subject.call }.to change(Decidim::Taxonomy, :count).by(1) + end + + it "broadcasts ok" do + expect { subject.call }.to broadcast(:ok) + end + + it "sets the name" do + subject.call + expect(Decidim::Taxonomy.last.name).to eq(name) + end + + it "sets the organization" do + subject.call + expect(Decidim::Taxonomy.last.organization).to eq(organization) + end + + context "when parent_id is provided" do + let!(:parent) { create(:taxonomy, organization:) } + let!(:parent_id) { parent.id } + + it "sets the parent" do + expect do + subject.call + end.to change(Decidim::Taxonomy, :count).by(1) + + created_taxonomy = Decidim::Taxonomy.find_by(parent_id: parent.id) + + expect(created_taxonomy.parent).to eq(parent) + expect(created_taxonomy.id).not_to eq(parent.id) + expect(created_taxonomy.name).to eq(name) + end + end + + it "traces the action", versioning: true do + expect(Decidim.traceability) + .to receive(:create!) + .with( + Decidim::Taxonomy, + form.current_user, + hash_including(:name, :organization, :parent_id), + hash_including(extra: hash_including(:parent_name)) + ) + .and_call_original + + expect { subject.call }.to change(Decidim::ActionLog, :count) + action_log = Decidim::ActionLog.last + expect(action_log.version).to be_present + end + end + end +end diff --git a/decidim-admin/spec/commands/decidim/admin/destroy_component_spec.rb b/decidim-admin/spec/commands/decidim/admin/destroy_component_spec.rb index 0299bb28e44d9..67f2bbbc41e62 100644 --- a/decidim-admin/spec/commands/decidim/admin/destroy_component_spec.rb +++ b/decidim-admin/spec/commands/decidim/admin/destroy_component_spec.rb @@ -42,5 +42,18 @@ module Decidim::Admin expect(result_component).not_to be_persisted end end + + context "when the component has a reminder associated with it" do + let!(:reminder) { create(:reminder, user: current_user, component:) } + + it "destroys the component" do + expect { subject.call }.to broadcast(:ok) + expect(Decidim::Component.where(id: component.id)).not_to exist + end + + it "destroys the associated reminders" do + expect { subject.call }.to change(Decidim::Reminder, :count).by(-1) + end + end end end diff --git a/decidim-admin/spec/commands/decidim/admin/destroy_taxonomy_spec.rb b/decidim-admin/spec/commands/decidim/admin/destroy_taxonomy_spec.rb new file mode 100644 index 0000000000000..33f3ee96bdef3 --- /dev/null +++ b/decidim-admin/spec/commands/decidim/admin/destroy_taxonomy_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::Admin + describe DestroyTaxonomy do + subject { described_class.new(taxonomy, user) } + + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, :confirmed, organization:) } + let(:taxonomy) { create(:taxonomy, organization:) } + + it "destroys the taxonomy" do + subject.call + expect { taxonomy.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it "broadcasts ok" do + expect do + subject.call + end.to broadcast(:ok) + end + + it "traces the action", versioning: true do + expect(Decidim.traceability) + .to receive(:perform_action!) + .with( + :delete, + taxonomy, + user, + extra: hash_including(:parent_name) + ) + .and_call_original + + expect { subject.call }.to change(Decidim::ActionLog, :count) + action_log = Decidim::ActionLog.last + expect(action_log.version).to be_present + end + end +end diff --git a/decidim-admin/spec/commands/decidim/admin/reorder_taxonomies_spec.rb b/decidim-admin/spec/commands/decidim/admin/reorder_taxonomies_spec.rb new file mode 100644 index 0000000000000..b8318f57f1ddc --- /dev/null +++ b/decidim-admin/spec/commands/decidim/admin/reorder_taxonomies_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::Admin + describe ReorderTaxonomies do + subject { described_class.new(*args) } + + let(:args) { [organization, order] } + let(:organization) { create(:organization) } + + let!(:taxonomy1) { create(:taxonomy, weight: 1, organization:) } + let!(:taxonomy2) { create(:taxonomy, weight: 2, organization:) } + let!(:taxonomy3) { create(:taxonomy, weight: 3, organization:) } + let!(:taxonomy4) { create(:taxonomy, weight: 40, organization:) } + let!(:external_taxonomy) { create(:taxonomy, weight: 11) } + + let(:order) { [taxonomy3.id, taxonomy1.id, taxonomy2.id] } + + context "when the order is nil" do + let(:order) { nil } + + it "is not valid" do + expect { subject.call }.to broadcast(:invalid) + end + end + + context "when the order is empty" do + let(:order) { [] } + + it "is not valid" do + expect { subject.call }.to broadcast(:invalid) + end + end + + context "when the order is valid" do + it "is valid" do + expect { subject.call }.to broadcast(:ok) + end + + it "reorders the blocks" do + subject.call + taxonomy1.reload + taxonomy2.reload + taxonomy3.reload + taxonomy4.reload + external_taxonomy.reload + + expect(taxonomy3.weight).to eq 1 + expect(taxonomy1.weight).to eq 2 + expect(taxonomy2.weight).to eq 3 + expect(taxonomy4.weight).to eq 40 + expect(external_taxonomy.weight).to eq 11 + end + end + end +end diff --git a/decidim-admin/spec/commands/decidim/admin/update_taxonomy_spec.rb b/decidim-admin/spec/commands/decidim/admin/update_taxonomy_spec.rb new file mode 100644 index 0000000000000..ccbef52422a36 --- /dev/null +++ b/decidim-admin/spec/commands/decidim/admin/update_taxonomy_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::Admin + describe UpdateTaxonomy do + subject { described_class.new(form, taxonomy) } + + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, :confirmed, organization:) } + let(:taxonomy) { create(:taxonomy, organization:) } + let(:parent) { create(:taxonomy, organization:) } + let(:form) do + double( + invalid?: invalid, + current_user: user, + name:, + parent_id: parent.id + ) + end + + let(:name) { Decidim::Faker::Localized.literal("New name") } + let(:parent_id) { parent.id } + let(:invalid) { false } + + context "when the form is not valid" do + let(:invalid) { true } + + it "is not valid" do + expect { subject.call }.to broadcast(:invalid) + end + end + + context "when the form is valid" do + before do + subject.call + taxonomy.reload + end + + it "updates the name of the taxonomy" do + expect(translated(taxonomy.name)).to eq("New name") + end + + it "updates the parent_id of the taxonomy" do + expect(taxonomy.parent_id).to eq(parent.id) + end + + it "traces the action", versioning: true do + expect(Decidim.traceability) + .to receive(:update!) + .with( + taxonomy, + form.current_user, + hash_including(:name, :parent_id), + hash_including(extra: hash_including(:parent_name)) + ) + .and_call_original + + expect { subject.call }.to change(Decidim::ActionLog, :count) + action_log = Decidim::ActionLog.last + expect(action_log.version).to be_present + end + end + end +end diff --git a/decidim-admin/spec/controllers/taxonomies_controller_spec.rb b/decidim-admin/spec/controllers/taxonomies_controller_spec.rb new file mode 100644 index 0000000000000..5016d0ad53dd1 --- /dev/null +++ b/decidim-admin/spec/controllers/taxonomies_controller_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Admin + describe TaxonomiesController do + routes { Decidim::Admin::Engine.routes } + + let(:organization) { create(:organization) } + let(:current_user) { create(:user, :admin, :confirmed, organization:) } + let(:taxonomy) { create(:taxonomy, organization:) } + + before do + request.env["decidim.current_organization"] = organization + sign_in current_user, scope: :user + end + + describe "GET index" do + let!(:taxonomy1) { create(:taxonomy, name: { en: "Category1" }, organization:) } + let!(:taxonomy2) { create(:taxonomy, name: { en: "Category2" }, organization:) } + + it "assigns @taxonomies" do + get :index, params: { q: { name_or_children_name_cont: "Category1" } } + expect(assigns(:taxonomies)).to include(taxonomy1) + expect(assigns(:taxonomies)).not_to include(taxonomy2) + end + + it "renders the index template" do + get :index, params: { q: { name_or_children_name_cont: "Category1" } } + expect(response).to render_template("index") + end + end + + describe "GET new" do + it "assigns a new form instance" do + get :new + form = assigns(:form) + expect(form).to be_a(Decidim::Admin::TaxonomyForm) + expect(form.id).to be_nil + end + + it "renders the new template" do + get :new + expect(response).to render_template("new") + end + end + + describe "POST create" do + let(:valid_params) { { taxonomy: { name: { en: "New Taxonomy" }, weight: 1 } } } + let(:invalid_params) { { taxonomy: { name: { en: "" }, weight: nil } } } + + it "creates a new taxonomy with valid params" do + expect do + post :create, params: valid_params + end.to change(Decidim::Taxonomy, :count).by(1) + end + + it "does not create a new taxonomy with invalid params" do + expect do + post :create, params: invalid_params + end.not_to change(Decidim::Taxonomy, :count) + end + end + + describe "GET edit" do + let!(:sub_taxonomy1) { create(:taxonomy, parent: taxonomy, name: { en: "Sub 1" }, organization:) } + let!(:sub_taxonomy2) { create(:taxonomy, parent: taxonomy, name: { en: "Sub 2" }, organization:) } + + it "assigns the requested taxonomy to @form" do + get :edit, params: { id: taxonomy.id } + expect(assigns(:form).attributes).to include("id" => taxonomy.id) + end + + it "assigns @taxonomies" do + get :index, params: { id: taxonomy.id, q: { name_or_children_name_cont: "Sub 1" } } + expect(assigns(:taxonomies)).to include(sub_taxonomy1) + expect(assigns(:taxonomies)).not_to include(sub_taxonomy2) + end + + it "renders the edit template" do + get :edit, params: { id: taxonomy.id } + expect(response).to render_template("edit") + end + + context "when editing a non-root taxonomy" do + let(:child_taxonomy) { create(:taxonomy, parent: taxonomy, organization:) } + + it "redirects to the root" do + get :edit, params: { id: child_taxonomy.id } + expect(response).to redirect_to(edit_taxonomy_path(taxonomy)) + end + end + end + + describe "PATCH update" do + let(:valid_params) { { id: taxonomy.id, taxonomy: { name: { en: "Updated Taxonomy" }, weight: 1 } } } + let(:invalid_params) { { id: taxonomy.id, taxonomy: { name: { en: "" }, weight: nil } } } + + it "updates the taxonomy with valid params" do + patch :update, params: valid_params + taxonomy.reload + + expect(taxonomy.name["en"]).to eq("Updated Taxonomy") + end + + it "does not update the taxonomy with invalid params" do + patch :update, params: invalid_params + taxonomy.reload + + expect(taxonomy.name["en"]).not_to eq("") + end + end + + describe "DELETE destroy" do + it "deletes the taxonomy" do + delete :destroy, params: { id: taxonomy.id } + expect(Decidim::Taxonomy).not_to exist(taxonomy.id) + end + end + end + end +end diff --git a/decidim-admin/spec/forms/decidim/admin/taxonomy_form_spec.rb b/decidim-admin/spec/forms/decidim/admin/taxonomy_form_spec.rb new file mode 100644 index 0000000000000..b4e9aa505d2f4 --- /dev/null +++ b/decidim-admin/spec/forms/decidim/admin/taxonomy_form_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::Admin + describe TaxonomyForm do + subject { described_class.from_params(attributes).with_context(context) } + + let(:organization) { create(:organization) } + let(:name) { attributes_for(:taxonomy)[:name] } + let(:attributes) { { name: } } + let(:context) do + { + current_organization: organization + } + end + + context "when everything is OK" do + it { is_expected.to be_valid } + end + + context "when name is not present" do + let(:name) { { "en" => "" } } + + it { is_expected.to be_invalid } + end + + describe "#name" do + it "returns the name" do + expect(subject.name).to eq(name) + end + end + + describe "#organization" do + it "returns the current organization" do + expect(subject.organization).to eq(organization) + end + end + + describe "#parent_id" do + it "returns nil" do + expect(subject.parent_id).to be_nil + end + end + end +end diff --git a/decidim-admin/spec/forms/decidim/admin/taxonomy_item_form_spec.rb b/decidim-admin/spec/forms/decidim/admin/taxonomy_item_form_spec.rb new file mode 100644 index 0000000000000..4960ae8faf4b2 --- /dev/null +++ b/decidim-admin/spec/forms/decidim/admin/taxonomy_item_form_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::Admin + describe TaxonomyItemForm do + subject { described_class.from_params(attributes).with_context(context) } + + let(:organization) { create(:organization) } + let(:item_name) { attributes_for(:taxonomy)[:name] } + let(:root_taxonomy) { create(:taxonomy, organization:) } + let(:parent) { create(:taxonomy, organization:, parent: root_taxonomy) } + let(:parent_id) { parent.id } + let(:attributes) do + { + "item_name" => item_name, + "parent_id" => parent_id + } + end + let(:context) do + { + current_organization: organization + } + end + + context "when everything is OK" do + it { is_expected.to be_valid } + end + + context "when item_name is not present" do + let(:item_name) { { "en" => "" } } + + it { is_expected.to be_invalid } + end + + context "when parent_id is not present" do + let(:parent_id) { nil } + + it { is_expected.to be_invalid } + end + + describe "#item_name" do + it "returns the item_name" do + expect(subject.item_name).to eq(item_name) + end + end + + describe "#parent_id" do + it "returns the parent_id" do + expect(subject.parent_id).to eq(parent_id) + end + end + + describe ".from_params" do + let(:params) do + { + "taxonomy" => { "item_name_en" => "Test item" }, + "parent_id" => parent.id + } + end + + it "creates a form with the correct attributes" do + form = described_class.from_params(params).with_context(context) + expect(form.item_name).to eq({ "en" => "Test item" }) + expect(form.parent_id).to eq(parent.id) + end + end + + describe "#name" do + it "returns the item_name" do + expect(subject.name).to eq(subject.item_name) + end + end + end +end diff --git a/decidim-admin/spec/system/admin_checks_conflicts_spec.rb b/decidim-admin/spec/system/admin_checks_conflicts_spec.rb new file mode 100644 index 0000000000000..461bbf1e62a5c --- /dev/null +++ b/decidim-admin/spec/system/admin_checks_conflicts_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Admin checks conflicts" do + let(:organization) { create(:organization) } + let(:resource_controller) { Decidim::Admin::ConflictsController } + + let!(:user) { create(:user, :admin, :confirmed, organization:) } + let!(:first_conflictive_user) { create(:user, :admin, :confirmed, organization:) } + let!(:second_conflictive_user) { create(:user, :admin, :confirmed, organization:) } + + let!(:first_user_conflicts) { create_list(:conflict, 15, current_user: first_conflictive_user) } + let!(:second_user_conflicts) { create_list(:conflict, 15, current_user: second_conflictive_user) } + + before do + switch_to_host(organization.host) + login_as user, scope: :user + visit decidim_admin.root_path + click_on "Participants" + click_on "Verification conflicts" + end + + include_context "with filterable context" + + context "when listing conflicts" do + before { visit current_path } + + it_behaves_like "paginating a collection" do + let!(:collection) { create_list(:conflict, 50, current_user: first_conflictive_user) } + end + end + + context "when searching by current user name, nickname or email" do + before { visit current_path } + + it "can be searched by name" do + search_by_text(first_conflictive_user.name) + + expect(page).to have_content(first_conflictive_user.name) + expect(page).to have_no_content(second_conflictive_user.name) + end + + it "can be searched by nickname" do + search_by_text(first_conflictive_user.nickname) + + expect(page).to have_content(first_conflictive_user.name) + expect(page).to have_no_content(second_conflictive_user.name) + end + + it "can be searched by email" do + search_by_text(first_conflictive_user.email) + + expect(page).to have_content(first_conflictive_user.name) + expect(page).to have_no_content(second_conflictive_user.name) + end + end +end diff --git a/decidim-admin/spec/system/admin_manages_global_moderations_spec.rb b/decidim-admin/spec/system/admin_manages_global_moderations_spec.rb index 70cb195495800..5d2beb672e60a 100644 --- a/decidim-admin/spec/system/admin_manages_global_moderations_spec.rb +++ b/decidim-admin/spec/system/admin_manages_global_moderations_spec.rb @@ -92,7 +92,7 @@ end it_behaves_like "sorted moderations" do - let!(:reportables) { create_list(:dummy_resource, 17, component: current_component) } + let!(:reportables) { create_list(:dummy_resource, 27, component: current_component) } let(:moderations_link_text) { "Global moderations" } let(:moderations_link_in_admin_menu) { false } end diff --git a/decidim-admin/spec/system/admin_manages_taxonomies_spec.rb b/decidim-admin/spec/system/admin_manages_taxonomies_spec.rb new file mode 100644 index 0000000000000..c4b7be31a5ecd --- /dev/null +++ b/decidim-admin/spec/system/admin_manages_taxonomies_spec.rb @@ -0,0 +1,243 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Admin manages taxonomies" do + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, :confirmed, organization:) } + let(:attributes) { attributes_for(:taxonomy) } + + before do + switch_to_host(organization.host) + login_as(user, scope: :user) + visit decidim_admin.taxonomies_path + end + + it "displays the taxonomies" do + expect(page).to have_content("Taxonomies") + end + + context "when creating a new taxonomy" do + before do + click_on "New taxonomy" + fill_in_i18n( + :taxonomy_name, + "#taxonomy-name-tabs", + **attributes[:name].except("machine_translations") + ) + click_on "Create taxonomy" + end + + it "displays a success message" do + expect(page).to have_content("Taxonomy created successfully.") + end + + it "creates a new taxonomy" do + expect(page).to have_content(translated(attributes[:name])) + end + end + + context "when creating a new taxonomy with invalid data" do + before do + click_on "New taxonomy" + fill_in_i18n( + :taxonomy_name, + "#taxonomy-name-tabs", + en: "" + ) + click_on "Create taxonomy" + end + + it "displays an error message" do + expect(page).to have_content("cannot be blank") + end + end + + context "when creating a new taxonomy item" do + let!(:taxonomy) { create(:taxonomy, organization:) } + + before do + visit decidim_admin.taxonomies_path + click_edit_taxonomy + click_on "New item" + fill_in_i18n( + :taxonomy_item_name, + "#taxonomy-item_name-tabs", + **attributes[:name].except("machine_translations") + ) + click_on "Create item" + end + + it "creates a new taxonomy item" do + expect(page).to have_content(translated(attributes[:name])) + end + end + + context "when creating a new taxonomy item with invalid data" do + let!(:taxonomy) { create(:taxonomy, organization:) } + + before do + visit decidim_admin.taxonomies_path + click_edit_taxonomy + click_on "New item" + fill_in_i18n( + :taxonomy_item_name, + "#taxonomy-item_name-tabs", + en: "" + ) + click_on "Create item" + end + + it "displays an error message" do + expect(page).to have_content("cannot be blank") + end + end + + context "when creating a new taxonomy item for a parent taxonomy" do + let!(:taxonomy) { root_taxonomy } + let!(:root_taxonomy) { create(:taxonomy, organization:) } + let!(:parent_taxonomy) { create(:taxonomy, organization:, parent: root_taxonomy) } + + before do + visit decidim_admin.taxonomies_path + click_edit_taxonomy + click_on "New item" + fill_in_i18n( + :taxonomy_item_name, + "#taxonomy-item_name-tabs", + **attributes[:name].except("machine_translations") + ) + select translated(parent_taxonomy.name), from: "taxonomy_parent_id" + click_on "Create item" + end + + it "creates a new taxonomy item" do + within(".js-sortable tr", text: translated(parent_taxonomy.name)) do + expect(page).to have_content(translated(attributes[:name])) + end + end + end + + context "when editing a taxonomy" do + let!(:taxonomy) { create(:taxonomy, organization:) } + + before do + visit decidim_admin.taxonomies_path + click_edit_taxonomy + fill_in_i18n( + :taxonomy_name, + "#taxonomy-name-tabs", + **attributes[:name].except("machine_translations") + ) + click_on "Update" + end + + it "displays a success message" do + expect(page).to have_content("Taxonomy updated successfully.") + end + + it "updates the taxonomy" do + expect(page).to have_content(translated(translated(attributes[:name]))) + end + end + + context "when deleting a taxonomy" do + let!(:taxonomy) { create(:taxonomy, organization:) } + + before do + visit decidim_admin.taxonomies_path + click_delete_taxonomy + end + + it "displays a success message" do + expect(page).to have_content("Taxonomy successfully destroyed.") + end + + it "deletes the taxonomy" do + expect(page).to have_no_content(taxonomy.name) + end + end + + context "when reordering root taxonomies" do + let!(:taxonomy1) { create(:taxonomy, :with_children, children_count: 1, organization:) } + let!(:taxonomy2) { create(:taxonomy, :with_children, children_count: 2, organization:) } + let!(:taxonomy3) { create(:taxonomy, :with_children, children_count: 3, organization:) } + + before do + visit decidim_admin.taxonomies_path + end + + it "reorders the taxonomies", :js do + expect(page).to have_css(".js-sortable") + + within ".js-sortable" do + expect(page).to have_content(translated(taxonomy1.name)) + expect(page).to have_content(translated(taxonomy2.name)) + expect(page).to have_content(translated(taxonomy3.name)) + end + + page.execute_script(<<~JS) + var first = document.querySelector('.js-sortable tr:first-child'); + var last = document.querySelector('.js-sortable tr:last-child'); + last.parentNode.insertBefore(first, last.nextSibling); + var event = new Event('sortupdate', {bubbles: true}); + document.querySelector('.js-sortable').dispatchEvent(event); + JS + + within ".js-sortable tr:first-child td:nth-child(2)" do + expect(page).to have_content(translated(taxonomy2.name)) + end + within ".js-sortable tr:nth-child(2) td:nth-child(2)" do + expect(page).to have_content(translated(taxonomy3.name)) + end + within ".js-sortable tr:last-child td:nth-child(2)" do + expect(page).to have_content(translated(taxonomy1.name)) + end + + # Refresh the page to ensure the order is persisted + visit current_path + + within ".js-sortable tr:first-child td:nth-child(2)" do + expect(page).to have_content(translated(taxonomy2.name)) + end + within ".js-sortable tr:nth-child(2) td:nth-child(2)" do + expect(page).to have_content(translated(taxonomy3.name)) + end + within ".js-sortable tr:last-child td:nth-child(2)" do + expect(page).to have_content(translated(taxonomy1.name)) + end + end + end + + context "when multiple pages" do + let!(:taxonomies) { create_list(:taxonomy, 51, organization:) } + + before do + visit decidim_admin.taxonomies_path(page: 2) + end + + it "displays the pagination" do + expect(page).to have_content(translated(taxonomies[25].name)) + expect(page).to have_content("Drag over for previous page") + expect(page).to have_link("Prev") + + all(".js-sortable tr").last.drag_to(all(".js-sortable tr").first) + + expect(page).to have_content("Drag over for next page") + expect(page).to have_content(translated(taxonomies[25].name)) + expect(page).to have_no_content(translated(taxonomies[24].name)) + end + end + + def click_delete_taxonomy + within "tr", text: translated(taxonomy.name) do + accept_confirm { click_on "Delete" } + end + end + + def click_edit_taxonomy + within "tr", text: translated(taxonomy.name) do + click_on "Edit" + end + end +end diff --git a/decidim-admin/spec/system/participatory_space_private_user_spec.rb b/decidim-admin/spec/system/participatory_space_private_user_spec.rb index c3407c96840fb..9ff2a75bb9044 100644 --- a/decidim-admin/spec/system/participatory_space_private_user_spec.rb +++ b/decidim-admin/spec/system/participatory_space_private_user_spec.rb @@ -8,11 +8,9 @@ let!(:user) { create(:user, :admin, :confirmed, organization:) } let(:assembly) { create(:assembly, organization:, private_space: true) } + let!(:private_users) { create_list(:assembly_private_user, 26, privatable_to: assembly, user: create(:user, organization: assembly.organization)) } + before do - 21.times do |_i| - user = create(:user, organization:) - create(:assembly_private_user, user:, privatable_to: assembly) - end switch_to_host(organization.host) login_as user, scope: :user visit decidim_admin_assemblies.edit_assembly_path(assembly) diff --git a/decidim-admin/spec/system/space_admin_manages_global_moderations_spec.rb b/decidim-admin/spec/system/space_admin_manages_global_moderations_spec.rb index 6336aaf105de9..ff91bb1ef37f7 100644 --- a/decidim-admin/spec/system/space_admin_manages_global_moderations_spec.rb +++ b/decidim-admin/spec/system/space_admin_manages_global_moderations_spec.rb @@ -75,7 +75,7 @@ end it_behaves_like "sorted moderations" do - let!(:reportables) { create_list(:dummy_resource, 17, component: current_component) } + let!(:reportables) { create_list(:dummy_resource, 27, component: current_component) } let(:moderations_link_text) { "Global moderations" } let(:moderations_link_in_admin_menu) { false } end diff --git a/decidim-admin/spec/system/space_moderator_manages_global_moderations_spec.rb b/decidim-admin/spec/system/space_moderator_manages_global_moderations_spec.rb index 8854e9d60510a..15583647b28de 100644 --- a/decidim-admin/spec/system/space_moderator_manages_global_moderations_spec.rb +++ b/decidim-admin/spec/system/space_moderator_manages_global_moderations_spec.rb @@ -51,7 +51,7 @@ end it_behaves_like "sorted moderations" do - let!(:reportables) { create_list(:dummy_resource, 17, component: current_component) } + let!(:reportables) { create_list(:dummy_resource, 27, component: current_component) } let(:moderations_link_text) { "Global moderations" } let(:moderations_link_in_admin_menu) { false } end diff --git a/decidim-api/lib/decidim/api.rb b/decidim-api/lib/decidim/api.rb index 455c8759a6289..900b2043a7998 100644 --- a/decidim-api/lib/decidim/api.rb +++ b/decidim-api/lib/decidim/api.rb @@ -24,6 +24,10 @@ module Api 15 end + config_accessor :disclose_system_version do + %w(1 true yes).include?(ENV.fetch("DECIDIM_API_DISCLOSE_SYSTEM_VERSION", nil)) + end + # This declares all the types an interface or union can resolve to. This needs # to be done in order to be able to have them found. This is a shortcoming of # graphql-ruby and the way it deals with loading types, in combination with diff --git a/decidim-assemblies/config/locales/de.yml b/decidim-assemblies/config/locales/de.yml index 5c625d3dddc76..2f45e6937c19f 100644 --- a/decidim-assemblies/config/locales/de.yml +++ b/decidim-assemblies/config/locales/de.yml @@ -247,7 +247,7 @@ de: admin: Administrator collaborator: Mitarbeiter moderator: Moderator - valuator: Schätzer + valuator: Bewertende titles: assemblies: Gremien assemblies_types: Versammlungstypen diff --git a/decidim-assemblies/config/locales/pl.yml b/decidim-assemblies/config/locales/pl.yml index 5a77c998cbf0a..2df994f071720 100644 --- a/decidim-assemblies/config/locales/pl.yml +++ b/decidim-assemblies/config/locales/pl.yml @@ -453,6 +453,12 @@ pl: take_part: Dołącz index: promoted_assemblies: Wyróżnione zespoły + metadata: + children_item: + one: "%{count} zespół" + few: "%{count} zespoły" + many: "%{count} zespołów" + other: "%{count} zespołów" order_by_assemblies: assemblies: one: "%{count} zespół" diff --git a/decidim-assemblies/lib/decidim/assemblies/seeds.rb b/decidim-assemblies/lib/decidim/assemblies/seeds.rb index f23f716052a39..75a81a9fb9c79 100644 --- a/decidim-assemblies/lib/decidim/assemblies/seeds.rb +++ b/decidim-assemblies/lib/decidim/assemblies/seeds.rb @@ -8,6 +8,11 @@ class Seeds < Decidim::Seeds def call create_content_block! + taxonomy = create_taxonomy!(name: "Assembly Types", parent: nil) + 2.times do + create_taxonomy!(name: ::Faker::Lorem.word, parent: taxonomy) + end + 2.times do |_n| assembly = create_assembly! diff --git a/decidim-assemblies/spec/shared/manage_assembly_members_examples.rb b/decidim-assemblies/spec/shared/manage_assembly_members_examples.rb index 2688fa4e79a6b..337d494252b32 100644 --- a/decidim-assemblies/spec/shared/manage_assembly_members_examples.rb +++ b/decidim-assemblies/spec/shared/manage_assembly_members_examples.rb @@ -151,7 +151,7 @@ end context "when paginating" do - let!(:collection_size) { 20 } + let!(:collection_size) { 30 } let!(:collection) { create_list(:assembly_member, collection_size, assembly:) } let!(:resource_selector) { "#assembly_members tbody tr" } @@ -159,8 +159,8 @@ visit current_path end - it "lists 15 members per page by default" do - expect(page).to have_css(resource_selector, count: 15) + it "lists 25 members per page by default" do + expect(page).to have_css(resource_selector, count: 25) expect(page).to have_css("[data-pages] [data-page]", count: 2) click_on "Next" expect(page).to have_css("[data-pages] [data-page][aria-current='page']", text: "2") diff --git a/decidim-assemblies/spec/system/admin/assembly_moderator_manages_assembly_moderations_spec.rb b/decidim-assemblies/spec/system/admin/assembly_moderator_manages_assembly_moderations_spec.rb index 83335630cb36a..a6e4c89791a8f 100644 --- a/decidim-assemblies/spec/system/admin/assembly_moderator_manages_assembly_moderations_spec.rb +++ b/decidim-assemblies/spec/system/admin/assembly_moderator_manages_assembly_moderations_spec.rb @@ -19,6 +19,6 @@ it_behaves_like "manage moderations" it_behaves_like "sorted moderations" do - let!(:reportables) { create_list(:dummy_resource, 17, component: current_component) } + let!(:reportables) { create_list(:dummy_resource, 27, component: current_component) } end end diff --git a/decidim-blogs/spec/system/explore_posts_spec.rb b/decidim-blogs/spec/system/explore_posts_spec.rb index 44f3f2ea1e276..e847238e3b5df 100644 --- a/decidim-blogs/spec/system/explore_posts_spec.rb +++ b/decidim-blogs/spec/system/explore_posts_spec.rb @@ -48,15 +48,15 @@ end context "when paginating" do - let(:collection_size) { 15 } + let(:collection_size) { 25 } let!(:collection) { create_list(:post, collection_size, component:) } before do visit_component end - it "lists 10 resources per page by default" do - expect(page).to have_css("#blogs > a", count: 10) + it "lists 25 resources per page by default" do + expect(page).to have_css("#blogs > a", count: 25) expect(page).to have_css("[data-pages] [data-page]", count: 2) end end diff --git a/decidim-budgets/config/locales/en.yml b/decidim-budgets/config/locales/en.yml index ee8f2ac008192..4ad939e9f1a19 100644 --- a/decidim-budgets/config/locales/en.yml +++ b/decidim-budgets/config/locales/en.yml @@ -329,7 +329,7 @@ en: errors: budget_voting_rule_only_one: Only one voting rule must be enabled. budget_voting_rule_required: One voting rule is required. - geocoding_enabled: Geocoding enabled + geocoding_enabled: Maps enabled landing_page_content: Budgets landing page more_information_modal: More information modal projects_per_page: Projects per page diff --git a/decidim-budgets/spec/lib/decidim/budgets/admin/component_spec.rb b/decidim-budgets/spec/lib/decidim/budgets/admin/component_spec.rb index 2a15450d41692..3fb6b8bad91ed 100644 --- a/decidim-budgets/spec/lib/decidim/budgets/admin/component_spec.rb +++ b/decidim-budgets/spec/lib/decidim/budgets/admin/component_spec.rb @@ -49,7 +49,7 @@ def new_settings(name, data) Decidim::Component.build_settings(manifest, name, data, organization) end - describe "with geocoding enabled" do + describe "with maps enabled" do let(:geocoding_enabled) { true } # One budget rule must me activated let(:percent_enabled) { true } diff --git a/decidim-comments/app/cells/decidim/comments/comment/show.erb b/decidim-comments/app/cells/decidim/comments/comment/show.erb index b59d856d6e589..79128be3307c0 100644 --- a/decidim-comments/app/cells/decidim/comments/comment/show.erb +++ b/decidim-comments/app/cells/decidim/comments/comment/show.erb @@ -51,6 +51,13 @@ <% end %> <% end %> + <% if(extra_actions) %> + <% extra_actions.each do |action| %> +
  • + <%= link_to(*action) %> +
  • + <% end %> + <% end %>
    diff --git a/decidim-comments/app/cells/decidim/comments/comment_cell.rb b/decidim-comments/app/cells/decidim/comments/comment_cell.rb index 4577097807bc0..5c094456a0a76 100644 --- a/decidim-comments/app/cells/decidim/comments/comment_cell.rb +++ b/decidim-comments/app/cells/decidim/comments/comment_cell.rb @@ -68,6 +68,7 @@ def cache_hash hash.push(model.down_votes_count) hash.push(model.cache_key_with_version) hash.push(model.author.cache_key_with_version) + hash.push(extra_actions.to_s) @hash = hash.join(Decidim.cache_key_separator) end @@ -87,6 +88,27 @@ def order options[:order] || "older" end + def extra_actions + return @extra_actions if defined?(@extra_actions) && @extra_actions.present? + + @extra_actions = model.extra_actions_for(current_user) + return unless @extra_actions + + @extra_actions.map! do |action| + [ + "#{icon(action[:icon]) if action[:icon].present?}#{action[:label]}", + action[:url], + { + class: "dropdown__item" + } + ].tap do |link| + link[2][:method] = action[:method] if action[:method].present? + link[2][:remote] = action[:remote] if action[:remote].present? + link[2][:data] = action[:data] if action[:data].present? + end + end + end + def reply_id "comment#{model.id}-reply" end diff --git a/decidim-comments/app/models/decidim/comments/comment.rb b/decidim-comments/app/models/decidim/comments/comment.rb index 0763579c11d81..2d2919a5a53bd 100644 --- a/decidim-comments/app/models/decidim/comments/comment.rb +++ b/decidim-comments/app/models/decidim/comments/comment.rb @@ -206,6 +206,10 @@ def edited? Decidim::ActionLog.where(resource: self).exists?(["extra @> ?", Arel.sql("{\"edit\":true}")]) end + def extra_actions_for(current_user) + root_commentable.try(:actions_for_comment, self, current_user) + end + private def body_length diff --git a/decidim-comments/lib/decidim/comments/commentable.rb b/decidim-comments/lib/decidim/comments/commentable.rb index 889ba3de7d91d..5a7c7f4fffb40 100644 --- a/decidim-comments/lib/decidim/comments/commentable.rb +++ b/decidim-comments/lib/decidim/comments/commentable.rb @@ -67,6 +67,17 @@ def update_comments_count update_columns(comments_count:, updated_at: Time.current) end # rubocop:enable Rails/SkipsModelValidations + + # Public: Returns an array with extra actions available for a comment and a user. + # Returns an array of hashes with the following keys: + # - label: The label to be displayed in the UI. + # - url: The action to be performed when the user clicks the label. + # - method: The HTTP method to be used when performing the action (optional). + # - icon: The icon to be displayed next to the label (optional). + # - data: Any "data-*" attributes to be included in the link (optional). + def actions_for_comment(_comment, _current_user) + [] + end end end end diff --git a/decidim-comments/spec/cells/decidim/comments/comment_cell_spec.rb b/decidim-comments/spec/cells/decidim/comments/comment_cell_spec.rb index 4ff1ba7003f87..25e02f421e331 100644 --- a/decidim-comments/spec/cells/decidim/comments/comment_cell_spec.rb +++ b/decidim-comments/spec/cells/decidim/comments/comment_cell_spec.rb @@ -269,5 +269,28 @@ module Decidim::Comments end end end + + describe "#extra_actions" do + let(:current_user) { create(:user, :confirmed, organization: component.organization) } + let(:actions) do + [{ + label: "Poke comment", + url: "/poke" + }] + end + + before do + allow(commentable).to receive(:actions_for_comment).with(comment, current_user).and_return(actions) + end + + it "renders the extra actions" do + expect(subject).to have_link("Poke comment", href: "/poke") + end + + it "generates a cache hash with the action data" do + hash = my_cell.send(:cache_hash) + expect(hash).to include(actions.to_s) + end + end end end diff --git a/decidim-comments/spec/models/comment_spec.rb b/decidim-comments/spec/models/comment_spec.rb index 5dbbbdefbd36c..6d1d3ce37276d 100644 --- a/decidim-comments/spec/models/comment_spec.rb +++ b/decidim-comments/spec/models/comment_spec.rb @@ -322,6 +322,24 @@ module Comments end end end + + describe "#extra_actions_for" do + it "returns blank" do + expect(comment.extra_actions_for(author)).to eq([]) + end + + context "when the root commentable provides actions" do + let(:actions) { "Some actions" } + + before do + allow(commentable).to receive(:actions_for_comment).with(comment, author).and_return(actions) + end + + it "returns the actions" do + expect(comment.extra_actions_for(author)).to eq(actions) + end + end + end end end end diff --git a/decidim-comments/spec/system/admin_manages_comments_spec.rb b/decidim-comments/spec/system/admin_manages_comments_spec.rb index 716797f3f8b74..7064550a41a15 100644 --- a/decidim-comments/spec/system/admin_manages_comments_spec.rb +++ b/decidim-comments/spec/system/admin_manages_comments_spec.rb @@ -20,6 +20,6 @@ it_behaves_like "manage moderations" it_behaves_like "sorted moderations" do - let!(:reportables) { create_list(:dummy_resource, 17, component: current_component) } + let!(:reportables) { create_list(:dummy_resource, 27, component: current_component) } end end diff --git a/decidim-conferences/config/locales/de.yml b/decidim-conferences/config/locales/de.yml index aaa6a444f6e12..792a40e4f53e4 100644 --- a/decidim-conferences/config/locales/de.yml +++ b/decidim-conferences/config/locales/de.yml @@ -234,7 +234,7 @@ de: admin: Administrator collaborator: Mitarbeiter moderator: Moderator - valuator: Schätzer + valuator: Bewertende media_link: fields: date: Datum diff --git a/decidim-conferences/spec/shared/manage_media_links_examples.rb b/decidim-conferences/spec/shared/manage_media_links_examples.rb index 85f849b369afa..002402605ad4f 100644 --- a/decidim-conferences/spec/shared/manage_media_links_examples.rb +++ b/decidim-conferences/spec/shared/manage_media_links_examples.rb @@ -94,7 +94,7 @@ end context "when paginating" do - let!(:collection_size) { 15 } + let!(:collection_size) { 30 } let!(:collection) { create_list(:media_link, collection_size, conference:) } let!(:resource_selector) { "#media_links tbody tr" } @@ -102,8 +102,8 @@ visit current_path end - it "lists 10 media links per page by default" do - expect(page).to have_css(resource_selector, count: 10) + it "lists 25 media links per page by default" do + expect(page).to have_css(resource_selector, count: 25) expect(page).to have_css("[data-pages] [data-page]", count: 2) click_on "Next" expect(page).to have_css("[data-pages] [data-page][aria-current='page']", text: "2") diff --git a/decidim-conferences/spec/system/admin/conference_moderator_manages_conference_moderations_spec.rb b/decidim-conferences/spec/system/admin/conference_moderator_manages_conference_moderations_spec.rb index 0df5fd7ed2d69..76c0352ea166d 100644 --- a/decidim-conferences/spec/system/admin/conference_moderator_manages_conference_moderations_spec.rb +++ b/decidim-conferences/spec/system/admin/conference_moderator_manages_conference_moderations_spec.rb @@ -19,6 +19,6 @@ it_behaves_like "manage moderations" it_behaves_like "sorted moderations" do - let!(:reportables) { create_list(:dummy_resource, 17, component: current_component) } + let!(:reportables) { create_list(:dummy_resource, 27, component: current_component) } end end diff --git a/decidim-core/app/commands/decidim/create_omniauth_registration.rb b/decidim-core/app/commands/decidim/create_omniauth_registration.rb index 5ddd4457032c9..84cecd17b1502 100644 --- a/decidim-core/app/commands/decidim/create_omniauth_registration.rb +++ b/decidim-core/app/commands/decidim/create_omniauth_registration.rb @@ -36,6 +36,8 @@ def call trigger_omniauth_registration broadcast(:ok, @user) + rescue NeedTosAcceptance + broadcast(:add_tos_errors, @user) rescue ActiveRecord::RecordInvalid => e broadcast(:error, e.record) end @@ -73,10 +75,12 @@ def create_or_find_user file = url.open @user.avatar.attach(io: file, filename:) end + @user.tos_agreement = form.tos_agreement + @user.accepted_tos_version = Time.current + raise NeedTosAcceptance if @user.tos_agreement.blank? + @user.skip_confirmation! if verified_email - @user.tos_agreement = "1" @user.save! - @user.after_confirmation if verified_email end end @@ -129,11 +133,17 @@ def trigger_omniauth_registration name: form.name, nickname: form.normalized_nickname, avatar_url: form.avatar_url, - raw_data: form.raw_data + raw_data: form.raw_data, + tos_agreement: form.tos_agreement, + newsletter_notifications_at: form.newsletter_at, + accepted_tos_version: form.current_organization.tos_version ) end end + class NeedTosAcceptance < StandardError + end + class InvalidOauthSignature < StandardError end end diff --git a/decidim-core/app/controllers/concerns/decidim/headers/browser_feature_permissions.rb b/decidim-core/app/controllers/concerns/decidim/headers/browser_feature_permissions.rb new file mode 100644 index 0000000000000..0c94e3b66f7dc --- /dev/null +++ b/decidim-core/app/controllers/concerns/decidim/headers/browser_feature_permissions.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "active_support/concern" + +module Decidim + module Headers + # This module controls the "Permissions-Policy" header to define the + # specific sets of browser features that the website is able to use. + module BrowserFeaturePermissions + extend ActiveSupport::Concern + + included do + after_action :define_permissions_policy + end + + private + + def define_permissions_policy + return if response.media_type != "text/html" + return if response.headers["Permissions-Policy"].present? + + # Allow the "unload" and "onbeforeunload" events to be used at the + # current domain to prevent the user unintentionally changing the page + # when they have something important to do on the page, such as an + # unsaved form. + # + # This header is required because Chrome is phasing this event out due + # to some performance issues with the back/forward cache feature of the + # browser. However, currently there are no alternative events that would + # allow preventing accidental page reloads, tab closing or window + # closing. + # + # For further information, see: + # https://developer.chrome.com/docs/web-platform/deprecating-unload + # https://github.com/fergald/docs/blob/master/explainers/permissions-policy-unload.md + # + # Note that even Google suggests using the "beforeunload" for this + # particular use case: + # https://developer.chrome.com/docs/web-platform/page-lifecycle-api#events + # + # beforeunload + # Important: the beforeunload event should only be used to alert the + # user of unsaved changes. Once those changes are saved, the event + # should be removed. It should never be added unconditionally to the + # page, as doing so can hurt performance in some cases. + response.headers["Permissions-Policy"] = "unload=(self)" + end + end + end +end diff --git a/decidim-core/app/controllers/concerns/decidim/paginable.rb b/decidim-core/app/controllers/concerns/decidim/paginable.rb index 3d0795e5008b8..72cc699a0beb1 100644 --- a/decidim-core/app/controllers/concerns/decidim/paginable.rb +++ b/decidim-core/app/controllers/concerns/decidim/paginable.rb @@ -7,7 +7,7 @@ module Decidim module Paginable extend ActiveSupport::Concern - OPTIONS = [10, 20, 50, 100].freeze + OPTIONS = [25, 50, 100].freeze included do helper_method :per_page, :page_offset diff --git a/decidim-core/app/controllers/decidim/application_controller.rb b/decidim-core/app/controllers/decidim/application_controller.rb index 40bd3c53423eb..8b8cc44b564a2 100644 --- a/decidim-core/app/controllers/decidim/application_controller.rb +++ b/decidim-core/app/controllers/decidim/application_controller.rb @@ -16,6 +16,7 @@ class ApplicationController < ::DecidimController include NeedsTosAccepted include Headers::HttpCachingDisabler include Headers::ContentSecurityPolicy + include Headers::BrowserFeaturePermissions include ActionAuthorization include ForceAuthentication include SafeRedirect diff --git a/decidim-core/app/controllers/decidim/devise/omniauth_registrations_controller.rb b/decidim-core/app/controllers/decidim/devise/omniauth_registrations_controller.rb index cbe73973113d8..f3d6673ae5f7c 100644 --- a/decidim-core/app/controllers/decidim/devise/omniauth_registrations_controller.rb +++ b/decidim-core/app/controllers/decidim/devise/omniauth_registrations_controller.rb @@ -7,6 +7,7 @@ class OmniauthRegistrationsController < ::Devise::OmniauthCallbacksController include FormFactory include Decidim::DeviseControllers include Decidim::DeviseAuthenticationMethods + include NeedsTosAccepted def new @form = form(OmniauthRegistrationForm).from_params(params[:user]) @@ -36,6 +37,12 @@ def create render :new end + on(:add_tos_errors) do + set_flash_message :alert, :add_tos_errors if @form.valid_tos? + session[:verified_email] = verified_email + render :new_tos_fields + end + on(:error) do |user| if user.errors[:email] set_flash_message :alert, :failure, kind: @form.provider.capitalize, reason: t("decidim.devise.omniauth_registrations.create.email_already_exists") @@ -75,7 +82,7 @@ def user_params_from_oauth_hash end def verified_email - @verified_email ||= oauth_data.dig(:info, :email) + @verified_email ||= oauth_data.dig(:info, :email).presence || session[:verified_email] end def oauth_hash diff --git a/decidim-core/app/forms/decidim/omniauth_registration_form.rb b/decidim-core/app/forms/decidim/omniauth_registration_form.rb index 4ea5e5dc36ba5..0ce7fd15f2afa 100644 --- a/decidim-core/app/forms/decidim/omniauth_registration_form.rb +++ b/decidim-core/app/forms/decidim/omniauth_registration_form.rb @@ -11,6 +11,7 @@ class OmniauthRegistrationForm < Form attribute :provider, String attribute :uid, String attribute :tos_agreement, Boolean + attribute :newsletter, Boolean attribute :oauth_signature, String attribute :avatar_url, String attribute :raw_data, Hash @@ -21,11 +22,23 @@ class OmniauthRegistrationForm < Form validates :uid, presence: true def self.create_signature(provider, uid) - Digest::MD5.hexdigest("#{provider}-#{uid}-#{Rails.application.secrets.secret_key_base}") + Digest::MD5.hexdigest("#{provider}-#{uid}-#{Rails.application.secret_key_base}") end def normalized_nickname UserBaseEntity.nicknamize(nickname || name, organization: current_organization) end + + def newsletter_at + return nil unless newsletter? + + Time.current + end + + def valid_tos? + return if tos_agreement.nil? + + errors.add :tos_agreement, :accepted + end end end diff --git a/decidim-core/app/helpers/decidim/modal_helper.rb b/decidim-core/app/helpers/decidim/modal_helper.rb index bc44f2f0346da..9f30f439f4c09 100644 --- a/decidim-core/app/helpers/decidim/modal_helper.rb +++ b/decidim-core/app/helpers/decidim/modal_helper.rb @@ -34,5 +34,28 @@ def decidim_modal(opts = {}, &) end end end + + def decidim_drawer(opts = {}, &) + opts[:closable] = true unless opts.has_key?(:closable) + + button = if opts[:closable] == false + "" + else + content_tag( + :button, + "×".html_safe, + type: :button, + data: { dialog_close: opts[:id] || "", dialog_closable: "" }, + "aria-label": t("close_modal", scope: "decidim.shared.confirm_modal") + ) + end + + content = opts[:remote].nil? ? button + capture(&).html_safe : button + icon("loader-3-line") + content_tag(:div, id: opts[:id], data: { dialog: opts[:id] || "", drawer: true }.merge(opts[:data] || {})) do + content_tag(:div, id: "#{opts[:id]}-content", class: opts[:class]) do + content + end + end + end end end diff --git a/decidim-core/app/jobs/decidim/event_publisher_job.rb b/decidim-core/app/jobs/decidim/event_publisher_job.rb index 58598d04ba639..4241edf5a0bea 100644 --- a/decidim-core/app/jobs/decidim/event_publisher_job.rb +++ b/decidim-core/app/jobs/decidim/event_publisher_job.rb @@ -4,21 +4,26 @@ module Decidim class EventPublisherJob < ApplicationJob queue_as :events - attr_reader :resource + attr_reader :resource, :data def perform(event_name, data) @resource = data[:resource] + @data = data return unless data[:force_send] || notifiable? - EmailNotificationGeneratorJob.perform_later( - event_name, - data[:event_class], - data[:resource], - data[:followers], - data[:affected_users], - data[:extra] - ) + if event_type.include?(:email) + EmailNotificationGeneratorJob.perform_later( + event_name, + data[:event_class], + data[:resource], + data[:followers], + data[:affected_users], + data[:extra] + ) + end + + return unless event_type.include?(:notification) NotificationGeneratorJob.perform_later( event_name, @@ -32,6 +37,10 @@ def perform(event_name, data) private + def event_type + (data[:event_class].presence && data[:event_class].safe_constantize&.types) || [] + end + # Whether this event should be notified or not. Useful when you want the # event to decide based on the params. # diff --git a/decidim-core/app/models/decidim/organization.rb b/decidim-core/app/models/decidim/organization.rb index 9215737ddf4ef..5be5af8810402 100644 --- a/decidim-core/app/models/decidim/organization.rb +++ b/decidim-core/app/models/decidim/organization.rb @@ -35,6 +35,8 @@ class Organization < ApplicationRecord has_many :templates, foreign_key: "decidim_organization_id", class_name: "Decidim::Templates::Template", dependent: :destroy if defined? Decidim::Templates + has_many :taxonomies, foreign_key: "decidim_organization_id", class_name: "Decidim::Taxonomy", inverse_of: :organization, dependent: :destroy + # Users registration mode. Whether users can register or access the system. Does not affect users that access through Omniauth integrations. # enabled: Users registration and sign in are enabled (default value). # existing: Users cannot be registered in the system. Only existing users can sign in. diff --git a/decidim-core/app/models/decidim/taxonomization.rb b/decidim-core/app/models/decidim/taxonomization.rb new file mode 100644 index 0000000000000..db502a5b32bb5 --- /dev/null +++ b/decidim-core/app/models/decidim/taxonomization.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Decidim + # Represents the link between a taxonomy and any taxonomizable entity. + class Taxonomization < ApplicationRecord + belongs_to :taxonomy, + class_name: "Decidim::Taxonomy", + counter_cache: :taxonomizations_count, + inverse_of: :taxonomizations + + belongs_to :taxonomizable, polymorphic: true + + validate :prevent_root_taxonomization + + private + + def prevent_root_taxonomization + return unless taxonomy.root? + + errors.add(:taxonomy, :invalid) + end + end +end diff --git a/decidim-core/app/models/decidim/taxonomy.rb b/decidim-core/app/models/decidim/taxonomy.rb new file mode 100644 index 0000000000000..5619334c1ae6f --- /dev/null +++ b/decidim-core/app/models/decidim/taxonomy.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Decidim + # Represents a hierarchical classification used to organize various entities within the system. + # Taxonomies are primarily used to categorize and manage different aspects of participatory spaces, + # such as proposals, assemblies, and other components, within an organization. + class Taxonomy < ApplicationRecord + include Decidim::TranslatableResource + include Decidim::FilterableResource + include Decidim::Traceable + + before_create :set_default_weight + + translatable_fields :name + + belongs_to :organization, + foreign_key: "decidim_organization_id", + class_name: "Decidim::Organization", + inverse_of: :taxonomies + + belongs_to :parent, + class_name: "Decidim::Taxonomy", + counter_cache: :children_count, + optional: true + + has_many :children, + foreign_key: "parent_id", + class_name: "Decidim::Taxonomy", + dependent: :destroy + + has_many :taxonomizations, class_name: "Decidim::Taxonomization", dependent: :destroy + + validates :name, presence: true + validates :weight, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true + validate :validate_max_children_levels + + default_scope { order(:weight) } + + ransacker_i18n :name + + scope :search_by_name, lambda { |name| + where("name ->> ? ILIKE ?", I18n.locale.to_s, "%#{name}%") + } + + def self.log_presenter_class_for(_log) + Decidim::AdminLog::TaxonomyPresenter + end + + def self.ransackable_scopes(_auth_object = nil) + [:search_by_name] + end + + def translated_name + Decidim::TaxonomyPresenter.new(self).translated_name + end + + def root_taxonomy + @root_taxonomy ||= root? ? self : parent.root_taxonomy + end + + def parent_ids + @parent_ids ||= parent_id ? parent.parent_ids + [parent_id] : [] + end + + def root? = parent_id.nil? + + def removable? + true + end + + private + + def set_default_weight + return if weight.present? + + self.weight = Taxonomy.where(parent_id:).count + end + + def validate_max_children_levels + return unless parent_id + + errors.add(:base, :invalid) if parent_ids.size > 3 + end + end +end diff --git a/decidim-core/app/packs/src/decidim/confirm.js b/decidim-core/app/packs/src/decidim/confirm.js index c69446ca23ee6..f1ddfd3ca8ded 100644 --- a/decidim-core/app/packs/src/decidim/confirm.js +++ b/decidim-core/app/packs/src/decidim/confirm.js @@ -5,12 +5,14 @@ * it to gain control over the confirm events BEFORE rails-ujs is loaded. */ -import Rails from "@rails/ujs" +const { Rails } = window; class ConfirmDialog { constructor(sourceElement) { this.$modal = $("#confirm-modal"); - this.$source = sourceElement; + if (sourceElement) { + this.$source = $(sourceElement); + } this.$content = $("[data-confirm-modal-content]", this.$modal); this.$buttonConfirm = $("[data-confirm-ok]", this.$modal); this.$buttonCancel = $("[data-confirm-cancel]", this.$modal); @@ -29,22 +31,37 @@ class ConfirmDialog { this.$buttonConfirm.on("click", (ev) => { ev.preventDefault(); - window.Decidim.currentDialogs["confirm-modal"].close() - resolve(true); - this.$source.focus(); + this.close(() => resolve(true)); }); this.$buttonCancel.on("click", (ev) => { ev.preventDefault(); - window.Decidim.currentDialogs["confirm-modal"].close() - resolve(false); - this.$source.focus(); + this.close(() => resolve(false)); }); }); } + + close(afterClose) { + window.Decidim.currentDialogs["confirm-modal"].close() + afterClose(); + if (this.$source) { + this.$source.focus(); + } + } } +const runConfirm = (message, sourceElement = null) => new Promise((resolve) => { + const dialog = new ConfirmDialog(sourceElement); + dialog.confirm(message).then((answer) => { + let completed = true; + if (sourceElement) { + completed = Rails.fire(sourceElement, "confirm:complete", [answer]); + } + resolve(answer && completed); + }); +}); + // Override the default confirm dialog by Rails // See: // https://github.com/rails/rails/blob/fba1064153d8e2f4654df7762a7d3664b93e9fc8/actionview/app/assets/javascripts/rails-ujs/features/confirm.coffee @@ -64,37 +81,35 @@ const allowAction = (ev, element) => { return false; } - const dialog = new ConfirmDialog( - $(element) - ); - dialog.confirm(message).then((answer) => { - const completed = Rails.fire(element, "confirm:complete", [answer]); - if (answer && completed) { - // Allow the event to propagate normally and re-dispatch it without - // the confirm data attribute which the Rails internal method is - // checking. - $(element).data("confirm", null); - $(element).removeAttr("data-confirm"); - - // The submit button click events will not do anything if they are - // dispatched as is. In these cases, just submit the underlying form. - if (ev.type === "click" && - ( - $(element).is('button[type="submit"]') || - $(element).is('input[type="submit"]') - ) - ) { - $(element).parents("form").submit(); - } else { - let origEv = ev.originalEvent || ev; - let newEv = origEv; - if (typeof Event === "function") { - // Clone the event because otherwise some click events may not - // work properly when re-dispatched. - newEv = new origEv.constructor(origEv.type, origEv); - } - ev.target.dispatchEvent(newEv); + runConfirm(message, element).then((answer) => { + if (!answer) { + return; + } + + // Allow the event to propagate normally and re-dispatch it without + // the confirm data attribute which the Rails internal method is + // checking. + $(element).data("confirm", null); + $(element).removeAttr("data-confirm"); + + // The submit button click events will not do anything if they are + // dispatched as is. In these cases, just submit the underlying form. + if (ev.type === "click" && + ( + $(element).is('button[type="submit"]') || + $(element).is('input[type="submit"]') + ) + ) { + $(element).parents("form").submit(); + } else { + let origEv = ev.originalEvent || ev; + let newEv = origEv; + if (typeof Event === "function") { + // Clone the event because otherwise some click events may not + // work properly when re-dispatched. + newEv = new origEv.constructor(origEv.type, origEv); } + ev.target.dispatchEvent(newEv); } }); @@ -129,26 +144,31 @@ const handleDocumentEvent = (ev, matchSelectors) => { }); }; -document.addEventListener("click", (ev) => { - return handleDocumentEvent(ev, [ - Rails.linkClickSelector, - Rails.buttonClickSelector, - Rails.formInputClickSelector - ]); -}); -document.addEventListener("change", (ev) => { - return handleDocumentEvent(ev, [Rails.inputChangeSelector]); -}); -document.addEventListener("submit", (ev) => { - return handleDocumentEvent(ev, [Rails.formSubmitSelector]); -}); +// Note that this needs to be run **before** Rails.start() +export const initializeConfirm = () => { + document.addEventListener("click", (ev) => { + return handleDocumentEvent(ev, [ + Rails.linkClickSelector, + Rails.buttonClickSelector, + Rails.formInputClickSelector + ]); + }); + document.addEventListener("change", (ev) => { + return handleDocumentEvent(ev, [Rails.inputChangeSelector]); + }); + document.addEventListener("submit", (ev) => { + return handleDocumentEvent(ev, [Rails.formSubmitSelector]); + }); -// This is needed for the confirm dialog to work with Foundation Abide. -// Abide registers its own submit click listeners since Foundation 5.6.x -// which will be handled before the document listeners above. This would -// break the custom confirm functionality when used with Foundation Abide. -document.addEventListener("DOMContentLoaded", function() { - $(Rails.formInputClickSelector).on("click.confirm", (ev) => { - handleConfirm(ev, getMatchingEventTarget(ev, Rails.formInputClickSelector)); + // This is needed for the confirm dialog to work with Foundation Abide. + // Abide registers its own submit click listeners since Foundation 5.6.x + // which will be handled before the document listeners above. This would + // break the custom confirm functionality when used with Foundation Abide. + document.addEventListener("DOMContentLoaded", function() { + $(Rails.formInputClickSelector).on("click.confirm", (ev) => { + handleConfirm(ev, getMatchingEventTarget(ev, Rails.formInputClickSelector)); + }); }); -}); +}; + +export default runConfirm; diff --git a/decidim-core/app/packs/src/decidim/form_remote.js b/decidim-core/app/packs/src/decidim/form_remote.js index 40c5be91e958d..36fa646abf164 100644 --- a/decidim-core/app/packs/src/decidim/form_remote.js +++ b/decidim-core/app/packs/src/decidim/form_remote.js @@ -1,4 +1,4 @@ -import Rails from "@rails/ujs"; +const { Rails } = window; // Make the remote form submit buttons disabled when the form is being // submitted to avoid multiple submits. diff --git a/decidim-core/app/packs/src/decidim/impersonation.js b/decidim-core/app/packs/src/decidim/impersonation.js index d3de232db77df..d9ddd1ee503c7 100644 --- a/decidim-core/app/packs/src/decidim/impersonation.js +++ b/decidim-core/app/packs/src/decidim/impersonation.js @@ -15,7 +15,7 @@ $(() => { }, 1000); // Prevent reload when page is already unloading, otherwise it may cause infinite reloads. - window.addEventListener("beforeunload", () => { + window.addEventListener("pagehide", () => { clearInterval(exitInterval); return; }); diff --git a/decidim-core/app/packs/src/decidim/index.js b/decidim-core/app/packs/src/decidim/index.js index 2077d6cb82dc5..6f3fd45dc32a0 100644 --- a/decidim-core/app/packs/src/decidim/index.js +++ b/decidim-core/app/packs/src/decidim/index.js @@ -41,7 +41,6 @@ import "src/decidim/vizzs" import "src/decidim/responsive_horizontal_tabs" import "src/decidim/security/selfxss_warning" import "src/decidim/session_timeouter" -import "src/decidim/confirm" import "src/decidim/results_listing" import "src/decidim/impersonation" import "src/decidim/gallery" @@ -53,6 +52,7 @@ import "src/decidim/sticky_header" import "src/decidim/attachments" // local deps that require initialization +import ConfirmDialog, { initializeConfirm } from "src/decidim/confirm" import formDatePicker from "src/decidim/datepicker/form_datepicker" import Configuration from "src/decidim/configuration" import ExternalLink from "src/decidim/external_link" @@ -89,6 +89,7 @@ window.Decidim = window.Decidim || { addInputEmoji, EmojiButton, Dialogs, + ConfirmDialog, announceForScreenReader }; @@ -121,6 +122,8 @@ window.initFoundation = (element) => { }); }; +// Confirm initialization needs to happen before Rails.start() +initializeConfirm(); Rails.start() /** @@ -196,6 +199,8 @@ const initializer = (element = document) => { element.querySelectorAll("[data-toggle]").forEach((elem) => createToggle(elem)) element.querySelectorAll(".new_report").forEach((elem) => changeReportFormBehavior(elem)) + + document.dispatchEvent(new CustomEvent("decidim:loaded", { detail: { element } })); } // If no jQuery is used the Tribute feature used in comments to autocomplete diff --git a/decidim-core/app/packs/src/decidim/input_character_counter.js b/decidim-core/app/packs/src/decidim/input_character_counter.js index 221c22f27aa66..536f69bd00c62 100644 --- a/decidim-core/app/packs/src/decidim/input_character_counter.js +++ b/decidim-core/app/packs/src/decidim/input_character_counter.js @@ -74,7 +74,7 @@ export default class InputCharacterCounter { // If input is a hidden for WYSIWYG editor add it at the end if (this.$input.parent().is(".editor")) { - this.$input.parent().after(this.$target); + this.$input.parent().append(container); } else { const wrapper = document.createElement("span") wrapper.className = "input-character-counter" diff --git a/decidim-core/app/packs/src/decidim/session_timeouter.js b/decidim-core/app/packs/src/decidim/session_timeouter.js index 051d2f6dc2238..e4a6bc0f5c9c2 100644 --- a/decidim-core/app/packs/src/decidim/session_timeouter.js +++ b/decidim-core/app/packs/src/decidim/session_timeouter.js @@ -127,7 +127,7 @@ $(() => { setTimer(timeoutInSeconds); }); - window.addEventListener("beforeunload", () => { + window.addEventListener("pagehide", () => { clearInterval(exitInterval); return; }); diff --git a/decidim-core/app/packs/src/decidim/user_registrations.js b/decidim-core/app/packs/src/decidim/user_registrations.js index 2bf007c250372..ffe87e3e8419c 100644 --- a/decidim-core/app/packs/src/decidim/user_registrations.js +++ b/decidim-core/app/packs/src/decidim/user_registrations.js @@ -2,6 +2,7 @@ import PasswordToggler from "src/decidim/password_toggler"; $(() => { const $userRegistrationForm = $("#register-form"); + const $userOmniauthRegistrationForm = $("#omniauth-register-form"); const $userGroupFields = $userRegistrationForm.find(".user-group-fields"); const userPassword = document.querySelector(".user-password"); const inputSelector = 'input[name="user[sign_up_as]"]'; @@ -19,9 +20,11 @@ $(() => { const checkNewsletter = (check) => { $userRegistrationForm.find(newsletterSelector).prop("checked", check); + $userOmniauthRegistrationForm.find(newsletterSelector).prop("checked", check); $newsletterModal.data("continue", true); window.Decidim.currentDialogs["sign-up-newsletter-modal"].close() $userRegistrationForm.submit(); + $userOmniauthRegistrationForm.submit(); } setGroupFieldsVisibility($userRegistrationForm.find(`${inputSelector}:checked`).val()); @@ -42,6 +45,16 @@ $(() => { } }); + $userOmniauthRegistrationForm.on("submit", (event) => { + const newsletterChecked = $userOmniauthRegistrationForm.find(newsletterSelector); + if (!$newsletterModal.data("continue")) { + if (!newsletterChecked.prop("checked")) { + event.preventDefault(); + window.Decidim.currentDialogs["sign-up-newsletter-modal"].open() + } + } + }); + $newsletterModal.find("[data-check]").on("click", (event) => { checkNewsletter($(event.target).data("check")); }); diff --git a/decidim-core/app/packs/src/decidim/utilities/dom.js b/decidim-core/app/packs/src/decidim/utilities/dom.js new file mode 100644 index 0000000000000..c272c19e17bc9 --- /dev/null +++ b/decidim-core/app/packs/src/decidim/utilities/dom.js @@ -0,0 +1,148 @@ +import confirmAction from "src/decidim/confirm" +import { getMessages } from "src/decidim/i18n" + +const { Rails } = window; + +const createUnloadPreventer = () => { + const preventUnloadConditions = []; + + const confirmMessage = getMessages("confirmUnload") || "Are you sure you want to leave this page?"; + + const canUnload = (event) => !preventUnloadConditions.some((condition) => condition(event)); + + // TLDR: + // The beforeunload event does not work during tests due to the deprecation of + // the unload event and ChromeDriver automatically accepting these dialogs. + // --- + // + // Even when there are custom listeners on links and forms, the beforeunload + // event is to ensure that the user does not accidentally reload the page or + // close the browser or the tab. Note that this does not work during the tests + // with ChromeDriver due to the deprecation of the unload event and + // ChromeDriver automatically accepting these dialogs. For the time being, + // this should work when a real user interacts with the browser along with the + // "Permissions-Policy" header set by the backend. For more information about + // the header, see Decidim::Headers::BrowserFeaturePermissions). + const unloadListener = (event) => { + if (canUnload(event)) { + return; + } + + // According to: + // https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event + // + // > [...] best practice is to trigger the dialog by invoking + // > preventDefault() on the event object, while also setting returnValue to + // > support legacy cases. + event.preventDefault(); + event.returnValue = true; + }; + + // The beforeunload event listener has to be registered AFTER a user + // interaction which is why it is wrapped around the next click event that + // happens after the first unload listener was registered. Otherwise it might + // not work due to the deprecation of the unload APIs in Chromium based + // browsers and possibly in the web standards in the future. + // + // According to: + // https://developer.chrome.com/docs/web-platform/page-lifecycle-api#the_beforeunload_event + // + // > Never add a beforeunload listener unconditionally or use it as an + // > end-of-session signal. Only add it when a user has unsaved work, and + // > remove it as soon as that work has been saved. + const registerBeforeUnload = () => { + window.removeEventListener("click", registerBeforeUnload); + window.addEventListener("beforeunload", unloadListener); + }; + + const disableBeforeUnload = () => { + window.removeEventListener("click", registerBeforeUnload); + window.removeEventListener("beforeunload", unloadListener); + }; + + const linkClickListener = (ev) => { + const link = ev.target?.closest("a"); + if (!link) { + return; + } + + if (canUnload(ev)) { + disableBeforeUnload(); + document.removeEventListener("click", linkClickListener); + return; + } + + window.exitUrl = link.href; + + ev.preventDefault(); + ev.stopPropagation(); + + confirmAction(confirmMessage, link).then((answer) => { + if (!answer) { + return; + } + + disableBeforeUnload(); + document.removeEventListener("click", linkClickListener); + link.click(); + }); + }; + + const formSubmitListener = (ev) => { + const source = ev.target?.closest("form"); + if (!source) { + return; + } + + if (canUnload(ev)) { + disableBeforeUnload(); + document.removeEventListener("submit", formSubmitListener); + return; + } + + const button = source.closest(Rails.formSubmitSelector); + if (!button) { + return; + } + + ev.preventDefault(); + ev.stopImmediatePropagation(); + ev.stopPropagation(); + + confirmAction(confirmMessage, button).then((answer) => { + if (!answer) { + return; + } + + disableBeforeUnload(); + document.removeEventListener("submit", formSubmitListener); + source.submit(); + }); + }; + + const registerPreventUnloadListeners = () => { + window.addEventListener("click", registerBeforeUnload); + document.addEventListener("click", linkClickListener); + document.addEventListener("submit", formSubmitListener); + }; + + return { + addPreventCondition: (condition) => { + if (typeof condition !== "function") { + return; + } + + if (preventUnloadConditions.length < 1) { + // The unload listeners are global, so only the first call to this + // function should result to registering these listeners. + registerPreventUnloadListeners(); + } + + preventUnloadConditions.push(condition); + } + }; +}; + +const unloadPreventer = createUnloadPreventer(); + +export const preventUnload = (condition) => unloadPreventer.addPreventCondition(condition); diff --git a/decidim-core/app/packs/stylesheets/decidim/_modal.scss b/decidim-core/app/packs/stylesheets/decidim/_modal.scss index 508c8f825facd..d5f937761663a 100644 --- a/decidim-core/app/packs/stylesheets/decidim/_modal.scss +++ b/decidim-core/app/packs/stylesheets/decidim/_modal.scss @@ -42,4 +42,42 @@ @apply w-full md:w-auto; } } + + &[data-drawer] { + & > * { + @apply h-full max-h-full w-0 inset-0 translate-x-0 translate-y-0 right-0 left-auto max-w-[90%] lg:max-w-[900px] transition-none transition-[width] duration-500 overflow-visible; + } + + &[aria-hidden="false"] { + > * { + @apply w-[60%]; + } + } + + [data-dialog-container] { + @apply grid-cols-none; + + &.layout-content { + @apply bg-white; + + > .spinner-container { + @apply sticky h-screen; + } + } + } + + [data-dialog-closable] { + @apply absolute top-0 right-auto -left-10 p-4 py-2 bg-white rounded-bl; + } + + .form-defaults { + h1 { + @apply text-2xl font-semibold text-black my-10 mx-4 border-b border-gray border-b-[1px] pb-8; + + > .button { + @apply text-sm float-right; + } + } + } + } } diff --git a/decidim-core/app/packs/stylesheets/decidim/editor.scss b/decidim-core/app/packs/stylesheets/decidim/editor.scss index c1b64b9e4f83b..5035beaa02391 100644 --- a/decidim-core/app/packs/stylesheets/decidim/editor.scss +++ b/decidim-core/app/packs/stylesheets/decidim/editor.scss @@ -21,7 +21,7 @@ } .editor-container { - @apply editor-props editor-suggestions-props flex flex-col mb-6 border editor-border; + @apply editor-props editor-suggestions-props flex flex-col mt-4 border editor-border; &.editor-disabled { .editor-input .ProseMirror { diff --git a/decidim-core/app/presenters/decidim/admin_log/taxonomy_presenter.rb b/decidim-core/app/presenters/decidim/admin_log/taxonomy_presenter.rb new file mode 100644 index 0000000000000..04f19f710da53 --- /dev/null +++ b/decidim-core/app/presenters/decidim/admin_log/taxonomy_presenter.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Decidim + module AdminLog + # This class holds the logic to present a `Decidim::Taxonomy` + # for the `AdminLog` log. + # + # Usage should be automatic and you should not need to call this class + # directly, but here is an example: + # + # action_log = Decidim::ActionLog.last + # view_helpers # => this comes from the views + # TaxonomyPresenter.new(action_log, view_helpers).present + class TaxonomyPresenter < Decidim::Log::BasePresenter + private + + def diff_fields_mapping + { + name: :i18n, + parent_id: :taxonomy + } + end + + def action_string + case action + when "create", "delete", "update" + if parent_name.present? + "decidim.admin_log.taxonomy.#{action}_with_parent" + else + "decidim.admin_log.taxonomy.#{action}" + end + else + super + end + end + + def i18n_labels_taxonomy + "activemodel.attributes.taxonomy" + end + + def i18n_params + super.merge( + parent_taxonomy: h.translated_attribute(parent_name) + ) + end + + def parent_name + action_log.extra.dig("extra", "parent_name") + end + end + end +end diff --git a/decidim-core/app/presenters/decidim/log/value_types/taxonomy_presenter.rb b/decidim-core/app/presenters/decidim/log/value_types/taxonomy_presenter.rb new file mode 100644 index 0000000000000..fa928aafb881f --- /dev/null +++ b/decidim-core/app/presenters/decidim/log/value_types/taxonomy_presenter.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Decidim + module Log + module ValueTypes + # This class presents the given value as a Decidim::Taxonomy. Check + # the `DefaultPresenter` for more info on how value + # presenters work. + class TaxonomyPresenter < DefaultPresenter + # Public: Presents the value as a Decidim::Taxonomy. If the taxonomy can + # be found, it shows its title. Otherwise it shows its ID. + # + # Returns an HTML-safe String. + def present + return unless value + return h.translated_attribute(taxonomy.name) if taxonomy + + I18n.t("not_found", id: value, scope: "decidim.log.value_types.taxonomy_presenter") + end + + private + + def taxonomy + @taxonomy ||= Decidim::Taxonomy.find_by(id: value) + end + end + end + end +end diff --git a/decidim-core/app/presenters/decidim/taxonomy_presenter.rb b/decidim-core/app/presenters/decidim/taxonomy_presenter.rb new file mode 100644 index 0000000000000..27b636cbe10eb --- /dev/null +++ b/decidim-core/app/presenters/decidim/taxonomy_presenter.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Decidim + # + # Decorator for taxonomies. + # + class TaxonomyPresenter < SimpleDelegator + include Decidim::TranslationsHelper + + def translated_name + @translated_name ||= translated_attribute name + end + end +end diff --git a/decidim-core/app/views/decidim/devise/omniauth_registrations/new.html.erb b/decidim-core/app/views/decidim/devise/omniauth_registrations/new.html.erb index 9565e5089ecd9..67d845f426072 100644 --- a/decidim-core/app/views/decidim/devise/omniauth_registrations/new.html.erb +++ b/decidim-core/app/views/decidim/devise/omniauth_registrations/new.html.erb @@ -13,7 +13,7 @@ <%= form_required_explanation %> - <%= decidim_form_for(@form, namespace: "registration", as: resource_name, url: omniauth_registrations_path(resource_name)) do |f| %> + <%= decidim_form_for(@form, namespace: "registration", as: resource_name, url: omniauth_registrations_path(resource_name), html: { id: "omniauth-register-form" }) do |f| %>
    <%= f.text_field :name, help_text: t("decidim.devise.omniauth_registrations.new.username_help"), autocomplete: "name", placeholder: "John Doe" %> @@ -22,6 +22,8 @@ <%= f.email_field :email, autocomplete: "email", placeholder: t("placeholder_email", scope: "decidim.devise.shared") %> + <%= render partial: "decidim/devise/shared/tos_fields", locals: { form: f, user: :user } %> + <%= f.hidden_field :uid %> <%= f.hidden_field :provider %> <%= f.hidden_field :oauth_signature %> @@ -35,3 +37,5 @@
    <% end %> <% end %> + +<%= render "decidim/devise/shared/newsletter_modal" %> diff --git a/decidim-core/app/views/decidim/devise/omniauth_registrations/new_tos_fields.html.erb b/decidim-core/app/views/decidim/devise/omniauth_registrations/new_tos_fields.html.erb new file mode 100644 index 0000000000000..85dbe1cfcf78e --- /dev/null +++ b/decidim-core/app/views/decidim/devise/omniauth_registrations/new_tos_fields.html.erb @@ -0,0 +1,29 @@ +<%= render layout: "layouts/decidim/shared/layout_center" do %> +
    +

    <%= t("decidim.devise.omniauth_registrations.new_tos_fields.sign_up_title") %>

    +
    + + <%= decidim_form_for(@form, namespace: "registration", as: resource_name, url: omniauth_registrations_path(resource_name), html: { id: "omniauth-register-form" }) do |f| %> + +
    + <%= f.hidden_field :name %> + <%= f.hidden_field :nickname %> + <%= f.hidden_field :email %> + + <%= render partial: "decidim/devise/shared/tos_fields", locals: { form: f, user: :user } %> + + <%= f.hidden_field :uid %> + <%= f.hidden_field :provider %> + <%= f.hidden_field :oauth_signature %> +
    + +
    + +
    + <% end %> +<% end %> + +<%= render "decidim/devise/shared/newsletter_modal" %> diff --git a/decidim-core/app/views/decidim/devise/registrations/new.html.erb b/decidim-core/app/views/decidim/devise/registrations/new.html.erb index 17da235460ef2..fa1cb1cb85bd0 100644 --- a/decidim-core/app/views/decidim/devise/registrations/new.html.erb +++ b/decidim-core/app/views/decidim/devise/registrations/new.html.erb @@ -40,22 +40,7 @@ <%= render partial: "decidim/account/password_fields", locals: { form: f, user: :user } %> -
    -

    <%= t("decidim.devise.registrations.new.tos_title") %>

    - -
    - <% terms_of_service_summary_content_blocks.each do |content_block| %> - <%= cell content_block.manifest.cell, content_block %> - <% end %> -
    - - <%= f.check_box :tos_agreement, label: t("decidim.devise.registrations.new.tos_agreement", link: link_to(t("decidim.devise.registrations.new.terms"), page_path("terms-of-service"))), label_options: { class: "form__wrapper-checkbox-label" } %> -
    - -
    -

    <%= t("decidim.devise.registrations.new.newsletter_title") %>

    - <%= f.check_box :newsletter, label: t("decidim.devise.registrations.new.newsletter"), checked: @form.newsletter, label_options: { class: "form__wrapper-checkbox-label" } %> -
    + <%= render partial: "decidim/devise/shared/tos_fields", locals: { form: f, user: :user } %>
    - <% if defined?(Decidim::Templates) %> + <% if Decidim.module_installed?(:templates) %> <%= render "decidim/templates/admin/proposal_answer_templates/template_chooser", form: f %> <% end %> diff --git a/decidim-proposals/app/views/decidim/proposals/admin/proposals/_bulk-actions.html.erb b/decidim-proposals/app/views/decidim/proposals/admin/proposals/_bulk-actions.html.erb index 431f7fcba2917..9a9f29e9dc0fe 100644 --- a/decidim-proposals/app/views/decidim/proposals/admin/proposals/_bulk-actions.html.erb +++ b/decidim-proposals/app/views/decidim/proposals/admin/proposals/_bulk-actions.html.erb @@ -35,3 +35,6 @@ <%= render partial: "decidim/proposals/admin/proposals/bulk_actions/merge" %> <%= render partial: "decidim/proposals/admin/proposals/bulk_actions/split" %> <%= render partial: "decidim/proposals/admin/proposals/bulk_actions/publish_answers" %> +<% if templates_available? %> + <%= render partial: "decidim/proposals/admin/proposals/bulk_actions/apply_answer_template", locals: { templates: find_templates_for_select(current_component) } %> +<% end %> diff --git a/decidim-proposals/app/views/decidim/proposals/admin/proposals/_proposal-tr.html.erb b/decidim-proposals/app/views/decidim/proposals/admin/proposals/_proposal-tr.html.erb index b575744ae6274..ab77ab38fc54c 100644 --- a/decidim-proposals/app/views/decidim/proposals/admin/proposals/_proposal-tr.html.erb +++ b/decidim-proposals/app/views/decidim/proposals/admin/proposals/_proposal-tr.html.erb @@ -1,4 +1,6 @@ -> +> <%= check_box_tag "proposal_ids_s[]", proposal.id, false, class: "js-check-all-proposal js-proposal-list-check js-proposal-id-#{proposal.id}" %>
    diff --git a/decidim-proposals/app/views/decidim/proposals/admin/proposals/bulk_actions/_apply_answer_template.html.erb b/decidim-proposals/app/views/decidim/proposals/admin/proposals/bulk_actions/_apply_answer_template.html.erb new file mode 100644 index 0000000000000..c2aec6951e1b6 --- /dev/null +++ b/decidim-proposals/app/views/decidim/proposals/admin/proposals/bulk_actions/_apply_answer_template.html.erb @@ -0,0 +1,22 @@ +
    + <%= form_tag(update_multiple_answers_proposals_path, method: :post, id: "js-form-apply-answer-template", class: "form form-defaults flex items-center gap-x-2") do %> +
    + <% proposals.each do |proposal| %> + <%= check_box_tag "proposal_ids[]", proposal.id, false, class: "js-check-all-proposal js-proposal-id-#{proposal.id}" %> + <% end %> +
    + <%= hidden_field_tag :template_id %> + + <%= bulk_templates_select( + current_component, + templates.present? ? + t("apply_answer_template", scope: "decidim.proposals.admin.proposals.index") : + t("no_templates_available", scope: "decidim.proposals.admin.proposals.index"), + id: "template_id_select" + ) %> + + <%= submit_tag(t("decidim.proposals.admin.proposals.index.update"), id: "js-submit-apply-answer-template", class: "button button__sm button__secondary small button--simple float-left") %> + + + <% end %> +
    diff --git a/decidim-proposals/app/views/decidim/proposals/admin/proposals/bulk_actions/_dropdown.html.erb b/decidim-proposals/app/views/decidim/proposals/admin/proposals/bulk_actions/_dropdown.html.erb index 6b014272cb311..d326fbabf9fb6 100644 --- a/decidim-proposals/app/views/decidim/proposals/admin/proposals/bulk_actions/_dropdown.html.erb +++ b/decidim-proposals/app/views/decidim/proposals/admin/proposals/bulk_actions/_dropdown.html.erb @@ -49,6 +49,13 @@ <% end %> + <% if templates_available? %> +
  • + +
  • + <% end %>