diff --git a/Gemfile.lock b/Gemfile.lock index 4bc13f4..7f20cc1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -301,14 +301,6 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - carrierwave (2.2.6) - activemodel (>= 5.0.0) - activesupport (>= 5.0.0) - addressable (~> 2.6) - image_processing (~> 1.1) - marcel (~> 1.0.0) - mini_mime (>= 0.1.3) - ssrf_filter (~> 1.0) cells (4.1.8) declarative-builder (~> 0.2.0) declarative-option (< 0.2.0) @@ -409,23 +401,13 @@ GEM activemodel (>= 3.2) mime-types (>= 1.0) flamegraph (0.9.5) - fog-aws (3.21.0) - fog-core (~> 2.1) - fog-json (~> 1.1) - fog-xml (~> 0.1) fog-core (2.6.0) builder excon (~> 1.0) formatador (>= 0.2, < 2.0) mime-types - fog-json (1.2.0) - fog-core - multi_json (~> 1.10) fog-local (0.8.0) fog-core (>= 1.27, < 3.0) - fog-xml (0.1.4) - fog-core - nokogiri (>= 1.5.11, < 2.0.0) formatador (1.1.0) foundation_rails_helper (4.0.1) actionpack (>= 4.1, < 7.1) @@ -532,7 +514,6 @@ GEM mini_mime (1.1.5) minitest (5.25.1) msgpack (1.7.5) - multi_json (1.15.0) multi_xml (0.7.1) bigdecimal (~> 3.1) net-http (0.5.0) @@ -813,7 +794,6 @@ GEM spring-watcher-listen (2.1.0) listen (>= 2.7, < 4.0) spring (>= 4) - ssrf_filter (1.1.2) stackprof (0.2.26) stringio (3.1.2) temple (0.10.3) @@ -892,7 +872,6 @@ DEPENDENCIES brakeman (~> 6.1) bullet byebug (~> 11.0) - carrierwave dalli decidim-accountability! decidim-admin! @@ -914,7 +893,6 @@ DEPENDENCIES decidim-verifications! dotenv-rails (~> 2.7) flamegraph - fog-aws letter_opener_web (~> 2.0) listen (~> 3.1) memory_profiler diff --git a/app/commands/admin/reorder_scopes.rb b/app/commands/admin/reorder_scopes.rb new file mode 100644 index 0000000..ea2a3e4 --- /dev/null +++ b/app/commands/admin/reorder_scopes.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Admin + class ReorderScopes < Decidim::Command + def initialize(organization, scope, ids) + @organization = organization + @scope = scope + @ids = ids + end + + def call + return broadcast(:invalid) if @ids.blank? + + reorder_scopes + broadcast(:ok) + end + + def collection + @collection ||= Decidim::Scope.where(id: @ids, organization: @organization) + end + + def reorder_scopes + transaction do + set_new_weights + end + end + + def set_new_weights + @ids.each do |id| + current_scope = collection.find { |block| block.id == id.to_i } + next if current_scope.blank? + + current_scope.update!(weight: @ids.index(id) + 1) + end + end + end +end diff --git a/app/forms/decidim/user_interest_scope_form.rb b/app/forms/decidim/user_interest_scope_form.rb new file mode 100644 index 0000000..8ac0947 --- /dev/null +++ b/app/forms/decidim/user_interest_scope_form.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Decidim + # The form object that handles the data behind updating a user's + # interests in their profile page. + class UserInterestScopeForm < Form + mimic :scope + + attribute :name, JsonbAttributes + attribute :checked, Boolean + attribute :children, [UserInterestScopeForm] + + def map_model(model_hash) + scope = model_hash[:scope] + user = model_hash[:user] + + self.id = scope.id + self.name = scope.name + self.checked = user.interested_scopes_ids.include?(scope.id) + self.children = scope.children.sort_by(&:weight).map do |children_scope| + UserInterestScopeForm.from_model(scope: children_scope, user:) + end + end + end +end diff --git a/app/forms/decidim/user_interests_form.rb b/app/forms/decidim/user_interests_form.rb new file mode 100644 index 0000000..89da2e2 --- /dev/null +++ b/app/forms/decidim/user_interests_form.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Decidim + # The form object that handles the data behind updating a user's + # interests in their profile page. + class UserInterestsForm < Form + mimic :user + + attribute :scopes, [UserInterestScopeForm] + + def newsletter_notifications_at + return unless newsletter_notifications + + Time.current + end + + def map_model(user) + self.scopes = user.organization.scopes.top_level.sort_by(&:weight).map do |scope| + UserInterestScopeForm.from_model(scope:, user:) + end + end + end +end diff --git a/app/packs/entrypoints/application.js b/app/packs/entrypoints/application.js new file mode 100644 index 0000000..1d2bfce --- /dev/null +++ b/app/packs/entrypoints/application.js @@ -0,0 +1,21 @@ +/* eslint no-console:0 */ +// This file is automatically compiled by Webpack, along with any other files +// present in this directory. You're encouraged to place your actual application logic in +// a relevant structure within app/packs and only use these pack files to reference +// that code so it'll be compiled. +// +// To reference this file, add <%= javascript_pack_tag 'application' %> to the appropriate +// layout file, like app/views/layouts/application.html.erb + +// Uncomment to copy all static images under ../images to the output folder and reference +// them with the image_pack_tag helper in views (e.g <%= image_pack_tag 'rails.png' %>) +// or the `imagePath` JavaScript helper below. +// +// const images = require.context('../images', true) +// const imagePath = (name) => images(name, true) + +// Activate Active Storage +// import * as ActiveStorage from "@rails/activestorage" +// ActiveStorage.start() + +import "src/decidim/admin/reorder_scopes"; diff --git a/app/packs/entrypoints/decidim_custom_scopes.scss b/app/packs/entrypoints/decidim_custom_scopes.scss new file mode 100644 index 0000000..1755399 --- /dev/null +++ b/app/packs/entrypoints/decidim_custom_scopes.scss @@ -0,0 +1 @@ +@import "stylesheets/decidim/scopes/scopes-custom.scss"; diff --git a/app/packs/src/decidim/admin/reorder_scopes.js b/app/packs/src/decidim/admin/reorder_scopes.js new file mode 100644 index 0000000..110bd4a --- /dev/null +++ b/app/packs/src/decidim/admin/reorder_scopes.js @@ -0,0 +1,19 @@ +$(document).ready(() => { + let activeBlocks = Array.prototype.slice.call(document.querySelectorAll(".js-list-scopes li")); + const defaultOrder = activeBlocks.map(block => block.dataset.scopeId); + + document.addEventListener("dragend", () => { + activeBlocks = Array.prototype.slice.call(document.querySelectorAll(".js-list-scopes li")); + let activeBlocksManifestName = activeBlocks.map(block => block.dataset.scopeId); + let sortUrl = document.querySelector(".js-list-scopes").dataset.sortUrl; + + if (JSON.stringify(activeBlocksManifestName) === JSON.stringify(defaultOrder)) { return; } + + $.ajax({ + method: "PUT", + url: sortUrl, + contentType: "application/json", + data: JSON.stringify({ manifests: activeBlocksManifestName }) + }); + }) +}); diff --git a/app/packs/stylesheets/decidim/scopes/_scopes-custom.scss b/app/packs/stylesheets/decidim/scopes/_scopes-custom.scss new file mode 100644 index 0000000..6a15221 --- /dev/null +++ b/app/packs/stylesheets/decidim/scopes/_scopes-custom.scss @@ -0,0 +1,32 @@ +.draggable-list .draggable-content { + cursor: move; + justify-content: space-between; + align-items: center; + font-weight: 600; + border: none !important; + background-color: transparent !important; + padding: 0.5rem 1rem; +} + +.custom-text { + color: black; +} + +.custom-list { + border: 1px solid lightgray !important; + margin: 0.4rem +} +.draggable-content, .draggable-content__title, .draggable-content__icons { + display:flex; + align-items: center; +} +.draggable-content__title > a { + margin-left: 0.5rem; +} +.draggable-content__icons { + justify-content: space-between; + width: 8%; +} +.action-icon--remove { + fill: #e66a5d; +} diff --git a/app/views/decidim/admin/scopes/index.html.erb b/app/views/decidim/admin/scopes/index.html.erb new file mode 100644 index 0000000..685abd5 --- /dev/null +++ b/app/views/decidim/admin/scopes/index.html.erb @@ -0,0 +1,65 @@ +<% add_decidim_page_title(t("decidim.admin.scopes.titles.scopes")) %> +
+
+
+
+
+

+ <% if parent_scope %> + <%= scope_breadcrumbs(parent_scope).join(" - ").html_safe %> <%= link_to t("actions.add", scope: "decidim.admin"), new_scope_scope_path(parent_scope), class: "button tiny button--title" if allowed_to? :create, :scope %><%= link_to t("actions.edit", scope: "decidim.admin"), edit_scope_path(parent_scope), class: "button tiny button--title" if allowed_to? :edit, :scope, scope: parent_scope %> + <% else %> + <%= t "decidim.admin.scopes.titles.scopes" %> <%= link_to t("actions.add", scope: "decidim.admin"), new_scope_path, class: "button tiny button--title" if allowed_to? :create, :scope %> + <% end %> +

+
+
+ <% if @scopes.any? %> +
+ + + + + + + + +
<%= t("models.scope.fields.name", scope: "decidim.admin") %><%= t("models.scope.fields.scope_type", scope: "decidim.admin") %>
+ + +
    "> + <% @scopes.each do |scope| %> +
  • +
    +
    + <%= icon "drag-move-2-fill", class: "icon--small", role: "img", "aria-hidden": true %> + <%= link_to translated_attribute(scope.name), scope_scopes_path(scope), class:"custom-text" %> +
    +
    + <%= icon_link_to "zoom-in-line", scope_scopes_path(scope), t("actions.browse", scope: "decidim.admin"), class: "action-icon--browse", method: :get, data: {} %> + + <% if allowed_to? :update, :scope, scope: scope %> + <%= icon_link_to "pencil-line", [:edit, scope], t("actions.edit", scope: "decidim.admin"), class: "action-icon--edit", method: :get, data: {} %> + <% end %> + + <% if allowed_to? :destroy, :scope, scope: scope %> + <%= icon_link_to "close-circle-line", scope, t("actions.destroy", scope: "decidim.admin"), class: "action-icon--remove", method: :delete, data: { confirm: t("actions.confirm_destroy", scope: "decidim.admin") } %> + <% end %> +
    +
    +
  • + <% end %> +
+ +
+
+ <% else %> +

<%= t("decidim.admin.scopes.no_scopes") %>

+ <% end %> +
+
+
+
+
+ +<%= append_stylesheet_pack_tag "decidim_custom_scopes", media: "all" %> +<%= append_javascript_pack_tag 'application' %> diff --git a/config/application.rb b/config/application.rb index b553bc9..4a29bd4 100644 --- a/config/application.rb +++ b/config/application.rb @@ -26,5 +26,11 @@ class Application < Rails::Application # # config.time_zone = "Central Time (US & Canada)" # config.eager_load_paths << Rails.root.join("extras") + config.after_initialize do + # extends + require "extends/controllers/decidim/admin/scopes_controller_extends" + require "extends/controllers/decidim/scopes_controller_extends" + require "extends/helpers/decidim/check_boxes_tree_helper_extends" + end end end diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml new file mode 100644 index 0000000..08c8f56 --- /dev/null +++ b/config/i18n-tasks.yml @@ -0,0 +1,185 @@ +# i18n-tasks finds and manages missing and unused translations: https://github.com/glebm/i18n-tasks + +# The "main" locale. +base_locale: en +## All available locales are inferred from the data by default. Alternatively, specify them explicitly: +# locales: [es, fr] +## Reporting locale, default: en. Available: en, ru. +# internal_locale: en + +# Read and write translations. +data: + ## Translations are read from the file system. Supported format: YAML, JSON. + ## Provide a custom adapter: + # adapter: I18n::Tasks::Data::FileSystem + + # Locale files or `Dir.glob` patterns where translations are read from: + read: + ## Default: + # - config/locales/%{locale}.yml + ## More files: + # - config/locales/**/*.%{locale}.yml + + # Locale files to write new keys to, based on a list of key pattern => file rules. Matched from top to bottom: + # `i18n-tasks normalize -p` will force move the keys according to these rules + write: + ## For example, write devise and simple form keys to their respective files: + # - ['{devise, simple_form}.*', 'config/locales/\1.%{locale}.yml'] + ## Catch-all default: + # - config/locales/%{locale}.yml + + # External locale data (e.g. gems). + # This data is not considered unused and is never written to. + external: + ## Example (replace %#= with %=): + # - "<%#= %x[bundle info vagrant --path].chomp %>/templates/locales/%{locale}.yml" + + ## Specify the router (see Readme for details). Valid values: conservative_router, pattern_router, or a custom class. + # router: conservative_router + + yaml: + write: + # do not wrap lines at 80 characters + line_width: -1 + + ## Pretty-print JSON: + # json: + # write: + # indent: ' ' + # space: ' ' + # object_nl: "\n" + # array_nl: "\n" + +# Find translate calls +search: + ## Paths or `Find.find` patterns to search in: + # paths: + # - app/ + + ## Root directories for relative keys resolution. + # relative_roots: + # - app/controllers + # - app/helpers + # - app/mailers + # - app/presenters + # - app/views + + ## Directories where method names which should not be part of a relative key resolution. + # By default, if a relative translation is used inside a method, the name of the method will be considered part of the resolved key. + # Directories listed here will not consider the name of the method part of the resolved key + # + # relative_exclude_method_name_paths: + # - + + ## Files or `File.fnmatch` patterns to exclude from search. Some files are always excluded regardless of this setting: + ## *.jpg *.jpeg *.png *.gif *.svg *.ico *.eot *.otf *.ttf *.woff *.woff2 *.pdf *.css *.sass *.scss *.less + ## *.yml *.json *.zip *.tar.gz *.swf *.flv *.mp3 *.wav *.flac *.webm *.mp4 *.ogg *.opus *.webp *.map *.xlsx + exclude: + - app/assets/images + - app/assets/fonts + - app/assets/videos + - app/assets/builds + + ## Alternatively, the only files or `File.fnmatch patterns` to search in `paths`: + ## If specified, this settings takes priority over `exclude`, but `exclude` still applies. + # only: ["*.rb", "*.html.slim"] + + ## If `strict` is `false`, guess usages such as t("categories.#{category}.title"). The default is `true`. + # strict: true + + ## Allows adding ast_matchers for finding translations using the AST-scanners + ## The available matchers are: + ## - RailsModelMatcher + ## Matches ActiveRecord translations like + ## User.human_attribute_name(:email) and User.model_name.human + ## - DefaultI18nSubjectMatcher + ## Matches ActionMailer's default_i18n_subject method + ## + ## To implement your own, please see `I18n::Tasks::Scanners::AstMatchers::BaseMatcher`. + # ast_matchers: + # - 'I18n::Tasks::Scanners::AstMatchers::RailsModelMatcher' + # - 'I18n::Tasks::Scanners::AstMatchers::DefaultI18nSubjectMatcher' + + ## Multiple scanners can be used. Their results are merged. + ## The options specified above are passed down to each scanner. Per-scanner options can be specified as well. + ## See this example of a custom scanner: https://github.com/glebm/i18n-tasks/wiki/A-custom-scanner-example + +## Translation Services +# translation: +# # Google Translate +# # Get an API key and set billing info at https://code.google.com/apis/console to use Google Translate +# google_translate_api_key: "AbC-dEf5" +# # DeepL Pro Translate +# # Get an API key and subscription at https://www.deepl.com/pro to use DeepL Pro +# deepl_api_key: "48E92789-57A3-466A-9959-1A1A1A1A1A1A" +# # deepl_host: "https://api.deepl.com" +# # deepl_version: "v2" +# # deepl_glossary_ids: +# # - f28106eb-0e06-489e-82c6-8215d6f95089 +# # - 2c6415be-1852-4f54-9e1b-d800463496b4 +# # add additional options to the DeepL.translate call: https://www.deepl.com/docs-api/translate-text/translate-text/ +# deepl_options: +# formality: prefer_less +# # OpenAI +# openai_api_key: "sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" +# # openai_model: "gpt-3.5-turbo" # see https://platform.openai.com/docs/models +# # may contain `%{from}` and `%{to}`, which will be replaced by source and target locale codes, respectively (using `Kernel.format`) +# # openai_system_prompt: >- +# # You are a professional translator that translates content from the %{from} locale +# # to the %{to} locale in an i18n locale array. +# # +# # The array has a structured format and contains multiple strings. Your task is to translate +# # each of these strings and create a new array with the translated strings. +# # +# # HTML markups (enclosed in < and > characters) must not be changed under any circumstance. +# # Variables (starting with %%{ and ending with }) must not be changed under any circumstance. +# # +# # Keep in mind the context of all the strings for a more accurate translation. + +## Do not consider these keys missing: +ignore_missing: + - time.buttons.select + - decidim.admin.models.assembly.fields.promoted +# - 'errors.messages.{accepted,blank,invalid,too_short,too_long}' +# - '{devise,simple_form}.*' + +## Consider these keys used: +ignore_unused: + - decidim.admin.models.assembly.fields.promoted + - time.buttons.select + - decidim.admin.scopes.update.error + - decidim.admin.scopes.update.success + +# - 'activerecord.attributes.*' +# - '{devise,kaminari,will_paginate}.*' +# - 'simple_form.{yes,no}' +# - 'simple_form.{placeholders,hints,labels}.*' +# - 'simple_form.{error_notification,required}.:' + +## Exclude these keys from the `i18n-tasks eq-base' report: +# ignore_eq_base: +# all: +# - common.ok +# fr,es: +# - common.brand + +## Exclude these keys from the `i18n-tasks check-consistent-interpolations` report: +# ignore_inconsistent_interpolations: +# - 'activerecord.attributes.*' + +## Ignore these keys completely: +# ignore: +# - kaminari.* + +## Sometimes, it isn't possible for i18n-tasks to match the key correctly, +## e.g. in case of a relative key defined in a helper method. +## In these cases you can use the built-in PatternMapper to map patterns to keys, e.g.: +# +# <%# I18n::Tasks.add_scanner 'I18n::Tasks::Scanners::PatternMapper', +# only: %w(*.html.haml *.html.slim), +# patterns: [['= title\b', '.page_title']] %> +# +# The PatternMapper can also match key literals via a special %{key} interpolation, e.g.: +# + # <%# I18n::Tasks.add_scanner 'I18n::Tasks::Scanners::PatternMapper', +# patterns: [['\bSpree\.t[( ]\s*%{key}', 'spree.%{key}']] %> diff --git a/config/locales/en.yml b/config/locales/en.yml index c81dd88..dded84e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,7 +1,24 @@ en: decidim: admin: + actions: + add: Add + browse: Browse + confirm_destroy: Confirm destroy + destroy: Destroy + edit: Edit models: assembly: fields: promoted: "Promoted" + scope: + fields: + name: Name + scope_type: Scope type + scopes: + no_scopes: No scopes at this level. + update: + error: There was a problem updating this scope. + success: Scope updated successfully + titles: + scopes: Scopes diff --git a/config/locales/fr.yml b/config/locales/fr.yml index c552ba5..514b363 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -1,4 +1,24 @@ fr: + decidim: + admin: + actions: + add: Ajouter + browse: Naviguer + confirm_destroy: Confirmer la suppression + destroy: Supprimer + edit: Modifier + models: + scope: + fields: + name: Nom + scope_type: Type de secteur + scopes: + no_scopes: Aucun secteur à ce niveau. + update: + error: Il y a eu une erreur lors de la mise à jour du secteur. + success: Secteur mis à jour avec succès. + titles: + scopes: Secteurs time: buttons: - select: Sélectionner \ No newline at end of file + select: Sélectionner diff --git a/db/migrate/20250129141932_add_weight_to_scopes.rb b/db/migrate/20250129141932_add_weight_to_scopes.rb new file mode 100644 index 0000000..728ff95 --- /dev/null +++ b/db/migrate/20250129141932_add_weight_to_scopes.rb @@ -0,0 +1,5 @@ +class AddWeightToScopes < ActiveRecord::Migration[7.0] + def change + add_column :decidim_scopes, :weight, :integer, default: 0 + end +end diff --git a/db/schema.rb b/db/schema.rb index b54e419..f762702 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_09_18_113781) do +ActiveRecord::Schema[7.0].define(version: 2025_01_29_141932) do # These are extensions that must be enabled in order to support this database enable_extension "ltree" enable_extension "pg_trgm" @@ -1354,6 +1354,7 @@ t.integer "parent_id" t.string "code", null: false t.integer "part_of", default: [], null: false, array: true + t.integer "weight", default: 0 t.index ["decidim_organization_id", "code"], name: "index_decidim_scopes_on_decidim_organization_id_and_code", unique: true t.index ["decidim_organization_id"], name: "index_decidim_scopes_on_decidim_organization_id" t.index ["parent_id"], name: "index_decidim_scopes_on_parent_id" diff --git a/lib/extends/controllers/decidim/admin/scopes_controller_extends.rb b/lib/extends/controllers/decidim/admin/scopes_controller_extends.rb new file mode 100644 index 0000000..786105e --- /dev/null +++ b/lib/extends/controllers/decidim/admin/scopes_controller_extends.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "active_support/concern" + +module Decidim + module Admin + module ScopesControllerExtends + extend ActiveSupport::Concern + included do + def index + enforce_permission_to :read, :scope + @scopes = children_scopes.sort_by(&:weight) + end + + def update + enforce_permission_to(:update, :scope, scope:) + @form = form(ScopeForm).from_params(params) + + return update_scopes if params[:id] == "refresh_scopes" + + UpdateScope.call(@form, scope) do + on(:ok) do + flash[:notice] = I18n.t("scopes.update.success", scope: "decidim.admin") + redirect_to current_scopes_path + end + + on(:invalid) do + flash.now[:alert] = I18n.t("scopes.update.error", scope: "decidim.admin") + render :edit + end + end + end + + private + + def update_scopes + ::Admin::ReorderScopes.call(current_organization, :scopes, params[:manifests]) do + on(:ok) do + flash[:notice] = I18n.t("scopes.update.success", scope: "decidim.admin") + end + end + end + end + end + end +end + +Decidim::Admin::ScopesController.include(Decidim::Admin::ScopesControllerExtends) diff --git a/lib/extends/controllers/decidim/scopes_controller_extends.rb b/lib/extends/controllers/decidim/scopes_controller_extends.rb new file mode 100644 index 0000000..5c2b700 --- /dev/null +++ b/lib/extends/controllers/decidim/scopes_controller_extends.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "active_support/concern" + +module ScopesControllerExtends + extend ActiveSupport::Concern + included do + def picker + enforce_permission_to :pick, :scope + + context = picker_context(root, title, max_depth) + required = params&.[](:required) != "false" + + scopes, parent_scopes = resolve_picker_scopes(root, current) + + render( + :picker, + layout: nil, + locals: { + required:, + title:, + root:, + current: (current || root), + scopes: scopes&.sort_by(&:weight), + parent_scopes: parent_scopes.sort_by(&:weight), + picker_target_id: (params[:target_element_id] || "content"), + global_value: params[:global_value], + max_depth:, + context: + } + ) + end + end +end + +Decidim::ScopesController.include(ScopesControllerExtends) diff --git a/lib/extends/helpers/decidim/check_boxes_tree_helper_extends.rb b/lib/extends/helpers/decidim/check_boxes_tree_helper_extends.rb new file mode 100644 index 0000000..9c5e751 --- /dev/null +++ b/lib/extends/helpers/decidim/check_boxes_tree_helper_extends.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module CheckBoxesTreeHelperExtends + def filter_scopes_values + return filter_scopes_values_from_parent(current_component.scope) if current_component.scope.present? + + main_scopes = current_participatory_space.scopes.top_level + .includes(:scope_type, :children) + .sort_by(&:weight) + filter_scopes_values_from(main_scopes) + end + + def filter_scopes_values_from_parent(scope) + scopes_values = [] + scope.children.sort_by(&:weight).each do |child| + unless child.children + scopes_values << Decidim::CheckBoxesTreeHelper::TreePoint.new(child.id.to_s, translated_attribute(child.name, current_participatory_space.organization)) + next + end + scopes_values << Decidim::CheckBoxesTreeHelper::TreeNode.new( + Decidim::CheckBoxesTreeHelper::TreePoint.new(child.id.to_s, translated_attribute(child.name, current_participatory_space.organization)), + scope_children_to_tree(child) + ) + end + + filter_tree_from(scopes_values) + end + + def filter_scopes_values_from(scopes, participatory_space = nil) + scopes_values = scopes.compact.sort_by(&:weight).flat_map do |scope| + Decidim::CheckBoxesTreeHelper::TreeNode.new( + Decidim::CheckBoxesTreeHelper::TreePoint.new(scope.id.to_s, translated_attribute(scope.name)), + scope_children_to_tree(scope) + ) + end + + scopes_values.prepend(Decidim::CheckBoxesTreeHelper::TreePoint.new("global", t("decidim.scopes.global"))) if participatory_space&.scope.blank? + + filter_tree_from(scopes_values) + end + + def scope_children_to_tree(scope, participatory_space = nil) + return if participatory_space.present? && scope.scope_type && scope.scope_type == current_participatory_space.try(:scope_type_max_depth) + return unless scope.children.any? + + sorted_children = scope.children.includes(:scope_type, :children).sort_by(&:weight) + + sorted_children.flat_map do |child| + Decidim::CheckBoxesTreeHelper::TreeNode.new( + Decidim::CheckBoxesTreeHelper::TreePoint.new(child.id.to_s, translated_attribute(child.name)), + scope_children_to_tree(child, participatory_space) + ) + end + end +end + +Decidim::CheckBoxesTreeHelper.module_eval do + prepend(CheckBoxesTreeHelperExtends) +end diff --git a/spec/helpers/check_boxes_tree_helper_spec.rb b/spec/helpers/check_boxes_tree_helper_spec.rb new file mode 100644 index 0000000..1d1b806 --- /dev/null +++ b/spec/helpers/check_boxes_tree_helper_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + describe CheckBoxesTreeHelper do + let(:helper) do + Class.new(ActionView::Base) do + include CheckBoxesTreeHelper + include TranslatableAttributes + end.new(ActionView::LookupContext.new(ActionController::Base.view_paths), {}, []) + end + + let!(:organization) { create(:organization) } + let!(:participatory_space) { create(:participatory_process, organization:) } + let!(:component) { create(:component, participatory_space:) } + + before do + allow(helper).to receive(:current_participatory_space).and_return(participatory_space) + allow(helper).to receive(:current_component).and_return(component) + end + + describe "#filter_scopes_values" do + let(:root) { helper.filter_scopes_values } + let(:leaf) { helper.filter_scopes_values.leaf } + let(:nodes) { helper.filter_scopes_values.node } + + context "when the participatory space does not have a scope" do + it "returns the global scope" do + expect(leaf.value).to eq("") + expect(nodes.count).to eq(1) + expect(nodes.first).to be_a(Decidim::CheckBoxesTreeHelper::TreePoint) + expect(nodes.first.value).to eq("global") + end + end + + context "when the participatory space has a scope with subscopes" do + let(:participatory_space) { create(:participatory_process, :with_scope, organization:) } + let!(:subscopes) { create_list :subscope, 5, parent: participatory_space.scope } + + it "returns all the subscopes" do + expect(leaf.value).to eq("") + expect(root).to be_a(Decidim::CheckBoxesTreeHelper::TreeNode) + expect(root.node.count).to eq(5) + end + end + + context "when the component does not have a scope" do + before do + component.update!(settings: { scopes_enabled: true, scope_id: nil }) + end + + it "returns the global scope" do + expect(leaf.value).to eq("") + expect(nodes.count).to eq(1) + expect(nodes.first).to be_a(Decidim::CheckBoxesTreeHelper::TreePoint) + expect(nodes.first.value).to eq("global") + end + end + + context "when the component has a scope with subscopes" do + let(:participatory_space) { create(:participatory_process, :with_scope, organization:) } + let!(:subscopes) { create_list :subscope, 5, parent: participatory_space.scope } + + before do + component.update!(settings: { scopes_enabled: true, scope_id: participatory_space.scope.id }) + end + + it "returns all the subscopes" do + expect(leaf.value).to eq("") + expect(root).to be_a(Decidim::CheckBoxesTreeHelper::TreeNode) + expect(root.node.count).to eq(5) + end + end + end + + context "when there is weight in the scopes" do + let(:participatory_space) { create(:participatory_process, :with_scope, organization:) } + let!(:subscopes) { create_list(:subscope, 5, parent: participatory_space.scope) } + + before do + subscopes.shuffle.each_with_index { |subscope, index| subscope.update!(weight: index) } + end + + it "returns the subscopes sorted by weight" do + expected_ids = subscopes.sort_by(&:weight).map { |subscope| subscope.id.to_s } + actual_values = helper.filter_scopes_values.node.map { |node| node.values.first.value.to_s } + expect(actual_values).to eq(expected_ids) + end + + it "assigns weights correctly after shuffle" do + weights = subscopes.map(&:weight) + expect(weights).to contain_exactly(0, 1, 2, 3, 4) + end + + it "sorts subscopes correctly by weight" do + sorted_subscopes = subscopes.sort_by(&:weight) + expect(subscopes.sort_by(&:weight)).to eq(sorted_subscopes) + end + + it "checks that the helper method returns sorted subscopes" do + sorted_subscopes = subscopes.sort_by(&:weight).map { |subscope| subscope.id.to_s } + expect(helper.filter_scopes_values.node.map { |node| node.values.first.value.to_s }).to eq(sorted_subscopes) + end + + it "returns false when the subscopes are not sorted by weight" do + unsorted_subscopes = subscopes.shuffle + unsorted_values = unsorted_subscopes.map { |subscope| subscope.id.to_s } + expect(helper.filter_scopes_values.node.map { |node| node.values.first.value.to_s }).not_to eq(unsorted_values) + end + + it "returns false when subscopes are not sorted in ascending order of weight" do + reversed_subscopes = subscopes.sort_by(&:weight).reverse + reversed_values = reversed_subscopes.map { |subscope| subscope.id.to_s } + expect(helper.filter_scopes_values.node.map { |node| node.values.first.value.to_s }).not_to eq(reversed_values) + end + end + end +end diff --git a/spec/system/admin_manages_organization_scopes_spec.rb b/spec/system/admin_manages_organization_scopes_spec.rb new file mode 100644 index 0000000..c3e4db0 --- /dev/null +++ b/spec/system/admin_manages_organization_scopes_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Organization scopes" do + include Decidim::SanitizeHelper + + let(:organization) { create :organization, default_locale: :en, available_locales: [:en, :es, :ca, :fr] } + let(:admin) { create :user, :admin, :confirmed, organization: } + let!(:attributes) { attributes_for(:scope) } + + before do + switch_to_host(organization.host) + end + + describe "Managing scopes" do + let!(:scope_type) { create(:scope_type, organization: admin.organization) } + + before do + login_as admin, scope: :user + visit decidim_admin.root_path + click_link_or_button "Settings" + click_link_or_button "Scopes" + end + + it "can create new scopes" do + click_link_or_button "Add" + + within ".new_scope" do + fill_in_i18n :scope_name, "#scope-name-tabs", **attributes[:name].except("machine_translations") + fill_in "Code", with: "MY-DISTRICT" + select scope_type.name["en"], from: :scope_scope_type_id + + find("*[type=submit]").click + end + + expect(page).to have_admin_callout("successfully") + + within ".draggable-list" do + expect(page).to have_content(translated(attributes[:name])) + end + + visit decidim_admin.root_path + expect(page).to have_content("created the #{translated(attributes[:name])} scope") + end + + context "with existing scopes" do + let!(:scope) { create(:scope, organization:) } + + before do + visit current_path + end + + it "can edit them" do + within ".draggable-content", text: translated(scope.name) do + click_link_or_button "Edit" + end + + within ".edit_scope" do + fill_in_i18n :scope_name, "#scope-name-tabs", **attributes[:name].except("machine_translations") + find("*[type=submit]").click + end + + expect(page).to have_admin_callout("successfully") + + within ".draggable-list" do + expect(page).to have_content(translated(attributes[:name])) + end + + visit decidim_admin.root_path + expect(page).to have_content("updated the #{translated(attributes[:name])} scope") + end + + it "can delete them" do + within ".draggable-content", text: translated(scope.name) do + accept_confirm { click_link_or_button "Destroy" } + end + + expect(page).to have_admin_callout("successfully") + + within ".card-section" do + expect(page).to have_no_content(translated(scope.name)) + end + end + + it "can create a new subcope" do + within ".draggable-content", text: translated(scope.name) do + find("a", text: translated(scope.name)).click + end + + click_link_or_button "Add" + + within ".new_scope" do + fill_in_i18n :scope_name, "#scope-name-tabs", en: "My nice subdistrict", + es: "Mi lindo subdistrito", + ca: "El meu bonic subbarri" + fill_in "Code", with: "MY-SUBDISTRICT" + select scope_type.name["en"], from: :scope_scope_type_id + + find("*[type=submit]").click + end + + expect(page).to have_admin_callout("successfully") + + within ".draggable-list" do + expect(page).to have_content("My nice subdistrict") + end + end + end + end +end