diff --git a/.github/workflows/build_app.yml b/.github/workflows/build_app.yml index 35e54a56..51b70953 100644 --- a/.github/workflows/build_app.yml +++ b/.github/workflows/build_app.yml @@ -3,12 +3,12 @@ on: inputs: ruby_version: description: 'Ruby Version' - default: "3.1.1" + default: "3.3.4" type: string required: false node_version: description: 'Node version' - default: '18.17.1' + default: '22.14.0' required: false type: string jobs: @@ -34,19 +34,19 @@ jobs: env: POSTGRES_PASSWORD: postgres steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 1 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ inputs.ruby_version }} bundler-cache: true - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: ${{ inputs.node_version }} cache: 'npm' cache-dependency-path: ./package-lock.json - - uses: actions/cache@v3 + - uses: actions/cache@v4 id: app-cache with: path: ./spec/decidim_dummy_app/ diff --git a/.github/workflows/ci_tests.yml b/.github/workflows/ci_tests.yml index 20fe4057..d8a29d14 100644 --- a/.github/workflows/ci_tests.yml +++ b/.github/workflows/ci_tests.yml @@ -9,14 +9,11 @@ on: pull_request: branches-ignore: - "chore/l10n*" - paths: - - "*" - - ".github/**" env: CI: "true" - RUBY_VERSION: 3.1.1 - NODE_VERSION: 18.17.1 + RUBY_VERSION: 3.3.4 + NODE_VERSION: 22.14.0 concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} @@ -33,7 +30,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 1 - uses: actions/setup-node@master diff --git a/.github/workflows/test_app.yml b/.github/workflows/test_app.yml index 70894d82..36c69244 100644 --- a/.github/workflows/test_app.yml +++ b/.github/workflows/test_app.yml @@ -3,7 +3,7 @@ on: inputs: ruby_version: description: 'Ruby Version' - default: "3.1.1" + default: "3.3.4" required: false type: string test_command: @@ -13,7 +13,7 @@ on: chrome_version: description: 'Chrome & Chromedriver version' required: false - default: "126.0.6478.182" + default: "139.0.7258.66" type: string jobs: @@ -30,7 +30,7 @@ jobs: services: validator: image: ghcr.io/validator/validator:latest - ports: ["8888:8888"] + ports: [ "8888:8888" ] postgres: image: postgres:14 ports: ["5432:5432"] @@ -41,8 +41,15 @@ jobs: --health-retries 5 env: POSTGRES_PASSWORD: postgres + redis: + image: redis + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 1 - uses: ruby/setup-ruby@v1 @@ -59,7 +66,7 @@ jobs: name: Install Chrome version ${{inputs.chrome_version}} with: chromedriver-version: ${{inputs.chrome_version}} - - uses: actions/cache@v3 + - uses: actions/cache@v4 id: app-cache with: path: ./spec/decidim_dummy_app/ diff --git a/.gitignore b/.gitignore index c8b09278..4c126b12 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ spec/decidim_dummy_app # default development application development_app .rubocop-https*-yml +.byebug_history +.rbenv-vars +.env \ No newline at end of file diff --git a/.rubocop.yml b/.rubocop.yml index b901a133..d2b0cf97 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,4 +1,5 @@ -inherit_from: https://raw.githubusercontent.com/decidim/decidim/release/0.28-stable/.rubocop.yml +inherit_gem: + decidim-dev: rubocop-decidim.yml AllCops: Include: @@ -15,6 +16,7 @@ AllCops: - "db/schema.rb" - "vendor/**/*" -RSpec/FilePath: +RSpec/DescribeClass: Exclude: - - "spec/serializers/user_export_serializer_spec.rb" + - "spec/system/**/*" + - spec/i18n_spec.rb diff --git a/.ruby-version b/.ruby-version index 94ff29cc..a0891f56 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.1.1 +3.3.4 diff --git a/Gemfile b/Gemfile index ed3f02df..9043fcbf 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,6 @@ # frozen_string_literal: true -DECIDIM_VERSION = "~> 0.28" +DECIDIM_VERSION = "~> 0.31.0" source "https://rubygems.org" @@ -8,10 +8,10 @@ ruby RUBY_VERSION gem "decidim", DECIDIM_VERSION gem "decidim-extra_user_fields", path: "." +gem "decidim-initiatives", DECIDIM_VERSION -gem "bootsnap", "~> 1.3" -gem "country_select", "~> 4.0" -gem "puma", ">= 4.3" +gem "bootsnap", "~> 1.7" +gem "puma", ">= 6.3.1" group :development, :test do gem "byebug", "~> 11.0", platform: :mri @@ -23,12 +23,5 @@ group :development do gem "letter_opener_web", "~> 2.0" gem "listen", "~> 3.1" gem "rubocop-faker" - gem "spring", "~> 2.0" - gem "spring-watcher-listen", "~> 2.0" gem "web-console", "~> 4.2" end - -group :test do - gem "rubocop-factory_bot", "!= 2.26.0", require: false - gem "rubocop-rspec_rails", "!= 2.29.0", require: false -end diff --git a/Gemfile.lock b/Gemfile.lock index 1539fe48..5b871809 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,99 +1,114 @@ PATH remote: . specs: - decidim-extra_user_fields (0.28.0) - country_select (~> 4.0) - decidim-core (>= 0.28) + decidim-extra_user_fields (0.31.0) + country_select (~> 10.0) + decidim-core (>= 0.31) deface (~> 1.5) GEM remote: https://rubygems.org/ specs: - actioncable (6.1.7.8) - actionpack (= 6.1.7.8) - activesupport (= 6.1.7.8) + actioncable (7.2.3) + actionpack (= 7.2.3) + activesupport (= 7.2.3) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.7.8) - actionpack (= 6.1.7.8) - activejob (= 6.1.7.8) - activerecord (= 6.1.7.8) - activestorage (= 6.1.7.8) - activesupport (= 6.1.7.8) - mail (>= 2.7.1) - actionmailer (6.1.7.8) - actionpack (= 6.1.7.8) - actionview (= 6.1.7.8) - activejob (= 6.1.7.8) - activesupport (= 6.1.7.8) - mail (~> 2.5, >= 2.5.4) - rails-dom-testing (~> 2.0) - actionpack (6.1.7.8) - actionview (= 6.1.7.8) - activesupport (= 6.1.7.8) - rack (~> 2.0, >= 2.0.9) + zeitwerk (~> 2.6) + actionmailbox (7.2.3) + actionpack (= 7.2.3) + activejob (= 7.2.3) + activerecord (= 7.2.3) + activestorage (= 7.2.3) + activesupport (= 7.2.3) + mail (>= 2.8.0) + actionmailer (7.2.3) + actionpack (= 7.2.3) + actionview (= 7.2.3) + activejob (= 7.2.3) + activesupport (= 7.2.3) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (7.2.3) + actionview (= 7.2.3) + activesupport (= 7.2.3) + cgi + nokogiri (>= 1.8.5) + racc + rack (>= 2.2.4, < 3.3) + rack-session (>= 1.0.1) rack-test (>= 0.6.3) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.7.8) - actionpack (= 6.1.7.8) - activerecord (= 6.1.7.8) - activestorage (= 6.1.7.8) - activesupport (= 6.1.7.8) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (7.2.3) + actionpack (= 7.2.3) + activerecord (= 7.2.3) + activestorage (= 7.2.3) + activesupport (= 7.2.3) + globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (6.1.7.8) - activesupport (= 6.1.7.8) + actionview (7.2.3) + activesupport (= 7.2.3) builder (~> 3.1) - erubi (~> 1.4) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.1, >= 1.2.0) + cgi + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) active_link_to (1.0.5) actionpack addressable - activejob (6.1.7.8) - activesupport (= 6.1.7.8) + activejob (7.2.3) + activesupport (= 7.2.3) globalid (>= 0.3.6) - activemodel (6.1.7.8) - activesupport (= 6.1.7.8) - activerecord (6.1.7.8) - activemodel (= 6.1.7.8) - activesupport (= 6.1.7.8) - activestorage (6.1.7.8) - actionpack (= 6.1.7.8) - activejob (= 6.1.7.8) - activerecord (= 6.1.7.8) - activesupport (= 6.1.7.8) + activemodel (7.2.3) + activesupport (= 7.2.3) + activerecord (7.2.3) + activemodel (= 7.2.3) + activesupport (= 7.2.3) + timeout (>= 0.4.0) + activestorage (7.2.3) + actionpack (= 7.2.3) + activejob (= 7.2.3) + activerecord (= 7.2.3) + activesupport (= 7.2.3) marcel (~> 1.0) - mini_mime (>= 1.1.0) - activesupport (6.1.7.8) - concurrent-ruby (~> 1.0, >= 1.0.2) + activesupport (7.2.3) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) - tzinfo (~> 2.0) - zeitwerk (~> 2.3) - acts_as_list (1.2.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + acts_as_list (1.2.6) activerecord (>= 6.1) activesupport (>= 6.1) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) - ast (2.4.2) - base64 (0.2.0) - batch-loader (1.5.0) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + base64 (0.3.0) + batch-loader (2.0.6) bcrypt (3.1.20) - better_html (2.1.1) - actionview (>= 6.0) - activesupport (>= 6.0) + benchmark (0.5.0) + better_html (2.2.0) + actionview (>= 7.0) + activesupport (>= 7.0) ast (~> 2.0) erubi (~> 1.4) parser (>= 2.4) smart_properties - bigdecimal (3.1.8) + bigdecimal (3.3.1) bindex (0.8.1) bootsnap (1.18.3) msgpack (~> 1.2) - browser (2.7.1) + browser (6.2.0) builder (3.3.0) - bullet (7.1.6) + bullet (8.0.8) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) byebug (11.1.3) @@ -106,161 +121,166 @@ 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.7) - declarative-builder (< 0.2.0) + cells (4.1.8) + declarative-builder (~> 0.2.0) declarative-option (< 0.2.0) tilt (>= 1.4, < 3) uber (< 0.2.0) cells-erb (0.1.0) cells (~> 4.0) erbse (>= 0.1.1) - cells-rails (0.1.5) + cells-rails (0.1.6) actionpack (>= 5.0) cells (>= 4.1.6, < 5.0.0) - charlock_holmes (0.7.8) + cgi (0.5.1) + charlock_holmes (0.7.9) + chartkick (5.1.5) childprocess (5.1.0) logger (~> 1.5) - commonmarker (0.23.10) - concurrent-ruby (1.3.3) - countries (3.1.0) - i18n_data (~> 0.11.0) - sixarm_ruby_unaccent (~> 1.1) - unicode_utils (~> 1.4) - country_select (4.0.0) - countries (~> 3.0) - sort_alphabetical (~> 1.0) - crack (1.0.0) + chunky_png (1.4.0) + cmdparse (3.0.7) + commonmarker (0.23.12) + concurrent-ruby (1.3.5) + connection_pool (3.0.2) + countries (7.1.1) + unaccent (~> 0.3) + country_select (10.0.1) + countries (> 5.0, < 8.0) + crack (1.0.1) bigdecimal rexml crass (1.0.6) - css_parser (1.17.1) + css_parser (1.21.1) addressable - csv (3.3.0) - date (3.3.4) + csv (3.3.5) + data_migrate (11.3.1) + activerecord (>= 6.1) + railties (>= 6.1) + date (3.5.1) date_validator (0.12.0) activemodel (>= 3) activesupport (>= 3) - decidim (0.28.1) - decidim-accountability (= 0.28.1) - decidim-admin (= 0.28.1) - decidim-api (= 0.28.1) - decidim-assemblies (= 0.28.1) - decidim-blogs (= 0.28.1) - decidim-budgets (= 0.28.1) - decidim-comments (= 0.28.1) - decidim-core (= 0.28.1) - decidim-debates (= 0.28.1) - decidim-forms (= 0.28.1) - decidim-generators (= 0.28.1) - decidim-meetings (= 0.28.1) - decidim-pages (= 0.28.1) - decidim-participatory_processes (= 0.28.1) - decidim-proposals (= 0.28.1) - decidim-sortitions (= 0.28.1) - decidim-surveys (= 0.28.1) - decidim-system (= 0.28.1) - decidim-templates (= 0.28.1) - decidim-verifications (= 0.28.1) - decidim-accountability (0.28.1) - decidim-comments (= 0.28.1) - decidim-core (= 0.28.1) - decidim-admin (0.28.1) + decidim (0.31.0) + decidim-accountability (= 0.31.0) + decidim-admin (= 0.31.0) + decidim-api (= 0.31.0) + decidim-assemblies (= 0.31.0) + decidim-blogs (= 0.31.0) + decidim-budgets (= 0.31.0) + decidim-comments (= 0.31.0) + decidim-core (= 0.31.0) + decidim-debates (= 0.31.0) + decidim-forms (= 0.31.0) + decidim-generators (= 0.31.0) + decidim-meetings (= 0.31.0) + decidim-pages (= 0.31.0) + decidim-participatory_processes (= 0.31.0) + decidim-proposals (= 0.31.0) + decidim-sortitions (= 0.31.0) + decidim-surveys (= 0.31.0) + decidim-system (= 0.31.0) + decidim-verifications (= 0.31.0) + decidim-accountability (0.31.0) + decidim-comments (= 0.31.0) + decidim-core (= 0.31.0) + decidim-admin (0.31.0) active_link_to (~> 1.0) - decidim-core (= 0.28.1) + decidim-core (= 0.31.0) devise (~> 4.7) devise-i18n (~> 1.2) devise_invitable (~> 2.0, >= 2.0.9) - decidim-api (0.28.1) - commonmarker (~> 0.23.0, >= 0.23.9) - decidim-core (= 0.28.1) - graphql (~> 2.0.0) - graphql-docs (~> 3.0.1) + decidim-api (0.31.0) + decidim-core (= 0.31.0) + devise-jwt (~> 0.12.1) + graphql (~> 2.4.0, >= 2.4.17) + graphql-docs (~> 5.0) rack-cors (~> 1.0) - decidim-assemblies (0.28.1) - decidim-core (= 0.28.1) - decidim-blogs (0.28.1) - decidim-admin (= 0.28.1) - decidim-comments (= 0.28.1) - decidim-core (= 0.28.1) - decidim-budgets (0.28.1) - decidim-comments (= 0.28.1) - decidim-core (= 0.28.1) - decidim-comments (0.28.1) - decidim-core (= 0.28.1) + decidim-assemblies (0.31.0) + decidim-core (= 0.31.0) + decidim-blogs (0.31.0) + decidim-admin (= 0.31.0) + decidim-comments (= 0.31.0) + decidim-core (= 0.31.0) + decidim-budgets (0.31.0) + decidim-comments (= 0.31.0) + decidim-core (= 0.31.0) + decidim-comments (0.31.0) + decidim-core (= 0.31.0) redcarpet (~> 3.5, >= 3.5.1) - decidim-core (0.28.1) + decidim-core (0.31.0) active_link_to (~> 1.0) acts_as_list (~> 1.0) - batch-loader (~> 1.2) - browser (~> 2.7) - carrierwave (~> 2.2.5, >= 2.2.5) + batch-loader (~> 2.0) + browser (~> 6.2.0) cells-erb (~> 0.1.0) cells-rails (~> 0.1.3) charlock_holmes (~> 0.7) + chartkick (~> 5.1.2) + concurrent-ruby (~> 1.3.0) + data_migrate (~> 11.3) date_validator (~> 0.12.0) devise (~> 4.7) - devise-i18n (~> 1.2, < 1.11.1) + devise-i18n (~> 1.2) diffy (~> 3.3) doorkeeper (~> 5.6, >= 5.6.6) doorkeeper-i18n (~> 4.0) file_validators (~> 3.0) fog-local (~> 0.6) - foundation_rails_helper (~> 4.0) geocoder (~> 1.8) hashdiff (>= 0.4.0, < 2.0.0) + hexapdf (~> 1.1.0) + image_processing (~> 1.2) invisible_captcha (~> 0.12) kaminari (~> 1.2, >= 1.2.1) loofah (~> 2.19, >= 2.19.1) mime-types (>= 1.16, < 4.0) mini_magick (~> 4.9) - net-smtp (~> 0.3.1) + net-smtp (~> 0.5.0) + nokogiri (~> 1.16, >= 1.16.2) omniauth (~> 2.0) omniauth-facebook (~> 5.0) omniauth-google-oauth2 (~> 1.0) omniauth-rails_csrf_protection (~> 1.0) omniauth-twitter (~> 1.4) - paper_trail (~> 12.0) - pg (~> 1.4.0, < 2) + paper_trail (~> 16.0) + paranoia (~> 3.0.0) + pg (~> 1.5.0, < 2) pg_search (~> 2.2) premailer-rails (~> 1.10) - psych (~> 4.0) - rack (~> 2.2, >= 2.2.6.4) + rack (~> 2.2, >= 2.2.8.1) rack-attack (~> 6.0) - rails (~> 6.1.7, >= 6.1.7.4) - rails-i18n (~> 6.0) - ransack (~> 3.2.1) + rails (~> 7.2.0, >= 7.2.2.2) + rails-i18n (~> 7.0) + ransack (~> 4.2.0) redis (~> 4.1) - request_store (~> 1.5.0) + request_store (~> 1.7.0) + rqrcode (~> 2.2.0) rubyXL (~> 3.4) rubyzip (~> 2.0) - seven_zip_ruby (~> 1.3) - shakapacker (~> 7.1.0) - valid_email2 (~> 4.0) + shakapacker (~> 8.3.0) + valid_email2 (~> 7.0) web-push (~> 3.0) - wisper (~> 2.0) - decidim-debates (0.28.1) - decidim-comments (= 0.28.1) - decidim-core (= 0.28.1) - decidim-dev (0.28.1) - bullet (~> 7.0) + wisper (~> 3.0) + decidim-debates (0.31.0) + decidim-comments (= 0.31.0) + decidim-core (= 0.31.0) + decidim-dev (0.31.0) + bullet (~> 8.0.0) byebug (~> 11.0) capybara (~> 3.39) - decidim (= 0.28.1) - erb_lint (~> 0.4.0) + decidim-admin (= 0.31.0) + decidim-api (= 0.31.0) + decidim-comments (= 0.31.0) + decidim-core (= 0.31.0) + decidim-generators (= 0.31.0) + decidim-verifications (= 0.31.0) + erb_lint (~> 0.8.0) factory_bot_rails (~> 6.2) faker (~> 3.2) i18n-tasks (~> 1.0) - nokogiri (~> 1.14, >= 1.14.3) + nokogiri (~> 1.16, >= 1.16.2) parallel_tests (~> 4.2) - puma (~> 6.2, >= 6.3.1) + puma (~> 6.5) rails-controller-testing (~> 1.0) rspec (~> 3.12) rspec-cells (~> 0.3.7) @@ -268,56 +288,64 @@ GEM rspec-rails (~> 6.0) rspec-retry (~> 0.6.2) rspec_junit_formatter (~> 0.6.0) - rubocop (~> 1.50.0) - rubocop-faker (~> 1.1) - rubocop-rails (~> 2.19) - rubocop-rspec (~> 2.20) + rubocop (~> 1.78.0) + rubocop-capybara (~> 2.22.0, >= 2.22.1) + rubocop-factory_bot (~> 2.27.0) + rubocop-faker (~> 1.3, >= 1.3.0) + rubocop-graphql (~> 1.5, >= 1.5.6) + rubocop-performance (~> 1.25, >= 1.25.0) + rubocop-rails (~> 2.32.0, >= 2.32.0) + rubocop-rspec (~> 3.0, >= 3.6.0) + rubocop-rspec_rails (~> 2.31.0) + rubocop-rubycw (~> 0.2.0) 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) - decidim-forms (0.28.1) - decidim-core (= 0.28.1) - wicked_pdf (~> 2.1) - wkhtmltopdf-binary (~> 0.12) - decidim-generators (0.28.1) - decidim-core (= 0.28.1) - decidim-meetings (0.28.1) - decidim-core (= 0.28.1) - decidim-forms (= 0.28.1) + decidim-forms (0.31.0) + decidim-core (= 0.31.0) + decidim-generators (0.31.0) + decidim-core (= 0.31.0) + decidim-initiatives (0.31.0) + decidim-admin (= 0.31.0) + decidim-comments (= 0.31.0) + decidim-core (= 0.31.0) + decidim-verifications (= 0.31.0) + decidim-meetings (0.31.0) + decidim-core (= 0.31.0) + decidim-forms (= 0.31.0) icalendar (~> 2.5) - decidim-pages (0.28.1) - decidim-core (= 0.28.1) - decidim-participatory_processes (0.28.1) - decidim-core (= 0.28.1) - decidim-proposals (0.28.1) - decidim-comments (= 0.28.1) - decidim-core (= 0.28.1) - doc2text (~> 0.4.6) + decidim-pages (0.31.0) + decidim-core (= 0.31.0) + decidim-participatory_processes (0.31.0) + decidim-core (= 0.31.0) + decidim-proposals (0.31.0) + decidim-comments (= 0.31.0) + decidim-core (= 0.31.0) + doc2text (~> 0.4.0, >= 0.4.8) redcarpet (~> 3.5, >= 3.5.1) - decidim-sortitions (0.28.1) - decidim-admin (= 0.28.1) - decidim-comments (= 0.28.1) - decidim-core (= 0.28.1) - decidim-proposals (= 0.28.1) - decidim-surveys (0.28.1) - decidim-core (= 0.28.1) - decidim-forms (= 0.28.1) - decidim-system (0.28.1) + decidim-sortitions (0.31.0) + decidim-admin (= 0.31.0) + decidim-comments (= 0.31.0) + decidim-core (= 0.31.0) + decidim-proposals (= 0.31.0) + decidim-surveys (0.31.0) + decidim-core (= 0.31.0) + decidim-forms (= 0.31.0) + decidim-system (0.31.0) active_link_to (~> 1.0) - decidim-core (= 0.28.1) + decidim-core (= 0.31.0) devise (~> 4.7) devise-i18n (~> 1.2) devise_invitable (~> 2.0, >= 2.0.9) - decidim-templates (0.28.1) - decidim-core (= 0.28.1) - decidim-forms (= 0.28.1) - decidim-verifications (0.28.1) - decidim-core (= 0.28.1) - declarative-builder (0.1.0) - declarative-option (< 0.2.0) + decidim-verifications (0.31.0) + decidim-core (= 0.31.0) + declarative-builder (0.2.0) + trailblazer-option (~> 0.1.0) declarative-option (0.1.0) deface (1.9.0) actionview (>= 5.2) @@ -331,109 +359,147 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) - devise-i18n (1.11.0) + devise-i18n (1.15.0) devise (>= 4.9.0) - devise_invitable (2.0.9) + rails-i18n + devise-jwt (0.12.1) + devise (~> 4.0) + warden-jwt_auth (~> 0.10) + devise_invitable (2.0.11) actionmailer (>= 5.0) devise (>= 4.6) - diff-lcs (1.5.1) - diffy (3.4.2) - doc2text (0.4.7) - nokogiri (>= 1.13.2, < 1.17.0) + diff-lcs (1.6.2) + diffy (3.4.4) + doc2text (0.4.8) + nokogiri (>= 1.18.2) rubyzip (~> 2.3.0) - docile (1.4.0) - doorkeeper (5.7.1) + docile (1.4.1) + doorkeeper (5.8.2) railties (>= 5) doorkeeper-i18n (4.0.1) - erb_lint (0.4.0) + drb (2.2.3) + dry-auto_inject (1.1.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-configurable (1.3.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-core (1.1.0) + concurrent-ruby (~> 1.0) + logger + zeitwerk (~> 2.6) + erb (6.0.0) + erb_lint (0.8.0) activesupport better_html (>= 2.0.1) parser (>= 2.7.1.4) rainbow - rubocop + rubocop (>= 1) smart_properties erbse (0.1.4) temple - erubi (1.13.0) - escape_utils (1.2.2) - excon (0.111.0) + erubi (1.13.1) + escape_utils (1.3.0) + excon (1.3.2) + logger extended-markdown-filter (0.7.0) html-pipeline (~> 2.9) - factory_bot (6.4.6) - activesupport (>= 5.0.0) - factory_bot_rails (6.4.3) - factory_bot (~> 6.4) - railties (>= 5.0.0) + factory_bot (6.5.6) + activesupport (>= 6.1.0) + factory_bot_rails (6.5.1) + factory_bot (~> 6.5) + railties (>= 6.1.0) faker (3.3.1) i18n (>= 1.8.11, < 2) - faraday (2.10.0) - faraday-net_http (>= 2.0, < 3.2) + faraday (2.14.0) + faraday-net_http (>= 2.0, < 3.5) + json logger - faraday-net_http (3.1.0) - net-http - ffi (1.17.0) + faraday-net_http (3.4.2) + net-http (~> 0.5) + ffi (1.17.2) + fiber-storage (1.0.1) file_validators (3.0.0) activemodel (>= 3.2) mime-types (>= 1.0) - fog-core (2.4.0) + fog-core (2.6.0) builder - excon (~> 0.71) + excon (~> 1.0) formatador (>= 0.2, < 2.0) mime-types - fog-local (0.8.0) + fog-local (0.9.0) fog-core (>= 1.27, < 3.0) - formatador (1.1.0) - foundation_rails_helper (4.0.1) - actionpack (>= 4.1, < 7.1) - activemodel (>= 4.1, < 7.1) - activesupport (>= 4.1, < 7.1) - railties (>= 4.1, < 7.1) + formatador (1.2.3) + reline gemoji (3.0.1) - geocoder (1.8.3) + geocoder (1.8.6) base64 (>= 0.1.0) csv (>= 3.0.0) - globalid (1.2.1) + geom2d (0.4.1) + globalid (1.3.0) activesupport (>= 6.1) - graphql (2.0.31) + google-protobuf (4.33.2) + bigdecimal + rake (>= 13) + graphql (2.4.17) base64 - graphql-docs (3.0.1) - commonmarker (~> 0.16) - escape_utils (~> 1.2.2) + fiber-storage + logger + graphql-docs (5.2.0) + commonmarker (~> 0.23, >= 0.23.6) + escape_utils (~> 1.2) extended-markdown-filter (~> 0.4) gemoji (~> 3.0) graphql (~> 2.0) - html-pipeline (~> 2.9) - sass (~> 3.4) - hashdiff (1.1.0) + html-pipeline (~> 2.14, >= 2.14.3) + logger (~> 1.6) + ostruct (~> 0.6) + sass-embedded (~> 1.58) + hashdiff (1.2.1) hashie (5.0.0) - highline (3.0.1) + hexapdf (1.1.1) + cmdparse (~> 3.0, >= 3.0.3) + geom2d (~> 0.4, >= 0.4.1) + openssl (>= 2.2.1) + strscan (>= 3.1.2) + highline (3.1.2) + reline html-pipeline (2.14.3) activesupport (>= 2) nokogiri (>= 1.4) - htmlentities (4.3.4) - i18n (1.14.5) + htmlentities (4.4.2) + i18n (1.14.7) concurrent-ruby (~> 1.0) - i18n-tasks (1.0.14) + i18n-tasks (1.1.2) activesupport (>= 4.0.2) ast (>= 2.1.0) erubi - highline (>= 2.0.0) + highline (>= 3.0.0) i18n parser (>= 3.2.2.1) + prism rails-i18n rainbow (>= 2.2.2, < 4.0) + ruby-progressbar (~> 1.8, >= 1.8.1) terminal-table (>= 1.5.1) - i18n_data (0.11.0) - icalendar (2.10.1) + icalendar (2.12.1) + base64 ice_cube (~> 0.16) - ice_cube (0.16.4) - image_processing (1.12.2) - mini_magick (>= 4.9.5, < 5) + logger + ostruct + ice_cube (0.17.0) + image_processing (1.14.0) + mini_magick (>= 4.9.5, < 6) ruby-vips (>= 2.0.17, < 3) invisible_captcha (0.13.0) rails (>= 3.2.0) - json (2.7.2) - jwt (2.8.2) + io-console (0.8.1) + irb (1.15.3) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.17.1) + jwt (3.1.2) base64 kaminari (1.2.2) activesupport (>= 4.1.0) @@ -447,6 +513,7 @@ GEM activerecord kaminari-core (= 1.2.2) kaminari-core (1.2.2) + language_server-protocol (3.17.0.5) launchy (3.1.0) addressable (~> 2.8) childprocess (~> 5.0) @@ -458,72 +525,79 @@ GEM letter_opener (~> 1.7) railties (>= 5.2) rexml + lint_roller (1.1.0) listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - logger (1.6.0) - loofah (2.22.0) + logger (1.7.0) + loofah (2.24.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) - mail (2.8.1) + mail (2.9.0) + logger mini_mime (>= 0.1.1) net-imap net-pop net-smtp - marcel (1.0.4) - matrix (0.4.2) - method_source (1.1.0) - mime-types (3.5.2) - mime-types-data (~> 3.2015) - mime-types-data (3.2024.0702) + marcel (1.1.0) + matrix (0.4.3) + mime-types (3.7.0) + logger + mime-types-data (~> 3.2025, >= 3.2025.0507) + mime-types-data (3.2025.0924) mini_magick (4.13.2) mini_mime (1.1.5) - minitest (5.24.1) + mini_portile2 (2.8.9) + minitest (5.26.2) msgpack (1.7.2) - multi_xml (0.6.0) - net-http (0.4.1) - uri - net-imap (0.4.14) + multi_xml (0.7.2) + bigdecimal (~> 3.1) + net-http (0.8.0) + uri (>= 0.11.1) + net-imap (0.5.12) date net-protocol net-pop (0.1.2) net-protocol net-protocol (0.2.2) timeout - net-smtp (0.3.4) + net-smtp (0.5.1) net-protocol - nio4r (2.7.3) - nokogiri (1.16.6-x86_64-darwin) - racc (~> 1.4) - nokogiri (1.16.6-x86_64-linux) + nio4r (2.7.5) + nokogiri (1.18.10) + mini_portile2 (~> 2.8.2) racc (~> 1.4) - oauth (1.1.0) - oauth-tty (~> 1.0, >= 1.0.1) + oauth (1.1.3) + base64 (~> 0.1) + oauth-tty (~> 1.0, >= 1.0.6) snaky_hash (~> 2.0) - version_gem (~> 1.1) - oauth-tty (1.0.5) - version_gem (~> 1.1, >= 1.1.1) - oauth2 (2.0.9) - faraday (>= 0.17.3, < 3.0) - jwt (>= 1.0, < 3.0) + version_gem (~> 1.1, >= 1.1.9) + oauth-tty (1.0.6) + version_gem (~> 1.1, >= 1.1.9) + oauth2 (2.0.18) + faraday (>= 0.17.3, < 4.0) + jwt (>= 1.0, < 4.0) + logger (~> 1.2) multi_xml (~> 0.5) rack (>= 1.2, < 4) - snaky_hash (~> 2.0) - version_gem (~> 1.1) - omniauth (2.1.2) + snaky_hash (~> 2.0, >= 2.0.3) + version_gem (~> 1.1, >= 1.1.9) + omniauth (2.1.4) hashie (>= 3.4.6) + logger rack (>= 2.2.3) rack-protection omniauth-facebook (5.0.0) omniauth-oauth2 (~> 1.2) - omniauth-google-oauth2 (1.1.2) - jwt (>= 2.0) + omniauth-google-oauth2 (1.2.1) + jwt (>= 2.9.2) oauth2 (~> 2.0) omniauth (~> 2.0) omniauth-oauth2 (~> 1.8) - omniauth-oauth (1.2.0) + omniauth-oauth (1.2.1) oauth omniauth (>= 1.0, < 3) + rack (>= 1.6.2, < 4) omniauth-oauth2 (1.8.0) oauth2 (>= 1.4, < 3) omniauth (~> 2.0) @@ -533,38 +607,47 @@ GEM omniauth-twitter (1.4.0) omniauth-oauth (~> 1.1) rack - openssl (3.2.0) + openssl (3.3.2) orm_adapter (0.5.0) - paper_trail (12.3.0) - activerecord (>= 5.2) - request_store (~> 1.1) - parallel (1.25.1) - parallel_tests (4.7.1) + ostruct (0.6.3) + package_json (0.2.0) + paper_trail (16.0.0) + activerecord (>= 6.1) + request_store (~> 1.4) + parallel (1.27.0) + parallel_tests (4.10.1) parallel - parser (3.3.3.0) + paranoia (3.0.1) + activerecord (>= 6, < 8.1) + parser (3.3.9.0) ast (~> 2.4.1) racc - pg (1.4.6) - pg_search (2.3.6) - activerecord (>= 5.2) - activesupport (>= 5.2) + pg (1.5.9) + pg_search (2.3.7) + activerecord (>= 6.1) + activesupport (>= 6.1) polyglot (0.3.5) - premailer (1.23.0) + pp (0.6.3) + prettyprint + premailer (1.27.0) addressable - css_parser (>= 1.12.0) + css_parser (>= 1.19.0) htmlentities (>= 4.0.0) premailer-rails (1.12.0) actionmailer (>= 3) net-smtp premailer (~> 1.7, >= 1.7.9) - psych (4.0.6) + prettyprint (0.2.0) + prism (1.4.0) + psych (5.3.0) + date stringio - public_suffix (6.0.0) - puma (6.4.2) + public_suffix (7.0.0) + puma (6.6.1) nio4r (~> 2.0) - racc (1.8.0) - rack (2.2.9) - rack-attack (6.7.0) + racc (1.8.1) + rack (2.2.21) + rack-attack (6.8.0) rack (>= 1.0, < 4) rack-cors (1.1.1) rack (>= 2.0.0) @@ -573,81 +656,99 @@ GEM rack (~> 2.2, >= 2.2.4) rack-proxy (0.7.7) rack - rack-test (2.1.0) + rack-session (1.0.2) + rack (< 3) + rack-test (2.2.0) rack (>= 1.3) - rails (6.1.7.8) - actioncable (= 6.1.7.8) - actionmailbox (= 6.1.7.8) - actionmailer (= 6.1.7.8) - actionpack (= 6.1.7.8) - actiontext (= 6.1.7.8) - actionview (= 6.1.7.8) - activejob (= 6.1.7.8) - activemodel (= 6.1.7.8) - activerecord (= 6.1.7.8) - activestorage (= 6.1.7.8) - activesupport (= 6.1.7.8) + rackup (1.0.1) + rack (< 3) + webrick + rails (7.2.3) + actioncable (= 7.2.3) + actionmailbox (= 7.2.3) + actionmailer (= 7.2.3) + actionpack (= 7.2.3) + actiontext (= 7.2.3) + actionview (= 7.2.3) + activejob (= 7.2.3) + activemodel (= 7.2.3) + activerecord (= 7.2.3) + activestorage (= 7.2.3) + activesupport (= 7.2.3) bundler (>= 1.15.0) - railties (= 6.1.7.8) - sprockets-rails (>= 2.0.0) + railties (= 7.2.3) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) activesupport (>= 5.0.1.rc1) - rails-dom-testing (2.2.0) + rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.0) + rails-html-sanitizer (1.6.2) loofah (~> 2.21) - nokogiri (~> 1.14) - rails-i18n (6.0.0) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + rails-i18n (7.0.10) i18n (>= 0.7, < 2) - railties (>= 6.0.0, < 7) - railties (6.1.7.8) - actionpack (= 6.1.7.8) - activesupport (= 6.1.7.8) - method_source + railties (>= 6.0.0, < 8) + railties (7.2.3) + actionpack (= 7.2.3) + activesupport (= 7.2.3) + cgi + irb (~> 1.13) + rackup (>= 1.0.0) rake (>= 12.2) - thor (~> 1.0) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.2.1) - ransack (3.2.1) + rake (13.3.1) + ransack (4.2.1) activerecord (>= 6.1.5) activesupport (>= 6.1.5) i18n rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) - redcarpet (3.6.0) + rdoc (6.17.0) + erb + psych (>= 4.0.0) + tsort + redcarpet (3.6.1) redis (4.8.1) - regexp_parser (2.9.2) - request_store (1.5.1) + regexp_parser (2.11.2) + reline (0.6.3) + io-console (~> 0.5) + request_store (1.7.0) rack (>= 1.4) - responders (3.1.1) - actionpack (>= 5.2) - railties (>= 5.2) + responders (3.2.0) + actionpack (>= 7.0) + railties (>= 7.0) rexml (3.3.1) strscan - rspec (3.13.0) + rqrcode (2.2.0) + chunky_png (~> 1.0) + rqrcode_core (~> 1.0) + rqrcode_core (1.2.0) + rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-cells (0.3.9) + rspec-cells (0.3.10) cells (>= 4.0.0, < 6.0.0) - rspec-rails (>= 3.0.0, < 6.2.0) - rspec-core (3.13.0) + rspec-rails (>= 3.0.0) + rspec-core (3.13.6) rspec-support (~> 3.13.0) - rspec-expectations (3.13.1) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-html-matchers (0.10.0) nokogiri (~> 1) rspec (>= 3.0.0.a) - rspec-mocks (3.13.1) + rspec-mocks (3.13.7) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (6.1.3) + rspec-rails (6.1.5) actionpack (>= 6.1) activesupport (>= 6.1) railties (>= 6.1) @@ -657,62 +758,78 @@ GEM rspec-support (~> 3.13) rspec-retry (0.6.2) rspec-core (> 3.3) - rspec-support (3.13.1) + rspec-support (3.13.6) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.50.2) + rubocop (1.78.0) json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) parallel (~> 1.10) - parser (>= 3.2.0.0) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.28.0, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.45.1, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.31.3) - parser (>= 3.3.1.0) - rubocop-capybara (2.21.0) - rubocop (~> 1.41) - rubocop-factory_bot (2.25.1) - rubocop (~> 1.41) - rubocop-faker (1.1.0) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.46.0) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-capybara (2.22.1) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-factory_bot (2.27.1) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-faker (1.3.0) faker (>= 2.12.0) - rubocop (>= 0.82.0) - rubocop-rails (2.25.1) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) + rubocop-graphql (1.5.6) + lint_roller (~> 1.1) + rubocop (>= 1.72.1, < 2) + rubocop-performance (1.26.0) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rails (2.32.0) activesupport (>= 4.2.0) + lint_roller (~> 1.1) rack (>= 1.1) - rubocop (>= 1.33.0, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rspec (2.31.0) - rubocop (~> 1.40) - rubocop-capybara (~> 2.17) - rubocop-factory_bot (~> 2.22) - rubocop-rspec_rails (~> 2.28) - rubocop-rspec_rails (2.28.3) - rubocop (~> 1.40) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rspec (3.7.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-rspec_rails (2.31.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-rspec (~> 3.5) + rubocop-rubycw (0.2.2) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) ruby-progressbar (1.13.0) - ruby-vips (2.2.1) + ruby-vips (2.2.5) ffi (~> 1.12) - rubyXL (3.4.27) + logger + rubyXL (3.4.33) nokogiri (>= 1.10.8) rubyzip (>= 1.3.0) rubyzip (2.3.2) - sass (3.7.4) - sass-listen (~> 4.0.0) - sass-listen (4.0.0) - rb-fsevent (~> 0.9, >= 0.9.4) - rb-inotify (~> 0.9, >= 0.9.7) - selenium-webdriver (4.22.0) + sass-embedded (1.95.1) + google-protobuf (~> 4.31) + rake (>= 13) + securerandom (0.4.1) + selenium-webdriver (4.39.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) - rubyzip (>= 1.2.2, < 3.0) + rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) - semantic_range (3.0.0) - seven_zip_ruby (1.3.0) - shakapacker (7.1.0) + semantic_range (3.1.0) + shakapacker (8.3.0) activesupport (>= 5.2) + package_json rack-proxy (>= 0.6.1) railties (>= 5.2) semantic_range (>= 2.3.0) @@ -723,46 +840,40 @@ GEM simplecov-cobertura (2.1.0) rexml simplecov (~> 0.19) - simplecov-html (0.12.3) + simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) - sixarm_ruby_unaccent (1.2.2) smart_properties (1.17.0) - snaky_hash (2.0.1) - hashie - version_gem (~> 1.1, >= 1.1.1) - sort_alphabetical (1.1.0) - unicode_utils (>= 1.2.2) - spring (2.1.1) - spring-watcher-listen (2.0.1) + snaky_hash (2.0.3) + hashie (>= 0.1.0, < 6) + version_gem (>= 1.1.8, < 3) + spring (4.4.0) + spring-watcher-listen (2.1.0) listen (>= 2.7, < 4.0) - spring (>= 1.2, < 3.0) - sprockets (4.2.1) - concurrent-ruby (~> 1.0) - rack (>= 2.2.4, < 4) - sprockets-rails (3.5.1) - actionpack (>= 6.1) - activesupport (>= 6.1) - sprockets (>= 3.0.0) - ssrf_filter (1.1.2) - stringio (3.1.1) - strscan (3.1.0) - temple (0.10.3) - terminal-table (3.0.2) - unicode-display_width (>= 1.1.1, < 3) - thor (1.3.1) - tilt (2.4.0) - timeout (0.4.1) + spring (>= 4) + stringio (3.1.9) + strscan (3.1.5) + temple (0.10.4) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) + thor (1.4.0) + tilt (2.6.1) + timeout (0.5.0) + trailblazer-option (0.1.2) + tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) uber (0.1.0) - unicode-display_width (2.5.0) - unicode_utils (1.4.0) - uniform_notifier (1.16.0) - uri (0.13.0) - valid_email2 (4.0.6) - activemodel (>= 3.2) + unaccent (0.4.0) + unicode-display_width (3.1.5) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) + uniform_notifier (1.18.0) + uri (1.1.1) + useragent (0.16.11) + valid_email2 (7.0.13) + activemodel (>= 6.0) mail (~> 2.5) - version_gem (1.1.4) + version_gem (1.1.9) w3c_rspec_validators (0.3.0) rails rspec @@ -773,55 +884,54 @@ GEM rexml (~> 3.2) warden (1.2.9) rack (>= 2.0.9) + warden-jwt_auth (0.12.0) + dry-auto_inject (>= 0.8, < 2) + dry-configurable (>= 0.13, < 2) + jwt (>= 2.1, < 4) + warden (~> 1.2) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) - web-push (3.0.1) - jwt (~> 2.0) + web-push (3.0.2) + jwt (~> 3.0) openssl (~> 3.0) - webmock (3.23.1) + webmock (3.26.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - websocket (1.2.10) - websocket-driver (0.7.6) + webrick (1.9.2) + websocket (1.2.11) + websocket-driver (0.8.0) + base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - wicked_pdf (2.8.0) - activesupport - wisper (2.0.1) + wisper (3.0.0) wisper-rspec (1.1.0) - wkhtmltopdf-binary (0.12.6.7) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.6.16) + zeitwerk (2.7.3) PLATFORMS - x86_64-darwin-24 - x86_64-linux + ruby DEPENDENCIES - bootsnap (~> 1.3) + bootsnap (~> 1.7) byebug (~> 11.0) - country_select (~> 4.0) - decidim (~> 0.28) - decidim-dev (~> 0.28) + decidim (~> 0.31.0) + decidim-dev (~> 0.31.0) decidim-extra_user_fields! + decidim-initiatives (~> 0.31.0) faker (~> 3.3.1) letter_opener_web (~> 2.0) listen (~> 3.1) - puma (>= 4.3) - rubocop-factory_bot (!= 2.26.0) + puma (>= 6.3.1) rubocop-faker - rubocop-rspec_rails (!= 2.29.0) - spring (~> 2.0) - spring-watcher-listen (~> 2.0) web-console (~> 4.2) RUBY VERSION - ruby 3.1.1p18 + ruby 3.3.4p94 BUNDLED WITH - 2.4.10 + 2.5.17 diff --git a/README.md b/README.md index 9eb281ff..3f7b1737 100644 --- a/README.md +++ b/README.md @@ -15,39 +15,45 @@ This module also enables an Export action in the participants admin panel, which ## Installation -Pick the version of the gem that matches your Decidim version. - -For Decidim 0.27: +Add this line to your application's Gemfile: ```ruby -gem "decidim-extra_user_fields", git: "https://github.com/PopulateTools/decidim-module-extra_user_fields.git", branch: "release/0.27-stable" +gem 'decidim-extra_user_fields', github: 'openpoke/decidim-module-extra_user_fields' ``` -For Decidim 0.26: +And then execute: -```ruby -gem "decidim-extra_user_fields", git: "https://github.com/PopulateTools/decidim-module-extra_user_fields.git", branch: "release/0.26-stable" +```bash +bundle +bin/rails decidim:upgrade +bin/rails db:migrate ``` -For Decidim 0.25: +### Upgrading from older versions -```ruby -gem "decidim-extra_user_fields", git: "https://github.com/PopulateTools/decidim-module-extra_user_fields.git", branch: "release/0.25-stable" +If you're upgrading from a version before the boolean structure refactoring, you need to normalize the extra user fields structure. Run the following rake task: + +```bash +bin/rails decidim_extra_user_fields:normalize_structure ``` -For Decidim 0.24: +This will convert the old string-based field states (`disabled`/`optional`/`required`) to the new boolean-based structure (`enabled` and `required` boolean flags). -```ruby -gem "decidim-extra_user_fields", git: "https://github.com/PopulateTools/decidim-module-extra_user_fields.git", branch: "release/0.24-stable" -``` +> **EXPERTS ONLY** +> +> Under the hood, when running `bundle exec rails decidim:upgrade` the `decidim-extra_user_fields` gem will run the following (that can also be run manually if you consider): +> +> ```bash +> bin/rails decidim_extra_user_fields:install:migrations +> ``` -And then execute: +You can also the version of the gem that matches your Decidim version: -```bash -bundle install -# For versions >= 0.27 -bundle exec rake railties:install:migrations -bundle exec rake db:migrate + +```ruby +gem "decidim-extra_user_fields", github: "openpoke/decidim-module-extra_user_fields", branch: "release/0.31-stable" +gem "decidim-extra_user_fields", github: "openpoke/decidim-module-extra_user_fields", branch: "release/0.30-stable" +gem "decidim-extra_user_fields", github: "PopulateTools/decidim-module-extra_user_fields", branch: "release/0.29-stable" ``` ## Usage @@ -56,7 +62,7 @@ bundle exec rake db:migrate After installing the gem and migrating the database, you can enable the extra fields in the admin panel of the organization. Go to Settings > Manage extra user fields. There you can enable the fields you want to use. By default all fields are required and don't include any format validation. -![Admin panel](https://github.com/PopulateTools/decidim-module-extra_user_fields/blob/extra-fields-0-27/docs/resources/extra_user_fields_admin.png) +![Admin panel](docs/resources/extra_user_fields_admin.png) Most of the fields are plain text inputs, but other have a special format: @@ -67,16 +73,107 @@ Most of the fields are plain text inputs, but other have a special format: Once the fields are enabled, they will be shown in the user signup form and in the user profile. -![User signup](https://github.com/PopulateTools/decidim-module-extra_user_fields/blob/extra-fields-0-27/docs/resources/extra_user_fields_signup.png) +![User signup](docs/resources/extra_user_fields_signup.png) -![User profile](https://github.com/PopulateTools/decidim-module-extra_user_fields/blob/extra-fields-0-27/docs/resources/extra_user_fields_profile.png) +![User profile](docs/resources/extra_user_fields_profile.png) ### Admin users export An extra feature of this plugin is to enable an Export action in the participants admin panel. This action allows to download a list of participants in CSV, JSON or Excel. The fields included in the export are the Decidim User attributes plus the extra fields enabled in the admin panel. -![User export](https://github.com/PopulateTools/decidim-module-extra_user_fields/blob/extra-fields-0-27/docs/resources/extra_user_fields_export.png) +![User export](docs/resources/extra_user_fields_export.png) + + +## Configuration + +By default, the module is configured to read the configuration from ENV variables. + +Currently, the following ENV variables are supported: + +| ENV variable | Description | Default value | +| ------------ | ----------- |-------| +| EXTRA_USER_FIELDS_UNDERAGE_LIMIT | The minimum age limit to consider a user as underage. This is used to determine if the user falls into the underage category. | `18` | +| EXTRA_USER_FIELDS_UNDERAGE_OPTIONS | Options for selecting the age for when someone is considered "underage". | `15 16 17 18 19 20 21` | +| EXTRA_USER_FIELDS_GENDERS | Options for the gender field (you need to add the corresponding I18n keys, ie: `decidim.extra_user_fields.genders.prefer_not_to_say` ) | `female male other prefer_not_to_say` | +| EXTRA_USER_FIELDS_AGE_RANGES | Options for the age range field (you need to add the corresponding I18n keys, e.g., `decidim.extra_user_fields.age_ranges.up_to_16`) | `up_to_16 17_to_30 31_to_60 61_or_more prefer_not_to_say` | + +## Custom fields + +If your use case include fields not defined in this module, it is possible to define custom fields of different types: + +1. **Select fields** This configuration option allows you to define any number of extra user fields of the type "Select". +2. **Boolean fields** This configuration option allows you to define any number of extra user fields of the type "Boolean". These fields can be used for true/false values, such as "Accept terms and conditions" or "Subscribe to newsletter". +3. **Text fields** This configuration option allows you to define any number of extra user fields of the type "Text". These fields can be used for free-form text input, such as "Hobbies" or "Favorite quote". + + +See the next section "Configuration through an initializer" for more information. + + +### Configuration through an initializer + +It is also possible to configure the module using the an initializer: + +Create an initializer (for instance `config/initializers/extra_user_fields.rb`) and configure the following: + +```ruby +# config/initializers/extra_user_fields.rb + +Decidim::ExtraUserFields.configure do |config| + config.genders = [:female, :male, :other, :prefer_not_to_say] + config.age_ranges = ["30_or_younger", "31_or_older", "prefer_not_to_say"] + + ... + + + # I extra select fields are needed, they can be added here. + # The key is the field name and the value is a hash with the options. + # You can (optionally) add I18n keys for the options (if not the text will be used as it is). + # For the user interface, you can defined labels and descriptions for the fields (optionally): + # decidim.extra_user_fields.select_fields.field_name.label + # decidim.extra_user_fields.select_fields.field_name.description + # For the admin interface, you can defined labels and descriptions for the fields (optionally): + # decidim.extra_user_fields.admin.extra_user_fields.select_fields.field_name.label + # decidim.extra_user_fields.admin.extra_user_fields.select_fields.field_name.description + config_accessor :select_fields do + { + participant_type: { + # "" => "", + "individual" => "decidim.extra_user_fields.participant_types.individual", + "organization" => "decidim.extra_user_fields.participant_types.organization" + }, + favorite_pet: { + "cat" => "my_app.favorite_pets.cat", + "dog" => "my_app.favorite_pets.dog" + }, + # It is also possible to specify a proc/lambda that returns a suitable list for the select: + organization_country: ->(form) { ISO3166::Country.translations(form.locale).invert }, + } + end + + # If extra boolean fields are needed, they can be added as an Array here. + # For the user interface, you can defined labels and descriptions for the fields (optionally): + # decidim.extra_user_fields.boolean_fields.field_name.label + # decidim.extra_user_fields.boolean_fields.field_name.description + # For the admin interface, you can defined labels and descriptions for the fields (optionally): + # decidim.extra_user_fields.admin.extra_user_fields.boolean_fields.field_name.label + # decidim.extra_user_fields.admin.extra_user_fields.boolean_fields.field_name.description + config_accessor :boolean_fields do + [:ngo, :newsletter] + end + + # If extra text fields are needed, they can be added as an Array here + # For the user interface, you can define labels and descriptions for the fields (optionally): + # decidim.extra_user_fields.text_fields.field_name.label + # decidim.extra_user_fields.text_fields.field_name.description + # For the admin interface, you can define labels and descriptions for the fields (optionally): + # decidim.extra_user_fields.admin.extra_user_fields.text_fields.field_name.label + # decidim.extra_user_fields.admin.extra_user_fields.text_fields.field_name.description + config_accessor :text_fields do + [ :hobbies, :favorite_quote ] + end +end +``` ## Contributing diff --git a/Rakefile b/Rakefile index 45f3f268..02b01e21 100644 --- a/Rakefile +++ b/Rakefile @@ -5,14 +5,14 @@ require "decidim/dev/common_rake" def install_module(path) Dir.chdir(path) do - system("bundle exec rake decidim_extra_user_fields:install:migrations") - system("bundle exec rake db:migrate") + system("bundle exec rails decidim_extra_user_fields:install:migrations") + system("bundle exec rails db:migrate") end end def seed_db(path) Dir.chdir(path) do - system("bundle exec rake db:seed") + system("bundle exec rails db:seed") end end diff --git a/app/commands/concerns/decidim/extra_user_fields/create_registrations_commands_overrides.rb b/app/commands/concerns/decidim/extra_user_fields/create_registrations_commands_overrides.rb index dd93ac90..561a0984 100644 --- a/app/commands/concerns/decidim/extra_user_fields/create_registrations_commands_overrides.rb +++ b/app/commands/concerns/decidim/extra_user_fields/create_registrations_commands_overrides.rb @@ -50,9 +50,13 @@ def extended_data postal_code: form.postal_code, date_of_birth: form.date_of_birth, gender: form.gender, + age_range: form.age_range, phone_number: form.phone_number, location: form.location, underage: form.underage, + select_fields: form.select_fields, + boolean_fields: form.boolean_fields, + text_fields: form.text_fields, statutory_representative_email: form.statutory_representative_email ) end diff --git a/app/commands/concerns/decidim/extra_user_fields/omniauth_commands_overrides.rb b/app/commands/concerns/decidim/extra_user_fields/omniauth_commands_overrides.rb index 1cfe9733..7ae4ed28 100644 --- a/app/commands/concerns/decidim/extra_user_fields/omniauth_commands_overrides.rb +++ b/app/commands/concerns/decidim/extra_user_fields/omniauth_commands_overrides.rb @@ -14,12 +14,14 @@ def call verify_oauth_signature! begin - if existing_identity - user = existing_identity.user - verify_user_confirmed(user) + if (@identity = existing_identity) + @user = existing_identity.user + verify_user_confirmed(@user) + trigger_omniauth_event("decidim.user.omniauth_login") - return broadcast(:ok, user) + return broadcast(:ok, @user) end + return broadcast(:invalid) if form.invalid? transaction do @@ -27,9 +29,11 @@ def call send_email_to_statutory_representative @identity = create_identity end - trigger_omniauth_registration + trigger_omniauth_event broadcast(:ok, @user) + rescue Decidim::NeedTosAcceptance + broadcast(:add_tos_errors, @user) rescue ActiveRecord::RecordInvalid => e broadcast(:error, e.record) end @@ -37,6 +41,10 @@ def call private + attr_reader :form, :verified_email + + REGEXP_SANITIZER = /[<>?%&\^*#@()\[\]=+:;"{}\\|]/ + def create_or_find_user @user = User.find_or_initialize_by( email: verified_email, @@ -47,28 +55,29 @@ def create_or_find_user # If user has left the account unconfirmed and later on decides to sign # in with omniauth with an already verified account, the account needs # to be marked confirmed. - @user.skip_confirmation! if !@user.confirmed? && @user.email == verified_email + if !@user.confirmed? && @user.email == verified_email + @user.skip_confirmation! + @user.after_confirmation + end + @user.tos_agreement = "1" + @user.extended_data = extended_data + @user.save! else - generated_password = SecureRandom.hex - @user.email = (verified_email || form.email) - @user.name = form.name + @user.name = form.name.gsub(REGEXP_SANITIZER, "") @user.nickname = form.normalized_nickname - @user.newsletter_notifications_at = nil - @user.password = generated_password - @user.password_confirmation = generated_password - if form.avatar_url.present? - url = URI.parse(form.avatar_url) - filename = File.basename(url.path) - file = url.open - @user.avatar.attach(io: file, filename:) - end + @user.newsletter_notifications_at = form.newsletter_at + @user.password = SecureRandom.hex + attach_avatar(form.avatar_url) if form.avatar_url.present? + @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.extended_data = extended_data + @user.save! + @user.after_confirmation if verified_email end - - @user.tos_agreement = "1" - @user.extended_data = extended_data - @user.save! end def extended_data @@ -77,9 +86,13 @@ def extended_data postal_code: form.postal_code, date_of_birth: form.date_of_birth, gender: form.gender, + age_range: form.age_range, phone_number: form.phone_number, location: form.location, underage: form.underage, + select_fields: form.select_fields, + boolean_fields: form.boolean_fields, + text_fields: form.text_fields, statutory_representative_email: form.statutory_representative_email ) end diff --git a/app/commands/concerns/decidim/extra_user_fields/update_account_commands_overrides.rb b/app/commands/concerns/decidim/extra_user_fields/update_account_commands_overrides.rb index 2907c1e2..ebaa9426 100644 --- a/app/commands/concerns/decidim/extra_user_fields/update_account_commands_overrides.rb +++ b/app/commands/concerns/decidim/extra_user_fields/update_account_commands_overrides.rb @@ -11,24 +11,28 @@ module UpdateAccountCommandsOverrides private def update_personal_data - @user.locale = @form.locale - @user.name = @form.name - @user.nickname = @form.nickname - @user.email = @form.email - @user.personal_url = @form.personal_url - @user.about = @form.about - @user.extended_data = extended_data + current_user.locale = @form.locale + current_user.name = @form.name + current_user.nickname = @form.nickname + current_user.email = @form.email + current_user.personal_url = @form.personal_url + current_user.about = @form.about + current_user.extended_data = extended_data end def extended_data - @extended_data ||= (@user&.extended_data || {}).merge( + @extended_data ||= (current_user&.extended_data || {}).merge( country: @form.country, postal_code: @form.postal_code, date_of_birth: @form.date_of_birth, gender: @form.gender, + age_range: @form.age_range, phone_number: @form.phone_number, location: @form.location, underage: @form.underage, + select_fields: @form.select_fields, + boolean_fields: @form.boolean_fields, + text_fields: @form.text_fields, statutory_representative_email: @form.statutory_representative_email ) end diff --git a/app/commands/decidim/extra_user_fields/admin/export_pivot_data.rb b/app/commands/decidim/extra_user_fields/admin/export_pivot_data.rb new file mode 100644 index 00000000..b6da267d --- /dev/null +++ b/app/commands/decidim/extra_user_fields/admin/export_pivot_data.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Decidim + module ExtraUserFields + module Admin + # Unified command to export pivot table data for both Insights (single space) + # and Benchmarking (multiple spaces). + class ExportPivotData < Decidim::Command + # Public: Initializes the command. + # + # format - A String with the export format (CSV, JSON, Excel). + # current_user - The user performing the export. + # spaces - An Array of participatory spaces. + # pivot_params - A Hash with :metric, :row_field, :col_field. + # export_name - A String label for the export (e.g. "insights", "benchmarking"). + def initialize(format, current_user, spaces, pivot_params, export_name:) + @format = format + @current_user = current_user + @spaces = spaces + @pivot_params = pivot_params + @export_name = export_name + end + + def call + traceability_target = spaces.size == 1 ? spaces.first : current_user.organization + + Decidim.traceability.perform_action!( + :"export_#{export_name}", + traceability_target, + current_user + ) do + ExportPivotDataJob.perform_later(current_user, format, spaces, pivot_params, export_name) + end + + broadcast(:ok) + end + + private + + attr_reader :format, :current_user, :spaces, :pivot_params, :export_name + end + end + end +end diff --git a/app/commands/decidim/extra_user_fields/admin/update_extra_user_fields.rb b/app/commands/decidim/extra_user_fields/admin/update_extra_user_fields.rb index d2e74438..4244ce0f 100644 --- a/app/commands/decidim/extra_user_fields/admin/update_extra_user_fields.rb +++ b/app/commands/decidim/extra_user_fields/admin/update_extra_user_fields.rb @@ -38,28 +38,77 @@ def update_extra_user_fields! ) end - # rubocop:disable Metrics/CyclomaticComplexity def extra_user_fields { "enabled" => form.enabled.presence || false, - "date_of_birth" => { "enabled" => form.date_of_birth.presence || false }, - "country" => { "enabled" => form.country.presence || false }, - "postal_code" => { "enabled" => form.postal_code.presence || false }, - "gender" => { "enabled" => form.gender.presence || false }, + **standard_fields, + **phone_number_fields, + "underage" => underage_fields, + "select_fields" => normalize_select_fields, + "boolean_fields" => normalize_boolean_fields, + "text_fields" => normalize_text_fields + } + end + + def standard_fields + (Decidim::ExtraUserFields::PROFILE_FIELDS - %w(phone_number)).index_with do |field| + enabled = form.public_send(:"#{field}_enabled") == true + { + "enabled" => enabled, + "required" => enabled && form.public_send(:"#{field}_required") == true + } + end + end + + def phone_number_fields + enabled = form.phone_number_enabled == true + { "phone_number" => { - "enabled" => form.phone_number.presence || false, + "enabled" => enabled, + "required" => enabled && form.phone_number_required == true, "pattern" => form.phone_number_pattern.presence, "placeholder" => form.phone_number_placeholder.presence - }, - "location" => { "enabled" => form.location.presence || false }, - "underage" => { "enabled" => form.underage || false }, - "underage_limit" => form.underage_limit || Decidim::ExtraUserFields::Engine::DEFAULT_UNDERAGE_LIMIT - # Block ExtraUserFields SaveFieldInConfig + }.compact + } + end - # EndBlock + def underage_fields + enabled = form.underage_enabled == true + { + "enabled" => enabled, + "required" => enabled && form.underage_required == true, + "limit" => form.underage_limit || Decidim::ExtraUserFields.underage_limit } end - # rubocop:enable Metrics/CyclomaticComplexity + + def normalize_select_fields + normalize_collection_fields(:select_fields) + end + + def normalize_boolean_fields + normalize_collection_fields(:boolean_fields, allow_required: false) + end + + def normalize_text_fields + normalize_collection_fields(:text_fields) + end + + def normalize_collection_fields(name, allow_required: true) + data = form.public_send(name) + return {} unless data.is_a?(Hash) + + data.transform_values do |field_data| + next({ "enabled" => false, "required" => false }) if field_data.blank? || !field_data.is_a?(Hash) + + enabled = field_data["enabled"] == "true" + required = allow_required && field_data["required"] == "true" + + { + "enabled" => enabled, + "required" => enabled && required + } + end + end end end end diff --git a/app/controllers/concerns/decidim/extra_user_fields/account_controller_overrides.rb b/app/controllers/concerns/decidim/extra_user_fields/account_controller_overrides.rb new file mode 100644 index 00000000..0b37f290 --- /dev/null +++ b/app/controllers/concerns/decidim/extra_user_fields/account_controller_overrides.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Decidim + module ExtraUserFields + # Overrides AccountController#show to trigger validation + # when the user has incomplete mandatory extra user fields. + # This makes the form render with error highlights on load. + module AccountControllerOverrides + extend ActiveSupport::Concern + + def show + super + + return unless current_organization.respond_to?(:has_required_extra_user_fields?) + return unless current_organization.has_required_extra_user_fields? + return if current_organization.extra_user_fields_complete?(current_user) + + @account.validate + end + end + end +end diff --git a/app/controllers/concerns/decidim/extra_user_fields/admin/pivot_params_concern.rb b/app/controllers/concerns/decidim/extra_user_fields/admin/pivot_params_concern.rb new file mode 100644 index 00000000..79a70f69 --- /dev/null +++ b/app/controllers/concerns/decidim/extra_user_fields/admin/pivot_params_concern.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Decidim + module ExtraUserFields + module Admin + module PivotParamsConcern + extend ActiveSupport::Concern + + included do + helper_method :current_metric, :current_row_field, :current_col_field, + :available_metrics, :available_fields + end + + private + + def current_metric + @current_metric ||= detect_metric(params[:metric]) || available_metrics.first + end + + def current_row_field + @current_row_field ||= detect_field(params[:rows]) || available_fields.second || available_fields.first + end + + def current_col_field + @current_col_field ||= detect_field(params[:cols]) || available_fields.first + end + + def available_metrics + @available_metrics ||= InsightMetrics.available_metrics + end + + def available_fields + @available_fields ||= Decidim::ExtraUserFields.insight_fields + end + + def pivot_params + { metric: current_metric, row_field: current_row_field, col_field: current_col_field } + end + + def detect_metric(name) + name = name.to_s + name if InsightMetrics.valid_metric?(name) + end + + def detect_field(name) + name = name.to_s + name if available_fields.include?(name) + end + end + end + end +end diff --git a/app/controllers/concerns/decidim/extra_user_fields/needs_extra_user_fields_completed.rb b/app/controllers/concerns/decidim/extra_user_fields/needs_extra_user_fields_completed.rb new file mode 100644 index 00000000..4357e876 --- /dev/null +++ b/app/controllers/concerns/decidim/extra_user_fields/needs_extra_user_fields_completed.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Decidim + module ExtraUserFields + # Forces signed-in users to complete mandatory extra user fields + # before they can navigate the platform. + module NeedsExtraUserFieldsCompleted + extend ActiveSupport::Concern + + included do + before_action :extra_user_fields_completed_by_user + end + + private + + def extra_user_fields_completed_by_user + return unless request.format.html? + return unless current_user + return unless current_user.tos_accepted? + return unless current_organization.respond_to?(:has_required_extra_user_fields?) + return unless current_organization.has_required_extra_user_fields? + return if current_organization.extra_user_fields_complete?(current_user) + return if permitted_extra_fields_path? + + store_location_for( + current_user, + stored_location_for(current_user) || request.path + ) + + flash[:warning] = t("decidim.extra_user_fields.force_extra_user_fields.redirect_message") + redirect_to decidim.account_path + end + + def permitted_extra_fields_path? + return true if request.path.start_with?(decidim.download_your_data_path.split("?").first) + + permitted_paths = [ + decidim.account_path, + decidim.delete_account_path, + decidim.destroy_user_session_path, + decidim.accept_tos_path + ] + + # Strip query string (e.g. ?locale=fr) before comparing against request.path, + # matching the pattern used by Decidim core in NeedsTosAccepted. + permitted_paths.find { |el| el.split("?").first == request.path } + end + end + end +end diff --git a/app/controllers/decidim/extra_user_fields/admin/benchmarking_controller.rb b/app/controllers/decidim/extra_user_fields/admin/benchmarking_controller.rb new file mode 100644 index 00000000..5fd4d31d --- /dev/null +++ b/app/controllers/decidim/extra_user_fields/admin/benchmarking_controller.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Decidim + module ExtraUserFields + module Admin + class BenchmarkingController < Decidim::Admin::ApplicationController + include PivotParamsConcern + layout "decidim/admin/insights" + + helper InsightsHelper + helper BenchmarkingHelper + + helper_method :comparative_pivot_presenter, :selected_spaces, :available_spaces + + def show + enforce_permission_to :read, :insights + end + + def export + enforce_permission_to :export, :insights + + ExportPivotData.call( + params[:export_format], + current_user, + selected_spaces, + pivot_params, + export_name: "benchmarking" + ) do + on(:ok) do + flash[:notice] = t("decidim.admin.exports.notice") + redirect_back(fallback_location: decidim_extra_user_fields.benchmarking_path) + end + end + end + + private + + def comparative_pivot_presenter + @comparative_pivot_presenter ||= ComparativePivotPresenter.new( + pivot_tables_by_space, + row_field: current_row_field, + col_field: current_col_field + ) + end + + def pivot_tables_by_space + selected_spaces.index_with do |space| + PivotTableBuilder.new( + participatory_space: space, + metric_name: current_metric, + row_field: current_row_field, + col_field: current_col_field + ).call + end + end + + def selected_spaces + @selected_spaces ||= parse_selected_spaces + end + + def available_spaces + @available_spaces ||= Decidim.participatory_space_manifests.flat_map do |manifest| + manifest.model_class_name.constantize.where(organization: current_organization) + end + end + + def permission_class_chain + [ + ::Decidim::ExtraUserFields::Admin::Permissions, + ::Decidim::Admin::Permissions + ] + end + + def parse_selected_spaces + return [] if params[:spaces].blank? + + params[:spaces].filter_map do |key| + klass_name, _, id = key.rpartition(":") + next unless id.present? && klass_name.present? + next unless Decidim.participatory_space_manifests.any? { |m| m.model_class_name == klass_name } + + klass_name.constantize.find_by(id: id, organization: current_organization) + end + end + end + end + end +end diff --git a/app/controllers/decidim/extra_user_fields/admin/extra_user_fields_controller.rb b/app/controllers/decidim/extra_user_fields/admin/extra_user_fields_controller.rb index 2216a535..7c3d9b85 100644 --- a/app/controllers/decidim/extra_user_fields/admin/extra_user_fields_controller.rb +++ b/app/controllers/decidim/extra_user_fields/admin/extra_user_fields_controller.rb @@ -6,7 +6,8 @@ module Admin # This controller is the abstract class from which all other controllers of # this engine inherit. class ExtraUserFieldsController < Admin::ApplicationController - layout "decidim/admin/settings" + layout "decidim/admin/users" + add_breadcrumb_item_from_menu :admin_user_menu def index enforce_permission_to :read, :extra_user_fields @@ -25,7 +26,7 @@ def update UpdateExtraUserFields.call(@form) do on(:ok) do flash[:notice] = t(".success") - render action: "index" + redirect_to root_path end on(:invalid) do diff --git a/app/controllers/decidim/extra_user_fields/admin/insights_controller.rb b/app/controllers/decidim/extra_user_fields/admin/insights_controller.rb new file mode 100644 index 00000000..e9f2abaf --- /dev/null +++ b/app/controllers/decidim/extra_user_fields/admin/insights_controller.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Decidim + module ExtraUserFields + module Admin + class InsightsController < Decidim::Admin::ApplicationController + include Decidim::Admin::ParticipatorySpaceAdminContext + include PivotParamsConcern + participatory_space_admin_layout + helper InsightsHelper + + helper_method :pivot_table_presenter + + before_action :set_breadcrumbs + + def show + enforce_permission_to :read, :insights + end + + def export + enforce_permission_to :export, :insights + + ExportPivotData.call( + params[:export_format], + current_user, + [current_participatory_space], + pivot_params, + export_name: "insights" + ) do + on(:ok) do + flash[:notice] = t("decidim.admin.exports.notice") + redirect_back(fallback_location: root_path) + end + end + end + + private + + def pivot_table_presenter + @pivot_table_presenter ||= PivotTablePresenter.new( + PivotTableBuilder.new( + participatory_space: current_participatory_space, + metric_name: current_metric, + row_field: current_row_field, + col_field: current_col_field + ).call + ) + end + + def permission_class_chain + [ + ::Decidim::ExtraUserFields::Admin::Permissions, + current_participatory_space.manifest.permissions_class, + ::Decidim::Admin::Permissions + ] + end + + def current_participatory_space + @current_participatory_space ||= find_participatory_space_from_params + end + + def find_participatory_space_from_params + Decidim.participatory_space_manifests.each do |manifest| + model_name = manifest.model_class_name.demodulize.underscore + slug = params["#{model_name}_slug"] + next if slug.blank? + + return manifest.model_class_name.constantize.find_by!(organization: current_organization, slug:) + end + + raise ActiveRecord::RecordNotFound, "No participatory space found" + end + + def set_breadcrumbs + Decidim.participatory_space_manifests.each do |manifest| + model_name = manifest.model_class_name.demodulize.underscore + next if params["#{model_name}_slug"].blank? + + secondary_breadcrumb_menus << :"admin_#{model_name}_menu" + break + end + end + end + end + end +end diff --git a/app/forms/concerns/decidim/extra_user_fields/forms_definitions.rb b/app/forms/concerns/decidim/extra_user_fields/forms_definitions.rb index 982d7acc..1d0974fc 100644 --- a/app/forms/concerns/decidim/extra_user_fields/forms_definitions.rb +++ b/app/forms/concerns/decidim/extra_user_fields/forms_definitions.rb @@ -11,41 +11,42 @@ module FormsDefinitions included do include ::Decidim::ExtraUserFields::ApplicationHelper - # Block ExtraUserFields Attributes - attribute :country, String attribute :postal_code, String attribute :date_of_birth, Decidim::Attributes::LocalizedDate attribute :gender, String + attribute :age_range, String attribute :phone_number, String attribute :location, String attribute :underage, ActiveRecord::Type::Boolean attribute :statutory_representative_email, String - - # EndBlock - - # Block ExtraUserFields Validations - - validates :country, presence: true, if: :country? - validates :postal_code, presence: true, if: :postal_code? - validates :date_of_birth, presence: true, if: :date_of_birth? - validates :gender, presence: true, inclusion: { in: Decidim::ExtraUserFields::Engine::DEFAULT_GENDER_OPTIONS.map(&:to_s) }, if: :gender? - validates :phone_number, presence: true, if: :phone_number? + attribute :select_fields, Hash, default: {} + attribute :boolean_fields, Array, default: [] + attribute :text_fields, Hash, default: {} + + validates :country, presence: true, if: :country_required? + validates :postal_code, presence: true, if: :postal_code_required? + validates :date_of_birth, presence: true, if: :date_of_birth_required? + validates :gender, presence: true, if: :gender_required? + validates :gender, inclusion: { in: Decidim::ExtraUserFields.genders.map(&:to_s) }, allow_blank: true, if: :gender? + validates :age_range, presence: true, if: :age_range_required? + validates :age_range, inclusion: { in: Decidim::ExtraUserFields.age_ranges.map(&:to_s) }, allow_blank: true, if: :age_range? + validates :phone_number, presence: true, if: :phone_number_required? validates( :phone_number, format: { with: ->(form) { Regexp.new(form.current_organization.extra_user_field_configuration(:phone_number)["pattern"]) } }, if: :phone_number_format? ) - validates :location, presence: true, if: :location? + validates :location, presence: true, if: :location_required? validates :underage, presence: true, if: :underage? validates :statutory_representative_email, presence: true, "valid_email_2/email": { disposable: true }, if: :underage_accepted? validate :birth_date_under_limit - - # EndBlock + validate :select_fields_configured + validate :required_collection_fields end def map_model(model) @@ -55,59 +56,56 @@ def map_model(model) self.postal_code = extended_data[:postal_code] self.date_of_birth = Date.parse(extended_data[:date_of_birth]) if extended_data[:date_of_birth].present? self.gender = extended_data[:gender] + self.age_range = extended_data[:age_range] self.phone_number = extended_data[:phone_number] self.location = extended_data[:location] self.underage = extended_data[:underage] + self.select_fields = extended_data[:select_fields] || {} + self.boolean_fields = extended_data[:boolean_fields] || [] + self.text_fields = extended_data[:text_fields] || {} self.statutory_representative_email = extended_data[:statutory_representative_email] - - # Block ExtraUserFields MapModel - - # EndBlock end - private - - # Block ExtraUserFields EnableFieldMethod - def country? - extra_user_fields_enabled && current_organization.activated_extra_field?(:country) - end - - def date_of_birth? - extra_user_fields_enabled && current_organization.activated_extra_field?(:date_of_birth) + # Virtual readers for individual custom collection fields. + # These let the form builder access per-field values and errors + # so it can apply is-invalid-label / is-invalid-input automatically. + Decidim::ExtraUserFields.select_fields.each_key do |field_name| + define_method(:"select_fields_#{field_name}") do + (select_fields || {})[field_name.to_s] || (select_fields || {})[field_name.to_sym] + end end - def gender? - extra_user_fields_enabled && current_organization.activated_extra_field?(:gender) + Decidim::ExtraUserFields.text_fields.each do |field_name| + define_method(:"text_fields_#{field_name}") do + (text_fields || {})[field_name.to_s] || (text_fields || {})[field_name.to_sym] + end end - def postal_code? - extra_user_fields_enabled && current_organization.activated_extra_field?(:postal_code) + Decidim::ExtraUserFields::PROFILE_FIELDS.map(&:to_sym).each do |field| + define_method(:"#{field}?") { extra_user_fields_enabled && current_organization.activated_extra_field?(field) } + define_method(:"#{field}_required?") { extra_user_fields_enabled && current_organization.required_extra_field?(field) } end - def phone_number? - extra_user_fields_enabled && current_organization.activated_extra_field?(:phone_number) - end + private def phone_number_format? - return unless phone_number? + return false unless phone_number? current_organization.extra_user_field_configuration(:phone_number)["pattern"].present? end - def location? - extra_user_fields_enabled && current_organization.activated_extra_field?(:location) - end - def underage? extra_user_fields_enabled && current_organization.activated_extra_field?(:underage) end + def select_fields? + extra_user_fields_enabled && current_organization.activated_extra_field?(:select_fields) + end + def underage_accepted? underage? && underage == "1" end - # EndBlock - def extra_user_fields_enabled @extra_user_fields_enabled ||= current_organization.extra_user_fields_enabled? end @@ -137,7 +135,40 @@ def underage_within_limit?(age) end def underage_limit - current_organization.extra_user_fields["underage_limit"] + current_organization.extra_user_fields.dig("underage", "limit") + end + + def select_fields_configured + return unless select_fields? + + select_fields.each do |field, value| + next unless current_organization.extra_user_field_configuration(:select_fields).include?(field.to_s) + + conf = Decidim::ExtraUserFields.select_fields.with_indifferent_access[field] + next unless conf.is_a?(Hash) + next if conf.with_indifferent_access.has_key?(value) + + label = I18n.t("decidim.extra_user_fields.select_fields.#{field}.label", default: field.to_s.humanize) + errors.add(:base, I18n.t("decidim.extra_user_fields.errors.select_fields", field: label)) + end + end + + def required_collection_fields + return unless extra_user_fields_enabled + + [:select_fields, :text_fields].each do |collection| + next unless current_organization.activated_extra_field?(collection) + + active = current_organization.extra_user_field_configuration(collection) + next unless active.is_a?(Hash) + + active.each do |field_name, _| + next unless current_organization.collection_field_required?(collection, field_name) + next if send(collection)[field_name.to_s].present? || send(collection)[field_name.to_sym].present? + + errors.add(:"#{collection}_#{field_name}", :blank) + end + end end end end diff --git a/app/forms/decidim/extra_user_fields/admin/extra_user_fields_form.rb b/app/forms/decidim/extra_user_fields/admin/extra_user_fields_form.rb index 5354b5cf..7ed69c35 100644 --- a/app/forms/decidim/extra_user_fields/admin/extra_user_fields_form.rb +++ b/app/forms/decidim/extra_user_fields/admin/extra_user_fields_form.rb @@ -7,36 +7,76 @@ class ExtraUserFieldsForm < Decidim::Form include TranslatableAttributes attribute :enabled, Boolean - attribute :country, Boolean - attribute :postal_code, Boolean - attribute :date_of_birth, Boolean - attribute :gender, Boolean - attribute :phone_number, Boolean - attribute :location, Boolean - attribute :underage, Boolean + + # Profile fields - each field has enabled and required booleans + Decidim::ExtraUserFields::PROFILE_FIELDS.each do |field| + attribute :"#{field}_enabled", Boolean + attribute :"#{field}_required", Boolean + end + + # Underage is separate (not in PROFILE_FIELDS) + attribute :underage_enabled, Boolean + attribute :underage_required, Boolean attribute :underage_limit, Integer attribute :phone_number_pattern, String translatable_attribute :phone_number_placeholder, String - # Block ExtraUserFields Attributes - # EndBlock + # Collection fields - stored as hashes with field names as keys + attribute :select_fields, Hash, default: {} + attribute :boolean_fields, Hash, default: {} + attribute :text_fields, Hash, default: {} def map_model(model) self.enabled = model.extra_user_fields["enabled"] - self.country = model.extra_user_fields.dig("country", "enabled") - self.postal_code = model.extra_user_fields.dig("postal_code", "enabled") - self.date_of_birth = model.extra_user_fields.dig("date_of_birth", "enabled") - self.gender = model.extra_user_fields.dig("gender", "enabled") - self.phone_number = model.extra_user_fields.dig("phone_number", "enabled") - self.location = model.extra_user_fields.dig("location", "enabled") - self.underage = model.extra_user_fields.dig("underage", "enabled") - self.underage_limit = model.extra_user_fields.fetch("underage_limit", Decidim::ExtraUserFields::Engine::DEFAULT_UNDERAGE_LIMIT) + + Decidim::ExtraUserFields::PROFILE_FIELDS.each do |field| + field_data = model.extra_user_fields[field] + if field_data.is_a?(Hash) + send(:"#{field}_enabled=", field_data["enabled"] == true) + send(:"#{field}_required=", field_data["required"] == true) + else + # Default to disabled if field data is missing or nil + send(:"#{field}_enabled=", false) + send(:"#{field}_required=", false) + end + end + + # Load underage settings + underage_data = model.extra_user_fields["underage"] + if underage_data.is_a?(Hash) + self.underage_enabled = underage_data["enabled"] == true + self.underage_required = underage_data["required"] == true + self.underage_limit = underage_data["limit"] || Decidim::ExtraUserFields.underage_limit + else + self.underage_enabled = false + self.underage_required = false + self.underage_limit = Decidim::ExtraUserFields.underage_limit + end + self.phone_number_pattern = model.extra_user_fields.dig("phone_number", "pattern") self.phone_number_placeholder = model.extra_user_fields.dig("phone_number", "placeholder") - # Block ExtraUserFields MapModel + self.select_fields = normalize_collection_fields(model.extra_user_fields["select_fields"], Decidim::ExtraUserFields.select_fields.keys) + self.boolean_fields = normalize_collection_fields(model.extra_user_fields["boolean_fields"], Decidim::ExtraUserFields.boolean_fields.map(&:to_s)) + self.text_fields = normalize_collection_fields(model.extra_user_fields["text_fields"], Decidim::ExtraUserFields.text_fields) + end + + private + + def normalize_collection_fields(value, valid_keys) + valid = valid_keys.map(&:to_s) + defaults = valid.index_with { |_| { "enabled" => false, "required" => false } } + + return defaults unless value.is_a?(Hash) - # EndBlock + valid.each_with_object({}) do |k, result| + v = value[k] + result[k] = if v.is_a?(Hash) + v.merge("enabled" => v["enabled"] == true, "required" => v["enabled"] == true && v["required"] == true) + else + { "enabled" => false, "required" => false } + end + end end end end diff --git a/app/helpers/decidim/extra_user_fields/admin/application_helper.rb b/app/helpers/decidim/extra_user_fields/admin/application_helper.rb index 2d47155d..a0dae7d7 100644 --- a/app/helpers/decidim/extra_user_fields/admin/application_helper.rb +++ b/app/helpers/decidim/extra_user_fields/admin/application_helper.rb @@ -3,19 +3,49 @@ module Decidim module ExtraUserFields module Admin - # Custom helpers, scoped to the meetings admin engine. - # module ApplicationHelper - def extra_user_fields_export_users_dropdown - content_tag(:ul, class: "vertical menu add-components") do - Decidim::ExtraUserFields::AdminEngine::DEFAULT_EXPORT_FORMATS.map do |format| - content_tag(:li, class: "exports--format--#{format.downcase} export--users") do - link_to( - t("decidim.admin.exports.export_as", name: t("decidim.extra_user_fields.admin.exports.users"), export_format: format.upcase), - AdminEngine.routes.url_helpers.extra_user_fields_export_users_path(format:) - ) - end - end.join.html_safe + # Renders an export dropdown button with format options. + # + # dropdown_id - A String with the unique HTML id for the dropdown menu. + # formats - An Array of format strings (defaults to DEFAULT_EXPORT_FORMATS). + # block - Yields each format, must return [label, url] or [label, url, http_method]. + # + # Returns an ActiveSupport::SafeBuffer. + def export_dropdown(dropdown_id: "export-dropdown", formats: AdminEngine::DEFAULT_EXPORT_FORMATS, &block) + button = content_tag(:button, + class: "button button__sm button__transparent-secondary", + data: { controller: "dropdown", target: dropdown_id }) do + safe_join([ + t("actions.export", scope: "decidim.admin"), + icon("arrow-down-s-line"), + icon("arrow-down-s-line") + ]) + end + + items = formats.map do |fmt| + label, url, http_method = block.call(fmt) + link_opts = { class: "dropdown__button" } + link_opts[:method] = http_method if http_method + content_tag(:li, class: "dropdown__item") do + link_to(label, url, **link_opts) + end + end + + menu = content_tag(:ul, id: dropdown_id, class: "dropdown dropdown__bottom", aria: { hidden: true }) do + safe_join(items) + end + + content_tag(:div, class: "relative") { button + menu } + end + + # Renders the export dropdown for the users list. + def users_export_dropdown + export_name = t("decidim.extra_user_fields.admin.exports.users") + + export_dropdown(dropdown_id: "export-users-dropdown") do |fmt| + label = t("decidim.admin.exports.export_as", name: export_name, export_format: fmt) + url = AdminEngine.routes.url_helpers.extra_user_fields_export_users_path(format: fmt) + [label, url] end end end diff --git a/app/helpers/decidim/extra_user_fields/admin/benchmarking_helper.rb b/app/helpers/decidim/extra_user_fields/admin/benchmarking_helper.rb new file mode 100644 index 00000000..3df1d740 --- /dev/null +++ b/app/helpers/decidim/extra_user_fields/admin/benchmarking_helper.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Decidim + module ExtraUserFields + module Admin + module BenchmarkingHelper + # Label for the multi-select dropdown: "[Process] My Title" + def space_option_label(space) + type = space.class.model_name.human + title = space.title.is_a?(Hash) ? (space.title[I18n.locale.to_s] || space.title.values.first) : space.title.to_s + "[#{type}] #{title}" + end + + def space_option_value(space) + "#{space.class.name}:#{space.id}" + end + + def benchmarking_data_cell(space, row, col, space_index:, col_index:) + value = comparative_pivot_presenter.cell(space, row, col) + cell_type = row.nil? || col.nil? ? "gray" : "colored" + css = "insights-table__cell heatmap-cell--#{cell_type}" + css += " insights-table__space-divider" if col_index.zero? && space_index.positive? + + content_tag(:td, number_with_delimiter(value), + class: css, + style: comparative_pivot_presenter.cell_style(value, row, col)) + end + + def benchmarking_row_total_cell(row) + value = comparative_pivot_presenter.combined_row_total(row) + content_tag(:td, number_with_delimiter(value), + class: "insights-table__row-total heatmap-total", + style: comparative_pivot_presenter.row_total_style(value)) + end + + def benchmarking_col_total_cell(space, col, space_index:, col_index:) + value = comparative_pivot_presenter.space_col_total(space, col) + css = "insights-table__col-total heatmap-total" + css += " insights-table__space-divider" if col_index.zero? && space_index.positive? + + content_tag(:td, number_with_delimiter(value), + class: css, + style: comparative_pivot_presenter.col_total_style(value)) + end + + def space_name(space, limit: 40) + full = comparative_pivot_presenter.space_label(space) + return full if full.length <= limit + + content_tag(:span, truncate(full, length: limit), title: full, style: "cursor: help") + end + + def benchmarking_grand_total_cell + content_tag(:td, number_with_delimiter(comparative_pivot_presenter.combined_grand_total), + class: "insights-table__grand-total") + end + + def benchmarking_export_dropdown + export_name = t("decidim.admin.extra_user_fields.benchmarking.export_name") + base_params = { + metric: current_metric, + rows: current_row_field, + cols: current_col_field, + spaces: selected_spaces.map { |s| space_option_value(s) } + } + + export_dropdown(dropdown_id: "benchmarking-export-dropdown") do |fmt| + label = t("decidim.admin.exports.export_as", name: export_name, export_format: fmt) + url = AdminEngine.routes.url_helpers.benchmarking_export_path(**base_params, export_format: fmt) + [label, url, :post] + end + end + end + end + end +end diff --git a/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb b/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb new file mode 100644 index 00000000..f4ef2b80 --- /dev/null +++ b/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Decidim + module ExtraUserFields + module Admin + module InsightsHelper + # Renders a labeled + <%= t("decidim.extra_user_fields.admin.extra_user_fields.aria.enabled") %>" data-field-state-target="enabled" data-action="field-state#toggleEnabled" <%= "checked" if field_state["enabled"] %>> + + + +<% end %> diff --git a/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_custom_select_fields.html.erb b/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_custom_select_fields.html.erb new file mode 100644 index 00000000..c13019d7 --- /dev/null +++ b/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_custom_select_fields.html.erb @@ -0,0 +1,17 @@ +<% (form.object.select_fields || {}).each do |field, field_state| %> + + + <%= t("decidim.extra_user_fields.admin.extra_user_fields.select_fields.#{field}.label", default: field.to_s.humanize) %> + <% description = t("decidim.extra_user_fields.admin.extra_user_fields.select_fields.#{field}.description", default: "") %> + <% if description.present? %>
<%= description %><% end %> + + + + <%= t("decidim.extra_user_fields.admin.extra_user_fields.aria.enabled") %>" data-field-state-target="enabled" data-action="field-state#toggleEnabled" <%= "checked" if field_state["enabled"] %>> + + + + <%= t("decidim.extra_user_fields.admin.extra_user_fields.aria.required") %>" data-field-state-target="required" data-action="field-state#toggleRequired" <%= "checked" if field_state["required"] %> <%= "disabled" unless field_state["enabled"] %>> + + +<% end %> diff --git a/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_custom_text_fields.html.erb b/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_custom_text_fields.html.erb new file mode 100644 index 00000000..a18fd5ef --- /dev/null +++ b/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_custom_text_fields.html.erb @@ -0,0 +1,17 @@ +<% (form.object.text_fields || {}).each do |field, field_state| %> + + + <%= t("decidim.extra_user_fields.admin.extra_user_fields.text_fields.#{field}.label", default: field.to_s.humanize) %> + <% description = t("decidim.extra_user_fields.admin.extra_user_fields.text_fields.#{field}.description", default: "") %> + <% if description.present? %>
<%= description %><% end %> + + + + <%= t("decidim.extra_user_fields.admin.extra_user_fields.aria.enabled") %>" data-field-state-target="enabled" data-action="field-state#toggleEnabled" <%= "checked" if field_state["enabled"] %>> + + + + <%= t("decidim.extra_user_fields.admin.extra_user_fields.aria.required") %>" data-field-state-target="required" data-action="field-state#toggleRequired" <%= "checked" if field_state["required"] %> <%= "disabled" unless field_state["enabled"] %>> + + +<% end %> diff --git a/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_date_of_birth.html.erb b/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_date_of_birth.html.erb deleted file mode 100644 index 3e34bf27..00000000 --- a/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_date_of_birth.html.erb +++ /dev/null @@ -1,5 +0,0 @@ -
-
- <%= form.check_box :date_of_birth, label: t(".label"), help_text: t(".description") %> -
-
diff --git a/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_gender.html.erb b/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_gender.html.erb deleted file mode 100644 index 7405bb8d..00000000 --- a/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_gender.html.erb +++ /dev/null @@ -1,5 +0,0 @@ -
-
- <%= form.check_box :gender, label: t(".label"), help_text: t(".description") %> -
-
diff --git a/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_location.html.erb b/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_location.html.erb deleted file mode 100644 index 8f471179..00000000 --- a/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_location.html.erb +++ /dev/null @@ -1,5 +0,0 @@ -
-
- <%= form.check_box :location, label: t(".label"), help_text: t(".description") %> -
-
diff --git a/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_phone_number.html.erb b/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_phone_number.html.erb deleted file mode 100644 index d8a053eb..00000000 --- a/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_phone_number.html.erb +++ /dev/null @@ -1,9 +0,0 @@ -
-
- <%= form.check_box :phone_number, label: t(".label"), help_text: t(".description"), data: { toggle: "phone_fields" } %> -
-
> - <%= form.text_field :phone_number_pattern, help_text: t(".pattern_help_html"), label: t(".pattern"), placeholder: "^(\\+34|0034|34)?[ -]*[6-9][ -]*([0-9][ -]*){8}$", data: { type: :phone_number } %> - <%= form.translated :text_field, :phone_number_placeholder, label: t(".placeholder"), placeholder: "+34987654321" %> -
-
diff --git a/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_postal_code.html.erb b/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_postal_code.html.erb deleted file mode 100644 index 059ccc61..00000000 --- a/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_postal_code.html.erb +++ /dev/null @@ -1,5 +0,0 @@ -
-
- <%= form.check_box :postal_code, label: t(".label"), help_text: t(".description") %> -
-
diff --git a/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_underage.html.erb b/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_underage.html.erb deleted file mode 100644 index 27136305..00000000 --- a/app/views/decidim/extra_user_fields/admin/extra_user_fields/fields/_underage.html.erb +++ /dev/null @@ -1,7 +0,0 @@ -
-
-

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

- <%= form.check_box :underage, label: t(".label") %> - <%= form.select :underage_limit, ( Decidim::ExtraUserFields::Engine::DEFAULT_UNDERAGE_OPTIONS).to_a, selected: current_organization.extra_user_fields["underage_limit"] || Decidim::ExtraUserFields::Engine::DEFAULT_UNDERAGE_LIMIT, label: t(".limit") %> -
-
diff --git a/app/views/decidim/extra_user_fields/admin/extra_user_fields/index.html.erb b/app/views/decidim/extra_user_fields/admin/extra_user_fields/index.html.erb index 0e9bef85..9d215c63 100644 --- a/app/views/decidim/extra_user_fields/admin/extra_user_fields/index.html.erb +++ b/app/views/decidim/extra_user_fields/admin/extra_user_fields/index.html.erb @@ -1,4 +1,6 @@ <% add_decidim_page_title(t(".title")) %> +<%= append_javascript_pack_tag "decidim_extra_user_fields_admin" %> +<%= append_stylesheet_pack_tag "decidim_extra_user_fields_css" %>

<%= t ".title" %> diff --git a/app/views/decidim/extra_user_fields/admin/insights/_legend.html.erb b/app/views/decidim/extra_user_fields/admin/insights/_legend.html.erb new file mode 100644 index 00000000..3695a079 --- /dev/null +++ b/app/views/decidim/extra_user_fields/admin/insights/_legend.html.erb @@ -0,0 +1,24 @@ +
+ <%= t("decidim.admin.extra_user_fields.insights.legend.fewer") %> +
+ <% 5.times do %> +
+ <% end %> +
+ <%= t("decidim.admin.extra_user_fields.insights.legend.more") %> + + + + <%= t("decidim.admin.extra_user_fields.insights.legend.lower_total") %> +
+ <% 5.times do %> +
+ <% end %> +
+ <%= t("decidim.admin.extra_user_fields.insights.legend.higher_total") %> + + + + <%= t("decidim.admin.extra_user_fields.insights.legend.non_specified") %> + +
diff --git a/app/views/decidim/extra_user_fields/admin/insights/_pivot_table.html.erb b/app/views/decidim/extra_user_fields/admin/insights/_pivot_table.html.erb new file mode 100644 index 00000000..48cb7803 --- /dev/null +++ b/app/views/decidim/extra_user_fields/admin/insights/_pivot_table.html.erb @@ -0,0 +1,72 @@ +<% if pivot_table_presenter.empty? %> +
+
+

<%= t("decidim.admin.extra_user_fields.insights.no_data") %>

+
+
+<% else %> +
+
+
+
"> + + + + + + <% pivot_table_presenter.col_values.each do |col| %> + + <% end %> + + + + + <% pivot_table_presenter.row_values.each do |row| %> + <% row_total_value = pivot_table_presenter.row_total(row) %> + + + <% pivot_table_presenter.col_values.each do |col| %> + <% value = pivot_table_presenter.cell(row, col) %> + <% cell_type = (row.nil? || col.nil?) ? "gray" : "colored" %> + + <% end %> + + + <% end %> + + + + + <% pivot_table_presenter.col_values.each do |col| %> + <% col_total_value = pivot_table_presenter.col_total(col) %> + + <% end %> + + + +
<%= t("decidim.admin.extra_user_fields.insights.pivot_table_label") %>
+ <%= field_label(current_row_field) %> \ <%= field_label(current_col_field) %> + + <%= field_value_label(current_col_field, col) %> + + <%= t("decidim.admin.extra_user_fields.insights.row_total") %> +
+ <%= field_value_label(current_row_field, row) %> + + <%= number_with_delimiter(value) %> + + <%= number_with_delimiter(row_total_value) %> +
+ <%= t("decidim.admin.extra_user_fields.insights.column_total") %> + + <%= number_with_delimiter(col_total_value) %> + + <%= number_with_delimiter(pivot_table_presenter.grand_total) %> +
+
+
+
+
+ + <%= render partial: "decidim/extra_user_fields/admin/insights/legend" %> +<% end %> diff --git a/app/views/decidim/extra_user_fields/admin/insights/_selectors.html.erb b/app/views/decidim/extra_user_fields/admin/insights/_selectors.html.erb new file mode 100644 index 00000000..01e3df5b --- /dev/null +++ b/app/views/decidim/extra_user_fields/admin/insights/_selectors.html.erb @@ -0,0 +1,7 @@ +<%= form_tag(request.path, method: :get) do %> +
+ <%= insight_selector_field(:rows, available_fields, current_row_field) { |f| field_label(f) } %> + <%= insight_selector_field(:cols, available_fields, current_col_field) { |f| field_label(f) } %> + <%= insight_selector_field(:metric, available_metrics, current_metric) { |m| metric_label(m) } %> +
+<% end %> diff --git a/app/views/decidim/extra_user_fields/admin/insights/show.html.erb b/app/views/decidim/extra_user_fields/admin/insights/show.html.erb new file mode 100644 index 00000000..254cb01a --- /dev/null +++ b/app/views/decidim/extra_user_fields/admin/insights/show.html.erb @@ -0,0 +1,22 @@ +<% append_stylesheet_pack_tag "decidim_extra_user_fields_css" %> +<% add_decidim_page_title(t("decidim.admin.extra_user_fields.insights.title")) %> +
+
+

+ <%= t("decidim.admin.extra_user_fields.insights.title") %> +

+ <% if allowed_to?(:export, :insights) %> + <%= insights_export_dropdown %> + <% end %> +
+ +
+

<%= t("decidim.admin.extra_user_fields.insights.description") %>

+
+ + <%= render partial: "decidim/extra_user_fields/admin/insights/selectors" %> + +
+ <%= render partial: "decidim/extra_user_fields/admin/insights/pivot_table" %> +
+
diff --git a/bin/dev b/bin/dev new file mode 100755 index 00000000..e009f8d9 --- /dev/null +++ b/bin/dev @@ -0,0 +1,5 @@ +#!/usr/bin/env sh + +cd development_app + +./bin/dev diff --git a/bin/rails b/bin/rails index a7216441..961aa723 100755 --- a/bin/rails +++ b/bin/rails @@ -1,16 +1,6 @@ #!/usr/bin/env ruby # frozen_string_literal: true -# This command will automatically be run when you run "rails" with Rails gems -# installed from the root of your application. +Dir.chdir("development_app") -ENGINE_ROOT = File.expand_path("..", __dir__) -ENGINE_PATH = File.expand_path("../lib/decidim/extra_user_fields/engine", __dir__) - -# Set up gems listed in the Gemfile. -ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) - -require "bundler/setup" - -require "rails/all" -require "rails/engine/commands" +load "bin/rails" diff --git a/bin/shakapacker b/bin/shakapacker new file mode 100755 index 00000000..86aefe83 --- /dev/null +++ b/bin/shakapacker @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +Dir.chdir("development_app") + +load "bin/shakapacker" diff --git a/bin/shakapacker-dev-server b/bin/shakapacker-dev-server new file mode 100755 index 00000000..0226ffec --- /dev/null +++ b/bin/shakapacker-dev-server @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +Dir.chdir("development_app") + +load "bin/shakapacker-dev-server" diff --git a/config/assets.rb b/config/assets.rb index 9be6ca7b..d5fc1ce4 100644 --- a/config/assets.rb +++ b/config/assets.rb @@ -12,18 +12,19 @@ # in your JavaScript entrypoints (or other JavaScript files within Decidim) # using `import "src/decidim/foo"` after you have registered the additional path # as follows. -Decidim::Webpacker.register_path("#{base_path}/app/packs") +Decidim::Shakapacker.register_path("#{base_path}/app/packs") # Register the entrypoints for your module. These entrypoints can be included # within your application using `javascript_pack_tag` and if you include any # SCSS files within the entrypoints, they become available for inclusion using # `stylesheet_pack_tag`. -Decidim::Webpacker.register_entrypoints( +Decidim::Shakapacker.register_entrypoints( decidim_extra_user_fields: "#{base_path}/app/packs/entrypoints/decidim_extra_user_fields.js", - decidim_extra_user_fields_css: "#{base_path}/app/packs/entrypoints/decidim_extra_user_fields.scss" + decidim_extra_user_fields_css: "#{base_path}/app/packs/entrypoints/decidim_extra_user_fields.scss", + decidim_extra_user_fields_admin: "#{base_path}/app/packs/entrypoints/decidim_extra_user_fields_admin.js" ) # If you want to import some extra SCSS files in the Decidim main SCSS file # without adding any extra stylesheet inclusion tags, you can use the following # method to register the stylesheet import for the main application. -# Decidim::Webpacker.register_stylesheet_import("stylesheets/decidim/homepage_interactive_map/map.scss") +# Decidim::Shakapacker.register_stylesheet_import("stylesheets/decidim/homepage_interactive_map/map.scss") diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index aa01e245..38f233db 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -8,7 +8,13 @@ ignore_unused: - activemodel.attributes.user.* - activemodel.errors.models.user.* - decidim.admin.extra_user_fields.menu.title + - decidim.admin.extra_user_fields.insights.* + - decidim.admin.extra_user_fields.benchmarking.* - decidim.extra_user_fields.genders.* + - decidim.extra_user_fields.age_ranges.* + - decidim.extra_user_fields.insight_age_spans.* + - decidim.extra_user_fields.admin.extra_user_fields.fields.* + - decidim.extra_user_fields.admin.extra_user_fields.form.states.* ignore_missing: - decidim.participatory_processes.scopes.global diff --git a/config/locales/ca.yml b/config/locales/ca.yml index 3e2a5178..d929134f 100644 --- a/config/locales/ca.yml +++ b/config/locales/ca.yml @@ -3,9 +3,10 @@ ca: activemodel: attributes: user: + age_range: Quina edat tens? country: País date_of_birth: Data de naixement - gender: Gènere + gender: Quin gènere identifiques? location: Localització phone_number: Telèfon postal_code: Codi postal @@ -25,6 +26,49 @@ ca: exports: export_as: Exporta com a %{export_format} extra_user_fields: + benchmarking: + export_name: Anàlisi comparativa + menu_title: Anàlisi comparativa + no_data: No s'han trobat dades de participació per als espais seleccionats. + select_spaces: Escolliu espais participatius per comparar + select_spaces_prompt: Seleccioneu un o més espais participatius per comparar. + table_label: Taula d'anàlisi comparativa + title: Estadístiques comparatives entre espais + insights: + column_total: Total de columna + description: Explora l'activitat dels participants segons les dimensions + del perfil. Selecciona una mètrica i dos camps de perfil per veure com + es distribueix la participació. + export_name: Estadístiques + fields: + age_span: Rang d'edat + country: País + gender: Gènere + location: Localització + postal_code: Codi postal + legend: + fewer: Menys + higher_total: Total més alt + lower_total: Total més baix + more: Més + non_specified: No especificat / Prefereixo no dir-ho + menu_title: Estadístiques + metrics: + budget_votes: Vots pressupostaris + comments: Comentaris + participants: Participants + proposals_created: Propostes creades + proposals_supported: Propostes recolzades + no_data: No s'han trobat dades de participació per a aquest espai amb els + criteris seleccionats. + non_specified: No especificat / Prefereixo no dir-ho + pivot_table_label: Taula de dades de participació + row_total: Total de fila + selectors: + cols: Columnes (eix X) + metric: Mètrica + rows: Files (eix Y) + title: Estadístiques de l'espai participatiu menu: title: Gestiona camps d'usuari addicionals components: @@ -35,7 +79,19 @@ ca: exports: users: Participants extra_user_fields: + aria: + enabled: habilitat + required: obligatori + boolean_fields: + ngo: + description: Aquest camp és un camp booleà. L'usuari podrà marcar si + pertany a una ONG + label: Habilitar camp ONG fields: + age_range: + description: Aquest camp és una llista de rangs d'edat. Si está activat, + l'usuari haurà de triar un rang d'edat. + label: Habilitar camp de rang d'edat country: description: Aquest camp és una llista de països. Si está activat, l'usuari haurà de triar un país. @@ -72,6 +128,7 @@ ca: és menor d'edat label: Activar camp d'autorització parental limit: Estableix l'edat límit (per exemple 18 anys) + limit_label: Edat límit form: callout: help: Activa la funcionalitat de camps d'usuari addicionals personalitzats @@ -81,22 +138,73 @@ ca: extra_user_fields: extra_user_fields_enabled: Activa els camps d'usuari addicionals section: Camps addicionals disponibles per al formulari d'inscripció + section_extras: Camps personalitzats addicionals + section_extras_description: Si heu configurat camps d'usuari addicionals, + podeu gestionar-los aquí (Consulteu la secció "Configuració mitjançant + un inicialitzador" al README del connector). + table: + enabled: Habilitat + field: Camp + required: Obligatori global: title: Activar / desactivar la funcionalitat + states: + disabled: Desactivat + enabled: Activat + optional: Opcional + required: Obligatori index: save: Desa la configuració title: Gestiona camps d'usuari addicionals + select_fields: + participant_type: + description: Aquest camp és una llista de tipus de participants. Si + està marcat, l'usuari haurà de triar un tipus de participant + label: Habilitar camp de tipus de participant + text_fields: + motto: + description: Aquest camp és un camp de text. Si està marcat, l'usuari + pot omplir una frase o lema personal + label: Habilitar camp "El meu lema" update: failure: S'ha produït un error en l'actualització success: Camps d'usuari addicionals actualitzats correctament a l'organització + age_ranges: + 17_to_30: De 17 a 30 + 31_to_60: De 31 a 60 + 61_or_more: 61 o més + prefer_not_to_say: Prefereixo no dir-ho + up_to_16: 16 o menys + boolean_fields: + ngo: + label: Sóc membre d'una organització no governamental (ONG) + errors: + select_fields: El camp "%{field}" no és vàlid. + force_extra_user_fields: + redirect_message: Si us plau, completeu la informació del vostre perfil abans + de continuar. genders: female: Dona male: Home other: Altre + prefer_not_to_say: Prefereixo no dir-ho + insight_age_spans: + 21_to_30: 21 a 30 + 31_to_40: 31 a 40 + 41_to_50: 41 a 50 + 51_to_60: 51 a 60 + 61_or_more: Més de 60 + up_to_20: Menys de 20 registration_form: signup: legend: Més informació underage: Sóc menor de %{limit} anys i accepto rebre una autorització parental + select_fields: + participant_type: + label: Participeu com a persona individual o oficialment en nom d'una organització? + text_fields: + motto: + label: Quin és el teu lema? statutory_representative: inform: body: | diff --git a/config/locales/de.yml b/config/locales/de.yml index 50d9c8d6..74c67206 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -3,18 +3,22 @@ de: activemodel: attributes: user: + age_range: Wie alt sind Sie? country: Land date_of_birth: Geburtsdatum - gender: Geschlecht + gender: Welches Geschlecht identifizieren Sie? location: Standort phone_number: Telefonnummer postal_code: Postleitzahl + statutory_representative_email: E-Mail des gesetzlichen Vertreters + underage: Minderjährig errors: models: user: attributes: date_of_birth: - underage: ungültig. Wenn Sie minderjährig sind, müssen Sie die Erlaubnis der Eltern einholen + underage: ungültig. Wenn Sie minderjährig sind, müssen Sie die Erlaubnis + der Eltern einholen decidim: admin: actions: @@ -22,6 +26,50 @@ de: exports: export_as: Exportieren im Format %{export_format} extra_user_fields: + benchmarking: + export_name: Vergleichsanalyse + menu_title: Vergleichsanalyse + no_data: Für die ausgewählten Räume wurden keine Teilnahmedaten gefunden. + select_spaces: Wählen Sie partizipative Räume zum Vergleich + select_spaces_prompt: Wählen Sie einen oder mehrere partizipative Räume + zum Vergleich aus. + table_label: Vergleichsanalyse-Tabelle + title: Vergleichende Statistiken über Räume hinweg + insights: + column_total: Spaltensumme + description: Erkunden Sie die Teilnehmeraktivität über Profildimensionen + hinweg. Wählen Sie eine Metrik und zwei Profilfelder aus, um zu sehen, + wie die Teilnahme verteilt ist. + export_name: Einblicke + fields: + age_span: Altersgruppe + country: Land + gender: Geschlecht + location: Standort + postal_code: Postleitzahl + legend: + fewer: Weniger + higher_total: Höhere Summe + lower_total: Niedrigere Summe + more: Mehr + non_specified: Nicht angegeben / Möchte nicht sagen + menu_title: Einblicke + metrics: + budget_votes: Budgetstimmen + comments: Kommentare + participants: Teilnehmer + proposals_created: Erstellte Vorschläge + proposals_supported: Unterstützte Vorschläge + no_data: Für diesen Bereich wurden mit den ausgewählten Kriterien keine + Teilnahmedaten gefunden. + non_specified: Nicht angegeben / Möchte nicht sagen + pivot_table_label: Teilnahmedatentabelle + row_total: Zeilensumme + selectors: + cols: Spalten (X-Achse) + metric: Metrik + rows: Zeilen (Y-Achse) + title: Einblicke in den partizipativen Raum menu: title: Benutzerdefinierte Anmeldefelder verwalten components: @@ -32,51 +80,132 @@ de: exports: users: Teilnehmer extra_user_fields: + aria: + enabled: aktiviert + required: erforderlich + boolean_fields: + ngo: + description: Dieses Feld ist ein boolesches Feld. Der Benutzer kann + angeben, ob er einer NGO angehört + label: NGO-Feld aktivieren fields: + age_range: + description: Dieses Feld ist eine Liste von Altersgruppen. Der Benutzer + kann eine Altersgruppe auswählen. + label: Das Feld Altersgruppe aktivieren country: - description: Dieses Feld enthält eine Liste von Ländern. Der Benutzer kann ein Land auswählen. + description: Dieses Feld enthält eine Liste von Ländern. Der Benutzer + kann ein Land auswählen. label: Das Feld Land aktivieren date_of_birth: - description: Dieses Feld ist ein Feld für das Geburtsdatum. Der Benutzer kann ein Datum auswählen. + description: Dieses Feld ist ein Feld für das Geburtsdatum. Der Benutzer + kann ein Datum auswählen. label: Das Feld Geburtsdatum aktivieren gender: - description: Dieses Feld ist ein Feld für die Geschlechtsidentität. Der Benutzer kann ein Geschlecht auswählen. + description: Dieses Feld ist ein Feld für die Geschlechtsidentität. + Der Benutzer kann ein Geschlecht auswählen. label: Das Feld Geschlecht aktivieren location: - description: Dieses Feld ermöglicht das Hinzufügen von Text. Der Benutzer kann einen Ort auswählen. + description: Dieses Feld ermöglicht das Hinzufügen von Text. Der Benutzer + kann einen Ort auswählen. label: Das Feld Standort aktivieren phone_number: - description: Dieses Feld ist ein Telefonnummernfeld. Der Benutzer kann eine Nummer auswählen. + description: Dieses Feld ist ein Telefonnummernfeld. Der Benutzer kann + eine Nummer auswählen. label: Das Feld Telefonnummer aktivieren postal_code: - description: Dieses Feld ist für die Postleitzahl. Der Benutzer kann eine Postleitzahl auswählen. + description: Dieses Feld ist für die Postleitzahl. Der Benutzer kann + eine Postleitzahl auswählen. label: Das Postleitzahlenfeld aktivieren. + underage: + description: Dieses Feld ist ein boolesches Feld. Der Benutzer kann + angeben, ob er minderjährig ist + label: Feld für elterliche Genehmigung aktivieren + limit: Dies legt die Altersgrenze fest (z.B. 18 Jahre) + limit_label: Altersgrenze form: callout: - help: Aktivieren Sie die Funktion für benutzerdefinierte Anmeldefelder, um zusätzliche Felder in Ihrem Anmeldeformular zu verwalten. Auch bei aktivierter Option wird das Anmeldeformular nur aktualisiert, wenn mindestens ein zusätzliches Feld aktiviert ist. + help: Aktivieren Sie die Funktion für benutzerdefinierte Anmeldefelder, + um zusätzliche Felder in Ihrem Anmeldeformular zu verwalten. Auch + bei aktivierter Option wird das Anmeldeformular nur aktualisiert, + wenn mindestens ein zusätzliches Feld aktiviert ist. extra_user_fields: extra_user_fields_enabled: Benutzerdefinierte Anmeldefelder aktivieren section: Verfügbare Anmeldefelder für das Anmeldeformular + section_extras: Zusätzliche benutzerdefinierte Felder + section_extras_description: Wenn Sie zusätzliche Benutzerfelder konfiguriert + haben, können Sie diese hier verwalten (siehe Abschnitt "Konfiguration + über einen Initializer" im Plugin-README). + table: + enabled: Aktiviert + field: Feld + required: Erforderlich global: title: Aktivieren / Deaktivieren von benutzerdefinierten Anmeldefeldern + states: + disabled: Deaktiviert + enabled: Aktiviert + optional: Optional + required: Erforderlich index: save: Speichern title: Benutzerdefinierte Anmeldefelder verwalten + select_fields: + participant_type: + description: Dieses Feld ist eine Liste von Teilnehmertypen. Der Benutzer + muss einen Teilnehmertyp auswählen + label: Teilnehmertyp-Feld aktivieren + text_fields: + motto: + description: Dieses Feld ist ein Textfeld. Der Benutzer kann einen persönlichen + Satz oder ein Motto eingeben + label: Feld "Mein Motto" aktivieren update: failure: Bei der Aktualisierung ist ein Fehler aufgetreten. success: Die Anmeldefelder wurden erfolgreich aktualisiert. + age_ranges: + 17_to_30: 17 bis 30 + 31_to_60: 31 bis 60 + 61_or_more: 61 oder älter + prefer_not_to_say: Bevorzuge es, nicht zu sagen + up_to_16: 16 oder jünger + boolean_fields: + ngo: + label: Ich bin Mitglied einer Nichtregierungsorganisation (NGO) + errors: + select_fields: Das Feld "%{field}" ist ungültig. + force_extra_user_fields: + redirect_message: Bitte vervollständigen Sie Ihre Profilinformationen, bevor + Sie fortfahren. genders: female: Frau male: Mann other: Divers + prefer_not_to_say: Bevorzuge es, nicht zu sagen + insight_age_spans: + 21_to_30: 21 bis 30 + 31_to_40: 31 bis 40 + 41_to_50: 41 bis 50 + 51_to_60: 51 bis 60 + 61_or_more: Mehr als 60 + up_to_20: Weniger als 20 registration_form: signup: legend: Weitere Informationen + underage: Ich bin unter %{limit} Jahre alt und stimme zu, eine elterliche + Genehmigung einzuholen + select_fields: + participant_type: + label: Nehmen Sie als Einzelperson oder offiziell im Namen einer Organisation + teil? + text_fields: + motto: + label: Was ist Ihr Motto? statutory_representative: inform: body: | Hallo, Sie wurden als gesetzlicher Vertreter von %{name} für die Registrierung bei %{organization} benannt. - Beste grüße, + Beste Grüße, Das %{organization} Team subject: Sie wurden als gesetzlicher Vertreter benannt diff --git a/config/locales/en.yml b/config/locales/en.yml index af86e18e..3f6a9f5c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3,9 +3,10 @@ en: activemodel: attributes: user: + age_range: How old are you? country: Country date_of_birth: Date of birth - gender: Gender + gender: Which gender do you identify with? location: Location phone_number: Phone Number postal_code: Postal code @@ -24,6 +25,47 @@ en: exports: export_as: Export %{export_format} extra_user_fields: + benchmarking: + export_name: Benchmarking + menu_title: Benchmarking + no_data: No participation data found for the selected spaces. + select_spaces: Choose participatory spaces for comparison + select_spaces_prompt: Select one or more participatory spaces to compare. + table_label: Benchmarking comparison table + title: Comparative stats across spaces + insights: + column_total: Column Total + description: Explore participant activity across profile dimensions. Select + a metric and two profile fields to see how participation is distributed. + export_name: Insights + fields: + age_span: Age span + country: Country + gender: Gender + location: Location + postal_code: Postal code + legend: + fewer: Fewer + higher_total: Higher total + lower_total: Lower total + more: More + non_specified: Not specified / Prefer not to say + menu_title: Insights + metrics: + budget_votes: Budget votes + comments: Comments + participants: Participants + proposals_created: Proposals created + proposals_supported: Proposals supported + no_data: No participation data found for this space with the selected criteria. + non_specified: Not specified / Prefer not to say + pivot_table_label: Participation data table + row_total: Row Total + selectors: + cols: Columns (X axis) + metric: Metric + rows: Rows (Y axis) + title: Participatory Space Insights menu: title: Manage extra user fields components: @@ -34,27 +76,39 @@ en: exports: users: Participants extra_user_fields: + aria: + enabled: enabled + required: required + boolean_fields: + ngo: + description: This field is a Boolean field. User will be able to check + if is a NGO + label: Enable NGO field fields: + age_range: + description: This field is a list of age ranges. The user will have + to choose an age range + label: Age range country: - description: This field is a list of countries. If checked, user will - have to choose a country - label: Enable country field + description: This field is a list of countries. The user will have to + choose a country + label: Country date_of_birth: - description: This field is a Date field. If checked, user will have - to register a birth date by using a Date picker - label: Enable date of birth field + description: This field is a Date field. The user will have to register + a birth date by using a Date picker + label: Date of birth gender: - description: This field is a list of genders. If checked, user will - have to choose a gender - label: Enable gender field + description: This field is a list of genders. The user will have to + choose a gender + label: Gender location: - description: This field is a String field. If checked, user will have - to fill in a location - label: Enable location field + description: This field is a String field. The user will have to fill + in a location + label: Location phone_number: - description: This field is a telephone field. If checked, user will - have to fill in a phone number - label: Enable phone number field + description: This field is a telephone field. The user will have to + fill in a phone number + label: Phone number pattern: Phone numbers validation pattern (regexp) pattern_help_html: Copy this regular expression ^(\+34|0034|34)?[ -]*[6-9][ -]*([0-9][ -]*){8}$ to validate this phone format @@ -62,14 +116,15 @@ en: target="_blank">here. placeholder: Phone number placeholder postal_code: - description: This field is a String field. If checked, user will have - to fill in a postal code - label: Enable postal code field + description: This field is a String field. The user will have to fill + in a postal code + label: Postal code underage: description: This field is a Boolean field. User will be able to check if is underage label: Enable parental authorization field limit: This sets the age limit (ex. 18 years old) + limit_label: Age limit form: callout: help: Enable custom extra user fields functionality to be able to manage @@ -78,22 +133,73 @@ en: extra_user_fields: extra_user_fields_enabled: Enable extra user fields section: Available extra fields for signup form + section_extras: Additional custom fields + section_extras_description: If you have configured any extra user fields, + you can manage them here (See section "Configuration through an initializer" + in the plugin README). + table: + enabled: Enabled + field: Field + required: Required global: title: Activate / deactivate functionality + states: + disabled: Disabled + enabled: Enabled + optional: Optional + required: Required index: save: Save configuration title: Manage extra user fields + select_fields: + participant_type: + description: This field is a list of participant types. If checked, + user will have to choose a participant type + label: Enable participant type field + text_fields: + motto: + description: This field is a String field. If checked, user can fill + in a personal phrase or motto + label: Enable "My Motto" field update: failure: An error occurred on update success: Extra user fields correctly updated in organization + age_ranges: + 17_to_30: 17 to 30 + 31_to_60: 31 to 60 + 61_or_more: 61 or older + prefer_not_to_say: Prefer not to say + up_to_16: 16 or younger + boolean_fields: + ngo: + label: I am a member of a non-governmental organization (NGO) + errors: + select_fields: The field "%{field}" is not valid. + force_extra_user_fields: + redirect_message: Please complete your profile information before continuing. genders: female: Female male: Male other: Other + prefer_not_to_say: Prefer not to say + insight_age_spans: + 21_to_30: 21 to 30 + 31_to_40: 31 to 40 + 41_to_50: 41 to 50 + 51_to_60: 51 to 60 + 61_or_more: More than 60 + up_to_20: Less than 20 registration_form: signup: legend: More information underage: I am under %{limit} years old and I agree to get a parental authorization + select_fields: + participant_type: + label: Are you participating as an individual, or officially on behalf of + an organization? + text_fields: + motto: + label: What is your motto? statutory_representative: inform: body: | diff --git a/config/locales/es.yml b/config/locales/es.yml index 5df4c307..2b710dc6 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -3,9 +3,10 @@ es: activemodel: attributes: user: + age_range: Cuántos años tienes? country: País date_of_birth: Fecha de nacimiento - gender: Género + gender: Con qué género te identificas? location: Localización phone_number: Teléfono postal_code: Código postal @@ -25,6 +26,50 @@ es: exports: export_as: Exportar como %{export_format} extra_user_fields: + benchmarking: + export_name: Análisis comparativo + menu_title: Análisis comparativo + no_data: No se encontraron datos de participación para los espacios seleccionados. + select_spaces: Elija espacios participativos para comparar + select_spaces_prompt: Seleccione uno o más espacios participativos para + comparar. + table_label: Tabla de análisis comparativo + title: Estadísticas comparativas entre espacios + insights: + column_total: Total de columna + description: Explora la actividad de los participantes en las dimensiones + del perfil. Selecciona una métrica y dos campos de perfil para ver cómo + se distribuye la participación. + export_name: Estadísticas + fields: + age_span: Rango de edad + country: País + gender: Género + location: Localización + postal_code: Código postal + legend: + fewer: Menos + higher_total: Total más alto + lower_total: Total más bajo + more: Más + non_specified: No especificado / Prefiero no decirlo + menu_title: Estadísticas + metrics: + budget_votes: Votos presupuestarios + comments: Comentarios + participants: Participantes + proposals_created: Propuestas creadas + proposals_supported: Propuestas apoyadas + no_data: No se encontraron datos de participación para este espacio con + los criterios seleccionados. + non_specified: No especificado / Prefiero no decirlo + pivot_table_label: Tabla de datos de participación + row_total: Total de fila + selectors: + cols: Columnas (eje X) + metric: Métrica + rows: Filas (eje Y) + title: Estadísticas del espacio participativo menu: title: Administrar campos de usuario adicionales components: @@ -35,7 +80,19 @@ es: exports: users: Participantes extra_user_fields: + aria: + enabled: habilitado + required: obligatorio + boolean_fields: + ngo: + description: Este campo es un campo booleano. El usuario podrá marcar + si pertenece a una ONG + label: Habilitar campo ONG fields: + age_range: + description: Este campo es una lista de rangos de edad. Si está marcado, + el usuario tendrá que elegir un rango de edad. + label: Habilitar campo de rango de edad country: description: Este campo es una lista de países. Si está marcado, el usuario tendrá que elegir un país. @@ -71,6 +128,7 @@ es: es menor de edad label: Activar campo de autorización parental limit: Establece la edad límite (por ejemplo 18 años) + limit_label: Edad límite form: callout: help: Activa la funcionalidad de campos de usuario adicionales personalizados @@ -80,23 +138,74 @@ es: extra_user_fields: extra_user_fields_enabled: Activa los campos de usuario adicionales section: Campos adicionales disponibles para el formulario de inscripción + section_extras: Campos personalizados adicionales + section_extras_description: Si has configurado campos de usuario adicionales, + puedes gestionarlos aquí (Consulta la sección "Configuración mediante + un inicializador" en el README del plugin). + table: + enabled: Habilitado + field: Campo + required: Obligatorio global: title: Activar / desactivar la funcionalidad + states: + disabled: Desactivado + enabled: Activado + optional: Opcional + required: Obligatorio index: save: Guarda la configuración title: Gestiona campos de usuario adicionales + select_fields: + participant_type: + description: Este campo es una lista de tipos de participantes. Si está + marcado, el usuario tendrá que elegir un tipo de participante + label: Habilitar campo de tipo de participante + text_fields: + motto: + description: Este campo es un campo de texto. Si está marcado, el usuario + puede completar una frase o lema personal + label: Habilitar campo "Mi lema" update: failure: Se ha producido un error en la actualización success: Campos de usuario adicionales actualizados correctamente en la organización + age_ranges: + 17_to_30: 17 a 30 + 31_to_60: 31 a 60 + 61_or_more: 61 o más + prefer_not_to_say: Prefiero no decirlo + up_to_16: 16 o menos + boolean_fields: + ngo: + label: Soy miembro de una organización no gubernamental (ONG) + errors: + select_fields: El campo "%{field}" no es válido. + force_extra_user_fields: + redirect_message: Por favor, completa la información de tu perfil antes de + continuar. genders: female: Mujer male: Hombre other: Otro + prefer_not_to_say: Prefiero no decirlo + insight_age_spans: + 21_to_30: 21 a 30 + 31_to_40: 31 a 40 + 41_to_50: 41 a 50 + 51_to_60: 51 a 60 + 61_or_more: Más de 60 + up_to_20: Menos de 20 registration_form: signup: legend: Más información underage: Soy menor de %{limit} años y acepto recibir una autorización parental + select_fields: + participant_type: + label: "¿Participas como individuo o oficialmente en nombre de una organización?" + text_fields: + motto: + label: "¿Cuál es tu lema?" statutory_representative: inform: body: | diff --git a/config/locales/eu.yml b/config/locales/eu.yml new file mode 100644 index 00000000..b20c2bac --- /dev/null +++ b/config/locales/eu.yml @@ -0,0 +1,92 @@ +--- +eu: + activemodel: + attributes: + user: + age_range: Zenbat urte dituzu? + country: Herria + date_of_birth: Jaioteguna + gender: Zer generotako pertsona zara? + location: Helbidea + phone_number: Telefono zenbakia + postal_code: Posta kodea + decidim: + admin: + actions: + export: Esportatu + exports: + export_as: "%{export_format} gisa esportatu" + extra_user_fields: + menu: + title: Kudeatu extra user fields + components: + extra_user_fields: + name: ExtraUserFields + extra_user_fields: + admin: + exports: + users: Parte-hartzaileak + extra_user_fields: + aria: + enabled: gaituta + required: beharrezkoa + fields: + age_range: + description: Eremu hau adin tarte bat da. Erabiltzaileek adin tarte + bat aukeratu ahal izango dute. + label: Adin tartearen eremua aktibatu + country: + description: Eremu hau herrien zerrenda bat da. Erabiltzaileek herri + bat aukeratu ahal izango dute. + label: Herriaren eremua aktibatu + date_of_birth: + description: Eremu hau data bat da. Erabiltzaileak bere jaioteguna jarri + ahal izango du data aukeratuz. + label: Jaiotegunaren eremua aktibatu + gender: + description: Eremu hau generoen zerrenda bat da. Erabiltzaileak generoa + aukeratu ahal izango du erregistroa egitean. + label: Generoaren eremua aktibatu + location: + description: Eremu hau testua da. Erabiltzaileak helbidea idatzi ahal + izango du bertan. + label: Helbidearen eremua aktibatu + phone_number: + description: Eremu hau testua da. Erabiltzaileak bere telefono zenbakia + idatzi ahal izango du bertan. + label: Telefono zenbakiaren eremua aktibatu + postal_code: + description: Eremu hau testua da. Erabiltzaileak bere posta kodea idatzi + ahal izango du bertan. + label: Posta kodearen eremua aktibatu + form: + callout: + help: Extra user fields-en funtzionaltasunak aktibatu. Honi esker erregistroa + egiteko unean eremu gehigarriak agertuko dira. Gogoratu aukera hau + aktibatuta ere, erregistroaren formularioan behean aktibatutako + eremuak baino ez dituela gehituko. + extra_user_fields: + extra_user_fields_enabled: Extra User Fields aktibatu + section: Eregistroa egiteko formularioan erabilgarri dauden eremu gehigarriak. + global: + title: Funtzionatasuna Aktibatu / Desaktibatu + index: + save: Ezarpenak gorde + title: Extra user fields kudeatu + update: + failure: Erakundearen datuak eguneratzean errore bat sortu da + success: Extra user fields zuzen eguneratu dira erakundean + age_ranges: + 17_to_30: 17tik 30era + 31_to_60: 31tik 60ra + 61_or_more: 61 edo gehiago + prefer_not_to_say: Ez esan nahi + up_to_16: 16 edo gutxiago + genders: + female: Emakumea + male: Gizona + other: Beste bat + prefer_not_to_say: Ez esan nahi + registration_form: + signup: + legend: Argibide gehiago diff --git a/config/locales/fr.yml b/config/locales/fr.yml index fd9beea4..c0c829bd 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -3,9 +3,10 @@ fr: activemodel: attributes: user: + age_range: Quel âge avez-vous? country: Pays date_of_birth: Date de naissance - gender: Genre + gender: Quel genre identifiez-vous? location: Localisation phone_number: Numéro de téléphone postal_code: Code postal @@ -25,6 +26,50 @@ fr: exports: export_as: Exporter au format %{export_format} extra_user_fields: + benchmarking: + export_name: Analyse comparative + menu_title: Analyse comparative + no_data: Aucune donnée de participation trouvée pour les espaces sélectionnés. + select_spaces: Choisissez des espaces participatifs à comparer + select_spaces_prompt: Sélectionnez un ou plusieurs espaces participatifs + à comparer. + table_label: Tableau d'analyse comparative + title: Statistiques comparatives entre espaces + insights: + column_total: Total colonne + description: Explorez l'activité des participants selon les dimensions de + profil. Sélectionnez une métrique et deux champs de profil pour voir comment + la participation est répartie. + export_name: Aperçus + fields: + age_span: Tranche d'âge + country: Pays + gender: Genre + location: Localisation + postal_code: Code postal + legend: + fewer: Moins + higher_total: Total élevé + lower_total: Total faible + more: Plus + non_specified: Non spécifié / Préfère ne pas dire + menu_title: Aperçus + metrics: + budget_votes: Votes budgétaires + comments: Commentaires + participants: Participants + proposals_created: Propositions créées + proposals_supported: Propositions soutenues + no_data: Aucune donnée de participation trouvée pour cet espace avec les + critères sélectionnés. + non_specified: Non spécifié / Préfère ne pas dire + pivot_table_label: Tableau des données de participation + row_total: Total ligne + selectors: + cols: Colonnes (axe X) + metric: Métrique + rows: Lignes (axe Y) + title: Aperçus de l'espace participatif menu: title: Gérer les champs d'inscription personnalisés components: @@ -35,27 +80,39 @@ fr: exports: users: Participants extra_user_fields: + aria: + enabled: activé + required: obligatoire + boolean_fields: + ngo: + description: Ce champ est un champ booléen. L'utilisateur pourra cocher + s'il représente une ONG. + label: Activer le champ ONG fields: + age_range: + description: Ce champ est une liste de tranches d'âge. L'utilisateur + devra choisir une tranche d'âge + label: Tranche d'âge country: - description: Ce champ contient une liste de pays. L'utilisateur pourra - choisir un pays. - label: Activer le champ pays + description: Ce champ contient une liste de pays. L'utilisateur devra + choisir un pays + label: Pays date_of_birth: description: Ce champ est un champ date de naissance. L'utilisateur - pourra choisir une date. - label: Activer le champ date de naissance + devra saisir une date de naissance + label: Date de naissance gender: - description: Ce champ contient une liste de genres. L'utilisateur pourra - choisir un genre. - label: Activer le champ genre + description: Ce champ contient une liste de genres. L'utilisateur devra + choisir un genre + label: Genre location: - description: Ce champ permet l'ajout de texte. L'utilisateur pourra - choisir une localisation. - label: Activer le champ localisation + description: Ce champ permet l'ajout de texte. L'utilisateur devra saisir + une localisation + label: Localisation phone_number: description: Ce champ est un champ de numéro de téléphone. L'utilisateur - pourra choisir un numéro. - label: Activer le champ numéro de téléphone + devra saisir un numéro + label: Numéro de téléphone pattern: Motif de validation des numéros de téléphone (regex) pattern_help_html: Copiez cette expression régulière ^(\+34|0034|34)?[ -]*[6-9][ -]*([0-9][ -]*){8}$ pour valider ce format de téléphone @@ -63,14 +120,15 @@ fr: ici. placeholder: Placeholder pour le numéro de téléphone postal_code: - description: Ce champ est un champ code postal. L'utilisateur pourra - choisir un code postal. - label: Activer le champ code postal + description: Ce champ est un champ code postal. L'utilisateur devra + saisir un code postal + label: Code postal underage: description: Ce champ est un champ booléen. L'utilisateur pourra cocher - s'il est mineur. + s'il est mineur label: Activer le champ d'autorisation parentale limit: Cela définit la limite d'âge (ex. 18 ans) + limit_label: Limite d'âge form: callout: help: Activez la fonctionnalité des champs d'inscription personnalisés @@ -80,23 +138,75 @@ fr: extra_user_fields: extra_user_fields_enabled: Activer les champs d'inscription personnalisés section: Champs d'inscription disponibles pour le formulaire d'inscription + section_extras: Champs supplémentaires personnalisés + section_extras_description: Si vous avez configuré des champs d'utilisateur + supplémentaires, vous pouvez les gérer ici (Voir la section "Configuration + via un initializer" dans le README du plugin). + table: + enabled: Activé + field: Champ + required: Obligatoire global: title: Activer / Désactiver les champs d'inscription personnalisés + states: + disabled: Désactivé + enabled: Activé + optional: Optionnel + required: Obligatoire index: save: Sauvegarder title: Gérer les champs d'inscription personnalisés + select_fields: + participant_type: + description: Ce champ est une liste de types de participants. L'utilisateur + devra choisir un type de participant. + label: Activer le champ type de participant + text_fields: + motto: + description: Ce champ permet l'ajout de texte. L'utilisateur pourra + choisir un slogan. + label: Activer le champ slogan update: failure: Une erreur est survenue lors de la mise à jour - success: Les champs d'inscription ont été mis à jour avec succ§s + success: Les champs d'inscription ont été mis à jour avec succès + age_ranges: + 17_to_30: 17 à 30 + 31_to_60: 31 à 60 + 61_or_more: 61 ou plus + prefer_not_to_say: Préfère ne pas dire + up_to_16: 16 ou moins + boolean_fields: + ngo: + label: Je suis membre d'une organisation non gouvernementale (ONG) + errors: + select_fields: Le champ "%{field}" n'est pas valide. + force_extra_user_fields: + redirect_message: Veuillez compléter les informations de votre profil avant + de continuer. genders: female: Femme male: Homme other: Autre + prefer_not_to_say: Préfère ne pas dire + insight_age_spans: + 21_to_30: 21 à 30 + 31_to_40: 31 à 40 + 41_to_50: 41 à 50 + 51_to_60: 51 à 60 + 61_or_more: Plus de 60 + up_to_20: Moins de 20 registration_form: signup: legend: Plus d'information underage: Je suis âgé de moins de %{limit} ans et j'accepte d'obtenir une autorisation parentale + select_fields: + participant_type: + label: Participez-vous en tant qu'individu ou officiellement au nom d'une + organisation? + text_fields: + motto: + label: Quelle est votre devise? statutory_representative: inform: body: | diff --git a/decidim-extra_user_fields.gemspec b/decidim-extra_user_fields.gemspec index 50077d9e..847fa109 100644 --- a/decidim-extra_user_fields.gemspec +++ b/decidim-extra_user_fields.gemspec @@ -10,7 +10,7 @@ Gem::Specification.new do |s| s.email = ["fernando@populate.tools"] s.license = "AGPL-3.0" s.homepage = "https://github.com/PopulateTools/decidim-module-extra_user_fields" - s.required_ruby_version = ">= 3.0.2" + s.required_ruby_version = ">= 3.3.0" s.name = "decidim-extra_user_fields" s.summary = "Decidim module to add extra fields to users." @@ -18,7 +18,7 @@ Gem::Specification.new do |s| s.files = Dir["{app,config,lib}/**/*", "LICENSE-AGPLv3.txt", "Rakefile", "README.md"] - s.add_dependency "country_select", "~> 4.0" + s.add_dependency "country_select", "~> 10.0" s.add_dependency "decidim-core", Decidim::ExtraUserFields.decidim_version s.add_dependency "deface", "~> 1.5" end diff --git a/lib/decidim/extra_user_fields.rb b/lib/decidim/extra_user_fields.rb index e21bce3f..a47fbb1a 100644 --- a/lib/decidim/extra_user_fields.rb +++ b/lib/decidim/extra_user_fields.rb @@ -3,10 +3,104 @@ require "decidim/extra_user_fields/admin" require "decidim/extra_user_fields/engine" require "decidim/extra_user_fields/admin_engine" +require "decidim/extra_user_fields/insights_engine" require "decidim/extra_user_fields/form_builder_methods" module Decidim # This namespace holds the logic of the `ExtraUserFields` module. module ExtraUserFields + include ActiveSupport::Configurable + + PROFILE_FIELDS = %w(country postal_code date_of_birth gender age_range phone_number location).freeze + + config_accessor :underage_limit do + ENV.fetch("EXTRA_USER_FIELDS_UNDERAGE_LIMIT", 18).to_i + end + + config_accessor :underage_options do + ENV.fetch("EXTRA_USER_FIELDS_UNDERAGE_OPTIONS", "15 16 17 18 19 20 21").split.map(&:to_i) + end + + # These options require the I18n translations to be set in the locale files. + # decidim.extra_user_fields.genders.female + # decidim.extra_user_fields.genders.male + # decidim.extra_user_fields.genders. ... + config_accessor :genders do + ENV.fetch("EXTRA_USER_FIELDS_GENDERS", "female male other prefer_not_to_say").split + end + + # These options require the I18n translations to be set in the locale files. + # decidim.extra_user_fields.age_range.up_to_16 + # decidim.extra_user_fields.age_range.17_to_30 + # decidim.extra_user_fields.age_range. ... + config_accessor :age_ranges do + ENV.fetch("EXTRA_USER_FIELDS_AGE_RANGES", "up_to_16 17_to_30 31_to_60 61_or_more prefer_not_to_say").split + end + + # If extra select fields are needed, they can be added as a Hash here. + # The key is the field name and the value is a hash with the options. + # You can (optionally) add I18n keys for the options (if not the text will be used as it is). + # For the user interface, you can defined labels and descriptions for the fields (optionally): + # decidim.extra_user_fields.select_fields.field_name.label + # decidim.extra_user_fields.select_fields.field_name.description + # For the admin interface, you can defined labels and descriptions for the fields (optionally): + # decidim.extra_user_fields.admin.extra_user_fields.select_fields.field_name.label + # decidim.extra_user_fields.admin.extra_user_fields.select_fields.field_name.description + config_accessor :select_fields do + { + participant_type: { + # "" => "", + "individual" => "decidim.extra_user_fields.participant_types.individual", + "organization" => "decidim.extra_user_fields.participant_types.organization" + } + } + end + + # If extra boolean fields are needed, they can be added as an Array here. + # For the user interface, you can defined labels and descriptions for the fields (optionally): + # decidim.extra_user_fields.boolean_fields.field_name.label + # decidim.extra_user_fields.boolean_fields.field_name.description + # For the admin interface, you can defined labels and descriptions for the fields (optionally): + # decidim.extra_user_fields.admin.extra_user_fields.boolean_fields.field_name.label + # decidim.extra_user_fields.admin.extra_user_fields.boolean_fields.field_name.description + config_accessor :boolean_fields do + [:ngo] + end + + # If extra text fields are needed, they can be added as an Array here. + # For the user interface, you can defined labels and descriptions for the fields (optionally): + # decidim.extra_user_fields.text_fields.field_name.label + # decidim.extra_user_fields.text_fields.field_name.description + # For the admin interface, you can defined labels and descriptions for the fields (optionally): + # decidim.extra_user_fields.admin.extra_user_fields.text_fields.field_name.label + # decidim.extra_user_fields.admin.extra_user_fields.text_fields.field_name.description + config_accessor :text_fields do + [:motto] + end + + # Extra user fields allowed as pivot table axes in the Insights page. + # Only categorical fields with limited unique values make sense here. + config_accessor :insight_fields do + ENV.fetch("EXTRA_USER_FIELDS_INSIGHT_FIELDS", "gender age_span country").split + end + + # Age spans used by InsightFields::AgeSpan to bucket computed ages from date_of_birth. + # These are distinct from `age_ranges` (the form dropdown values). + config_accessor :insight_age_spans do + ENV.fetch("EXTRA_USER_FIELDS_INSIGHT_AGE_SPANS", "up_to_20 21_to_30 31_to_40 41_to_50 51_to_60 61_or_more").split + end + + # If extra insight metrics are needed, they can be added as a Hash here. + # The key is the metric identifier and the value is a fully-qualified class name. + # Each class must implement `initialize(participatory_space)` and `call` returning { user_id => count }. + config_accessor :insight_metrics do + { + "participants" => "Decidim::ExtraUserFields::Metrics::ParticipantsMetric", + "proposals_created" => "Decidim::ExtraUserFields::Metrics::ProposalsCreatedMetric", + "proposals_supported" => "Decidim::ExtraUserFields::Metrics::ProposalsSupportedMetric", + "comments" => "Decidim::ExtraUserFields::Metrics::CommentsMetric", + "budget_votes" => "Decidim::ExtraUserFields::Metrics::BudgetVotesMetric" + } + end end end diff --git a/lib/decidim/extra_user_fields/admin_engine.rb b/lib/decidim/extra_user_fields/admin_engine.rb index 0c058a31..b145b54c 100644 --- a/lib/decidim/extra_user_fields/admin_engine.rb +++ b/lib/decidim/extra_user_fields/admin_engine.rb @@ -17,7 +17,10 @@ class AdminEngine < ::Rails::Engine end resources :extra_user_fields, only: [:index] - match "/extra_user_fields" => "extra_user_fields#update", :via => :patch, as: "update" + match "/extra_user_fields" => "extra_user_fields#update", :via => :patch, :as => "update" + + get "benchmarking", to: "benchmarking#show" + post "benchmarking/export", to: "benchmarking#export", as: :benchmarking_export root to: "extra_user_fields#index" end @@ -36,16 +39,80 @@ class AdminEngine < ::Rails::Engine end end - initializer "decidim_extra_user_fields.admin_settings_menu" do - Decidim.menu :admin_settings_menu do |menu| + initializer "decidim_extra_user_fields.admin_user_menu" do + Decidim.menu :admin_user_menu do |menu| menu.add_item :extra_user_fields, t("decidim.admin.extra_user_fields.menu.title"), decidim_extra_user_fields.root_path, - position: 11, + position: 5, icon_name: "list-check" end end + initializer "decidim_extra_user_fields.admin_insights_menu" do + Decidim.menu :admin_insights_menu do |menu| + menu.add_item :benchmarking, + t("decidim.admin.extra_user_fields.benchmarking.menu_title"), + decidim_extra_user_fields.benchmarking_path, + position: 2, + icon_name: "bar-chart-box-line", + active: is_active_link?(decidim_extra_user_fields.benchmarking_path) + end + + Decidim.menu :admin_menu do |menu| + menu.remove_item :insights + + menu.add_item :insights_with_benchmarking, + I18n.t("menu.insights", scope: "decidim.admin"), + decidim_admin.statistics_path, + icon_name: "line-chart", + position: 11, + if: allowed_to?(:read, :statistics), + active: [ + %w( + decidim/admin/statistics + decidim/demographics/admin/settings + decidim/demographics/admin/questions + decidim/demographics/admin/responses + decidim/demographics/admin/publish_responses + decidim/extra_user_fields/admin/benchmarking + ), [] + ] + end + end + + initializer "decidim_extra_user_fields.insights_routes" do + Decidim::Core::Engine.routes do + Decidim.participatory_space_manifests.each do |manifest| + model_name = manifest.model_class_name.demodulize.underscore + slug_param = "#{model_name}_slug" + + scope "/admin/#{manifest.name}/:#{slug_param}" do + mount Decidim::ExtraUserFields::InsightsEngine, + at: "/insights", + as: "decidim_admin_#{model_name}_insights" + end + end + end + end + + initializer "decidim_extra_user_fields.insights_menu" do + Decidim.participatory_space_manifests.each do |manifest| + model_name = manifest.model_class_name.demodulize.underscore + slug_param = "#{model_name}_slug" + menu_name = :"admin_#{model_name}_menu" + route_helper = "decidim_admin_#{model_name}_insights" + + Decidim.menu menu_name do |menu| + menu.add_item :insights, + I18n.t("decidim.admin.extra_user_fields.insights.menu_title"), + send(route_helper).root_path(slug_param => current_participatory_space.slug), + icon_name: "bar-chart-2-line", + position: 9 + end + end + end + def load_seed nil end diff --git a/lib/decidim/extra_user_fields/engine.rb b/lib/decidim/extra_user_fields/engine.rb index e6a1cb69..fd5abf38 100644 --- a/lib/decidim/extra_user_fields/engine.rb +++ b/lib/decidim/extra_user_fields/engine.rb @@ -11,12 +11,6 @@ module ExtraUserFields class Engine < ::Rails::Engine isolate_namespace Decidim::ExtraUserFields - DEFAULT_GENDER_OPTIONS = [:male, :female, :other].freeze - - DEFAULT_UNDERAGE_LIMIT = 18 - - DEFAULT_UNDERAGE_OPTIONS = (15..21) - routes do # Add engine routes here # resources :extra_user_fields @@ -54,6 +48,14 @@ class Engine < ::Rails::Engine prepend Decidim::ExtraUserFields::OrganizationOverrides end + Decidim::ApplicationController.class_eval do + include Decidim::ExtraUserFields::NeedsExtraUserFieldsCompleted + end + + Decidim::AccountController.class_eval do + prepend Decidim::ExtraUserFields::AccountControllerOverrides + end + Decidim::FormBuilder.class_eval do include Decidim::ExtraUserFields::FormBuilderMethods end diff --git a/lib/decidim/extra_user_fields/form_builder_methods.rb b/lib/decidim/extra_user_fields/form_builder_methods.rb index a1345a33..df3c7b61 100644 --- a/lib/decidim/extra_user_fields/form_builder_methods.rb +++ b/lib/decidim/extra_user_fields/form_builder_methods.rb @@ -5,13 +5,31 @@ module ExtraUserFields # This module adds the FormBuilder methods for extra user fields module FormBuilderMethods def custom_country_select(name, options = {}) - label_text = options[:label].to_s - label_text = label_for(name) if label_text.blank? + label_text = options[:label].presence || label_for(name) + html = +"" + html << (label_text + required_for_attribute(name)) if options.fetch(:label, true) + html << sanitize_country_select(country_select(name)) + html.html_safe + end + + def phone_field(attribute, options = {}) + field(attribute, options) do |opts| + if opts[:placeholder].blank? && object.respond_to?(:"#{attribute}_extra_user_field_placeholder") + opts[:placeholder] = translated_attribute(object.send(:"#{attribute}_extra_user_field_placeholder")) + end + + opts[:pattern] ||= object.send(:"#{attribute}_extra_user_field_pattern") if object.respond_to?(:"#{attribute}_extra_user_field_pattern") + opts[:type] = "tel" + + @template.text_field(@object_name, attribute, objectify_options(opts)) + end + end + + private - template = "" - template += (label_text + required_for_attribute(name)) if options.fetch(:label, true) - template += country_select(name) - template.html_safe + # Remove non-standard attrs added by country_select that fail HTML/accessibility validation + def sanitize_country_select(html) + html.gsub(/\s(skip_default_ids|allow_method_names_outside_object)="[^"]*"/, "") end end end diff --git a/lib/decidim/extra_user_fields/insights_engine.rb b/lib/decidim/extra_user_fields/insights_engine.rb new file mode 100644 index 00000000..991a1c16 --- /dev/null +++ b/lib/decidim/extra_user_fields/insights_engine.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Decidim + module ExtraUserFields + # Engine for the Insights feature, mounted under participatory space admin URLs. + # Provides pivot-table statistics scoped to a specific participatory space. + class InsightsEngine < ::Rails::Engine + isolate_namespace Decidim::ExtraUserFields + + routes do + root to: "admin/insights#show" + post "export", to: "admin/insights#export", as: :export + end + end + end +end diff --git a/lib/decidim/extra_user_fields/structure_normalizer.rb b/lib/decidim/extra_user_fields/structure_normalizer.rb new file mode 100644 index 00000000..4bf699d6 --- /dev/null +++ b/lib/decidim/extra_user_fields/structure_normalizer.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +module Decidim + module ExtraUserFields + class StructureNormalizer + def normalize_all + Decidim::Organization.find_each do |organization| + next if organization.extra_user_fields.blank? + + normalized = normalize_structure(organization.extra_user_fields) + organization.update_column(:extra_user_fields, normalized) # rubocop:disable Rails/SkipsModelValidations + end + end + + private + + def normalize_structure(fields) + { + "enabled" => fields["enabled"].presence || false + }.tap do |result| + # Normalize standard profile fields + normalize_profile_fields(fields, result) + + # Normalize phone_number (keep extra properties) + normalize_phone_number(fields, result) + + # Normalize underage (merge underage_limit into it) + normalize_underage(fields, result) + + # Normalize collection fields + normalize_select_fields(fields, result) + normalize_boolean_fields(fields, result) + normalize_text_fields(fields, result) + end + end + + def normalize_profile_fields(fields, result) + %w(country postal_code date_of_birth gender age_range location).each do |field| + next unless fields.has_key?(field) + + field_value = fields[field] + if already_normalized?(field_value) + result[field] = field_value + else + state = if field_value.is_a?(Hash) && field_value.has_key?("enabled") + field_value["enabled"] + else + field_value + end + result[field] = convert_state_to_booleans(state) + end + end + end + + def normalize_phone_number(fields, result) + return unless fields.has_key?("phone_number") + + phone_data = fields["phone_number"] + if phone_data.is_a?(Hash) + if already_normalized?(phone_data) + result["phone_number"] = phone_data + else + state = phone_data["enabled"] + result["phone_number"] = convert_state_to_booleans(state).merge( + "pattern" => phone_data["pattern"], + "placeholder" => phone_data["placeholder"] + ).compact + end + else + result["phone_number"] = convert_state_to_booleans(phone_data) + end + end + + def normalize_underage(fields, result) + underage_data = fields["underage"] + underage_limit = fields["underage_limit"] + + if underage_data.is_a?(Hash) + # New format or partial migration + enabled = underage_data["enabled"] + limit = underage_data["limit"] || underage_limit || 18 + required = underage_data["required"] == true + result["underage"] = { + "enabled" => enabled == true || enabled == "true", + "required" => required, + "limit" => limit + } + elsif underage_data.present? + # Legacy boolean + result["underage"] = { + "enabled" => underage_data == true || underage_data == "true", + "required" => false, + "limit" => underage_limit || 18 + } + else + result["underage"] = { + "enabled" => false, + "required" => false, + "limit" => underage_limit || 18 + } + end + end + + def normalize_select_fields(fields, result) + select_data = fields["select_fields"] + return if select_data.blank? + + result["select_fields"] = normalize_collection(select_data) + end + + def normalize_boolean_fields(fields, result) + boolean_data = fields["boolean_fields"] + return if boolean_data.blank? + + result["boolean_fields"] = normalize_collection(boolean_data) + end + + def normalize_text_fields(fields, result) + text_data = fields["text_fields"] + return if text_data.blank? + + result["text_fields"] = normalize_collection(text_data) + end + + def normalize_collection(data) + if data.is_a?(Array) + data.index_with { |_| { "enabled" => true, "required" => false } } + elsif data.is_a?(Hash) + data.transform_values do |value| + if value.is_a?(Hash) && value.has_key?("enabled") + value + else + convert_state_to_booleans(value) + end + end + else + {} + end + end + + def already_normalized?(value) + value.is_a?(Hash) && + [true, false].include?(value["enabled"]) && + [true, false].include?(value["required"]) + end + + def convert_state_to_booleans(state) + case state + when "optional", true + { "enabled" => true, "required" => false } + when "required" + { "enabled" => true, "required" => true } + else # "disabled", false, nil, or unknown + { "enabled" => false, "required" => false } + end + end + end + end +end diff --git a/lib/decidim/extra_user_fields/test/factories.rb b/lib/decidim/extra_user_fields/test/factories.rb index 5fae2744..63db0f89 100644 --- a/lib/decidim/extra_user_fields/test/factories.rb +++ b/lib/decidim/extra_user_fields/test/factories.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true require "decidim/core/test/factories" +require "decidim/proposals/test/factories" +require "decidim/budgets/test/factories" +require "decidim/comments/test/factories" FactoryBot.define do factory :extra_user_fields_component, parent: :component do diff --git a/lib/decidim/extra_user_fields/version.rb b/lib/decidim/extra_user_fields/version.rb index f28f7fea..f0ac56c4 100644 --- a/lib/decidim/extra_user_fields/version.rb +++ b/lib/decidim/extra_user_fields/version.rb @@ -4,11 +4,11 @@ module Decidim # This holds the decidim-extra_user_fields version. module ExtraUserFields def self.version - "0.28.0" + "0.31.0" end def self.decidim_version - [">= 0.28"].freeze + [">= 0.31"].freeze end end end diff --git a/lib/tasks/decidim_extra_user_fields_upgrade_tasks.rake b/lib/tasks/decidim_extra_user_fields_upgrade_tasks.rake new file mode 100644 index 00000000..5fdb868c --- /dev/null +++ b/lib/tasks/decidim_extra_user_fields_upgrade_tasks.rake @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +Rake::Task["decidim:choose_target_plugins"].enhance do + ENV["FROM"] = "#{ENV.fetch("FROM", nil)},decidim_extra_user_fields" +end diff --git a/lib/tasks/normalize_extra_user_fields.rake b/lib/tasks/normalize_extra_user_fields.rake new file mode 100644 index 00000000..a89981c2 --- /dev/null +++ b/lib/tasks/normalize_extra_user_fields.rake @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "decidim/extra_user_fields/structure_normalizer" + +namespace :decidim_extra_user_fields do + desc "Normalize extra_user_fields structure from old format to new boolean format" + task normalize_structure: :environment do + normalizer = Decidim::ExtraUserFields::StructureNormalizer.new + normalizer.normalize_all + puts "✓ Extra user fields structure normalized successfully" + end +end diff --git a/spec/commands/decidim/create_omniauth_registration_spec.rb b/spec/commands/decidim/create_omniauth_registration_spec.rb index a87436a4..4a1e1445 100644 --- a/spec/commands/decidim/create_omniauth_registration_spec.rb +++ b/spec/commands/decidim/create_omniauth_registration_spec.rb @@ -12,23 +12,42 @@ module Comments let(:uid) { "12345" } let(:oauth_signature) { OmniauthRegistrationForm.create_signature(provider, uid) } let(:verified_email) { email } + let(:tos_agreement) { true } let(:country) { "Argentina" } let(:date_of_birth) { "01/01/2000" } let(:gender) { "other" } + let(:age_range) { "17_to_30" } let(:location) { "Paris" } let(:phone_number) { "0123456789" } let(:postal_code) { "75001" } let(:underage) { false } + let(:select_fields) do + { + "participant_type" => "individual" + } + end + let(:boolean_fields) do + ["ngo"] + end + let(:text_fields) do + { + "motto" => "I think, therefore I am" + } + end let(:statutory_representative_email) { nil } let(:extended_data) do { country:, date_of_birth:, gender:, + age_range:, location:, phone_number:, postal_code:, underage:, + select_fields:, + boolean_fields:, + text_fields:, statutory_representative_email: } end @@ -44,13 +63,18 @@ module Comments "nickname" => "facebook_user", "oauth_signature" => oauth_signature, "avatar_url" => "http://www.example.com/foo.jpg", + "tos_agreement" => tos_agreement, "country" => country, "postal_code" => postal_code, "date_of_birth" => date_of_birth, "gender" => gender, + "age_range" => age_range, "phone_number" => phone_number, "location" => location, "underage" => underage, + "select_fields" => select_fields, + "boolean_fields" => boolean_fields, + "text_fields" => text_fields, "statutory_representative_email" => statutory_representative_email } } @@ -112,6 +136,18 @@ module Comments expect(user.newsletter_notifications_at).to be_nil expect(user).to be_confirmed expect(user.valid_password?("decidim123456789")).to be(true) + + expect(user.extended_data["country"]).to eq(country) + expect(user.extended_data["postal_code"]).to eq(postal_code) + expect(user.extended_data["date_of_birth"]).to eq(date_of_birth.to_date.iso8601) + expect(user.extended_data["gender"]).to eq(gender) + expect(user.extended_data["age_range"]).to eq(age_range) + expect(user.extended_data["phone_number"]).to eq(phone_number) + expect(user.extended_data["location"]).to eq(location) + expect(user.extended_data["underage"]).to eq(underage) + expect(user.extended_data["select_fields"]).to eq(select_fields) + expect(user.extended_data["boolean_fields"]).to eq(boolean_fields) + expect(user.extended_data["text_fields"]).to eq(text_fields) end # NOTE: This is important so that the users who are only @@ -124,26 +160,48 @@ module Comments expect(user.password_updated_at).to be_nil end - it "notifies about registration with oauth data" do - user = create(:user, email:, organization:) - identity = Decidim::Identity.new(id: 1234) - allow(command).to receive(:create_identity).and_return(identity) - - expect(ActiveSupport::Notifications) - .to receive(:publish) - .with( - "decidim.user.omniauth_registration", - user_id: user.id, - identity_id: 1234, - provider:, - uid:, - email:, - name: "Facebook User", - nickname: "facebook_user", - avatar_url: "http://www.example.com/foo.jpg", - raw_data: {} - ) - command.call + context "when notifying about registration with oauth data" do + let(:identity) { Decidim::Identity.new(id: 1234) } + + before do + allow(command).to receive(:create_identity).and_return(identity) + end + + context "when the user is already confirmed" do + it "publishes the omniauth registration event" do + user = create(:user, :confirmed, email:, organization:) + expect(ActiveSupport::Notifications) + .to receive(:publish) + .with( + "decidim.user.omniauth_registration", + user_id: user.id, + identity_id: 1234, + provider:, + uid:, + email:, + name: "Facebook User", + nickname: "facebook_user", + avatar_url: "http://www.example.com/foo.jpg", + raw_data: {}, + tos_agreement: true, + accepted_tos_version: user.accepted_tos_version, + newsletter_notifications_at: user.newsletter_notifications_at + ) + command.call + end + end + + context "when the user is not confirmed" do + it "confirms the user and publishes the omniauth registration event" do + user = create(:user, email:, organization:) + allow(ActiveSupport::Notifications).to receive(:publish).and_call_original + expect(ActiveSupport::Notifications) + .to receive(:publish) + .with("decidim.user.omniauth_registration", hash_including(user_id: user.id)) + command.call + expect(user.reload).to be_confirmed + end + end end describe "user linking" do diff --git a/spec/commands/decidim/create_registration_spec.rb b/spec/commands/decidim/create_registration_spec.rb index 080fde6b..3fdbcb21 100644 --- a/spec/commands/decidim/create_registration_spec.rb +++ b/spec/commands/decidim/create_registration_spec.rb @@ -18,10 +18,24 @@ module Comments let(:country) { "Argentina" } let(:date_of_birth) { "01/01/2000" } let(:gender) { "other" } + let(:age_range) { "17_to_30" } let(:location) { "Paris" } let(:phone_number) { "0123456789" } let(:postal_code) { "75001" } let(:underage) { "0" } + let(:select_fields) do + { + participant_type: "individual" + } + end + let(:boolean_fields) do + ["ngo"] + end + let(:text_fields) do + { + motto: "I think, therefore I am" + } + end let(:statutory_representative_email) { nil } let(:extended_data) do { @@ -32,6 +46,7 @@ module Comments phone_number:, postal_code:, underage:, + age_range:, statutory_representative_email: } end @@ -49,9 +64,13 @@ module Comments "postal_code" => postal_code, "date_of_birth" => date_of_birth, "gender" => gender, + "age_range" => age_range, "phone_number" => phone_number, "location" => location, "underage" => underage, + "select_fields" => select_fields, + "boolean_fields" => boolean_fields, + "text_fields" => text_fields, "statutory_representative_email" => statutory_representative_email } } @@ -122,10 +141,14 @@ module Comments country:, date_of_birth: Date.parse(date_of_birth), gender:, + age_range:, location:, phone_number:, postal_code:, underage:, + select_fields:, + boolean_fields:, + text_fields:, statutory_representative_email: } ).and_call_original diff --git a/spec/commands/decidim/extra_user_fields/admin/update_extra_user_fields_spec.rb b/spec/commands/decidim/extra_user_fields/admin/update_extra_user_fields_spec.rb index 36310002..be547bf0 100644 --- a/spec/commands/decidim/extra_user_fields/admin/update_extra_user_fields_spec.rb +++ b/spec/commands/decidim/extra_user_fields/admin/update_extra_user_fields_spec.rb @@ -9,42 +9,45 @@ module Admin let(:organization) { create(:organization, extra_user_fields: {}) } let(:user) { create(:user, :admin, :confirmed, organization:) } - let(:extra_user_fields_enabled) { true } - let(:postal_code) { true } - let(:country) { true } - let(:gender) { true } - let(:date_of_birth) { true } - let(:phone_number) { true } let(:phone_number_pattern) { "^(\\+34)?[0-9 ]{9,12}$" } let(:phone_number_placeholder) { "+34999888777" } - let(:location) { true } - let(:underage) { true } - let(:underage_limit) { 18 } - # Block ExtraUserFields RspecVar - # EndBlock - - # rubocop:disable Style/TrailingCommaInHashLiteral let(:form_params) do { - "enabled" => extra_user_fields_enabled, - "postal_code" => postal_code, - "country" => country, - "gender" => gender, - "date_of_birth" => date_of_birth, - "phone_number" => phone_number, + "enabled" => true, + "country_enabled" => true, + "country_required" => true, + "gender_enabled" => true, + "gender_required" => false, + "age_range_enabled" => true, + "age_range_required" => false, + "date_of_birth_enabled" => true, + "date_of_birth_required" => true, + "postal_code_enabled" => true, + "postal_code_required" => false, + "phone_number_enabled" => true, + "phone_number_required" => false, "phone_number_pattern" => phone_number_pattern, - "phone_number_placeholder" => phone_number_placeholder, - "location" => location, - "underage" => underage, - "underage_limit" => underage_limit, - # Block ExtraUserFields ExtraUserFields - - # EndBlock + "phone_number_placeholder_en" => phone_number_placeholder, + "location_enabled" => false, + "location_required" => false, + "underage_enabled" => true, + "underage_required" => false, + "underage_limit" => 18, + "select_fields" => { + "participant_type" => { "enabled" => "true", "required" => "false" }, + "non_existing_field" => { "enabled" => "true", "required" => "false" } + }, + "boolean_fields" => { + "ngo" => { "enabled" => "true", "required" => "false" }, + "non_existing_field" => { "enabled" => "true", "required" => "false" } + }, + "text_fields" => { + "motto" => { "enabled" => "true", "required" => "false" }, + "non_existing_field" => { "enabled" => "true", "required" => "false" } + } } end - # rubocop:enable Style/TrailingCommaInHashLiteral - let(:form) do ExtraUserFieldsForm.from_params( form_params @@ -84,17 +87,20 @@ module Admin extra_user_fields = organization.extra_user_fields expect(extra_user_fields).to include("enabled" => true) - expect(extra_user_fields).to include("country" => { "enabled" => true }) - expect(extra_user_fields).to include("date_of_birth" => { "enabled" => true }) - expect(extra_user_fields).to include("gender" => { "enabled" => true }) - expect(extra_user_fields).to include("country" => { "enabled" => true }) - expect(extra_user_fields).to include("phone_number" => { "enabled" => true, "pattern" => phone_number_pattern, "placeholder" => phone_number_placeholder }) - expect(extra_user_fields).to include("location" => { "enabled" => true }) - expect(extra_user_fields).to include("underage" => { "enabled" => true }) - expect(extra_user_fields).to include("underage_limit" => 18) - # Block ExtraUserFields InclusionSpec - - # EndBlock + expect(extra_user_fields).to include("country" => { "enabled" => true, "required" => true }) + expect(extra_user_fields).to include("date_of_birth" => { "enabled" => true, "required" => true }) + expect(extra_user_fields).to include("postal_code" => { "enabled" => true, "required" => false }) + expect(extra_user_fields).to include("gender" => { "enabled" => true, "required" => false }) + expect(extra_user_fields).to include("age_range" => { "enabled" => true, "required" => false }) + phone = extra_user_fields["phone_number"] + expect(phone).to include("enabled" => true, "required" => false, "pattern" => phone_number_pattern) + expect(phone["placeholder"]).to include("en" => phone_number_placeholder) + expect(extra_user_fields).to include("location" => { "enabled" => false, "required" => false }) + expect(extra_user_fields).to include("underage" => { "enabled" => true, "required" => false, "limit" => 18 }) + expect(extra_user_fields["select_fields"]).to include("participant_type" => { "enabled" => true, "required" => false }) + expect(extra_user_fields["boolean_fields"]).to include("ngo" => { "enabled" => true, "required" => false }) + expect(extra_user_fields["text_fields"]).to include("motto" => { "enabled" => true, "required" => false }) + expect(extra_user_fields).not_to have_key("underage_limit") end end end diff --git a/spec/controllers/force_extra_user_fields_spec.rb b/spec/controllers/force_extra_user_fields_spec.rb new file mode 100644 index 00000000..e107fffa --- /dev/null +++ b/spec/controllers/force_extra_user_fields_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + describe ApplicationController do + controller do + def index + render plain: "OK" + end + end + + let(:organization) { create(:organization, extra_user_fields:) } + let(:user) { create(:user, :confirmed, organization:, extended_data:) } + let(:extended_data) { {} } + let(:extra_user_fields) { { "enabled" => true, "country" => { "enabled" => true, "required" => true } } } + + before do + request.env["decidim.current_organization"] = organization + sign_in user, scope: :user + end + + context "when user has incomplete extra fields" do + it "redirects to account path" do + get :index + + expect(response).to redirect_to("/account") + end + + it "sets a warning flash message" do + get :index + + expect(flash[:warning]).to include("complete your profile") + end + end + + context "when user has completed extra fields" do + let(:extended_data) { { "country" => "ES" } } + + it "does not redirect" do + get :index + + expect(response).to have_http_status(:ok) + end + end + + context "when no fields are required (all optional)" do + let(:extra_user_fields) { { "enabled" => true, "country" => { "enabled" => true, "required" => false } } } + + it "does not redirect" do + get :index + + expect(response).to have_http_status(:ok) + end + end + + context "when request format is JSON" do + it "does not redirect" do + get :index, format: :json + + expect(response).to have_http_status(:ok) + end + end + + context "when user has not accepted ToS" do + let(:user) { create(:user, :confirmed, :tos_not_accepted, organization:, extended_data:) } + + it "does not trigger extra fields redirect" do + get :index + + expect(response).not_to redirect_to("/account") + end + end + end +end diff --git a/spec/factories.rb b/spec/factories.rb index 3fda6535..51cf3a73 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -9,7 +9,10 @@ create_static_pages { true } end - name { Faker::Company.unique.name } + name do + Decidim.available_locales.index_with { |_locale| Faker::Company.unique.name } + end + reference_prefix { Faker::Name.suffix } time_zone { "UTC" } twitter_handler { Faker::Hipster.word } @@ -25,7 +28,6 @@ users_registration_mode { :enabled } official_img_footer { Decidim::Dev.test_file("avatar.jpg", "image/jpeg") } official_url { Faker::Internet.url } - highlighted_content_banner_enabled { false } enable_omnipresent_banner { false } badges_enabled { true } user_groups_enabled { true } @@ -34,7 +36,7 @@ admin_terms_of_service_body { Decidim::Faker::Localized.wrapped("

", "

") { generate_localized_title } } force_users_to_authenticate_before_access_organization { false } machine_translation_display_priority { "original" } - external_domain_whitelist { ["example.org", "twitter.com", "facebook.com", "youtube.com", "github.com", "mytesturl.me"] } + external_domain_allowlist { ["example.org", "twitter.com", "facebook.com", "youtube.com", "github.com", "mytesturl.me"] } smtp_settings do { "from" => "test@example.org", diff --git a/spec/forms/decidim/account_form_spec.rb b/spec/forms/decidim/account_form_spec.rb index ad76c2d3..09dfc1f6 100644 --- a/spec/forms/decidim/account_form_spec.rb +++ b/spec/forms/decidim/account_form_spec.rb @@ -22,9 +22,13 @@ module Decidim postal_code:, date_of_birth:, gender:, + age_range:, phone_number:, location:, underage:, + select_fields:, + boolean_fields:, + text_fields:, statutory_representative_email: ).with_context( current_organization: organization, @@ -37,14 +41,16 @@ module Decidim let(:extra_user_fields) do { "enabled" => true, - "country" => { "enabled" => true }, - "postal_code" => { "enabled" => true }, - "date_of_birth" => { "enabled" => true }, - "gender" => { "enabled" => true }, - "phone_number" => { "enabled" => true, "pattern" => phone_number_pattern, "placeholder" => nil }, - "location" => { "enabled" => true }, - "underage" => { "enabled" => true }, - "underage_limit" => 18 + "country" => { "enabled" => true, "required" => false }, + "postal_code" => { "enabled" => true, "required" => false }, + "date_of_birth" => { "enabled" => true, "required" => false }, + "gender" => { "enabled" => true, "required" => false }, + "phone_number" => { "enabled" => true, "required" => false, "pattern" => phone_number_pattern, "placeholder" => nil }, + "location" => { "enabled" => true, "required" => false }, + "underage" => { "enabled" => true, "required" => false, "limit" => 18 }, + "select_fields" => { "participant_type" => { "enabled" => true, "required" => false } }, + "boolean_fields" => { "ngo" => { "enabled" => true, "required" => false } }, + "text_fields" => { "motto" => { "enabled" => true, "required" => false } } } end let(:phone_number_pattern) { "^(\\+34)?[0-9 ]{9,12}$" } @@ -62,10 +68,14 @@ module Decidim let(:country) { "Argentina" } let(:date_of_birth) { "01/01/2000" } let(:gender) { "other" } + let(:age_range) { "17_to_30" } let(:location) { "Paris" } let(:phone_number) { "0123456789" } let(:postal_code) { "75001" } let(:underage) { "0" } + let(:select_fields) { { participant_type: "individual" } } + let(:boolean_fields) { ["ngo"] } + let(:text_fields) { { motto: false } } let(:statutory_representative_email) { nil } context "with correct data" do @@ -90,6 +100,67 @@ module Decidim end end + context "with non configured select fields" do + let(:select_fields) { { participant_type: "individual", foo: "bar" } } + + it "is valid" do + expect(subject).to be_valid + end + end + + context "with incorrect select fields" do + let(:select_fields) { { participant_type: "i_dont_exist" } } + + it "is invalid" do + expect(subject).not_to be_valid + end + end + + context "when a select field is required and blank" do + let(:extra_user_fields) { { "enabled" => true, "country" => { "enabled" => true, "required" => false }, "select_fields" => { "participant_type" => { "enabled" => true, "required" => true } } } } + let(:select_fields) { { participant_type: "" } } + + it "is invalid" do + expect(subject).not_to be_valid + expect(subject.errors[:select_fields_participant_type]).to be_present + end + end + + context "when a text field is required and blank" do + let(:extra_user_fields) { { "enabled" => true, "country" => { "enabled" => true, "required" => false }, "text_fields" => { "motto" => { "enabled" => true, "required" => true } } } } + let(:text_fields) { { motto: "" } } + + it "is invalid" do + expect(subject).not_to be_valid + expect(subject.errors[:text_fields_motto]).to be_present + end + end + + context "when a text field is optional and blank" do + let(:extra_user_fields) { { "enabled" => true, "country" => { "enabled" => true, "required" => false }, "text_fields" => { "motto" => { "enabled" => true, "required" => false } } } } + let(:text_fields) { { motto: "" } } + + it "is valid" do + expect(subject).to be_valid + end + end + + context "with non configured boolean fields" do + let(:boolean_fields) { [:ngo, :foo] } + + it "is valid" do + expect(subject).to be_valid + end + end + + context "with non configured text fields" do + let(:text_fields) { { motto: false, foo: true } } + + it "is valid" do + expect(subject).to be_valid + end + end + describe "name" do context "with an empty name" do let(:name) { "" } @@ -125,14 +196,6 @@ module Decidim expect(subject).not_to be_valid end end - - context "and belongs to a group" do - let!(:existing_group) { create(:user_group, email:, organization:) } - - it "is invalid" do - expect(subject).not_to be_valid - end - end end context "when it is already in use in another organization" do @@ -161,14 +224,6 @@ module Decidim expect(subject).not_to be_valid end end - - context "and belongs to a group" do - let!(:existing_group) { create(:user_group, nickname:, organization:) } - - it "is invalid" do - expect(subject).not_to be_valid - end - end end context "when it is already in use in another organization" do @@ -231,6 +286,36 @@ module Decidim end end + context "when country is required and blank" do + let(:extra_user_fields) { { "enabled" => true, "country" => { "enabled" => true, "required" => true } } } + let(:country) { "" } + + it "is invalid" do + expect(subject).not_to be_valid + expect(subject.errors[:country]).to be_present + end + end + + context "when gender is required and blank" do + let(:extra_user_fields) { { "enabled" => true, "gender" => { "enabled" => true, "required" => true } } } + let(:gender) { "" } + + it "is invalid" do + expect(subject).not_to be_valid + expect(subject.errors[:gender]).to be_present + end + end + + context "when extra user fields module is disabled" do + let(:extra_user_fields) { { "enabled" => false, "country" => { "enabled" => true, "required" => true }, "gender" => { "enabled" => true, "required" => true } } } + let(:country) { "" } + let(:gender) { "" } + + it "bypasses extra field validations and is valid" do + expect(subject).to be_valid + end + end + describe "personal_url" do context "when it does not start with http" do let(:personal_url) { "example.org" } diff --git a/spec/forms/decidim/admin/extra_user_fields_form_spec.rb b/spec/forms/decidim/admin/extra_user_fields_form_spec.rb index 21890cb7..7fa5b08a 100644 --- a/spec/forms/decidim/admin/extra_user_fields_form_spec.rb +++ b/spec/forms/decidim/admin/extra_user_fields_form_spec.rb @@ -32,6 +32,202 @@ module Admin context "when everything is OK" do it { is_expected.to be_valid } end + + describe "validation" do + context "when a profile field enabled is true" do + let(:attributes) { { country_enabled: true, country_required: true } } + + it { is_expected.to be_valid } + end + + context "when a profile field is enabled but not required" do + let(:attributes) { { country_enabled: true, country_required: false } } + + it { is_expected.to be_valid } + end + + context "when a profile field is disabled" do + let(:attributes) { { country_enabled: false, country_required: false } } + + it { is_expected.to be_valid } + end + end + + describe "#map_model" do + let(:org_extra_user_fields) do + { + "enabled" => true, + "country" => { "enabled" => true, "required" => true }, + "postal_code" => { "enabled" => true, "required" => false }, + "gender" => { "enabled" => false, "required" => false }, + "date_of_birth" => { "enabled" => false, "required" => false }, + "age_range" => { "enabled" => false, "required" => false }, + "phone_number" => { "enabled" => true, "required" => false, "pattern" => "^\\+33", "placeholder" => { "en" => "+33..." } }, + "location" => { "enabled" => false, "required" => false }, + "underage" => { "enabled" => true, "required" => false, "limit" => 16 }, + "select_fields" => { "participant_type" => { "enabled" => true, "required" => true } }, + "boolean_fields" => { "ngo" => { "enabled" => true, "required" => false } }, + "text_fields" => { "motto" => { "enabled" => true, "required" => false } } + } + end + let(:model) { build(:organization, extra_user_fields: org_extra_user_fields) } + + before { subject.map_model(model) } + + it "loads standard field states" do + expect(subject.country_enabled).to be true + expect(subject.country_required).to be true + expect(subject.postal_code_enabled).to be true + expect(subject.postal_code_required).to be false + expect(subject.gender_enabled).to be false + expect(subject.gender_required).to be false + end + + it "loads phone_number config" do + expect(subject.phone_number_enabled).to be true + expect(subject.phone_number_required).to be false + expect(subject.phone_number_pattern).to eq("^\\+33") + expect(subject.phone_number_placeholder).to eq({ "en" => "+33..." }) + end + + it "loads underage config" do + expect(subject.underage_enabled).to be true + expect(subject.underage_required).to be false + expect(subject.underage_limit).to eq(16) + end + + it "loads collection fields" do + select_result = subject.select_fields + expect(select_result[:participant_type] || select_result["participant_type"]).to eq({ "enabled" => true, "required" => true }) + boolean_result = subject.boolean_fields + expect(boolean_result[:ngo] || boolean_result["ngo"]).to eq({ "enabled" => true, "required" => false }) + text_result = subject.text_fields + expect(text_result[:motto] || text_result["motto"]).to eq({ "enabled" => true, "required" => false }) + end + + context "with nil/missing standard field" do + let(:org_extra_user_fields) { { "enabled" => true } } + + it "normalizes to disabled" do + expect(subject.country_enabled).to be false + expect(subject.country_required).to be false + expect(subject.gender_enabled).to be false + expect(subject.gender_required).to be false + end + end + + context "with missing underage_limit" do + let(:org_extra_user_fields) { { "enabled" => true, "underage" => { "enabled" => false, "required" => false } } } + + it "falls back to default" do + expect(subject.underage_limit).to eq(Decidim::ExtraUserFields.underage_limit) + end + end + end + + describe "#map_model collection field filtering" do + let(:model) { build(:organization, extra_user_fields: org_fields) } + + before { subject.map_model(model) } + + context "when select_fields contain invalid keys" do + let(:org_fields) do + { + "enabled" => true, + "select_fields" => { + "participant_type" => { "enabled" => true, "required" => true }, + "bogus" => { "enabled" => true, "required" => false } + } + } + end + + it "filters invalid keys and keeps valid ones" do + result = subject.select_fields + pt = result["participant_type"] || result[:participant_type] + expect(pt).to be_present + expect(pt["enabled"]).to be true + expect(pt["required"]).to be true + expect(result.keys.map(&:to_s)).not_to include("bogus") + end + end + + context "when select_fields contain non-hash values" do + let(:org_fields) do + { "enabled" => true, "select_fields" => { "participant_type" => "foobar" } } + end + + it "normalizes entries with invalid values to disabled state" do + result = subject.select_fields + pt = result["participant_type"] || result[:participant_type] + expect(pt).to eq({ "enabled" => false, "required" => false }) + end + end + + context "when boolean_fields contain invalid keys" do + let(:org_fields) do + { + "enabled" => true, + "boolean_fields" => { + "ngo" => { "enabled" => true, "required" => false }, + "bogus" => { "enabled" => true, "required" => false } + } + } + end + + it "filters invalid entries" do + result = subject.boolean_fields + expect(result.keys.map(&:to_s)).to include("ngo") + expect(result.keys.map(&:to_s)).not_to include("bogus") + end + end + + context "when text_fields contain invalid keys" do + let(:org_fields) do + { + "enabled" => true, + "text_fields" => { + "motto" => { "enabled" => true, "required" => false }, + "bogus" => { "enabled" => true, "required" => true } + } + } + end + + it "filters invalid keys and keeps valid ones" do + result = subject.text_fields + motto = result["motto"] || result[:motto] + expect(motto).to be_present + expect(motto["enabled"]).to be true + expect(motto["required"]).to be false + expect(result.keys.map(&:to_s)).not_to include("bogus") + end + end + end + + describe "collection fields from params" do + context "with nil select_fields" do + let(:attributes) { { select_fields: nil } } + + it "returns empty hash" do + expect(subject.select_fields).to eq({}) + end + end + + context "with nil boolean_fields" do + let(:attributes) { { boolean_fields: nil } } + + it "returns empty hash" do + expect(subject.boolean_fields).to eq({}) + end + end + + context "with nil text_fields" do + let(:attributes) { { text_fields: nil } } + + it "returns empty hash" do + expect(subject.text_fields).to eq({}) + end + end + end end end end diff --git a/spec/forms/decidim/omniauth_registration_form_spec.rb b/spec/forms/decidim/omniauth_registration_form_spec.rb index 3b25b826..9650070c 100644 --- a/spec/forms/decidim/omniauth_registration_form_spec.rb +++ b/spec/forms/decidim/omniauth_registration_form_spec.rb @@ -21,9 +21,24 @@ module Decidim let(:country) { "Argentina" } let(:date_of_birth) { "01/01/2000" } let(:gender) { "other" } + let(:age_range) { "17_to_30" } let(:location) { "Paris" } let(:phone_number) { "0123456789" } let(:postal_code) { "75001" } + let(:underage) { false } + let(:select_fields) do + { + "participant_type" => "individual" + } + end + let(:boolean_fields) do + ["ngo"] + end + let(:text_fields) do + { + "motto" => false + } + end let(:attributes) do { @@ -38,8 +53,13 @@ module Decidim postal_code:, date_of_birth:, gender:, + age_range:, phone_number:, - location: + location:, + underage:, + select_fields:, + boolean_fields:, + text_fields: } end diff --git a/spec/forms/decidim/registration_form_spec.rb b/spec/forms/decidim/registration_form_spec.rb index 7d463839..857543c1 100644 --- a/spec/forms/decidim/registration_form_spec.rb +++ b/spec/forms/decidim/registration_form_spec.rb @@ -18,12 +18,13 @@ module Decidim let(:extra_user_fields) do { "enabled" => true, - "country" => { "enabled" => true }, - "postal_code" => { "enabled" => true }, - "date_of_birth" => { "enabled" => true }, - "gender" => { "enabled" => true }, - "phone_number" => { "enabled" => true, "pattern" => phone_number_pattern, "placeholder" => nil }, - "location" => { "enabled" => true } + "country" => { "enabled" => true, "required" => false }, + "postal_code" => { "enabled" => true, "required" => false }, + "date_of_birth" => { "enabled" => true, "required" => false }, + "gender" => { "enabled" => true, "required" => false }, + "age_range" => { "enabled" => true, "required" => false }, + "phone_number" => { "enabled" => true, "required" => false, "pattern" => phone_number_pattern, "placeholder" => nil }, + "location" => { "enabled" => true, "required" => false } } end let(:phone_number_pattern) { "^(\\+34)?[0-9 ]{9,12}$" } @@ -35,6 +36,7 @@ module Decidim let(:country) { "Argentina" } let(:date_of_birth) { "01/01/2000" } let(:gender) { "other" } + let(:age_range) { "17_to_30" } let(:location) { "Paris" } let(:phone_number) { "0123456789" } let(:postal_code) { "75001" } @@ -50,6 +52,7 @@ module Decidim postal_code:, date_of_birth:, gender:, + age_range:, phone_number:, location: } @@ -103,12 +106,6 @@ module Decidim it { is_expected.not_to be_valid } end end - - context "and a user_group has the email" do - let!(:user_group) { create(:user_group, organization:, email:) } - - it { is_expected.not_to be_valid } - end end context "when the name is an email" do @@ -154,7 +151,7 @@ module Decidim context "when the nickname already exists" do context "and a user has the nickname" do - let!(:another_user) { create(:user, organization:, nickname: name.upcase) } + let!(:another_user) { create(:user, organization:, nickname: name) } it { is_expected.to be_valid } @@ -168,12 +165,6 @@ module Decidim it { is_expected.to be_valid } end end - - context "and a user_group has the nickname" do - let!(:user_group) { create(:user_group, organization:, nickname: name) } - - it { is_expected.to be_valid } - end end context "when the nickname is too long" do diff --git a/spec/helpers/decidim/extra_user_fields/admin/benchmarking_helper_spec.rb b/spec/helpers/decidim/extra_user_fields/admin/benchmarking_helper_spec.rb new file mode 100644 index 00000000..c26ea998 --- /dev/null +++ b/spec/helpers/decidim/extra_user_fields/admin/benchmarking_helper_spec.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields::Admin + describe BenchmarkingHelper do + let(:presenter) do + instance_double( + Decidim::ExtraUserFields::ComparativePivotPresenter, + cell: 5, + cell_style: "--i:0.5;--tc:#1a1a1a;", + combined_row_total: 10, + row_total_style: "--i:0.8;--tc:#fff;", + space_col_total: 7, + col_total_style: "--i:0.6;--tc:#1a1a1a;", + combined_grand_total: 33, + space_label: "My Space" + ) + end + + before do + allow(helper).to receive(:comparative_pivot_presenter).and_return(presenter) + end + + describe "#space_option_label" do + it "returns type and translated title" do + space = create(:participatory_process, title: { "en" => "My Process" }) + label = helper.space_option_label(space) + expect(label).to include("My Process") + expect(label).to match(/\[.*\]/) + end + + it "falls back to first locale when current locale is missing" do + space = create(:participatory_process, title: { "fr" => "Mon Processus" }) + label = helper.space_option_label(space) + expect(label).to include("Mon Processus") + end + + it "handles string titles" do + space = OpenStruct.new(title: "Plain Title", class: Decidim::ParticipatoryProcess) + label = helper.space_option_label(space) + expect(label).to include("Plain Title") + expect(label).to match(/\[.*\]/) + end + end + + describe "#space_option_value" do + it "returns ClassName:id format" do + space = create(:participatory_process) + result = helper.space_option_value(space) + expect(result).to eq("#{space.class.name}:#{space.id}") + end + end + + describe "#benchmarking_data_cell" do + it "renders a td with heatmap-cell--colored class for non-nil row and col" do + result = helper.benchmarking_data_cell(:space, "female", "young", space_index: 0, col_index: 0) + expect(result).to include("heatmap-cell--colored") + expect(result).to include("--i:") + expect(result).to include(" true, + "phone_number" => { "enabled" => true, "required" => false, "pattern" => "^\\+33", "placeholder" => "+33..." }, + "select_fields" => { "participant_type" => { "enabled" => true, "required" => true } }, + "boolean_fields" => { "ngo" => { "enabled" => true, "required" => false } }, + "text_fields" => { "motto" => { "enabled" => true, "required" => false } } + } + end + + let(:helper_class) do + Class.new do + include Decidim::ExtraUserFields::ApplicationHelper + + attr_accessor :current_organization + end + end + let(:helper) do + h = helper_class.new + h.current_organization = organization + h + end + + describe "#gender_options_for_select" do + it "returns array of [gender, translation] pairs" do + result = helper.gender_options_for_select + expect(result).to be_an(Array) + expect(result.first).to eq(%w(female Female)) + expect(result.map(&:first)).to match_array(Decidim::ExtraUserFields.genders) + end + end + + describe "#age_range_options_for_select" do + it "returns array of [age_range, translation] pairs" do + result = helper.age_range_options_for_select + expect(result).to be_an(Array) + expect(result.map(&:first)).to match_array(Decidim::ExtraUserFields.age_ranges) + end + end + + describe "#phone_number_extra_user_field_pattern" do + it "returns pattern from org config" do + expect(helper.phone_number_extra_user_field_pattern).to eq("^\\+33") + end + + context "when no pattern is configured" do + let(:org_extra_user_fields) { { "enabled" => true, "phone_number" => { "enabled" => true, "required" => false } } } + + it "returns nil" do + expect(helper.phone_number_extra_user_field_pattern).to be_nil + end + end + end + + describe "#phone_number_extra_user_field_placeholder" do + it "returns placeholder from org config" do + expect(helper.phone_number_extra_user_field_placeholder).to eq("+33...") + end + end + + describe "#custom_select_fields_options" do + it "returns hash of active fields with mapped options" do + result = helper.custom_select_fields_options + expect(result).to have_key(:participant_type) + expect(result[:participant_type]).to be_an(Array) + end + + context "when no select fields are active" do + let(:org_extra_user_fields) { { "enabled" => true } } + + it "returns empty hash" do + expect(helper.custom_select_fields_options).to eq({}) + end + end + + context "when select_fields config is not a Hash" do + before { allow(Decidim::ExtraUserFields).to receive(:select_fields).and_return(nil) } + + it "returns empty hash" do + expect(helper.custom_select_fields_options).to eq({}) + end + end + end + + describe "#custom_boolean_fields" do + it "returns active boolean fields" do + result = helper.custom_boolean_fields + expect(result).to include(:ngo) + end + + context "when no boolean fields are active" do + let(:org_extra_user_fields) { { "enabled" => true } } + + it "returns empty array" do + expect(helper.custom_boolean_fields).to eq([]) + end + end + + context "when boolean_fields config is not an Array" do + before { allow(Decidim::ExtraUserFields).to receive(:boolean_fields).and_return(nil) } + + it "returns empty array" do + expect(helper.custom_boolean_fields).to eq([]) + end + end + end + + describe "#custom_text_fields" do + it "returns active text fields with required flag" do + result = helper.custom_text_fields + expect(result).to have_key(:motto) + expect(result[:motto]).to be false + end + + context "when text field is required" do + let(:org_extra_user_fields) { { "enabled" => true, "text_fields" => { "motto" => { "enabled" => true, "required" => true } } } } + + it "returns true for required flag" do + result = helper.custom_text_fields + expect(result[:motto]).to be true + end + end + + context "when no text fields are active" do + let(:org_extra_user_fields) { { "enabled" => true } } + + it "returns empty hash" do + expect(helper.custom_text_fields).to eq({}) + end + end + end + + describe "#custom_select_field_required?" do + it "delegates to org collection_field_required?" do + expect(helper.custom_select_field_required?("participant_type")).to be true + end + + context "when field is optional" do + let(:org_extra_user_fields) { { "enabled" => true, "select_fields" => { "participant_type" => { "enabled" => true, "required" => false } } } } + + it "returns false" do + expect(helper.custom_select_field_required?("participant_type")).to be false + end + end + end + + describe "#map_options" do + it "translates I18n keys and falls back to humanized" do + options = { "individual" => "decidim.extra_user_fields.participant_types.individual", "other_key" => "" } + result = helper.map_options(options) + + individual_entry = result.find { |_label, key| key == "individual" } + expect(individual_entry).not_to be_nil + + other_entry = result.find { |_label, key| key == "other_key" } + expect(other_entry).not_to be_nil + end + + context "with missing I18n keys" do + it "falls back to humanized label" do + options = { "some_option" => "decidim.nonexistent.key" } + result = helper.map_options(options) + expect(result.first[0]).to eq("Key") + end + end + end + end + end +end diff --git a/spec/jobs/decidim/extra_user_fields/admin/export_participants_job_spec.rb b/spec/jobs/decidim/extra_user_fields/admin/export_participants_job_spec.rb index 360facbd..a92db59b 100644 --- a/spec/jobs/decidim/extra_user_fields/admin/export_participants_job_spec.rb +++ b/spec/jobs/decidim/extra_user_fields/admin/export_participants_job_spec.rb @@ -11,24 +11,20 @@ module Admin let(:format) { "CSV" } it "sends an email with a file attached" do - ExportParticipantsJob.perform_now(organization, user, format) + perform_enqueued_jobs { ExportParticipantsJob.perform_now(organization, user, format) } email = last_email expect(email.subject).to include("participants") - attachment = email.attachments.first - - expect(attachment.read.length).to be_positive - expect(attachment.mime_type).to eq("application/zip") - expect(attachment.filename).to match(/^participants-[0-9]+-[0-9]+-[0-9]+-[0-9]+\.zip$/) + expect(last_email_body).to include("Your download is ready.") end context "when format is CSV" do it "uses the csv exporter" do - export_data = double + export_data = double(read: "", filename: "participants") expect(Decidim::Exporters::CSV).to(receive(:new).with(anything, Decidim::ExtraUserFields::UserExportSerializer)).and_return(double(export: export_data)) expect(ExportMailer) - .to(receive(:export).with(user, "participants", export_data)) - .and_return(double(deliver_now: true)) + .to(receive(:export).with(user, kind_of(Decidim::PrivateExport))) + .and_return(double(deliver_later: true)) ExportParticipantsJob.perform_now(organization, user, format) end end @@ -37,13 +33,13 @@ module Admin let(:format) { "JSON" } it "uses the json exporter" do - export_data = double + export_data = double(read: "", filename: "participants") expect(Decidim::Exporters::JSON) .to(receive(:new).with(anything, Decidim::ExtraUserFields::UserExportSerializer)) .and_return(double(export: export_data)) expect(ExportMailer) - .to(receive(:export).with(user, "participants", export_data)) - .and_return(double(deliver_now: true)) + .to(receive(:export).with(user, kind_of(Decidim::PrivateExport))) + .and_return(double(deliver_later: true)) ExportParticipantsJob.perform_now(organization, user, format) end end @@ -52,13 +48,13 @@ module Admin let(:format) { "Excel" } it "uses the excel exporter" do - export_data = double + export_data = double(read: "", filename: "participants") expect(Decidim::Exporters::Excel) .to(receive(:new).with(anything, Decidim::ExtraUserFields::UserExportSerializer)) .and_return(double(export: export_data)) expect(ExportMailer) - .to(receive(:export).with(user, "participants", export_data)) - .and_return(double(deliver_now: true)) + .to(receive(:export).with(user, kind_of(Decidim::PrivateExport))) + .and_return(double(deliver_later: true)) ExportParticipantsJob.perform_now(organization, user, format) end end diff --git a/spec/jobs/decidim/extra_user_fields/admin/export_pivot_data_job_spec.rb b/spec/jobs/decidim/extra_user_fields/admin/export_pivot_data_job_spec.rb new file mode 100644 index 00000000..9fdcb2d6 --- /dev/null +++ b/spec/jobs/decidim/extra_user_fields/admin/export_pivot_data_job_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module ExtraUserFields + module Admin + describe ExportPivotDataJob do + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, :confirmed, organization:) } + let!(:process_a) { create(:participatory_process, :with_steps, organization:) } + let(:format) { "CSV" } + let(:pivot_params) { { metric: "participants", row_field: "gender", col_field: "age_span" } } + + context "with single space (insights)" do + let(:spaces) { [process_a] } + + it "sends an export email" do + perform_enqueued_jobs do + described_class.perform_now(user, format, spaces, pivot_params, "insights") + end + email = last_email + expect(email.subject).to include("insights") + end + + it "uses the CSV exporter" do + export_data = double(read: "", filename: "insights") + expect(Decidim::Exporters::CSV) + .to(receive(:new).with(kind_of(Array), PivotTableRowSerializer)) + .and_return(double(export: export_data)) + expect(ExportMailer) + .to(receive(:export).with(user, kind_of(Decidim::PrivateExport))) + .and_return(double(deliver_later: true)) + described_class.perform_now(user, format, spaces, pivot_params, "insights") + end + end + + context "with multiple spaces (benchmarking)" do + let!(:process_b) { create(:participatory_process, :with_steps, organization:) } + let(:spaces) { [process_a, process_b] } + + it "sends an export email" do + perform_enqueued_jobs do + described_class.perform_now(user, format, spaces, pivot_params, "benchmarking") + end + email = last_email + expect(email.subject).to include("benchmarking") + end + + it "uses the CSV exporter" do + export_data = double(read: "", filename: "benchmarking") + expect(Decidim::Exporters::CSV) + .to(receive(:new).with(kind_of(Array), PivotTableRowSerializer)) + .and_return(double(export: export_data)) + expect(ExportMailer) + .to(receive(:export).with(user, kind_of(Decidim::PrivateExport))) + .and_return(double(deliver_later: true)) + described_class.perform_now(user, format, spaces, pivot_params, "benchmarking") + end + end + + context "when format is JSON" do + let(:format) { "JSON" } + let(:spaces) { [process_a] } + + it "uses the JSON exporter" do + export_data = double(read: "", filename: "insights") + expect(Decidim::Exporters::JSON) + .to(receive(:new).with(kind_of(Array), PivotTableRowSerializer)) + .and_return(double(export: export_data)) + expect(ExportMailer) + .to(receive(:export).with(user, kind_of(Decidim::PrivateExport))) + .and_return(double(deliver_later: true)) + described_class.perform_now(user, format, spaces, pivot_params, "insights") + end + end + + context "when format is Excel" do + let(:format) { "Excel" } + let(:spaces) { [process_a] } + + it "uses the Excel exporter" do + export_data = double(read: "", filename: "insights") + expect(Decidim::Exporters::Excel) + .to(receive(:new).with(kind_of(Array), PivotTableRowSerializer)) + .and_return(double(export: export_data)) + expect(ExportMailer) + .to(receive(:export).with(user, kind_of(Decidim::PrivateExport))) + .and_return(double(deliver_later: true)) + described_class.perform_now(user, format, spaces, pivot_params, "insights") + end + end + end + end + end +end diff --git a/spec/lib/overrides_spec.rb b/spec/lib/overrides_spec.rb new file mode 100644 index 00000000..3ad85ce0 --- /dev/null +++ b/spec/lib/overrides_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "spec_helper" + +# We make sure that the checksum of the file overriden is the same +# as the expected. If this test fails, it means that the overriden +# file should be updated to match any change/bug fix introduced in the core +checksums = [ + { + package: "decidim-core", + files: { + "/app/commands/decidim/create_registration.rb" => "c2fafd313dbe16624e3ef07584e946cd", + "/app/commands/decidim/create_omniauth_registration.rb" => "31ce55b44db4e53151f11524d26d8832", + "/app/commands/decidim/update_account.rb" => "2c4f0e5a693b4b46a8e39e12dd9ecb2a", + "/app/controllers/decidim/application_controller.rb" => "f8936a35f0d919101acad13f7c5a446e", + "/app/models/decidim/organization.rb" => "977969a742ef2ef7515395fcf6951df7", + "/app/views/decidim/account/show.html.erb" => "1c230c5c6bc02e0bb22e1ea92b0da96c", + "/app/views/decidim/devise/registrations/new.html.erb" => "861b8821bbdc05e7b337fcdb921415ba", + "/app/views/decidim/devise/omniauth_registrations/new.html.erb" => "b972ec211ff96702d449cf6c8846a613" + } + }, + { + package: "decidim-admin", + files: { + "/app/views/decidim/admin/officializations/index.html.erb" => "e68f2a9b4887212f21756de25394ff53" + } + } +] + +describe "Overriden files", type: :view do + checksums.each do |item| + spec = Gem::Specification.find_by_name(item[:package]) + item[:files].each do |file, signature| + it "#{spec.gem_dir}#{file} matches checksum" do + expect(md5("#{spec.gem_dir}#{file}")).to eq(signature) + end + end + end + + private + + def md5(file) + Digest::MD5.hexdigest(File.read(file)) + end +end diff --git a/spec/models/decidim/extra_user_fields/pivot_table_spec.rb b/spec/models/decidim/extra_user_fields/pivot_table_spec.rb new file mode 100644 index 00000000..cf065cf3 --- /dev/null +++ b/spec/models/decidim/extra_user_fields/pivot_table_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields + describe PivotTable do + subject(:pivot_table) { described_class.new(row_values: row_values, col_values: col_values, cells: cells) } + + let(:row_values) { %w(18_to_25 26_to_40) } + let(:col_values) { %w(female male) } + let(:cells) do + { + "18_to_25" => { "female" => 10, "male" => 5 }, + "26_to_40" => { "female" => 20, "male" => 15 } + } + end + + describe "#cell" do + it "returns the count for a given row and column" do + expect(pivot_table.cell("18_to_25", "female")).to eq(10) + expect(pivot_table.cell("26_to_40", "male")).to eq(15) + end + + it "returns 0 for missing cells" do + expect(pivot_table.cell("unknown", "female")).to eq(0) + end + end + + describe "#row_total" do + it "sums all columns for a given row" do + expect(pivot_table.row_total("18_to_25")).to eq(15) + expect(pivot_table.row_total("26_to_40")).to eq(35) + end + end + + describe "#col_total" do + it "sums all rows for a given column" do + expect(pivot_table.col_total("female")).to eq(30) + expect(pivot_table.col_total("male")).to eq(20) + end + end + + describe "#grand_total" do + it "returns the sum of all cells" do + expect(pivot_table.grand_total).to eq(50) + end + end + + describe "#max_value" do + it "returns the highest cell value" do + expect(pivot_table.max_value).to eq(20) + end + end + + describe "#specified_cell_range" do + it "returns min and max among all non-zero cells" do + expect(pivot_table.specified_cell_range).to eq([5, 20]) + end + + context "with nil rows/cols" do + let(:row_values) { ["18_to_25", nil] } + let(:col_values) { ["female", nil] } + let(:cells) { { "18_to_25" => { "female" => 10, nil => 3 }, nil => { "female" => 7, nil => 1 } } } + + it "excludes cells where row or col is nil" do + expect(pivot_table.specified_cell_range).to eq([10, 10]) + end + end + end + + describe "#all_cell_range" do + it "returns min and max among all cells" do + expect(pivot_table.all_cell_range).to eq([5, 20]) + end + end + + describe "#row_total_max" do + it "returns the highest row total" do + expect(pivot_table.row_total_max).to eq(35) + end + end + + describe "#col_total_max" do + it "returns the highest column total" do + expect(pivot_table.col_total_max).to eq(30) + end + end + + describe "#empty?" do + it "returns false when there is data" do + expect(pivot_table.empty?).to be(false) + end + + context "when all cells are zero" do + let(:cells) do + { + "18_to_25" => { "female" => 0, "male" => 0 } + } + end + + it "returns true" do + expect(pivot_table.empty?).to be(true) + end + end + + context "when there are no cells" do + let(:row_values) { [] } + let(:col_values) { [] } + let(:cells) { {} } + + it "returns true" do + expect(pivot_table.empty?).to be(true) + end + end + end + end +end diff --git a/spec/models/decidim/organization_spec.rb b/spec/models/decidim/organization_spec.rb index 4760cfca..7b617491 100644 --- a/spec/models/decidim/organization_spec.rb +++ b/spec/models/decidim/organization_spec.rb @@ -6,16 +6,9 @@ module Decidim describe Organization do subject(:organization) { build(:organization, extra_user_fields:) } - let(:extra_user_fields) do - { - "enabled" => extra_user_field, - "date_of_birth" => date_of_birth - } - end + let(:extra_user_fields) { { "enabled" => extra_user_field, "date_of_birth" => date_of_birth } } let(:extra_user_field) { true } - let(:date_of_birth) do - { "enabled" => true } - end + let(:date_of_birth) { { "enabled" => true, "required" => false } } let(:omniauth_secrets) do { facebook: { @@ -32,6 +25,10 @@ module Decidim enabled: true, client_id: nil, client_secret: nil + }, + test: { + enabled: true, + icon: "tools-line" } } end @@ -71,22 +68,26 @@ module Decidim describe "enabled omniauth providers" do subject(:enabled_providers) { organization.enabled_omniauth_providers } + let!(:previous_omniauth_providers) { Decidim.omniauth_providers } + + after do + Decidim.omniauth_providers = previous_omniauth_providers + end + context "when omniauth_settings are nil" do - context "when providers are enabled in secrets.yml" do - it "returns providers enabled in secrets.yml" do + context "when providers are enabled" do + before do + allow(Decidim).to receive(:omniauth_providers).and_return(omniauth_secrets) + end + + it "returns providers enabled" do expect(enabled_providers).to eq(omniauth_secrets) end end - context "when providers are not enabled in secrets.yml" do - let!(:previous_omniauth_secrets) { Rails.application.secrets[:omniauth] } - + context "when providers are not enabled" do before do - Rails.application.secrets[:omniauth] = nil - end - - after do - Rails.application.secrets[:omniauth] = previous_omniauth_secrets + allow(Decidim).to receive(:omniauth_providers).and_return({}) end it "returns no providers" do @@ -141,8 +142,562 @@ module Decidim end end + describe "#has_required_extra_user_fields?" do + context "when a standard field is required" do + let(:extra_user_fields) do + { + "enabled" => true, + "date_of_birth" => { "enabled" => true, "required" => true } + } + end + + it "returns true" do + expect(subject).to have_required_extra_user_fields + end + end + + context "when all fields are optional" do + let(:extra_user_fields) do + { + "enabled" => true, + "date_of_birth" => { "enabled" => true, "required" => false } + } + end + + it "returns false" do + expect(subject).not_to have_required_extra_user_fields + end + end + + context "when all fields are disabled" do + let(:extra_user_fields) do + { + "enabled" => true, + "date_of_birth" => { "enabled" => false, "required" => false } + } + end + + it "returns false" do + expect(subject).not_to have_required_extra_user_fields + end + end + + context "when extra user fields are disabled globally" do + let(:extra_user_fields) do + { + "enabled" => false, + "date_of_birth" => { "enabled" => true, "required" => true } + } + end + + it "returns false" do + expect(subject).not_to have_required_extra_user_fields + end + end + + context "when a select field is required" do + let(:extra_user_fields) do + { + "enabled" => true, + "select_fields" => { "participant_type" => { "enabled" => true, "required" => true } } + } + end + + it "returns true" do + expect(subject).to have_required_extra_user_fields + end + end + + context "when a text field is required" do + let(:extra_user_fields) do + { + "enabled" => true, + "text_fields" => { "motto" => { "enabled" => true, "required" => true } } + } + end + + it "returns true" do + expect(subject).to have_required_extra_user_fields + end + end + + context "when collection fields are optional" do + let(:extra_user_fields) do + { + "enabled" => true, + "select_fields" => { "participant_type" => { "enabled" => true, "required" => false } }, + "text_fields" => { "motto" => { "enabled" => true, "required" => false } } + } + end + + it "returns false" do + expect(subject).not_to have_required_extra_user_fields + end + end + end + + describe "#required_extra_field?" do + context "when field is required" do + let(:extra_user_fields) do + { + "enabled" => true, + "country" => { "enabled" => true, "required" => true } + } + end + + it "returns true" do + expect(subject.required_extra_field?(:country)).to be true + end + end + + context "when field is optional" do + let(:extra_user_fields) do + { + "enabled" => true, + "country" => { "enabled" => true, "required" => false } + } + end + + it "returns false" do + expect(subject.required_extra_field?(:country)).to be false + end + end + end + + describe "#extra_user_fields_complete?" do + let(:user) { build(:user, organization:, extended_data:) } + let(:extra_user_fields) do + { + "enabled" => true, + "date_of_birth" => { "enabled" => true, "required" => true }, + "country" => { "enabled" => true, "required" => true }, + "gender" => { "enabled" => false, "required" => false } + } + end + + context "when all required fields are filled in" do + let(:extended_data) { { "date_of_birth" => "2000-01-01", "country" => "ES" } } + + it "returns true" do + expect(subject.extra_user_fields_complete?(user)).to be true + end + end + + context "when a required field is missing" do + let(:extended_data) { { "date_of_birth" => "2000-01-01" } } + + it "returns false" do + expect(subject.extra_user_fields_complete?(user)).to be false + end + end + + context "when an optional field is missing" do + let(:extra_user_fields) do + { + "enabled" => true, + "date_of_birth" => { "enabled" => true, "required" => true }, + "country" => { "enabled" => true, "required" => false } + } + end + let(:extended_data) { { "date_of_birth" => "2000-01-01" } } + + it "returns true because optional fields are not enforced" do + expect(subject.extra_user_fields_complete?(user)).to be true + end + end + + context "when a disabled field is missing" do + let(:extended_data) { { "date_of_birth" => "2000-01-01", "country" => "ES" } } + + it "returns true even without gender" do + expect(subject.extra_user_fields_complete?(user)).to be true + end + end + + context "when no fields are required" do + let(:extra_user_fields) do + { + "enabled" => true, + "date_of_birth" => { "enabled" => false, "required" => false }, + "country" => { "enabled" => true, "required" => false } + } + end + let(:extended_data) { {} } + + it "returns true" do + expect(subject.extra_user_fields_complete?(user)).to be true + end + end + + context "when select_fields are activated" do + let(:extra_user_fields) do + { + "enabled" => true, + "date_of_birth" => { "enabled" => false, "required" => false }, + "select_fields" => { "participant_type" => { "enabled" => true, "required" => true } } + } + end + + context "when user has filled the select field" do + let(:extended_data) { { "select_fields" => { "participant_type" => "individual" } } } + + it "returns true" do + expect(subject.extra_user_fields_complete?(user)).to be true + end + end + + context "when user has not filled the select field" do + let(:extended_data) { { "select_fields" => {} } } + + it "returns false" do + expect(subject.extra_user_fields_complete?(user)).to be false + end + end + + context "when user has no select_fields data at all" do + let(:extended_data) { {} } + + it "returns false" do + expect(subject.extra_user_fields_complete?(user)).to be false + end + end + end + + context "when text_fields are activated" do + context "when text field is required" do + let(:extra_user_fields) do + { + "enabled" => true, + "date_of_birth" => { "enabled" => false, "required" => false }, + "text_fields" => { "motto" => { "enabled" => true, "required" => true } } + } + end + + context "when user has filled the text field" do + let(:extended_data) { { "text_fields" => { "motto" => "Carpe diem" } } } + + it "returns true" do + expect(subject.extra_user_fields_complete?(user)).to be true + end + end + + context "when user has not filled the text field" do + let(:extended_data) { { "text_fields" => {} } } + + it "returns false" do + expect(subject.extra_user_fields_complete?(user)).to be false + end + end + + context "when user has no text_fields data at all" do + let(:extended_data) { {} } + + it "returns false" do + expect(subject.extra_user_fields_complete?(user)).to be false + end + end + end + + context "when text field is optional" do + let(:extra_user_fields) do + { + "enabled" => true, + "date_of_birth" => { "enabled" => false, "required" => false }, + "text_fields" => { "motto" => { "enabled" => true, "required" => false } } + } + end + + context "when user has not filled the text field" do + let(:extended_data) { { "text_fields" => {} } } + + it "returns true" do + expect(subject.extra_user_fields_complete?(user)).to be true + end + end + + context "when user has no text_fields data at all" do + let(:extended_data) { {} } + + it "returns true" do + expect(subject.extra_user_fields_complete?(user)).to be true + end + end + end + end + + context "when boolean_fields are activated" do + let(:extra_user_fields) do + { + "enabled" => true, + "date_of_birth" => { "enabled" => false, "required" => false }, + "boolean_fields" => ["ngo"] + } + end + let(:extended_data) { {} } + + it "returns true because boolean fields do not block completion" do + expect(subject.extra_user_fields_complete?(user)).to be true + end + end + + context "when both standard and collection fields are activated" do + let(:extra_user_fields) do + { + "enabled" => true, + "country" => { "enabled" => true, "required" => true }, + "date_of_birth" => { "enabled" => false, "required" => false }, + "select_fields" => { "participant_type" => { "enabled" => true, "required" => true } }, + "text_fields" => { "motto" => { "enabled" => true, "required" => true } } + } + end + + context "when all fields are filled" do + let(:extended_data) do + { + "country" => "FR", + "select_fields" => { "participant_type" => "individual" }, + "text_fields" => { "motto" => "Carpe diem" } + } + end + + it "returns true" do + expect(subject.extra_user_fields_complete?(user)).to be true + end + end + + context "when standard field is missing" do + let(:extended_data) do + { + "select_fields" => { "participant_type" => "individual" }, + "text_fields" => { "motto" => "Carpe diem" } + } + end + + it "returns false" do + expect(subject.extra_user_fields_complete?(user)).to be false + end + end + + context "when select field is missing" do + let(:extended_data) do + { + "country" => "FR", + "select_fields" => {}, + "text_fields" => { "motto" => "Carpe diem" } + } + end + + it "returns false" do + expect(subject.extra_user_fields_complete?(user)).to be false + end + end + + context "when required text field is missing" do + let(:extended_data) do + { + "country" => "FR", + "select_fields" => { "participant_type" => "individual" }, + "text_fields" => {} + } + end + + it "returns false" do + expect(subject.extra_user_fields_complete?(user)).to be false + end + end + + context "when optional text field is missing" do + let(:extra_user_fields) do + { + "enabled" => true, + "country" => { "enabled" => true, "required" => true }, + "date_of_birth" => { "enabled" => false, "required" => false }, + "select_fields" => { "participant_type" => { "enabled" => true, "required" => true } }, + "text_fields" => { "motto" => { "enabled" => true, "required" => false } } + } + end + let(:extended_data) do + { + "country" => "FR", + "select_fields" => { "participant_type" => "individual" }, + "text_fields" => {} + } + end + + it "returns true" do + expect(subject.extra_user_fields_complete?(user)).to be true + end + end + end + end + + describe "#collection_field_required?" do + context "when collection field is required (Hash format)" do + let(:extra_user_fields) { { "enabled" => true, "select_fields" => { "participant_type" => { "enabled" => true, "required" => true } } } } + + it "returns true" do + expect(subject.collection_field_required?(:select_fields, :participant_type)).to be true + end + end + + context "when collection field is optional (Hash format)" do + let(:extra_user_fields) { { "enabled" => true, "select_fields" => { "participant_type" => { "enabled" => true, "required" => false } } } } + + it "returns false" do + expect(subject.collection_field_required?(:select_fields, :participant_type)).to be false + end + end + + context "when collection is absent" do + let(:extra_user_fields) { { "enabled" => true } } + + it "returns false" do + expect(subject.collection_field_required?(:select_fields, :participant_type)).to be false + end + end + end + + describe "#extra_user_field_configuration" do + context "when field is activated with extra config" do + let(:extra_user_fields) do + { + "enabled" => true, + "phone_number" => { "enabled" => true, "required" => false, "pattern" => "^\\+33", "placeholder" => "+33..." } + } + end + + it "returns config hash without the enabled/required keys" do + config = subject.extra_user_field_configuration(:phone_number) + expect(config).to eq({ "pattern" => "^\\+33", "placeholder" => "+33..." }) + expect(config).not_to have_key("enabled") + expect(config).not_to have_key("required") + end + end + + context "when field is disabled" do + let(:extra_user_fields) { { "enabled" => true, "country" => { "enabled" => false, "required" => false } } } + + it "returns empty hash" do + expect(subject.extra_user_field_configuration(:country)).to eq({}) + end + end + + context "when field is a collection (Hash format)" do + let(:extra_user_fields) { { "enabled" => true, "select_fields" => { "participant_type" => { "enabled" => true, "required" => true } } } } + + it "returns the collection hash" do + expect(subject.extra_user_field_configuration(:select_fields)).to eq({ "participant_type" => { "enabled" => true, "required" => true } }) + end + end + + context "when field does not exist" do + let(:extra_user_fields) { { "enabled" => true } } + + it "returns empty hash" do + expect(subject.extra_user_field_configuration(:nonexistent)).to eq({}) + end + end + end + + describe "#age_limit" do + context "when underage_limit is set" do + let(:extra_user_fields) { { "enabled" => true, "underage" => { "enabled" => true, "required" => false, "limit" => 16 } } } + + it "returns the integer value" do + expect(subject.age_limit).to eq(16) + end + end + + context "when underage_limit is not set" do + let(:extra_user_fields) { { "enabled" => true, "underage" => { "enabled" => true, "required" => false } } } + + it "returns 0" do + expect(subject.age_limit).to eq(0) + end + end + end + + describe "#extra_user_fields_complete? with Hash-format collection fields" do + let(:user) { build(:user, organization:, extended_data:) } + + context "when a required select field is filled (Hash format)" do + let(:extra_user_fields) do + { + "enabled" => true, + "date_of_birth" => { "enabled" => false, "required" => false }, + "select_fields" => { "participant_type" => { "enabled" => true, "required" => true } } + } + end + let(:extended_data) { { "select_fields" => { "participant_type" => "individual" } } } + + it "returns true" do + expect(subject.extra_user_fields_complete?(user)).to be true + end + end + + context "when a required select field is missing (Hash format)" do + let(:extra_user_fields) do + { + "enabled" => true, + "date_of_birth" => { "enabled" => false, "required" => false }, + "select_fields" => { "participant_type" => { "enabled" => true, "required" => true } } + } + end + let(:extended_data) { { "select_fields" => {} } } + + it "returns false" do + expect(subject.extra_user_fields_complete?(user)).to be false + end + end + + context "when a required text field is filled (Hash format)" do + let(:extra_user_fields) do + { + "enabled" => true, + "date_of_birth" => { "enabled" => false, "required" => false }, + "text_fields" => { "motto" => { "enabled" => true, "required" => true } } + } + end + let(:extended_data) { { "text_fields" => { "motto" => "Carpe diem" } } } + + it "returns true" do + expect(subject.extra_user_fields_complete?(user)).to be true + end + end + + context "when a required text field is missing (Hash format)" do + let(:extra_user_fields) do + { + "enabled" => true, + "date_of_birth" => { "enabled" => false, "required" => false }, + "text_fields" => { "motto" => { "enabled" => true, "required" => true } } + } + end + let(:extended_data) { { "text_fields" => {} } } + + it "returns false" do + expect(subject.extra_user_fields_complete?(user)).to be false + end + end + + context "when an optional collection field is missing (Hash format)" do + let(:extra_user_fields) do + { + "enabled" => true, + "date_of_birth" => { "enabled" => false, "required" => false }, + "select_fields" => { "participant_type" => { "enabled" => true, "required" => false } } + } + end + let(:extended_data) { {} } + + it "returns true" do + expect(subject.extra_user_fields_complete?(user)).to be true + end + end + end + describe "#activated_extra_field?" do - it "returns the value of given key" do + it "returns true for legacy boolean enabled" do expect(subject).to be_activated_extra_field(:date_of_birth) end @@ -159,6 +714,30 @@ module Decidim expect(subject).not_to be_activated_extra_field(:date_of_birth) end end + + context "when field is disabled" do + let(:date_of_birth) { { "enabled" => false, "required" => false } } + + it "returns false" do + expect(subject).not_to be_activated_extra_field(:date_of_birth) + end + end + + context "when field is optional" do + let(:date_of_birth) { { "enabled" => true, "required" => false } } + + it "returns true" do + expect(subject).to be_activated_extra_field(:date_of_birth) + end + end + + context "when field is required" do + let(:date_of_birth) { { "enabled" => true, "required" => true } } + + it "returns true" do + expect(subject).to be_activated_extra_field(:date_of_birth) + end + end end end end diff --git a/spec/permissions/admin/permissions_spec.rb b/spec/permissions/admin/permissions_spec.rb index 6e29702e..668c87a2 100644 --- a/spec/permissions/admin/permissions_spec.rb +++ b/spec/permissions/admin/permissions_spec.rb @@ -7,14 +7,8 @@ module Decidim::ExtraUserFields::Admin subject { described_class.new(user, permission_action, context).permissions.allowed? } let(:organization) { create(:organization) } - let(:context) do - { - current_organization: organization - } - end - let(:action) do - { scope: :admin, action: :read, subject: :extra_user_fields } - end + let(:context) { { current_organization: organization } } + let(:action) { { scope: :admin, action: :read, subject: :extra_user_fields } } let(:permission_action) { Decidim::PermissionAction.new(**action) } context "when user is admin" do @@ -23,29 +17,35 @@ module Decidim::ExtraUserFields::Admin it { is_expected.to be_truthy } context "when scope is not admin" do - let(:action) do - { scope: :foo, action: :read, subject: :extra_user_fields } - end + let(:action) { { scope: :foo, action: :read, subject: :extra_user_fields } } it_behaves_like "permission is not set" end + + context "when reading insights" do + let(:action) { { scope: :admin, action: :read, subject: :insights } } + + it { is_expected.to be_truthy } + end end context "when user is not admin" do let(:user) { create(:user, organization:) } context "and tries to read extra user fields" do - let(:action) do - { scope: :admin, action: :read, subject: :extra_user_fields } - end + let(:action) { { scope: :admin, action: :read, subject: :extra_user_fields } } it_behaves_like "permission is not set" end context "and tries to update extra user fields" do - let(:action) do - { scope: :admin, action: :update, subject: :extra_user_fields } - end + let(:action) { { scope: :admin, action: :update, subject: :extra_user_fields } } + + it_behaves_like "permission is not set" + end + + context "and tries to read insights" do + let(:action) { { scope: :admin, action: :read, subject: :insights } } it_behaves_like "permission is not set" end diff --git a/spec/presenters/decidim/extra_user_fields/comparative_pivot_presenter_spec.rb b/spec/presenters/decidim/extra_user_fields/comparative_pivot_presenter_spec.rb new file mode 100644 index 00000000..1c08753f --- /dev/null +++ b/spec/presenters/decidim/extra_user_fields/comparative_pivot_presenter_spec.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields + describe ComparativePivotPresenter do + subject(:presenter) { described_class.new(pivot_tables, row_field: "gender", col_field: "age_span") } + + let(:space_a) { OpenStruct.new(title: { "en" => "Space A" }, id: 1) } + let(:space_b) { OpenStruct.new(title: { "en" => "Space B" }, id: 2) } + + let(:pt_a) { PivotTable.new(row_values: %w(female male), col_values: %w(young old), cells: cells_a) } + let(:pt_b) { PivotTable.new(row_values: %w(male other), col_values: %w(old young), cells: cells_b) } + + let(:cells_a) { { "female" => { "young" => 10, "old" => 5 }, "male" => { "young" => 3, "old" => 2 } } } + let(:cells_b) { { "male" => { "old" => 4, "young" => 1 }, "other" => { "old" => 6, "young" => 2 } } } + + let(:pivot_tables) { { space_a => pt_a, space_b => pt_b } } + + describe "#spaces" do + it "returns the space keys" do + expect(presenter.spaces).to eq([space_a, space_b]) + end + end + + describe "#unified_row_values" do + it "merges and deduplicates row values preserving order" do + expect(presenter.unified_row_values).to eq(%w(female male other)) + end + + context "with nil in axes" do + let(:pt_a) { PivotTable.new(row_values: ["female", nil], col_values: %w(young), cells: { "female" => { "young" => 1 }, nil => { "young" => 2 } }) } + let(:pt_b) { PivotTable.new(row_values: %w(male), col_values: %w(young), cells: { "male" => { "young" => 3 } }) } + + it "places nil last" do + expect(presenter.unified_row_values).to eq(["female", "male", nil]) + end + end + end + + describe "#unified_col_values" do + it "merges and deduplicates column values preserving order" do + expect(presenter.unified_col_values).to eq(%w(young old)) + end + + context "with nil in axes" do + let(:pt_a) { PivotTable.new(row_values: %w(female), col_values: ["young", nil], cells: { "female" => { "young" => 1, nil => 2 } }) } + let(:pt_b) { PivotTable.new(row_values: %w(female), col_values: %w(old), cells: { "female" => { "old" => 3 } }) } + + it "places nil last" do + expect(presenter.unified_col_values).to eq(["young", "old", nil]) + end + end + end + + describe "#cell" do + it "returns the correct value for an existing cell" do + expect(presenter.cell(space_a, "female", "young")).to eq(10) + end + + it "returns 0 for a missing row in a space" do + expect(presenter.cell(space_a, "other", "young")).to eq(0) + end + + it "returns 0 for an unknown space" do + unknown = OpenStruct.new(title: "Unknown", id: 99) + expect(presenter.cell(unknown, "female", "young")).to eq(0) + end + end + + describe "#space_row_total" do + it "sums across all unified columns for a row" do + # space_a, female: young=10 + old=5 = 15 + expect(presenter.space_row_total(space_a, "female")).to eq(15) + end + + it "returns 0 for a row not in the space" do + # space_a has no "other" row + expect(presenter.space_row_total(space_a, "other")).to eq(0) + end + end + + describe "#space_col_total" do + it "sums across all unified rows for a column" do + # space_b, old: male=4 + other=6 + female=0 = 10 + expect(presenter.space_col_total(space_b, "old")).to eq(10) + end + end + + describe "#space_grand_total" do + it "sums all cells in a space" do + # space_a: 10+5+3+2 = 20 + expect(presenter.space_grand_total(space_a)).to eq(20) + end + end + + describe "#combined_row_total" do + it "sums a row across all spaces" do + # male: space_a(3+2) + space_b(1+4) = 10 + expect(presenter.combined_row_total("male")).to eq(10) + end + end + + describe "#combined_grand_total" do + it "sums all cells across all spaces" do + # space_a: 20, space_b: 1+4+2+6 = 13 => 33 + expect(presenter.combined_grand_total).to eq(33) + end + end + + describe "#cell_style" do + it "returns CSS variables for non-nil row and col" do + result = presenter.cell_style(10, "female", "young") + expect(result).to include("--i:") + expect(result).to include("--tc:") + end + + it "returns empty string for zero value" do + expect(presenter.cell_style(0, "female", "young")).to eq("") + end + + it "uses global_all_range when row is nil" do + result = presenter.cell_style(5, nil, "young") + expect(result).to include("--i:") + end + + it "uses global_all_range when col is nil" do + result = presenter.cell_style(5, "female", nil) + expect(result).to include("--i:") + end + end + + describe "#row_total_style" do + it "returns CSS variables for non-zero totals" do + result = presenter.row_total_style(15) + expect(result).to include("--i:") + expect(result).to include("--tc:") + end + + it "returns empty string for zero" do + expect(presenter.row_total_style(0)).to eq("") + end + end + + describe "#col_total_style" do + it "returns CSS variables for non-zero totals" do + result = presenter.col_total_style(10) + expect(result).to include("--i:") + expect(result).to include("--tc:") + end + + it "returns empty string for zero" do + expect(presenter.col_total_style(0)).to eq("") + end + end + + describe "#empty?" do + it "returns false when data exists" do + expect(presenter.empty?).to be(false) + end + + context "when all pivot tables are empty" do + let(:pt_a) { PivotTable.new(row_values: [], col_values: [], cells: {}) } + let(:pt_b) { PivotTable.new(row_values: [], col_values: [], cells: {}) } + + it "returns true" do + expect(presenter.empty?).to be(true) + end + end + end + + describe "#space_label" do + it "returns translated title from a hash" do + expect(presenter.space_label(space_a)).to eq("Space A") + end + + it "falls back to first available locale when current locale missing" do + space = OpenStruct.new(title: { "fr" => "Espace C" }) + expect(presenter.space_label(space)).to eq("Espace C") + end + + it "returns string title as-is" do + space = OpenStruct.new(title: "Plain Title") + expect(presenter.space_label(space)).to eq("Plain Title") + end + end + + context "with a single space" do + let(:pivot_tables) { { space_a => pt_a } } + + it "works correctly with one space" do + expect(presenter.spaces).to eq([space_a]) + expect(presenter.unified_row_values).to eq(%w(female male)) + expect(presenter.combined_grand_total).to eq(20) + end + end + + context "with no spaces" do + let(:pivot_tables) { {} } + + it "returns empty collections" do + expect(presenter.spaces).to eq([]) + expect(presenter.unified_row_values).to eq([]) + expect(presenter.unified_col_values).to eq([]) + expect(presenter.combined_grand_total).to eq(0) + expect(presenter.empty?).to be(true) + end + end + end +end diff --git a/spec/presenters/decidim/extra_user_fields/heatmap_intensity_spec.rb b/spec/presenters/decidim/extra_user_fields/heatmap_intensity_spec.rb new file mode 100644 index 00000000..6260bd7f --- /dev/null +++ b/spec/presenters/decidim/extra_user_fields/heatmap_intensity_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields + describe HeatmapIntensity do + # Wrapper to expose private methods for testing + let(:wrapper) do + Class.new do + include HeatmapIntensity + public :intensity_vars, :total_intensity_vars + end.new + end + + describe "#intensity_vars" do + it "returns empty string when value is zero" do + expect(wrapper.intensity_vars(0, 1, 10)).to eq("") + end + + it "returns empty string when max is zero" do + expect(wrapper.intensity_vars(5, 0, 0)).to eq("") + end + + it "returns zero intensity when min equals max" do + result = wrapper.intensity_vars(5, 5, 5) + expect(result).to include("--i:0.0") + expect(result).to include("--tc:#1a1a1a") + end + + it "returns zero intensity when value equals min" do + result = wrapper.intensity_vars(2, 2, 10) + expect(result).to include("--i:0.0") + expect(result).to include("--tc:#1a1a1a") + end + + it "returns full intensity when value equals max" do + result = wrapper.intensity_vars(10, 2, 10) + expect(result).to include("--i:1.0") + expect(result).to include("--tc:#fff") + end + + it "returns proportional intensity for mid-range values" do + result = wrapper.intensity_vars(6, 2, 10) + expect(result).to include("--i:0.5") + expect(result).to include("--tc:#1a1a1a") + end + + it "uses white text color when intensity is above 0.6" do + # intensity = (8 - 0) / (10 - 0) = 0.8 + result = wrapper.intensity_vars(8, 0, 10) + expect(result).to include("--tc:#fff") + end + + it "uses dark text color when intensity is exactly 0.6" do + # intensity = (6 - 0) / (10 - 0) = 0.6 — exactly 0.6 is NOT > 0.6 + result = wrapper.intensity_vars(6, 0, 10) + expect(result).to include("--tc:#1a1a1a") + end + + it "uses white text color when intensity exceeds 0.6" do + # intensity = (7 - 0) / (10 - 0) = 0.7 + result = wrapper.intensity_vars(7, 0, 10) + expect(result).to include("--tc:#fff") + end + + it "rounds intensity to 3 decimal places" do + # intensity = (1 - 0) / (3 - 0) = 0.333... + result = wrapper.intensity_vars(1, 0, 3) + expect(result).to include("--i:0.333") + end + end + + describe "#total_intensity_vars" do + it "returns empty string when value is zero" do + expect(wrapper.total_intensity_vars(0, 10)).to eq("") + end + + it "returns empty string when max is zero" do + expect(wrapper.total_intensity_vars(5, 0)).to eq("") + end + + it "returns full intensity when value equals max" do + result = wrapper.total_intensity_vars(10, 10) + expect(result).to include("--i:1.0") + expect(result).to include("--tc:#fff") + end + + it "returns proportional intensity" do + result = wrapper.total_intensity_vars(5, 10) + expect(result).to include("--i:0.5") + expect(result).to include("--tc:#1a1a1a") + end + end + end +end diff --git a/spec/presenters/decidim/extra_user_fields/pivot_table_presenter_spec.rb b/spec/presenters/decidim/extra_user_fields/pivot_table_presenter_spec.rb new file mode 100644 index 00000000..882857e0 --- /dev/null +++ b/spec/presenters/decidim/extra_user_fields/pivot_table_presenter_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields + describe PivotTablePresenter do + subject(:presenter) { described_class.new(pivot_table) } + + let(:pivot_table) { PivotTable.new(row_values: row_values, col_values: col_values, cells: cells) } + let(:row_values) { %w(female male) } + let(:col_values) { %w(young old) } + let(:cells) { { "female" => { "young" => 10, "old" => 5 }, "male" => { "young" => 3, "old" => 2 } } } + + describe "delegation" do + it "delegates data methods to pivot_table" do + expect(presenter.row_values).to eq(pivot_table.row_values) + expect(presenter.col_values).to eq(pivot_table.col_values) + expect(presenter.cell("female", "young")).to eq(10) + expect(presenter.row_total("female")).to eq(15) + expect(presenter.col_total("young")).to eq(13) + expect(presenter.grand_total).to eq(20) + expect(presenter.empty?).to be(false) + end + end + + describe "#cell_style" do + it "returns empty string when value is zero" do + expect(presenter.cell_style(0, "female", "young")).to eq("") + end + + it "returns CSS variables with intensity and text color" do + result = presenter.cell_style(10, "female", "young") + expect(result).to include("--i:") + expect(result).to include("--tc:") + end + + it "returns full intensity for the max value" do + result = presenter.cell_style(10, "female", "young") + expect(result).to include("--i:1.0") + expect(result).to include("--tc:#fff") + end + + it "returns zero intensity for the min value" do + result = presenter.cell_style(2, "male", "old") + expect(result).to include("--i:0.0") + expect(result).to include("--tc:#1a1a1a") + end + + context "when all specified cells have the same value" do + let(:cells) { { "female" => { "young" => 5, "old" => 5 }, "male" => { "young" => 5, "old" => 5 } } } + + it "returns zero intensity (no hotspots)" do + result = presenter.cell_style(5, "female", "young") + expect(result).to include("--i:0.0") + end + end + + context "when row or col is nil" do + let(:row_values) { ["female", nil] } + let(:col_values) { ["young", nil] } + let(:cells) { { "female" => { "young" => 10, nil => 5 }, nil => { "young" => 3, nil => 7 } } } + + it "returns CSS variables for gray cells" do + result = presenter.cell_style(7, nil, nil) + expect(result).to include("--i:") + expect(result).to include("--tc:") + end + + it "uses all_cell_range for nil cells" do + result = presenter.cell_style(10, "female", nil) + expect(result).to include("--i:") + end + end + end + + describe "#row_total_style" do + it "returns empty string when value is zero" do + expect(presenter.row_total_style(0)).to eq("") + end + + it "returns full intensity for the max row total" do + result = presenter.row_total_style(15) + expect(result).to include("--i:1.0") + expect(result).to include("--tc:#fff") + end + + it "returns proportional intensity for smaller totals" do + result = presenter.row_total_style(5) + expect(result).to include("--i:") + expect(result).to include("--tc:#1a1a1a") + end + end + + describe "#col_total_style" do + it "returns empty string when value is zero" do + expect(presenter.col_total_style(0)).to eq("") + end + + it "returns full intensity for the max column total" do + result = presenter.col_total_style(13) + expect(result).to include("--i:1.0") + expect(result).to include("--tc:#fff") + end + + it "returns proportional intensity for smaller totals" do + result = presenter.col_total_style(7) + expect(result).to include("--i:") + expect(result).to include("--tc:#1a1a1a") + end + end + + describe "edge cases" do + context "when there are no non-nil combinations" do + let(:row_values) { [nil] } + let(:col_values) { [nil] } + let(:cells) { { nil => { nil => 10 } } } + + it "returns CSS variables for the only cell" do + result = presenter.cell_style(10, nil, nil) + expect(result).to include("--i:") + end + end + + context "when there are no cells" do + let(:row_values) { [] } + let(:col_values) { [] } + let(:cells) { {} } + + it "returns empty string for row total style" do + expect(presenter.row_total_style(0)).to eq("") + end + + it "returns empty string for col total style" do + expect(presenter.col_total_style(0)).to eq("") + end + end + end + end +end diff --git a/spec/queries/decidim/extra_user_fields/insight_metrics_spec.rb b/spec/queries/decidim/extra_user_fields/insight_metrics_spec.rb new file mode 100644 index 00000000..ba5c8da9 --- /dev/null +++ b/spec/queries/decidim/extra_user_fields/insight_metrics_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields + describe InsightMetrics do + describe ".available_metrics" do + it "returns all metric names" do + expect(described_class.available_metrics).to contain_exactly( + "participants", + "proposals_created", + "proposals_supported", + "comments", + "budget_votes" + ) + end + end + + describe ".metric_class" do + it "returns the correct class for each metric" do + expect(described_class.metric_class("participants")).to eq(Metrics::ParticipantsMetric) + expect(described_class.metric_class("proposals_created")).to eq(Metrics::ProposalsCreatedMetric) + expect(described_class.metric_class("proposals_supported")).to eq(Metrics::ProposalsSupportedMetric) + expect(described_class.metric_class("comments")).to eq(Metrics::CommentsMetric) + expect(described_class.metric_class("budget_votes")).to eq(Metrics::BudgetVotesMetric) + end + + it "returns nil for unknown metrics" do + expect(described_class.metric_class("unknown")).to be_nil + end + end + + describe ".valid_metric?" do + it "returns true for valid metrics" do + expect(described_class.valid_metric?("participants")).to be(true) + end + + it "returns false for invalid metrics" do + expect(described_class.valid_metric?("unknown")).to be(false) + end + end + end +end diff --git a/spec/queries/decidim/extra_user_fields/metrics/budget_votes_metric_spec.rb b/spec/queries/decidim/extra_user_fields/metrics/budget_votes_metric_spec.rb new file mode 100644 index 00000000..946259e4 --- /dev/null +++ b/spec/queries/decidim/extra_user_fields/metrics/budget_votes_metric_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields::Metrics + describe BudgetVotesMetric do + subject { described_class.new(participatory_process) } + + let(:organization) { create(:organization) } + let(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let(:budgets_component) { create(:budgets_component, :published, participatory_space: participatory_process) } + let(:budget) { create(:budget, component: budgets_component) } + let(:user1) { create(:user, :confirmed, organization:) } + let(:user2) { create(:user, :confirmed, organization:) } + + context "when there are no budget votes" do + it "returns an empty hash" do + expect(subject.call).to eq({}) + end + end + + context "when users have voted on budgets" do + before do + order1 = create(:order, :with_projects, budget:, user: user1) + order1.update!(checked_out_at: Time.current) + + order2 = create(:order, :with_projects, budget:, user: user2) + order2.update!(checked_out_at: Time.current) + end + + it "returns the vote count per user" do + result = subject.call + expect(result[user1.id]).to eq(1) + expect(result[user2.id]).to eq(1) + end + end + + context "when an order is not checked out" do + before do + create(:order, budget:, user: user1) + end + + it "does not count it" do + expect(subject.call).to eq({}) + end + end + + context "when the component is unpublished" do + let(:unpublished_component) { create(:budgets_component, :unpublished, participatory_space: participatory_process) } + let(:unpublished_budget) { create(:budget, component: unpublished_component) } + + before do + order = create(:order, :with_projects, budget: unpublished_budget, user: user1) + order.update!(checked_out_at: Time.current) + end + + it "does not count votes from unpublished components" do + expect(subject.call).to eq({}) + end + end + + context "when votes belong to another space" do + let(:other_process) { create(:participatory_process, :with_steps, organization:) } + let(:other_component) { create(:budgets_component, :published, participatory_space: other_process) } + let(:other_budget) { create(:budget, component: other_component) } + + before do + order = create(:order, :with_projects, budget: other_budget, user: user1) + order.update!(checked_out_at: Time.current) + end + + it "does not count them" do + expect(subject.call).to eq({}) + end + end + end +end diff --git a/spec/queries/decidim/extra_user_fields/metrics/comments_metric_spec.rb b/spec/queries/decidim/extra_user_fields/metrics/comments_metric_spec.rb new file mode 100644 index 00000000..3e0e0966 --- /dev/null +++ b/spec/queries/decidim/extra_user_fields/metrics/comments_metric_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields::Metrics + describe CommentsMetric do + subject { described_class.new(participatory_process) } + + let(:organization) { create(:organization) } + let(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let(:proposal_component) { create(:proposal_component, :published, participatory_space: participatory_process) } + let(:user1) { create(:user, :confirmed, organization:) } + let(:user2) { create(:user, :confirmed, organization:) } + + context "when there are no comments" do + it "returns an empty hash" do + expect(subject.call).to eq({}) + end + end + + context "when users have commented on proposals" do + let!(:proposal) { create(:proposal, :published, component: proposal_component, users: [user1]) } + + before do + create_list(:comment, 3, commentable: proposal, author: user1) + create(:comment, commentable: proposal, author: user2) + end + + it "returns the comment count per user" do + result = subject.call + expect(result[user1.id]).to eq(3) + expect(result[user2.id]).to eq(1) + end + end + + context "when users have commented on budget projects" do + let(:budgets_component) { create(:budgets_component, :published, participatory_space: participatory_process) } + let(:budget) { create(:budget, component: budgets_component) } + let!(:project) { create(:project, budget:) } + + before do + create_list(:comment, 2, commentable: project, author: user1) + end + + it "returns the comment count per user" do + result = subject.call + expect(result[user1.id]).to eq(2) + end + end + + context "when comments span both proposals and budget projects" do + let!(:proposal) { create(:proposal, :published, component: proposal_component, users: [user1]) } + let(:budgets_component) { create(:budgets_component, :published, participatory_space: participatory_process) } + let(:budget) { create(:budget, component: budgets_component) } + let!(:project) { create(:project, budget:) } + + before do + create(:comment, commentable: proposal, author: user1) + create(:comment, commentable: project, author: user1) + end + + it "sums comments across resource types" do + expect(subject.call[user1.id]).to eq(2) + end + end + + context "when proposals are hidden" do + let!(:proposal) { create(:proposal, :published, :hidden, component: proposal_component, users: [user1]) } + + before do + create(:comment, commentable: proposal, author: user1) + end + + it "still counts comments (user participated in the space)" do + expect(subject.call[user1.id]).to eq(1) + end + end + + context "when the component is unpublished" do + let(:unpublished_component) { create(:proposal_component, :unpublished, participatory_space: participatory_process) } + let!(:proposal) { create(:proposal, :published, component: unpublished_component, users: [user1]) } + + before do + create(:comment, commentable: proposal, author: user1) + end + + it "still counts comments (user participated in the space)" do + expect(subject.call[user1.id]).to eq(1) + end + end + + context "when comments belong to another space" do + let(:other_process) { create(:participatory_process, :with_steps, organization:) } + let(:other_component) { create(:proposal_component, :published, participatory_space: other_process) } + let!(:proposal) { create(:proposal, :published, component: other_component, users: [user1]) } + + before do + create(:comment, commentable: proposal, author: user1) + end + + it "does not count them" do + expect(subject.call).to eq({}) + end + end + end +end diff --git a/spec/queries/decidim/extra_user_fields/metrics/participants_metric_spec.rb b/spec/queries/decidim/extra_user_fields/metrics/participants_metric_spec.rb new file mode 100644 index 00000000..fda6c7d1 --- /dev/null +++ b/spec/queries/decidim/extra_user_fields/metrics/participants_metric_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields::Metrics + describe ParticipantsMetric do + subject { described_class.new(participatory_process) } + + let(:organization) { create(:organization) } + let(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let(:proposal_component) { create(:proposal_component, :published, participatory_space: participatory_process) } + let(:user1) { create(:user, :confirmed, organization:) } + let(:user2) { create(:user, :confirmed, organization:) } + + context "when there are no activities" do + it "returns an empty hash" do + expect(subject.call).to eq({}) + end + end + + context "when users have created proposals" do + before do + create(:proposal, :published, component: proposal_component, users: [user1]) + create(:proposal, :published, component: proposal_component, users: [user2]) + end + + it "returns each user counted once" do + result = subject.call + expect(result[user1.id]).to eq(1) + expect(result[user2.id]).to eq(1) + end + end + + context "when a user has multiple activities" do + let!(:proposal) { create(:proposal, :published, component: proposal_component, users: [user1]) } + + before do + create(:comment, commentable: proposal, author: user1) + end + + it "still counts the user only once" do + result = subject.call + expect(result[user1.id]).to eq(1) + end + end + + context "when users supported proposals" do + let!(:proposal) { create(:proposal, :published, component: proposal_component, users: [user1]) } + + before do + create(:proposal_vote, proposal: proposal, author: user2) + end + + it "includes the supporter" do + result = subject.call + expect(result[user2.id]).to eq(1) + end + + it "does not double-count a user who also authored" do + create(:proposal_vote, proposal: proposal, author: user1) + result = subject.call + expect(result[user1.id]).to eq(1) + end + end + + context "when users voted on budgets" do + let(:budgets_component) { create(:budgets_component, :published, participatory_space: participatory_process) } + let(:budget) { create(:budget, component: budgets_component) } + + before do + order = create(:order, :with_projects, budget:, user: user1) + order.update!(checked_out_at: Time.current) + end + + it "includes the voter" do + result = subject.call + expect(result[user1.id]).to eq(1) + end + end + + context "when users commented on proposals" do + let!(:proposal) { create(:proposal, :published, component: proposal_component, users: [user1]) } + + before do + create(:comment, commentable: proposal, author: user2) + end + + it "includes the commenter" do + result = subject.call + expect(result).to have_key(user2.id) + end + end + + context "when proposals are hidden" do + before do + create(:proposal, :published, :hidden, component: proposal_component, users: [user1]) + end + + it "does not count the author" do + expect(subject.call).to eq({}) + end + end + + context "when the component is unpublished" do + let(:unpublished_component) { create(:proposal_component, :unpublished, participatory_space: participatory_process) } + + before do + create(:proposal, :published, component: unpublished_component, users: [user1]) + end + + it "does not count participants from unpublished components" do + expect(subject.call).to eq({}) + end + end + + context "when activities belong to another space" do + let(:other_process) { create(:participatory_process, :with_steps, organization:) } + let(:other_component) { create(:proposal_component, :published, participatory_space: other_process) } + + before do + create(:proposal, :published, component: other_component, users: [user1]) + end + + it "does not count them" do + expect(subject.call).to eq({}) + end + end + end +end diff --git a/spec/queries/decidim/extra_user_fields/metrics/proposals_created_metric_spec.rb b/spec/queries/decidim/extra_user_fields/metrics/proposals_created_metric_spec.rb new file mode 100644 index 00000000..d2c21723 --- /dev/null +++ b/spec/queries/decidim/extra_user_fields/metrics/proposals_created_metric_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields::Metrics + describe ProposalsCreatedMetric do + subject { described_class.new(participatory_process) } + + let(:organization) { create(:organization) } + let(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let(:proposal_component) { create(:proposal_component, :published, participatory_space: participatory_process) } + let(:user1) { create(:user, :confirmed, organization:) } + let(:user2) { create(:user, :confirmed, organization:) } + + context "when there are no proposals" do + it "returns an empty hash" do + expect(subject.call).to eq({}) + end + end + + context "when users have created proposals" do + before do + create_list(:proposal, 3, :published, component: proposal_component, users: [user1]) + create(:proposal, :published, component: proposal_component, users: [user2]) + end + + it "returns the count per user" do + result = subject.call + expect(result[user1.id]).to eq(3) + expect(result[user2.id]).to eq(1) + end + end + + context "when proposals belong to another space" do + let(:other_process) { create(:participatory_process, :with_steps, organization:) } + let(:other_component) { create(:proposal_component, :published, participatory_space: other_process) } + + before do + create(:proposal, :published, component: other_component, users: [user1]) + end + + it "does not count them" do + expect(subject.call).to eq({}) + end + end + + context "when proposals are hidden" do + before do + create(:proposal, :published, :hidden, component: proposal_component, users: [user1]) + end + + it "does not count them" do + expect(subject.call).to eq({}) + end + end + + context "when the component is unpublished" do + let(:unpublished_component) { create(:proposal_component, :unpublished, participatory_space: participatory_process) } + + before do + create(:proposal, :published, component: unpublished_component, users: [user1]) + end + + it "does not count proposals from unpublished components" do + expect(subject.call).to eq({}) + end + end + end +end diff --git a/spec/queries/decidim/extra_user_fields/metrics/proposals_supported_metric_spec.rb b/spec/queries/decidim/extra_user_fields/metrics/proposals_supported_metric_spec.rb new file mode 100644 index 00000000..a2f58274 --- /dev/null +++ b/spec/queries/decidim/extra_user_fields/metrics/proposals_supported_metric_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields::Metrics + describe ProposalsSupportedMetric do + subject { described_class.new(participatory_process) } + + let(:organization) { create(:organization) } + let(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let(:proposal_component) { create(:proposal_component, :published, participatory_space: participatory_process) } + let(:user1) { create(:user, :confirmed, organization:) } + let(:user2) { create(:user, :confirmed, organization:) } + + context "when there are no votes" do + it "returns an empty hash" do + expect(subject.call).to eq({}) + end + end + + context "when users have supported proposals" do + let!(:proposal1) { create(:proposal, :published, component: proposal_component, users: [user1]) } + let!(:proposal2) { create(:proposal, :published, component: proposal_component, users: [user1]) } + + before do + create(:proposal_vote, proposal: proposal1, author: user2) + create(:proposal_vote, proposal: proposal2, author: user2) + create(:proposal_vote, proposal: proposal1, author: user1) + end + + it "returns the vote count per user" do + result = subject.call + expect(result[user2.id]).to eq(2) + expect(result[user1.id]).to eq(1) + end + end + + context "when the proposal is hidden" do + let!(:proposal) { create(:proposal, :published, :hidden, component: proposal_component, users: [user1]) } + + before do + create(:proposal_vote, proposal: proposal, author: user2) + end + + it "does not count votes on hidden proposals" do + expect(subject.call).to eq({}) + end + end + + context "when the component is unpublished" do + let(:unpublished_component) { create(:proposal_component, :unpublished, participatory_space: participatory_process) } + let!(:proposal) { create(:proposal, :published, component: unpublished_component, users: [user1]) } + + before do + create(:proposal_vote, proposal: proposal, author: user2) + end + + it "does not count votes from unpublished components" do + expect(subject.call).to eq({}) + end + end + + context "when votes belong to another space" do + let(:other_process) { create(:participatory_process, :with_steps, organization:) } + let(:other_component) { create(:proposal_component, :published, participatory_space: other_process) } + let!(:proposal) { create(:proposal, :published, component: other_component, users: [user1]) } + + before do + create(:proposal_vote, proposal: proposal, author: user2) + end + + it "does not count them" do + expect(subject.call).to eq({}) + end + end + end +end diff --git a/spec/serializers/decidim/extra_user_fields/pivot_table_row_serializer_spec.rb b/spec/serializers/decidim/extra_user_fields/pivot_table_row_serializer_spec.rb new file mode 100644 index 00000000..a22107aa --- /dev/null +++ b/spec/serializers/decidim/extra_user_fields/pivot_table_row_serializer_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module ExtraUserFields + describe PivotTableRowSerializer do + subject { described_class.new(row_hash) } + + let(:row_hash) { { "Row" => "Female", "21 to 30" => 3, "31 to 40" => 1, "Total" => 4 } } + + describe "#serialize" do + it "returns the resource hash unchanged" do + expect(subject.serialize).to eq(row_hash) + end + end + end + end +end diff --git a/spec/serializers/decidim/extra_user_fields/user_export_serializer_spec.rb b/spec/serializers/decidim/extra_user_fields/user_export_serializer_spec.rb index 6d4d12cb..78643239 100644 --- a/spec/serializers/decidim/extra_user_fields/user_export_serializer_spec.rb +++ b/spec/serializers/decidim/extra_user_fields/user_export_serializer_spec.rb @@ -6,25 +6,25 @@ subject { described_class.new(resource) } let(:resource) { create(:user, extended_data: registration_metadata) } - # rubocop:disable Style/TrailingCommaInHashLiteral let(:registration_metadata) do { gender:, + age_range:, postal_code:, date_of_birth:, country:, phone_number:, location:, underage:, - statutory_representative_email:, - # Block ExtraUserFields ExtraUserFields - - # EndBlock + select_fields:, + boolean_fields:, + text_fields:, + statutory_representative_email: } end - # rubocop:enable Style/TrailingCommaInHashLiteral let(:gender) { "other" } + let(:age_range) { "17_to_30" } let(:postal_code) { "00000" } let(:date_of_birth) { "01/01/2000" } let(:country) { "Argentina" } @@ -33,9 +33,20 @@ let(:underage) { true } let(:underage_limit) { 18 } let(:statutory_representative_email) { "parent@example.org" } - # Block ExtraUserFields RspecVar + let(:select_fields) do + { + "participant_type" => "individual" + } + end + let(:boolean_fields) do + ["ngo"] + end + let(:text_fields) do + { + "motto" => "I think, therefore I am" + } + end - # EndBlock let(:serialized) { subject.serialize } describe "#serialize" do @@ -47,6 +58,10 @@ expect(serialized).to include(gender: resource.extended_data["gender"]) end + it "includes the age range" do + expect(serialized).to include(age_range: resource.extended_data["age_range"]) + end + it "includes the postal code" do expect(serialized).to include(postal_code: resource.extended_data["postal_code"]) end @@ -67,6 +82,18 @@ expect(serialized).to include(location: resource.extended_data["location"]) end + it "includes the select fields" do + expect(serialized).to include(select_fields: resource.extended_data["select_fields"]) + end + + it "includes the boolean fields" do + expect(serialized).to include(boolean_fields: resource.extended_data["boolean_fields"]) + end + + it "includes the text fields" do + expect(serialized).to include(text_fields: resource.extended_data["text_fields"]) + end + context "when users are blocked" do let(:resource) { create(:user, :blocked, extended_data: registration_metadata, blocked_at:) } let(:blocked_at) { Time.zone.now } diff --git a/spec/services/decidim/extra_user_fields/comparative_pivot_export_data_spec.rb b/spec/services/decidim/extra_user_fields/comparative_pivot_export_data_spec.rb new file mode 100644 index 00000000..bde33d44 --- /dev/null +++ b/spec/services/decidim/extra_user_fields/comparative_pivot_export_data_spec.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module ExtraUserFields + describe ComparativePivotExportData do + subject { described_class.new(presenter) } + + let(:pivot_table_a) do + PivotTable.new( + row_values: %w(female male), + col_values: %w(21_to_30 31_to_40), + cells: { + "female" => { "21_to_30" => 3, "31_to_40" => 1 }, + "male" => { "21_to_30" => 2, "31_to_40" => 5 } + } + ) + end + + let(:pivot_table_b) do + PivotTable.new( + row_values: %w(female male), + col_values: %w(21_to_30 31_to_40), + cells: { + "female" => { "21_to_30" => 1, "31_to_40" => 4 }, + "male" => { "21_to_30" => 6, "31_to_40" => 2 } + } + ) + end + + let(:space_a) { double("SpaceA", title: { "en" => "Space A" }, id: 1) } + let(:space_b) { double("SpaceB", title: { "en" => "Space B" }, id: 2) } + + describe "#row_header_key" do + let(:presenter) do + ComparativePivotPresenter.new( + { space_a => pivot_table_a }, + row_field: "gender", + col_field: "age_span" + ) + end + + it "returns translated field names joined with slash" do + expect(subject.row_header_key).to eq("Gender / Age span") + end + end + + context "with multiple spaces" do + let(:presenter) do + ComparativePivotPresenter.new( + { space_a => pivot_table_a, space_b => pivot_table_b }, + row_field: "gender", + col_field: "age_span" + ) + end + + describe "#rows" do + it "returns an array of hashes with correct size" do + expect(subject.rows.size).to eq(3) + end + + it "includes space-prefixed column headers" do + first_row = subject.rows.first + expect(first_row.keys).to include("[Space A] / 21 to 30", "[Space A] / 31 to 40", "[Space A] / Row Total") + expect(first_row.keys).to include("[Space B] / 21 to 30", "[Space B] / 31 to 40", "[Space B] / Row Total") + expect(first_row.keys).to include("Row Total") + end + + it "includes correct data values for first space" do + first_row = subject.rows.first + expect(first_row["[Space A] / 21 to 30"]).to eq(3) + expect(first_row["[Space A] / 31 to 40"]).to eq(1) + expect(first_row["[Space A] / Row Total"]).to eq(4) + end + + it "includes correct data values for second space" do + first_row = subject.rows.first + expect(first_row["[Space B] / 21 to 30"]).to eq(1) + expect(first_row["[Space B] / 31 to 40"]).to eq(4) + expect(first_row["[Space B] / Row Total"]).to eq(5) + end + + it "includes combined total" do + first_row = subject.rows.first + expect(first_row["Row Total"]).to eq(9) + end + + it "includes correct totals row" do + totals = subject.rows.last + expect(totals[subject.row_header_key]).to eq("Column Total") + expect(totals["[Space A] / 21 to 30"]).to eq(5) + expect(totals["[Space A] / 31 to 40"]).to eq(6) + expect(totals["[Space A] / Row Total"]).to eq(11) + expect(totals["[Space B] / 21 to 30"]).to eq(7) + expect(totals["[Space B] / 31 to 40"]).to eq(6) + expect(totals["[Space B] / Row Total"]).to eq(13) + expect(totals["Row Total"]).to eq(24) + end + end + end + + context "with single space" do + let(:presenter) do + ComparativePivotPresenter.new( + { space_a => pivot_table_a }, + row_field: "gender", + col_field: "age_span" + ) + end + + describe "#rows" do + it "returns an array of hashes with correct size" do + expect(subject.rows.size).to eq(3) + end + + it "uses plain column headers without prefix" do + first_row = subject.rows.first + expect(first_row.keys).to include("21 to 30", "31 to 40", "Row Total") + expect(first_row.keys.none? { |k| k.include?("[") }).to be(true) + end + + it "includes correct data values" do + first_row = subject.rows.first + expect(first_row["21 to 30"]).to eq(3) + expect(first_row["31 to 40"]).to eq(1) + expect(first_row["Row Total"]).to eq(4) + end + + it "includes correct totals row" do + totals = subject.rows.last + expect(totals[subject.row_header_key]).to eq("Column Total") + expect(totals["21 to 30"]).to eq(5) + expect(totals["31 to 40"]).to eq(6) + expect(totals["Row Total"]).to eq(11) + end + end + end + + context "with nil values (not specified)" do + let(:pivot_table_a) do + PivotTable.new( + row_values: ["female", nil], + col_values: ["21_to_30"], + cells: { + "female" => { "21_to_30" => 2 }, + nil => { "21_to_30" => 1 } + } + ) + end + + let(:pivot_table_b) do + PivotTable.new( + row_values: ["female"], + col_values: ["21_to_30"], + cells: { + "female" => { "21_to_30" => 3 } + } + ) + end + + let(:presenter) do + ComparativePivotPresenter.new( + { space_a => pivot_table_a, space_b => pivot_table_b }, + row_field: "gender", + col_field: "age_span" + ) + end + + it "labels nil values as not specified" do + row_labels = subject.rows.map { |r| r[subject.row_header_key] } + expect(row_labels).to include("Not specified / Prefer not to say") + end + end + end + end +end diff --git a/spec/services/decidim/extra_user_fields/field_processors/age_range_spec.rb b/spec/services/decidim/extra_user_fields/field_processors/age_range_spec.rb new file mode 100644 index 00000000..7eaed0d3 --- /dev/null +++ b/spec/services/decidim/extra_user_fields/field_processors/age_range_spec.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields + module FieldProcessors + describe AgeRange do + describe ".call" do + subject { described_class.call(extended_data) } + + context "when date_of_birth is present" do + let(:extended_data) { { "date_of_birth" => date_of_birth } } + + context "with age in the up_to_20 range" do + let(:date_of_birth) { 18.years.ago.to_date.to_s } + + it { is_expected.to eq("up_to_20") } + end + + context "with age in the 21_to_30 range" do + let(:date_of_birth) { 25.years.ago.to_date.to_s } + + it { is_expected.to eq("21_to_30") } + end + + context "with age in the 31_to_40 range" do + let(:date_of_birth) { 35.years.ago.to_date.to_s } + + it { is_expected.to eq("31_to_40") } + end + + context "with age in the 41_to_50 range" do + let(:date_of_birth) { 45.years.ago.to_date.to_s } + + it { is_expected.to eq("41_to_50") } + end + + context "with age in the 51_to_60 range" do + let(:date_of_birth) { 55.years.ago.to_date.to_s } + + it { is_expected.to eq("51_to_60") } + end + + context "with age in the 61_or_more range" do + let(:date_of_birth) { 70.years.ago.to_date.to_s } + + it { is_expected.to eq("61_or_more") } + end + end + + context "when only age_range is stored (no date_of_birth)" do + let(:extended_data) { { "age_range" => "17_to_30" } } + + it "returns nil because stored age_range is ignored" do + expect(subject).to be_nil + end + end + + context "when both date_of_birth and age_range are present" do + let(:extended_data) { { "age_range" => "17_to_30", "date_of_birth" => 45.years.ago.to_date.to_s } } + + it "uses date_of_birth only" do + expect(subject).to eq("41_to_50") + end + end + + context "when neither age_range nor date_of_birth is present" do + let(:extended_data) { {} } + + it { is_expected.to be_nil } + end + + context "with an invalid date_of_birth" do + let(:extended_data) { { "date_of_birth" => "not-a-date" } } + + it { is_expected.to be_nil } + end + + context "with a nil date_of_birth" do + let(:extended_data) { { "date_of_birth" => nil } } + + it { is_expected.to be_nil } + end + + context "with a future date_of_birth" do + let(:extended_data) { { "date_of_birth" => 5.years.from_now.to_date.to_s } } + + it { is_expected.to be_nil } + end + end + + describe "age boundary precision" do + subject { described_class.call("date_of_birth" => birth_date.to_s) } + + context "when the user turns 21 today" do + let(:birth_date) { 21.years.ago.to_date } + + it { is_expected.to eq("21_to_30") } + end + + context "when the user is still 20 (birthday tomorrow)" do + let(:birth_date) { 21.years.ago.to_date + 1.day } + + it { is_expected.to eq("up_to_20") } + end + + context "when the user turns 31 today" do + let(:birth_date) { 31.years.ago.to_date } + + it { is_expected.to eq("31_to_40") } + end + + context "when the user is still 30 (birthday tomorrow)" do + let(:birth_date) { 31.years.ago.to_date + 1.day } + + it { is_expected.to eq("21_to_30") } + end + + context "when the user turns 41 today" do + let(:birth_date) { 41.years.ago.to_date } + + it { is_expected.to eq("41_to_50") } + end + + context "when the user is still 40 (birthday tomorrow)" do + let(:birth_date) { 41.years.ago.to_date + 1.day } + + it { is_expected.to eq("31_to_40") } + end + + context "when the user turns 51 today" do + let(:birth_date) { 51.years.ago.to_date } + + it { is_expected.to eq("51_to_60") } + end + + context "when the user is still 50 (birthday tomorrow)" do + let(:birth_date) { 51.years.ago.to_date + 1.day } + + it { is_expected.to eq("41_to_50") } + end + + context "when the user turns 61 today" do + let(:birth_date) { 61.years.ago.to_date } + + it { is_expected.to eq("61_or_more") } + end + + context "when the user is still 60 (birthday tomorrow)" do + let(:birth_date) { 61.years.ago.to_date + 1.day } + + it { is_expected.to eq("51_to_60") } + end + end + end + end +end diff --git a/spec/services/decidim/extra_user_fields/insight_fields/age_span_spec.rb b/spec/services/decidim/extra_user_fields/insight_fields/age_span_spec.rb new file mode 100644 index 00000000..fb3b5e06 --- /dev/null +++ b/spec/services/decidim/extra_user_fields/insight_fields/age_span_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields + module InsightFields + describe AgeSpan do + subject { described_class.new } + + describe "#field_name" do + it { expect(subject.field_name).to eq("age_span") } + end + + describe "#extract" do + it "computes age span from date_of_birth" do + data = { "date_of_birth" => 25.years.ago.to_date.to_s } + expect(subject.extract(data)).to eq("21_to_30") + end + + it "returns nil when date_of_birth is missing" do + expect(subject.extract({})).to be_nil + end + + it "ignores the stored age_range field" do + data = { "age_range" => "17_to_30" } + expect(subject.extract(data)).to be_nil + end + end + + describe "#ordered_values" do + it "returns configured insight age spans" do + expect(subject.ordered_values).to eq(Decidim::ExtraUserFields.insight_age_spans) + end + end + + describe "#value_label" do + it "translates known age span values" do + expect(subject.value_label("21_to_30")).to eq("21 to 30") + expect(subject.value_label("61_or_more")).to eq("More than 60") + expect(subject.value_label("up_to_20")).to eq("Less than 20") + end + + it "falls back to humanized value for unknown spans" do + expect(subject.value_label("99_to_100")).to eq("99 to 100") + end + end + end + end +end diff --git a/spec/services/decidim/extra_user_fields/insight_fields/base_spec.rb b/spec/services/decidim/extra_user_fields/insight_fields/base_spec.rb new file mode 100644 index 00000000..02a549c4 --- /dev/null +++ b/spec/services/decidim/extra_user_fields/insight_fields/base_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields + module InsightFields + describe Base do + subject { described_class.new("custom_field") } + + describe "#extract" do + it "returns the value from extended_data" do + expect(subject.extract({ "custom_field" => "some_value" })).to eq("some_value") + end + + it "returns nil for blank values" do + expect(subject.extract({ "custom_field" => "" })).to be_nil + expect(subject.extract({ "custom_field" => nil })).to be_nil + expect(subject.extract({})).to be_nil + end + + it "normalizes prefer_not_to_say to nil" do + expect(subject.extract({ "custom_field" => "prefer_not_to_say" })).to be_nil + end + end + + describe "#ordered_values" do + it "returns nil by default" do + expect(subject.ordered_values).to be_nil + end + end + + describe "#value_label" do + it "falls back to humanized value" do + expect(subject.value_label("some_value")).to eq("Some value") + end + end + end + end +end diff --git a/spec/services/decidim/extra_user_fields/insight_fields/country_spec.rb b/spec/services/decidim/extra_user_fields/insight_fields/country_spec.rb new file mode 100644 index 00000000..15c0eb3e --- /dev/null +++ b/spec/services/decidim/extra_user_fields/insight_fields/country_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields + module InsightFields + describe Country do + subject { described_class.new } + + describe "#field_name" do + it { expect(subject.field_name).to eq("country") } + end + + describe "#extract" do + it "returns the country value from extended_data" do + expect(subject.extract({ "country" => "france" })).to eq("france") + end + + it "returns nil for blank values" do + expect(subject.extract({})).to be_nil + end + end + + describe "#ordered_values" do + it "returns nil (no predefined order)" do + expect(subject.ordered_values).to be_nil + end + end + + describe "#value_label" do + it "translates country codes to country names" do + expect(subject.value_label("DE")).to eq("Germany") + expect(subject.value_label("FR")).to eq("France") + end + + it "falls back to humanized value for unknown codes" do + expect(subject.value_label("unknown_code")).to eq("Unknown code") + end + end + end + end +end diff --git a/spec/services/decidim/extra_user_fields/insight_fields/gender_spec.rb b/spec/services/decidim/extra_user_fields/insight_fields/gender_spec.rb new file mode 100644 index 00000000..6b5fa41e --- /dev/null +++ b/spec/services/decidim/extra_user_fields/insight_fields/gender_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields + module InsightFields + describe Gender do + subject { described_class.new } + + describe "#field_name" do + it { expect(subject.field_name).to eq("gender") } + end + + describe "#extract" do + it "returns the gender value from extended_data" do + expect(subject.extract({ "gender" => "female" })).to eq("female") + end + + it "normalizes prefer_not_to_say to nil" do + expect(subject.extract({ "gender" => "prefer_not_to_say" })).to be_nil + end + + it "returns nil when gender is missing" do + expect(subject.extract({})).to be_nil + end + end + + describe "#ordered_values" do + it "returns configured genders without prefer_not_to_say" do + expect(subject.ordered_values).to eq(%w(female male other)) + expect(subject.ordered_values).not_to include("prefer_not_to_say") + end + end + + describe "#value_label" do + it "translates known gender values" do + expect(subject.value_label("female")).to eq("Female") + expect(subject.value_label("male")).to eq("Male") + expect(subject.value_label("other")).to eq("Other") + end + + it "falls back to humanized value for unknown genders" do + expect(subject.value_label("nonbinary")).to eq("Nonbinary") + end + end + end + end +end diff --git a/spec/services/decidim/extra_user_fields/insight_fields_spec.rb b/spec/services/decidim/extra_user_fields/insight_fields_spec.rb new file mode 100644 index 00000000..a5be52a4 --- /dev/null +++ b/spec/services/decidim/extra_user_fields/insight_fields_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields + describe InsightFields do + describe ".for" do + it "resolves gender to Gender class" do + expect(described_class.for("gender")).to be_a(InsightFields::Gender) + end + + it "resolves age_span to AgeSpan class" do + expect(described_class.for("age_span")).to be_a(InsightFields::AgeSpan) + end + + it "resolves country to Country class" do + expect(described_class.for("country")).to be_a(InsightFields::Country) + end + + it "falls back to Base for unknown fields" do + field = described_class.for("unknown_field") + expect(field).to be_a(InsightFields::Base) + expect(field.field_name).to eq("unknown_field") + end + end + end +end diff --git a/spec/services/decidim/extra_user_fields/pivot_table_builder_spec.rb b/spec/services/decidim/extra_user_fields/pivot_table_builder_spec.rb new file mode 100644 index 00000000..ee502b74 --- /dev/null +++ b/spec/services/decidim/extra_user_fields/pivot_table_builder_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields + describe PivotTableBuilder do + subject { described_class.new(participatory_space: participatory_process, metric_name: "participants", row_field: "gender", col_field: "age_span") } + + let(:organization) { create(:organization) } + let(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let(:proposal_component) { create(:proposal_component, :published, participatory_space: participatory_process) } + + context "when there are no participants" do + it "returns an empty pivot table" do + result = subject.call + expect(result).to be_empty + end + end + + context "when there are participants with extended data" do + let(:user_female_young) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "date_of_birth" => 25.years.ago.to_date.to_s }) } + let(:user_male_young) { create(:user, :confirmed, organization:, extended_data: { "gender" => "male", "date_of_birth" => 28.years.ago.to_date.to_s }) } + let(:user_female_old) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "date_of_birth" => 70.years.ago.to_date.to_s }) } + let(:user_no_data) { create(:user, :confirmed, organization:, extended_data: {}) } + + before do + create(:proposal, :published, component: proposal_component, users: [user_female_young]) + create(:proposal, :published, component: proposal_component, users: [user_male_young]) + create(:proposal, :published, component: proposal_component, users: [user_female_old]) + create(:proposal, :published, component: proposal_component, users: [user_no_data]) + end + + it "includes all configured gender values plus nil" do + result = subject.call + expect(result.row_values).to eq(%w(female male other) + [nil]) + end + + it "includes all configured insight_age_span values plus nil" do + result = subject.call + expect(result.col_values).to eq(%w(up_to_20 21_to_30 31_to_40 41_to_50 51_to_60 61_or_more) + [nil]) + end + + it "fills cells with correct counts" do + result = subject.call + expect(result.cell("female", "21_to_30")).to eq(1) + expect(result.cell("male", "21_to_30")).to eq(1) + expect(result.cell("female", "61_or_more")).to eq(1) + expect(result.cell(nil, nil)).to eq(1) + end + + it "shows zero for configured values with no data" do + result = subject.call + expect(result.cell("other", "21_to_30")).to eq(0) + expect(result.cell("female", "up_to_20")).to eq(0) + expect(result.cell("female", "31_to_40")).to eq(0) + end + + it "calculates correct totals" do + result = subject.call + expect(result.grand_total).to eq(4) + expect(result.row_total("female")).to eq(2) + expect(result.col_total("21_to_30")).to eq(2) + end + end + + context "when users only have stored age_range (no date_of_birth)" do + let(:user_young) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "age_range" => "17_to_30" }) } + let(:user_old) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "age_range" => "61_or_more" }) } + + before do + create(:proposal, :published, component: proposal_component, users: [user_young]) + create(:proposal, :published, component: proposal_component, users: [user_old]) + end + + it "treats them as not specified since stored age_range is ignored" do + result = subject.call + expect(result.cell("female", nil)).to eq(2) + end + + it "still includes all configured insight_age_span values in order" do + result = subject.call + expect(result.col_values).to eq(%w(up_to_20 21_to_30 31_to_40 41_to_50 51_to_60 61_or_more) + [nil]) + end + end + + context "with an invalid metric name" do + subject { described_class.new(participatory_space: participatory_process, metric_name: "invalid", row_field: "gender", col_field: "age_span") } + + it "returns an empty pivot table" do + result = subject.call + expect(result).to be_empty + end + end + end +end diff --git a/spec/system/account_spec.rb b/spec/system/account_spec.rb index 5c66f68d..cfc0e658 100644 --- a/spec/system/account_spec.rb +++ b/spec/system/account_spec.rb @@ -12,22 +12,22 @@ let(:organization) { create(:organization, extra_user_fields:) } let(:user) { create(:user, :confirmed, organization:, password:) } let(:password) { "dqCFgjfDbC7dPbrv" } - # rubocop:disable Style/TrailingCommaInHashLiteral + let(:extra_user_fields) do { "enabled" => true, "date_of_birth" => date_of_birth, "postal_code" => postal_code, "gender" => gender, + "select_fields" => select_fields, + "boolean_fields" => boolean_fields, + "text_fields" => text_fields, + "age_range" => age_range, "country" => country, "phone_number" => phone_number, - "location" => location, - # Block ExtraUserFields ExtraUserFields - - # EndBlock + "location" => location } end - # rubocop:enable Style/TrailingCommaInHashLiteral let(:date_of_birth) do { "enabled" => true } @@ -45,6 +45,10 @@ { "enabled" => true } end + let(:age_range) do + { "enabled" => true } + end + let(:phone_number) do { "enabled" => true, "pattern" => phone_number_pattern, "placeholder" => nil } end @@ -54,9 +58,9 @@ { "enabled" => true } end - # Block ExtraUserFields RspecVar - - # EndBlock + let(:select_fields) { { "participant_type" => { "enabled" => true, "required" => false } } } + let(:boolean_fields) { { "ngo" => { "enabled" => true, "required" => false } } } + let(:text_fields) { { "motto" => { "enabled" => true, "required" => false } } } before do switch_to_host(organization.host) @@ -80,10 +84,25 @@ visit decidim.account_path end - it_behaves_like "accessible page" + # TODO: Uncomment when Decidim fixes the bug in upload_modal.js + # There's an extra comma in the img tag (` false } } + # + # # NOTE: We skip running the accessibility test when `date_of_birth` is enabled + # # because the custom Decidim datepicker JavaScript removes accessibility attributes + # # like `title`, `aria-label`, and causes Axe validation errors. + # # + # # This test runs only when `date_of_birth` is disabled to avoid false negatives. + # + # it_behaves_like "accessible page" + # end describe "updating personal data" do let!(:encrypted_password) { user.encrypted_password } + let(:motto) { "I think, therefore I am." } before do within "form.edit_user" do @@ -92,9 +111,13 @@ fill_in :user_personal_url, with: "https://example.org" fill_in :user_about, with: "A Serbian-American inventor, electrical engineer, mechanical engineer, physicist, and futurist." - fill_in :user_date_of_birth, with: "01/01/2000" + fill_in_datepicker :user_date_of_birth_date, with: "01/01/2000" select "Other", from: :user_gender + select "17 to 30", from: :user_age_range select "Argentina", from: :user_country + select "Individual", from: :user_select_fields_participant_type + check "I am a member of a non-governmental organization (NGO)" + fill_in :user_text_fields_motto, with: motto fill_in :user_postal_code, with: "00000" fill_in :user_phone_number, with: "0123456789" fill_in :user_location, with: "Cahors" @@ -107,6 +130,10 @@ expect(page).to have_content("successfully") end + expect(page).to have_field(with: "Nikola Tesla") + expect(page).to have_field(with: "I think, therefore I am.") + expect(page).to have_select(selected: "Individual") + user.reload within_user_menu do @@ -115,6 +142,8 @@ expect(page).to have_content("example.org") expect(page).to have_content("Serbian-American") + expect(page).to have_content("Nikola Tesla") + expect(page).to have_content("A Serbian-American inventor, electrical engineer, mechanical engineer, physicist, and futurist.") # The user's password should not change when they did not update it expect(user.reload.encrypted_password).to eq(encrypted_password) @@ -132,7 +161,7 @@ end it "shows error when image is too big" do - find("#user_avatar_button").click + find_by_id("user_avatar_button").click within ".upload-modal" do click_on "Remove" @@ -164,6 +193,35 @@ end end end + + context "with text field blank" do + let(:motto) { "" } + + it "does not update the user's data" do + within_flash_messages do + expect(page).to have_content("successfully") + end + + expect(page).to have_field(with: "Nikola Tesla") + expect(page).to have_no_field(with: "I think, therefore I am.") + expect(page).to have_select(selected: "Individual") + end + + context "with text field mandatory" do + let(:text_fields) { { "motto" => { "enabled" => true, "required" => true } } } + + it "displays the field as mandatory" do + within "label[for='user_text_fields_motto']" do + expect(page).to have_css("span.label-required") + end + within "form.edit_user" do + fill_in :user_text_fields_motto, with: "" + find("*[type=submit]").click + end + expect(page).to have_content("cannot be blank") + end + end + end end context "when date_of_birth is not enabled" do @@ -198,6 +256,14 @@ it_behaves_like "does not display extra user field", "gender", "Gender" end + context "when age_range is not enabled" do + let(:age_range) do + { "enabled" => false } + end + + it_behaves_like "does not display extra user field", "age_range", "Age range" + end + context "when phone number is not enabled" do let(:phone_number) do { "enabled" => false } @@ -214,6 +280,30 @@ it_behaves_like "does not display extra user field", "location", "Location" end + context "when select_fields is not enabled" do + let(:select_fields) do + { "another_field" => { "enabled" => true, "required" => false } } + end + + it_behaves_like "does not display extra user field", "select_fields", "Select fields" + end + + context "when boolean_fields is not enabled" do + let(:boolean_fields) do + { "another_field" => { "enabled" => true, "required" => false } } + end + + it_behaves_like "does not display extra user field", "boolean_fields", "Boolean fields" + end + + context "when text_fields is not enabled" do + let(:text_fields) do + { "another_field" => { "enabled" => true, "required" => false } } + end + + it_behaves_like "does not display extra user field", "text_fields", "Text fields" + end + describe "when update password" do before do within "form.edit_user" do @@ -222,9 +312,13 @@ fill_in :user_personal_url, with: "https://example.org" fill_in :user_about, with: "A Serbian-American inventor, electrical engineer, mechanical engineer, physicist, and futurist." - fill_in :user_date_of_birth, with: "01/01/2000" + fill_in_datepicker :user_date_of_birth_date, with: "01/01/2000" select "Other", from: :user_gender + select "17 to 30", from: :user_age_range select "Argentina", from: :user_country + select "Individual", from: :user_select_fields_participant_type + check "I am a member of a non-governmental organization (NGO)" + fill_in :user_text_fields_motto, with: "I think, therefore I am." fill_in :user_postal_code, with: "00000" fill_in :user_phone_number, with: "0123456789" fill_in :user_location, with: "Cahors" @@ -282,9 +376,13 @@ fill_in :user_personal_url, with: "https://example.org" fill_in :user_about, with: "A Serbian-American inventor, electrical engineer, mechanical engineer, physicist, and futurist." - fill_in :user_date_of_birth, with: "01/01/2000" + fill_in_datepicker :user_date_of_birth_date, with: "01/01/2000" select "Other", from: :user_gender + select "17 to 30", from: :user_age_range select "Argentina", from: :user_country + select "Individual", from: :user_select_fields_participant_type + check "I am a member of a non-governmental organization (NGO)" + fill_in :user_text_fields_motto, with: "I think, therefore I am." fill_in :user_postal_code, with: "00000" fill_in :user_phone_number, with: "0123456789" fill_in :user_location, with: "Cahors" @@ -302,7 +400,7 @@ it "toggles the current password" do expect(page).to have_content("In order to confirm the changes to your account, please provide your current password.") - expect(find("#user_old_password")).to be_visible + expect(find_by_id("user_old_password")).to be_visible expect(page).to have_content "Current password" expect(page).to have_no_content "Password" end @@ -415,44 +513,6 @@ end end - context "when on the interests page" do - before do - visit decidim.user_interests_path - end - - it "does not find any scopes" do - expect(page).to have_content("My interests") - expect(page).to have_content("This organization does not have any scope yet") - end - - context "when scopes are defined" do - let!(:scopes) { create_list(:scope, 3, organization:) } - let!(:subscopes) { create_list(:subscope, 3, parent: scopes.first) } - - before do - visit decidim.user_interests_path - end - - it "display translated scope name" do - expect(page).to have_content("My interests") - within "label[for='user_scopes_#{scopes.first.id}_checked']" do - expect(page).to have_content(translated(scopes.first.name)) - end - end - - it "allows to choose interests" do - label_field = "label[for='user_scopes_#{scopes.first.id}_checked']" - expect(page).to have_content("My interests") - find(label_field).click - click_on "Update my interests" - - within_flash_messages do - expect(page).to have_content("Your interests have been successfully updated.") - end - end - end - end - context "when on the delete my account page" do before do visit decidim.delete_account_path @@ -463,9 +523,8 @@ end it "the user can delete their account" do - fill_in :delete_user_delete_account_delete_reason, with: "I just want to delete my account" - - within ".form__wrapper-block" do + within ".delete-account" do + fill_in :delete_user_delete_account_delete_reason, with: "I just want to delete my account" click_on "Delete my account" end @@ -513,7 +572,8 @@ context "when VAPID keys are set" do before do - Rails.application.secrets[:vapid] = vapid_keys + allow(Decidim).to receive(:vapid_public_key).and_return(vapid_keys[:public_key]) + allow(Decidim).to receive(:vapid_private_key).and_return(vapid_keys[:private_key]) driven_by(:pwa_chrome) switch_to_host(organization.host) login_as user, scope: :user @@ -536,28 +596,15 @@ expect(page).to have_content("successfully") end - find(:css, "#allow_push_notifications", visible: false).execute_script("this.checked = true") + find_by_id("allow_push_notifications", visible: false).execute_script("this.checked = true") end end end - context "when VAPID is disabled" do - before do - Rails.application.secrets[:vapid] = { enabled: false } - driven_by(:pwa_chrome) - switch_to_host(organization.host) - login_as user, scope: :user - visit decidim.notifications_settings_path - end - - it "does not show the push notifications switch" do - expect(page).to have_no_selector(".push-notifications") - end - end - context "when VAPID keys are not set" do before do - Rails.application.secrets.delete(:vapid) + allow(Decidim).to receive(:vapid_public_key).and_return(nil) + allow(Decidim).to receive(:vapid_private_key).and_return(nil) driven_by(:pwa_chrome) switch_to_host(organization.host) login_as user, scope: :user diff --git a/spec/system/admin_manages_officializations_spec.rb b/spec/system/admin_manages_officializations_spec.rb index 93a1b695..f1e2ea69 100644 --- a/spec/system/admin_manages_officializations_spec.rb +++ b/spec/system/admin_manages_officializations_spec.rb @@ -30,7 +30,7 @@ context "when clicking on export csv button" do before do - find("span.exports").click + click_on "Export" click_on "Export CSV" end diff --git a/spec/system/admin_manages_organization_extra_user_fields_spec.rb b/spec/system/admin_manages_organization_extra_user_fields_spec.rb index 1c46fec4..f1110889 100644 --- a/spec/system/admin_manages_organization_extra_user_fields_spec.rb +++ b/spec/system/admin_manages_organization_extra_user_fields_spec.rb @@ -12,7 +12,7 @@ end it "creates a new item in submenu" do - visit decidim_admin.edit_organization_path + visit decidim_admin.officializations_path within ".sidebar-menu" do expect(page).to have_content("Manage extra user fields") @@ -38,13 +38,358 @@ end end + it "displays enabled and required checkboxes for fields" do + within "#accordion-setup" do + country_row = find("input[name='extra_user_fields[country_enabled]']", visible: :hidden).ancestor("tr") + within(country_row) do + expect(page).to have_css("[data-field-state-target='enabled']") + expect(page).to have_css("[data-field-state-target='required']") + end + + expect(page).to have_field("extra_user_fields[underage_enabled]", type: "hidden", visible: :hidden) + end + end + + it "saves field state settings" do + check("extra_user_fields[enabled]") + + within "#accordion-setup" do + # Enable and require country + country_row = find("input[name='extra_user_fields[country_enabled]']", visible: :hidden).ancestor("tr") + within(country_row) do + find("[data-field-state-target='enabled']").check + find("[data-field-state-target='required']").check + end + + # Enable gender (optional) + gender_row = find("input[name='extra_user_fields[gender_enabled]']", visible: :hidden).ancestor("tr") + within(gender_row) do + find("[data-field-state-target='enabled']").check + end + end + + find("*[type=submit]", text: "Save configuration").click + expect(page).to have_content("Extra user fields correctly updated in organization") + + visit decidim_extra_user_fields.root_path + + within "#accordion-setup" do + country_row = find("input[name='extra_user_fields[country_enabled]']", visible: :hidden).ancestor("tr") + within(country_row) do + expect(find("[data-field-state-target='enabled']")).to be_checked + expect(find("[data-field-state-target='required']")).to be_checked + end + + gender_row = find("input[name='extra_user_fields[gender_enabled]']", visible: :hidden).ancestor("tr") + within(gender_row) do + expect(find("[data-field-state-target='enabled']")).to be_checked + expect(find("[data-field-state-target='required']")).not_to be_checked + end + end + end + context "when form is valid" do it "flashes a success message" do - page.check("extra_user_fields[enabled]") + check("extra_user_fields[enabled]") + + find("*[type=submit]", text: "Save configuration").click + expect(page).to have_content("Extra user fields correctly updated in organization") + end + end + + context "when custom select_fields" do + it "displays the custom select fields" do + within "#accordion-extras" do + expect(page).to have_content("Additional custom fields") + expect(page).to have_content("Enable participant type") + expect(page).to have_content("This field is a list of participant types") + + participant_row = find_by_id("extra_user_fields_select_field_participant_type", visible: :hidden).ancestor("tr") + within(participant_row) do + find("[data-field-state-target='enabled']").check + end + end + + find("*[type=submit]", text: "Save configuration").click + expect(page).to have_content("Extra user fields correctly updated in organization") + end + + it "persists select field after save and reload" do + within "#accordion-extras" do + participant_row = find_by_id("extra_user_fields_select_field_participant_type", visible: :hidden).ancestor("tr") + within(participant_row) do + find("[data-field-state-target='enabled']").check + end + end find("*[type=submit]", text: "Save configuration").click expect(page).to have_content("Extra user fields correctly updated in organization") + + visit decidim_extra_user_fields.root_path + + within "#accordion-extras" do + participant_row = find_by_id("extra_user_fields_select_field_participant_type", visible: :hidden).ancestor("tr") + within(participant_row) do + expect(find("[data-field-state-target='enabled']")).to be_checked + end + end end end + + context "when custom boolean_fields" do + it "displays the custom boolean fields" do + within "#accordion-extras" do + expect(page).to have_content("Additional custom fields") + expect(page).to have_content("Enable NGO field") + expect(page).to have_content("This field is a Boolean field. User will be able to check if is a NGO") + + ngo_row = find_by_id("extra_user_fields_boolean_field_ngo", visible: :hidden).ancestor("tr") + within(ngo_row) do + find("[data-field-state-target='enabled']").check + end + end + + find("*[type=submit]", text: "Save configuration").click + expect(page).to have_content("Extra user fields correctly updated in organization") + end + end + + context "when enabling all custom field types together" do + it "saves and restores all custom fields correctly" do + within "#accordion-extras" do + # Enable participant_type (select field) + participant_row = find_by_id("extra_user_fields_select_field_participant_type", visible: :hidden).ancestor("tr") + within(participant_row) do + find("[data-field-state-target='enabled']").check + end + + # Enable ngo (boolean field) + ngo_row = find_by_id("extra_user_fields_boolean_field_ngo", visible: :hidden).ancestor("tr") + within(ngo_row) do + find("[data-field-state-target='enabled']").check + end + + # Enable motto (text field) + motto_row = find_by_id("extra_user_fields_text_field_motto", visible: :hidden).ancestor("tr") + within(motto_row) do + find("[data-field-state-target='enabled']").check + end + end + + find("*[type=submit]", text: "Save configuration").click + expect(page).to have_content("Extra user fields correctly updated in organization") + + visit decidim_extra_user_fields.root_path + + within "#accordion-extras" do + participant_row = find_by_id("extra_user_fields_select_field_participant_type", visible: :hidden).ancestor("tr") + within(participant_row) do + expect(find("[data-field-state-target='enabled']")).to be_checked + end + + ngo_row = find_by_id("extra_user_fields_boolean_field_ngo", visible: :hidden).ancestor("tr") + within(ngo_row) do + expect(find("[data-field-state-target='enabled']")).to be_checked + end + + motto_row = find_by_id("extra_user_fields_text_field_motto", visible: :hidden).ancestor("tr") + within(motto_row) do + expect(find("[data-field-state-target='enabled']")).to be_checked + end + end + end + end + + context "when custom text_fields" do + it "displays the custom text fields" do + within "#accordion-extras" do + expect(page).to have_content("Additional custom fields") + expect(page).to have_content('Enable "My Motto" field') + expect(page).to have_content("This field is a String field. If checked, user can fill in a personal phrase or motto") + + motto_row = find_by_id("extra_user_fields_text_field_motto", visible: :hidden).ancestor("tr") + within(motto_row) do + find("[data-field-state-target='enabled']").check + end + end + + find("*[type=submit]", text: "Save configuration").click + expect(page).to have_content("Extra user fields correctly updated in organization") + end + end + end + + context "when phone number config is set" do + before do + visit decidim_extra_user_fields.root_path + end + + it "persists pattern and placeholder after save and reload" do + check("extra_user_fields[enabled]") + + within "#accordion-setup" do + phone_row = find("input[name='extra_user_fields[phone_number_enabled]']", visible: :hidden).ancestor("tbody") + within(phone_row) do + find("[data-field-state-target='enabled']").check + fill_in "extra_user_fields[phone_number_pattern]", with: "^\\+34[0-9]{9}$" + fill_in "extra_user_fields[phone_number_placeholder_en]", with: "+34600000000" + end + end + + find("*[type=submit]", text: "Save configuration").click + expect(page).to have_content("Extra user fields correctly updated in organization") + + visit decidim_extra_user_fields.root_path + + within "#accordion-setup" do + phone_row = find("input[name='extra_user_fields[phone_number_enabled]']", visible: :hidden).ancestor("tbody") + within(phone_row) do + expect(find("[data-field-state-target='enabled']")).to be_checked + expect(page).to have_field("extra_user_fields[phone_number_pattern]", with: "^\\+34[0-9]{9}$") + expect(page).to have_field("extra_user_fields[phone_number_placeholder_en]", with: "+34600000000") + end + end + end + end + + context "when underage config is set" do + before do + visit decidim_extra_user_fields.root_path + end + + it "persists underage limit after save and reload" do + check("extra_user_fields[enabled]") + + within "#accordion-setup" do + underage_tbody = find("input[name='extra_user_fields[underage_enabled]']", visible: :hidden).ancestor("tbody") + within(underage_tbody) do + find("[data-field-state-target='enabled']").check + select "16", from: "extra_user_fields[underage_limit]" + end + end + + find("*[type=submit]", text: "Save configuration").click + expect(page).to have_content("Extra user fields correctly updated in organization") + + visit decidim_extra_user_fields.root_path + + within "#accordion-setup" do + underage_tbody = find("input[name='extra_user_fields[underage_enabled]']", visible: :hidden).ancestor("tbody") + within(underage_tbody) do + expect(find("[data-field-state-target='enabled']")).to be_checked + expect(page).to have_select("extra_user_fields[underage_limit]", selected: "16") + end + end + end + end + + context "when disabling a previously enabled field" do + before do + visit decidim_extra_user_fields.root_path + end + + it "persists disabled state after save and reload" do + check("extra_user_fields[enabled]") + + # First enable country + within "#accordion-setup" do + country_row = find("input[name='extra_user_fields[country_enabled]']", visible: :hidden).ancestor("tr") + within(country_row) do + find("[data-field-state-target='enabled']").check + end + end + + find("*[type=submit]", text: "Save configuration").click + expect(page).to have_content("Extra user fields correctly updated in organization") + + visit decidim_extra_user_fields.root_path + + # Verify it's enabled + within "#accordion-setup" do + country_row = find("input[name='extra_user_fields[country_enabled]']", visible: :hidden).ancestor("tr") + within(country_row) do + expect(find("[data-field-state-target='enabled']")).to be_checked + end + end + + # Now disable it + within "#accordion-setup" do + country_row = find("input[name='extra_user_fields[country_enabled]']", visible: :hidden).ancestor("tr") + within(country_row) do + find("[data-field-state-target='enabled']").uncheck + end + end + + find("*[type=submit]", text: "Save configuration").click + expect(page).to have_content("Extra user fields correctly updated in organization") + + visit decidim_extra_user_fields.root_path + + # Verify it's now disabled + within "#accordion-setup" do + country_row = find("input[name='extra_user_fields[country_enabled]']", visible: :hidden).ancestor("tr") + within(country_row) do + expect(find("[data-field-state-target='enabled']")).not_to be_checked + end + end + end + end + + context "and no translations are provided" do + let(:custom_select_fields) { { animal_type: { dog: "I love dogs", cat: "I love cats" } } } + let(:custom_boolean_fields) { [:dog_person] } + let(:custom_text_fields) { [:pet_name] } + + before do + allow(Decidim::ExtraUserFields).to receive(:select_fields).and_return(custom_select_fields) + allow(Decidim::ExtraUserFields).to receive(:boolean_fields).and_return(custom_boolean_fields) + allow(Decidim::ExtraUserFields).to receive(:text_fields).and_return(custom_text_fields) + visit decidim_extra_user_fields.root_path + end + + it "displays the custom select fields" do + within "#accordion-extras" do + expect(page).to have_content("Additional custom fields") + expect(page).to have_content("Animal type") + + animal_row = find_by_id("extra_user_fields_select_field_animal_type", visible: :hidden).ancestor("tr") + within(animal_row) do + find("[data-field-state-target='enabled']").check + end + end + + find("*[type=submit]", text: "Save configuration").click + expect(page).to have_content("Extra user fields correctly updated in organization") + end + + it "displays the custom boolean fields" do + within "#accordion-extras" do + expect(page).to have_content("Additional custom fields") + expect(page).to have_content("Dog person") + + dog_row = find_by_id("extra_user_fields_boolean_field_dog_person", visible: :hidden).ancestor("tr") + within(dog_row) do + find("[data-field-state-target='enabled']").check + end + end + + find("*[type=submit]", text: "Save configuration").click + expect(page).to have_content("Extra user fields correctly updated in organization") + end + + it "displays the custom text fields" do + within "#accordion-extras" do + expect(page).to have_content("Additional custom fields") + expect(page).to have_content("Pet name") + + pet_row = find_by_id("extra_user_fields_text_field_pet_name", visible: :hidden).ancestor("tr") + within(pet_row) do + find("[data-field-state-target='enabled']").check + end + end + + find("*[type=submit]", text: "Save configuration").click + expect(page).to have_content("Extra user fields correctly updated in organization") + end end end diff --git a/spec/system/admin_views_benchmarking_spec.rb b/spec/system/admin_views_benchmarking_spec.rb new file mode 100644 index 00000000..88e5d7c8 --- /dev/null +++ b/spec/system/admin_views_benchmarking_spec.rb @@ -0,0 +1,297 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Admin views benchmarking" do + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, :confirmed, organization:) } + + before do + switch_to_host(organization.host) + login_as user, scope: :user + end + + context "when visiting the benchmarking page" do + before do + visit decidim_extra_user_fields.benchmarking_path + end + + it "renders the benchmarking menu item in the sidebar" do + within(".sidebar-menu") do + expect(page).to have_content("Benchmarking") + end + end + + it "highlights the Insights menu item in the main admin menu" do + expect(page).to have_css("[aria-current='page']", text: "Insights") + end + + it "displays the page title" do + expect(page).to have_content("Comparative stats across spaces") + end + + it "displays the description" do + expect(page).to have_content("Choose participatory spaces for comparison") + end + + it "shows the selector dropdowns" do + within(".insights-selectors") do + expect(page).to have_content("Rows (Y axis)") + expect(page).to have_content("Columns (X axis)") + expect(page).to have_content("Metric") + end + end + + it "shows prompt when no spaces are selected" do + expect(page).to have_content("Select one or more participatory spaces to compare") + expect(page).to have_no_table + end + end + + context "with participation data in two processes" do + let!(:process_a) { create(:participatory_process, :with_steps, organization:, title: { "en" => "Process Alpha" }) } + let!(:process_b) { create(:participatory_process, :with_steps, organization:, title: { "en" => "Process Beta" }) } + let!(:component_a) { create(:proposal_component, :published, participatory_space: process_a) } + let!(:component_b) { create(:proposal_component, :published, participatory_space: process_b) } + + let(:user_female_young) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "date_of_birth" => 25.years.ago.to_date.to_s }) } + let(:user_male_old) { create(:user, :confirmed, organization:, extended_data: { "gender" => "male", "date_of_birth" => 55.years.ago.to_date.to_s }) } + + before do + create(:proposal, :published, component: component_a, users: [user_female_young]) + create(:proposal, :published, component: component_a, users: [user_male_old]) + create(:proposal, :published, component: component_b, users: [user_female_young]) + end + + it "renders comparison table with selected spaces" do + space_a_value = "#{process_a.class.name}:#{process_a.id}" + space_b_value = "#{process_b.class.name}:#{process_b.id}" + visit decidim_extra_user_fields.benchmarking_path(spaces: [space_a_value, space_b_value]) + + expect(page).to have_css("table.insights-table[data-heatmap]") + + within("thead") do + expect(page).to have_content("Process Alpha") + expect(page).to have_content("Process Beta") + expect(page).to have_content("Row Total") + end + + within("tbody") do + expect(page).to have_content("21 to 30") + expect(page).to have_content("51 to 60") + end + + within("tfoot") do + expect(page).to have_content("Column Total") + end + end + + it "shows heatmap styling on data cells" do + space_a_value = "#{process_a.class.name}:#{process_a.id}" + space_b_value = "#{process_b.class.name}:#{process_b.id}" + visit decidim_extra_user_fields.benchmarking_path(spaces: [space_a_value, space_b_value]) + + expect(page).to have_css("td.heatmap-cell--colored, td.heatmap-cell--gray") + + cell = find("td.insights-table__cell", text: /[1-9]/, match: :first) + expect(cell[:style]).to match(/--i:/) + end + + it "shows the legend" do + space_a_value = "#{process_a.class.name}:#{process_a.id}" + visit decidim_extra_user_fields.benchmarking_path(spaces: [space_a_value]) + + within(".heatmap-legend") do + expect(page).to have_content("Fewer") + expect(page).to have_content("More") + end + end + end + + context "when spaces are selected but have no data" do + let!(:empty_process) { create(:participatory_process, :with_steps, organization:, title: { "en" => "Empty Process" }) } + + it "shows no-data message" do + space_value = "#{empty_process.class.name}:#{empty_process.id}" + visit decidim_extra_user_fields.benchmarking_path(spaces: [space_value]) + + expect(page).to have_content("No participation data found for the selected spaces") + expect(page).to have_no_table + end + end + + context "when switching metrics" do + let!(:process) { create(:participatory_process, :with_steps, organization:, title: { "en" => "Test Process" }) } + let!(:component) { create(:proposal_component, :published, participatory_space: process) } + let(:author) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "date_of_birth" => 25.years.ago.to_date.to_s }) } + let!(:proposal) { create(:proposal, :published, component:, users: [author]) } + + before do + create_list(:comment, 3, commentable: proposal, author:) + end + + it "shows comments metric data when selected" do + space_value = "#{process.class.name}:#{process.id}" + visit decidim_extra_user_fields.benchmarking_path(spaces: [space_value], metric: "comments") + + within("tfoot") do + expect(page).to have_content("3") + end + end + + it "shows participants metric by default" do + space_value = "#{process.class.name}:#{process.id}" + visit decidim_extra_user_fields.benchmarking_path(spaces: [space_value]) + + within("tfoot") do + expect(page).to have_content("1") + end + end + end + + context "when switching axes via query params" do + let!(:process) { create(:participatory_process, :with_steps, organization:, title: { "en" => "Axis Process" }) } + let!(:component) { create(:proposal_component, :published, participatory_space: process) } + let(:user_with_data) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "date_of_birth" => 25.years.ago.to_date.to_s, "country" => "france" }) } + + before do + create(:proposal, :published, component:, users: [user_with_data]) + end + + it "swaps rows and columns when params change" do + space_value = "#{process.class.name}:#{process.id}" + visit decidim_extra_user_fields.benchmarking_path(spaces: [space_value], rows: "gender", cols: "age_span") + + within("thead") do + expect(page).to have_content("21 to 30") + end + + within("tbody") do + expect(page).to have_content("Female") + end + end + + it "uses a different field when specified" do + space_value = "#{process.class.name}:#{process.id}" + visit decidim_extra_user_fields.benchmarking_path(spaces: [space_value], rows: "gender", cols: "country") + + within("thead") do + expect(page).to have_content("France") + end + + within("tbody") do + expect(page).to have_content("Female") + end + end + end + + context "when query params are invalid" do + let!(:process) { create(:participatory_process, :with_steps, organization:, title: { "en" => "Fallback Process" }) } + let!(:component) { create(:proposal_component, :published, participatory_space: process) } + let(:user_with_data) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "date_of_birth" => 25.years.ago.to_date.to_s }) } + + before do + create(:proposal, :published, component:, users: [user_with_data]) + end + + it "falls back to defaults for invalid rows param" do + space_value = "#{process.class.name}:#{process.id}" + visit decidim_extra_user_fields.benchmarking_path(spaces: [space_value], rows: "nonexistent_field") + + expect(page).to have_content("Comparative stats across spaces") + within("tbody") do + expect(page).to have_content("21 to 30") + end + end + + it "falls back to defaults for invalid metric param" do + space_value = "#{process.class.name}:#{process.id}" + visit decidim_extra_user_fields.benchmarking_path(spaces: [space_value], metric: "fake_metric") + + expect(page).to have_content("Comparative stats across spaces") + within("tfoot") do + expect(page).to have_content("1") + end + end + + it "gracefully handles invalid space ID" do + visit decidim_extra_user_fields.benchmarking_path(spaces: ["Decidim::ParticipatoryProcess:999999"]) + + expect(page).to have_content("Select one or more participatory spaces to compare") + end + + it "gracefully handles non-manifest class name" do + visit decidim_extra_user_fields.benchmarking_path(spaces: ["Decidim::User:1"]) + + expect(page).to have_content("Select one or more participatory spaces to compare") + end + + it "gracefully handles malformed space keys" do + visit decidim_extra_user_fields.benchmarking_path(spaces: [":1", "ClassName:", ""]) + + expect(page).to have_content("Select one or more participatory spaces to compare") + end + end + + context "when export button is available" do + let!(:process_a) { create(:participatory_process, :with_steps, organization:, title: { "en" => "Export Process" }) } + let!(:component_a) { create(:proposal_component, :published, participatory_space: process_a) } + let(:author) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "date_of_birth" => 25.years.ago.to_date.to_s }) } + + before do + create(:proposal, :published, component: component_a, users: [author]) + end + + it "shows the Export button when data is present" do + space_value = "#{process_a.class.name}:#{process_a.id}" + visit decidim_extra_user_fields.benchmarking_path(spaces: [space_value]) + + within(".item_show__header") do + expect(page).to have_content("Export") + end + end + + it "does not show the Export button when no spaces are selected" do + visit decidim_extra_user_fields.benchmarking_path + + within(".item_show__header") do + expect(page).to have_no_content("Export") + end + end + + it "shows flash notice when clicking export" do + space_value = "#{process_a.class.name}:#{process_a.id}" + visit decidim_extra_user_fields.benchmarking_path(spaces: [space_value]) + + within(".item_show__header") do + click_on "Export" + end + + click_on "Export CSV" + + expect(page).to have_content("Your export is currently in progress") + end + + it "sends an export email with benchmarking in the subject" do + space_value = "#{process_a.class.name}:#{process_a.id}" + visit decidim_extra_user_fields.benchmarking_path(spaces: [space_value]) + + within(".item_show__header") do + click_on "Export" + end + + perform_enqueued_jobs { click_on "Export CSV" } + + expect(last_email.subject).to include("benchmarking") + end + end + + context "when user is not admin" do + let(:user) { create(:user, :confirmed, organization:) } + + it "cannot access the benchmarking page" do + visit decidim_extra_user_fields.benchmarking_path + expect(page).to have_no_content("Comparative stats across spaces") + end + end +end diff --git a/spec/system/admin_views_insights_spec.rb b/spec/system/admin_views_insights_spec.rb new file mode 100644 index 00000000..2a35a7a4 --- /dev/null +++ b/spec/system/admin_views_insights_spec.rb @@ -0,0 +1,312 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Admin views insights" do + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, :confirmed, organization:) } + + before do + switch_to_host(organization.host) + login_as user, scope: :user + end + + context "with a participatory process" do + let!(:participatory_process) { create(:participatory_process, organization:) } + + context "when visiting the insights page" do + before do + visit decidim_admin_participatory_process_insights.root_path( + participatory_process_slug: participatory_process.slug + ) + end + + it "renders the sidebar menu with participatory space items" do + within(".sidebar-menu") do + expect(page).to have_content("Components") + expect(page).to have_content("Insights") + end + end + + it "displays the page title" do + expect(page).to have_content("Participatory Space Insights") + end + + it "displays the description" do + expect(page).to have_content("Explore participant activity across profile dimensions") + end + + it "shows the selector dropdowns" do + within(".insights-selectors") do + expect(page).to have_content("Rows (Y axis)") + expect(page).to have_content("Columns (X axis)") + expect(page).to have_content("Metric") + expect(page).to have_content("Gender") + expect(page).to have_content("Age span") + expect(page).to have_content("Participants") + end + end + + it "shows empty state when no participation data exists" do + expect(page).to have_content("No participation data found") + end + end + end + + context "with participation data in a process" do + let!(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let!(:proposal_component) { create(:proposal_component, :published, participatory_space: participatory_process) } + + let(:user_female_young) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "date_of_birth" => 25.years.ago.to_date.to_s }) } + let(:user_male_young) { create(:user, :confirmed, organization:, extended_data: { "gender" => "male", "date_of_birth" => 28.years.ago.to_date.to_s }) } + let(:user_no_data) { create(:user, :confirmed, organization:, extended_data: {}) } + + before do + create(:proposal, :published, component: proposal_component, users: [user_female_young]) + create(:proposal, :published, component: proposal_component, users: [user_male_young]) + create(:proposal, :published, component: proposal_component, users: [user_no_data]) + visit decidim_admin_participatory_process_insights.root_path( + participatory_process_slug: participatory_process.slug + ) + end + + it "renders the pivot table headers" do + # Default: rows=age_span, cols=gender + within("thead") do + expect(page).to have_content("Female") + expect(page).to have_content("Male") + expect(page).to have_content("Row Total") + end + end + + it "renders row labels in the pivot table" do + # Default: rows=age_span, cols=gender + within("tbody") do + expect(page).to have_content("21 to 30") + expect(page).to have_content("Not specified / Prefer not to say") + end + end + + it "renders the column totals and grand total" do + within("tfoot") do + expect(page).to have_content("Column Total") + expect(page).to have_content("3") + end + end + + it "shows the legend" do + within(".heatmap-legend") do + expect(page).to have_content("Fewer") + expect(page).to have_content("More") + end + end + end + + context "when switching axes via query params" do + let!(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let!(:proposal_component) { create(:proposal_component, :published, participatory_space: participatory_process) } + let(:user_with_data) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "date_of_birth" => 25.years.ago.to_date.to_s, "country" => "france" }) } + + before do + create(:proposal, :published, component: proposal_component, users: [user_with_data]) + end + + it "swaps rows and columns when params change" do + visit decidim_admin_participatory_process_insights.root_path( + participatory_process_slug: participatory_process.slug, + rows: "gender", + cols: "age_span" + ) + + within("thead") do + expect(page).to have_content("21 to 30") + end + + within("tbody") do + expect(page).to have_content("Female") + end + end + + it "uses a different field when specified" do + visit decidim_admin_participatory_process_insights.root_path( + participatory_process_slug: participatory_process.slug, + rows: "gender", + cols: "country" + ) + + within("thead") do + expect(page).to have_content("France") + end + + within("tbody") do + expect(page).to have_content("Female") + end + end + end + + context "when switching metrics" do + let!(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let!(:proposal_component) { create(:proposal_component, :published, participatory_space: participatory_process) } + let(:author) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "date_of_birth" => 25.years.ago.to_date.to_s }) } + let!(:proposal) { create(:proposal, :published, component: proposal_component, users: [author]) } + + before do + create_list(:comment, 3, commentable: proposal, author: author) + end + + it "shows comments metric data when selected" do + visit decidim_admin_participatory_process_insights.root_path( + participatory_process_slug: participatory_process.slug, + metric: "comments" + ) + + within("tfoot") do + expect(page).to have_content("3") + end + end + + it "shows participants metric by default" do + visit decidim_admin_participatory_process_insights.root_path( + participatory_process_slug: participatory_process.slug + ) + + within("tfoot") do + expect(page).to have_content("1") + end + end + end + + context "when cells have heatmap styling" do + let!(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let!(:proposal_component) { create(:proposal_component, :published, participatory_space: participatory_process) } + let(:user_with_data) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "date_of_birth" => 25.years.ago.to_date.to_s }) } + + before do + create(:proposal, :published, component: proposal_component, users: [user_with_data]) + visit decidim_admin_participatory_process_insights.root_path( + participatory_process_slug: participatory_process.slug + ) + end + + it "adds data-heatmap attribute to the table" do + expect(page).to have_css("table.insights-table[data-heatmap]") + end + + it "applies heatmap classes to data cells" do + expect(page).to have_css("td.heatmap-cell--colored, td.heatmap-cell--gray") + end + + it "applies inline heatmap style to data cells" do + cell = find("td.insights-table__cell", text: /[1-9]/, match: :first) + expect(cell[:style]).to match(/--i:/) + end + + it "applies inline heatmap style to row total cells" do + cell = find("td.heatmap-total.insights-table__row-total", text: /[1-9]/, match: :first) + expect(cell[:style]).to match(/--i:/) + end + + it "applies inline heatmap style to column total cells" do + cell = find("td.heatmap-total.insights-table__col-total", match: :first) + expect(cell[:style]).to match(/--i:/) + end + end + + context "when query params are invalid" do + let!(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let!(:proposal_component) { create(:proposal_component, :published, participatory_space: participatory_process) } + let(:user_with_data) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "date_of_birth" => 25.years.ago.to_date.to_s }) } + + before do + create(:proposal, :published, component: proposal_component, users: [user_with_data]) + end + + it "falls back to defaults for invalid rows param" do + visit decidim_admin_participatory_process_insights.root_path( + participatory_process_slug: participatory_process.slug, + rows: "nonexistent_field" + ) + + expect(page).to have_content("Participatory Space Insights") + # Default rows=age_span, so we see age span labels in tbody + within("tbody") do + expect(page).to have_content("21 to 30") + end + end + + it "falls back to defaults for invalid metric param" do + visit decidim_admin_participatory_process_insights.root_path( + participatory_process_slug: participatory_process.slug, + metric: "fake_metric" + ) + + expect(page).to have_content("Participatory Space Insights") + within("tfoot") do + expect(page).to have_content("1") + end + end + end + + context "with an assembly" do + let!(:assembly) { create(:assembly, organization:) } + + it "renders the sidebar menu with assembly items" do + visit decidim_admin_assembly_insights.root_path(assembly_slug: assembly.slug) + + within(".sidebar-menu") do + expect(page).to have_content("Components") + expect(page).to have_content("Insights") + end + + expect(page).to have_content("Participatory Space Insights") + end + end + + context "when exporting insights" do + let!(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let!(:proposal_component) { create(:proposal_component, :published, participatory_space: participatory_process) } + let(:user_with_data) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "date_of_birth" => 25.years.ago.to_date.to_s }) } + + before do + create(:proposal, :published, component: proposal_component, users: [user_with_data]) + visit decidim_admin_participatory_process_insights.root_path( + participatory_process_slug: participatory_process.slug + ) + end + + it "shows the export dropdown button" do + expect(page).to have_button("Export") + end + + it "shows export format options in the dropdown" do + click_on "Export" + expect(page).to have_content("CSV") + expect(page).to have_content("JSON") + expect(page).to have_content("Excel") + end + + it "triggers export and shows flash notice" do + click_on "Export" + perform_enqueued_jobs { click_on "CSV" } + expect(page).to have_content("Your export is currently in progress") + end + + it "sends an export email with insights in the subject" do + click_on "Export" + perform_enqueued_jobs { click_on "CSV" } + expect(last_email.subject).to include("insights") + end + end + + context "when user is not admin" do + let!(:participatory_process) { create(:participatory_process, organization:) } + let(:user) { create(:user, :confirmed, organization:) } + + it "cannot access the insights page" do + visit decidim_admin_participatory_process_insights.root_path( + participatory_process_slug: participatory_process.slug + ) + expect(page).to have_no_content("Participatory Space Insights") + end + end +end diff --git a/spec/system/force_extra_user_fields_spec.rb b/spec/system/force_extra_user_fields_spec.rb new file mode 100644 index 00000000..a4d50c14 --- /dev/null +++ b/spec/system/force_extra_user_fields_spec.rb @@ -0,0 +1,349 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Force extra user fields completion" do + let(:organization) { create(:organization, extra_user_fields:) } + let(:password) { "dqCFgjfDbC7dPbrv" } + let(:user) { create(:user, :confirmed, organization:, password:, extended_data:) } + let(:extended_data) { {} } + + let(:all_fields_disabled) do + { + "enabled" => true, + "country" => { "enabled" => false, "required" => false }, + "gender" => { "enabled" => false, "required" => false }, + "date_of_birth" => { "enabled" => false, "required" => false }, + "postal_code" => { "enabled" => false, "required" => false }, + "age_range" => { "enabled" => false, "required" => false }, + "phone_number" => { "enabled" => false, "required" => false }, + "location" => { "enabled" => false, "required" => false } + } + end + + let(:extra_user_fields) do + { + "enabled" => true, + "country" => { "enabled" => true, "required" => true }, + "gender" => { "enabled" => true, "required" => true }, + "date_of_birth" => { "enabled" => false, "required" => false }, + "postal_code" => { "enabled" => false, "required" => false }, + "age_range" => { "enabled" => false, "required" => false }, + "phone_number" => { "enabled" => false, "required" => false }, + "location" => { "enabled" => false, "required" => false } + } + end + + before do + switch_to_host(organization.host) + end + + context "when user has NOT completed extra fields" do + it "redirects to account page with a warning" do + login_as user, scope: :user + visit decidim.root_path + + expect(page).to have_current_path(decidim.account_path) + expect(page).to have_content("Please complete your profile information before continuing.") + end + + it "allows access to the account page and highlights empty required fields" do + login_as user, scope: :user + visit decidim.account_path + + expect(page).to have_current_path(decidim.account_path) + expect(page).to have_content("Country") + expect(page).to have_content("Which gender do you identify with?") + expect(page).to have_css("label[for='user_country'].is-invalid-label") + expect(page).to have_css("label[for='user_gender'].is-invalid-label") + end + + it "allows access to the delete account page" do + login_as user, scope: :user + visit decidim.delete_account_path + + expect(page).to have_current_path(decidim.delete_account_path) + end + + it "allows access to the download your data page" do + login_as user, scope: :user + visit decidim.download_your_data_path + + expect(page).to have_current_path(decidim.download_your_data_path) + end + + it "allows free navigation after completing profile" do + login_as user, scope: :user + visit decidim.root_path + + expect(page).to have_current_path(decidim.account_path) + expect(page).to have_css("label[for='user_country'].is-invalid-label") + expect(page).to have_css("label[for='user_gender'].is-invalid-label") + + within "form.edit_user" do + select "Argentina", from: :user_country + select "Female", from: :user_gender + find("*[type=submit]").click + end + + within_flash_messages do + expect(page).to have_content("successfully") + end + + visit decidim.notifications_settings_path + expect(page).to have_current_path(decidim.notifications_settings_path) + end + end + + context "when user HAS completed extra fields" do + let(:extended_data) { { "country" => "ES", "gender" => "female" } } + + it "does not redirect and allows normal navigation" do + login_as user, scope: :user + visit decidim.root_path + + expect(page).to have_current_path(decidim.root_path) + expect(page).to have_no_content("Please complete your profile information before continuing.") + end + end + + context "when all fields are optional (none required)" do + let(:extra_user_fields) do + { + "enabled" => true, + "country" => { "enabled" => true, "required" => false }, + "gender" => { "enabled" => true, "required" => false } + } + end + + it "does not redirect" do + login_as user, scope: :user + visit decidim.root_path + + expect(page).to have_current_path(decidim.root_path) + end + end + + context "when extra user fields module is disabled" do + let(:extra_user_fields) { { "enabled" => false, "country" => { "enabled" => true, "required" => true } } } + + it "does not redirect" do + login_as user, scope: :user + visit decidim.root_path + + expect(page).to have_current_path(decidim.root_path) + end + end + + context "when user has not accepted ToS and has incomplete extra fields" do + let(:user) { create(:user, :confirmed, :tos_not_accepted, organization:, password:, extended_data:) } + + it "redirects to ToS page first, then to account page after accepting ToS" do + login_as user, scope: :user + visit decidim.root_path + + tos_page = Decidim::StaticPage.find_by(slug: "terms-of-service", organization:) + + expect(page).to have_current_path(decidim.page_path(tos_page)) + expect(page).to have_content("Review updates to our terms of service") + + click_on "I agree with these terms" + + expect(page).to have_current_path(decidim.account_path) + expect(page).to have_content("Please complete your profile information before continuing.") + end + end + + context "when completing a required collection field" do + let(:extra_user_fields) { all_fields_disabled.merge("select_fields" => { "participant_type" => { "enabled" => true, "required" => true } }) } + + it "allows free navigation after filling the field" do + login_as user, scope: :user + visit decidim.root_path + + expect(page).to have_current_path(decidim.account_path) + + within "form.edit_user" do + select "Individual", from: "user_select_fields_participant_type" + find("*[type=submit]").click + end + + within_flash_messages do + expect(page).to have_content("successfully") + end + + visit decidim.notifications_settings_path + expect(page).to have_current_path(decidim.notifications_settings_path) + end + end + + context "when account page is refreshed with incomplete fields" do + it "stays on account page without redirect loop" do + login_as user, scope: :user + visit decidim.root_path + + expect(page).to have_current_path(decidim.account_path) + expect(page).to have_content("Please complete your profile information before continuing.") + + visit decidim.account_path + + expect(page).to have_current_path(decidim.account_path) + expect(page).to have_content("Country") + end + end + + context "when using a non-default locale" do + it "does not cause a redirect loop on account page" do + login_as user, scope: :user + visit "#{decidim.root_path}?locale=fr" + + expect(page).to have_current_path(/account/) + + visit "#{decidim.account_path}?locale=fr" + + expect(page).to have_current_path(/account/) + expect(page).to have_no_content("redirected you too many times") + end + end + + context "when ToS is accepted then extra fields redirect kicks in" do + let(:user) { create(:user, :confirmed, :tos_not_accepted, organization:, password:, extended_data:) } + + it "flows ToS -> account without loop" do + login_as user, scope: :user + visit decidim.root_path + + tos_page = Decidim::StaticPage.find_by(slug: "terms-of-service", organization:) + expect(page).to have_current_path(decidim.page_path(tos_page)) + + click_on "I agree with these terms" + + expect(page).to have_current_path(decidim.account_path) + expect(page).to have_content("Please complete your profile information before continuing.") + + visit decidim.account_path + + expect(page).to have_current_path(decidim.account_path) + expect(page).to have_content("Country") + end + end + + context "when only custom collection fields are required" do + let(:extra_user_fields) { all_fields_disabled.merge("select_fields" => { "participant_type" => { "enabled" => true, "required" => true } }) } + + it "opens account page stably without redirect loop" do + login_as user, scope: :user + visit decidim.root_path + + expect(page).to have_current_path(decidim.account_path) + expect(page).to have_content("Please complete your profile information before continuing.") + + visit decidim.account_path + + expect(page).to have_current_path(decidim.account_path) + end + end + + context "when user completes fields and navigates away" do + it "does not redirect back to account after completion" do + login_as user, scope: :user + visit decidim.root_path + + expect(page).to have_current_path(decidim.account_path) + + within "form.edit_user" do + select "Argentina", from: :user_country + select "Female", from: :user_gender + find("*[type=submit]").click + end + + within_flash_messages do + expect(page).to have_content("successfully") + end + + visit decidim.root_path + expect(page).to have_current_path(decidim.root_path) + + visit decidim.root_path + expect(page).to have_current_path(decidim.root_path) + end + end + + context "when user is not logged in" do + it "does not redirect" do + visit decidim.root_path + + expect(page).to have_current_path(decidim.root_path) + end + end + + describe "per-field redirect behavior" do + shared_examples "redirects when required field is empty" do |field| + let(:extra_user_fields) { all_fields_disabled.merge(field => { "enabled" => true, "required" => true }) } + + it "redirects when #{field} is required and empty" do + login_as user, scope: :user + visit decidim.root_path + + expect(page).to have_current_path(decidim.account_path) + end + end + + shared_examples "no redirect when required field is filled" do |field, value| + let(:extra_user_fields) { all_fields_disabled.merge(field => { "enabled" => true, "required" => true }) } + let(:extended_data) { { field => value } } + + it "does not redirect when #{field} is required and filled" do + login_as user, scope: :user + visit decidim.root_path + + expect(page).to have_current_path(decidim.root_path) + end + end + + shared_examples "redirects when required collection field is empty" do |collection, field| + let(:extra_user_fields) { all_fields_disabled.merge(collection => { field => { "enabled" => true, "required" => true } }) } + + it "redirects and highlights the field" do + login_as user, scope: :user + visit decidim.root_path + + expect(page).to have_current_path(decidim.account_path) + expect(page).to have_css("label[for='user_#{collection}_#{field}'].is-invalid-label") + end + end + + shared_examples "no redirect when required collection field is filled" do |collection, field, value| + let(:extra_user_fields) { all_fields_disabled.merge(collection => { field => { "enabled" => true, "required" => true } }) } + let(:extended_data) { { collection => { field => value } } } + + it "does not redirect when #{collection}.#{field} is required and filled" do + login_as user, scope: :user + visit decidim.root_path + + expect(page).to have_current_path(decidim.root_path) + end + end + + context "with profile fields" do + %w(country gender date_of_birth postal_code age_range phone_number location).each do |field| + values = { + "country" => "ES", "gender" => "female", "date_of_birth" => "1990-01-01", + "postal_code" => "08001", "age_range" => "17_to_30", "phone_number" => "+34600000000", + "location" => "Barcelona" + } + + it_behaves_like "redirects when required field is empty", field + it_behaves_like "no redirect when required field is filled", field, values[field] + end + end + + context "with collection fields" do + it_behaves_like "redirects when required collection field is empty", "select_fields", "participant_type" + it_behaves_like "no redirect when required collection field is filled", "select_fields", "participant_type", "individual" + + it_behaves_like "redirects when required collection field is empty", "text_fields", "motto" + it_behaves_like "no redirect when required collection field is filled", "text_fields", "motto", "Carpe diem" + end + end +end diff --git a/spec/system/registration_spec.rb b/spec/system/registration_spec.rb index 9250b932..2901fc52 100644 --- a/spec/system/registration_spec.rb +++ b/spec/system/registration_spec.rb @@ -6,20 +6,21 @@ def fill_registration_form fill_in :registration_user_name, with: "Nikola Tesla" fill_in :registration_user_email, with: "nikola.tesla@example.org" fill_in :registration_user_password, with: "sekritpass123" - page.check("registration_user_newsletter") - page.check("registration_user_tos_agreement") + check("registration_user_newsletter") + check("registration_user_tos_agreement") end def fill_extra_user_fields - fill_in :registration_user_date_of_birth, with: "01/01/2000" + fill_in_datepicker :registration_user_date_of_birth_date, with: "01/01/2000" select "Other", from: :registration_user_gender + select "17 to 30", from: :registration_user_age_range select "Argentina", from: :registration_user_country + select "Individual", from: :registration_user_select_fields_participant_type + check "registration_user_boolean_fields_ngo" + fill_in :registration_user_text_fields_motto, with: "I think, therefore I am." fill_in :registration_user_postal_code, with: "00000" fill_in :registration_user_phone_number, with: "0123456789" fill_in :registration_user_location, with: "Cahors" - # Block ExtraUserFields FillExtraUserFields - - # EndBlock end describe "Extra user fields" do @@ -33,50 +34,34 @@ def fill_extra_user_fields let(:organization) { create(:organization, extra_user_fields:) } let!(:terms_and_conditions_page) { Decidim::StaticPage.find_by(slug: "terms-and-conditions", organization:) } - # rubocop:disable Style/TrailingCommaInHashLiteral + let(:extra_user_fields) do { - # Block ExtraUserFields ExtraUserFields "enabled" => true, "date_of_birth" => date_of_birth, "postal_code" => postal_code, "gender" => gender, + "age_range" => age_range, "country" => country, "phone_number" => phone_number, "location" => location, - # EndBlock + "select_fields" => select_fields, + "boolean_fields" => boolean_fields, + "text_fields" => text_fields } end - # rubocop:enable Style/TrailingCommaInHashLiteral - - let(:date_of_birth) do - { "enabled" => true } - end - - let(:postal_code) do - { "enabled" => true } - end - - let(:country) do - { "enabled" => true } - end - let(:gender) do - { "enabled" => true } - end - - let(:phone_number) do - { "enabled" => true, "pattern" => phone_number_pattern, "placeholder" => nil } - end + let(:date_of_birth) { { "enabled" => true, "required" => true } } + let(:postal_code) { { "enabled" => true, "required" => true } } + let(:country) { { "enabled" => true, "required" => true } } + let(:gender) { { "enabled" => true, "required" => true } } + let(:age_range) { { "enabled" => true, "required" => true } } + let(:phone_number) { { "enabled" => true, "required" => true, "pattern" => phone_number_pattern, "placeholder" => nil } } let(:phone_number_pattern) { "^(\\+34)?[0-9 ]{9,12}$" } - - let(:location) do - { "enabled" => true } - end - - # Block ExtraUserFields RspecVar - - # EndBlock + let(:location) { { "enabled" => true, "required" => true } } + let(:select_fields) { { "participant_type" => { "enabled" => true, "required" => false } } } + let(:boolean_fields) { { "ngo" => { "enabled" => true, "required" => false } } } + let(:text_fields) { { "motto" => { "enabled" => true, "required" => false } } } before do switch_to_host(organization.host) @@ -86,14 +71,15 @@ def fill_extra_user_fields it "contains extra user fields" do within "#card__extra_user_fields" do expect(page).to have_content("Date of birth") - expect(page).to have_content("Gender") + expect(page).to have_content("Which gender do you identify with?") expect(page).to have_content("Country") expect(page).to have_content("Postal code") expect(page).to have_content("Phone Number") expect(page).to have_content("Location") - # Block ExtraUserFields ContainsFieldSpec - - # EndBlock + expect(page).to have_content("How old are you?") + expect(page).to have_content("Are you participating as an individual, or officially on behalf of an organization?") + expect(page).to have_content("I am a member of a non-governmental organization (NGO)") + expect(page).to have_content("What is your motto?") end end @@ -106,6 +92,18 @@ def fill_extra_user_fields end expect(page).to have_content("message with a confirmation link has been sent") + + extended_data = Decidim::User.unscoped.last.extended_data + expect(extended_data["text_fields"]["motto"]).to eq("I think, therefore I am.") + expect(extended_data["boolean_fields"]).to eq(["ngo"]) + expect(extended_data["select_fields"]["participant_type"]).to eq("individual") + expect(extended_data["date_of_birth"]).to eq("2000-01-01") + expect(extended_data["gender"]).to eq("other") + expect(extended_data["age_range"]).to eq("17_to_30") + expect(extended_data["country"]).to eq("AR") + expect(extended_data["postal_code"]).to eq("00000") + expect(extended_data["phone_number"]).to eq("0123456789") + expect(extended_data["location"]).to eq("Cahors") end context "with phone number pattern blank" do @@ -143,27 +141,46 @@ def fill_extra_user_fields it_behaves_like "mandatory extra user fields", "date_of_birth" it_behaves_like "mandatory extra user fields", "gender" + it_behaves_like "mandatory extra user fields", "age_range" it_behaves_like "mandatory extra user fields", "country" it_behaves_like "mandatory extra user fields", "postal_code" it_behaves_like "mandatory extra user fields", "phone_number" it_behaves_like "mandatory extra user fields", "location" - # Block ExtraUserFields ItBehavesLikeSpec - # EndBlock + context "when select field is required" do + let(:select_fields) { { "participant_type" => { "enabled" => true, "required" => true } } } + + it "displays required indicator on select field" do + within "label[for='registration_user_select_fields_participant_type']" do + expect(page).to have_css("span.label-required") + end + end + end + + context "when text field is required" do + let(:text_fields) { { "motto" => { "enabled" => true, "required" => true } } } + + it "displays required indicator on text field" do + within "label[for='registration_user_text_fields_motto']" do + expect(page).to have_css("span.label-required") + end + end + end context "when extra_user_fields is disabled" do let(:organization) { create(:organization, :extra_user_fields_disabled) } it "does not contain extra user fields" do expect(page).to have_no_content("Date of birth") - expect(page).to have_no_content("Gender") + expect(page).to have_no_content("Which gender do you identify with?") + expect(page).to have_no_content("How old are you?") expect(page).to have_no_content("Country") expect(page).to have_no_content("Postal code") expect(page).to have_no_content("Phone Number") expect(page).to have_no_content("Location") - # Block ExtraUserFields DoesNotContainFieldSpec - - # EndBlock + expect(page).to have_no_content("Which gender do you identify with?") + expect(page).to have_no_content("Are you participating as an individual, or officially on behalf of an organization?") + expect(page).to have_no_content("I am a member of a non-governmental organization (NGO)") end it "allows to create a new account" do