diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index aa165d0b6..000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,52 +0,0 @@ -module.exports = { - root: true, - parser: '@typescript-eslint/parser', - globals: { - process: true, - gon: true - }, - plugins: ['prettier', 'react-hooks'], - extends: [ - 'eslint:recommended', - 'prettier', - 'plugin:react/recommended', - 'plugin:react-hooks/recommended' - ], - env: { - es6: true, - browser: true - }, - rules: { - 'prettier/prettier': 'error', - 'react-hooks/rules-of-hooks': 'error', - 'react-hooks/exhaustive-deps': 'error', - 'react/prop-types': 'off', - 'react/no-deprecated': 'off' - }, - settings: { - react: { version: 'detect' } - }, - overrides: [ - { - files: ['.eslintrc.js', 'vite.config.ts', 'postcss.config.js'], - env: { node: true } - }, - { - files: ['**/*.ts', '**/*.tsx'], - plugins: ['@typescript-eslint'], - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:react-hooks/recommended', - 'prettier' - ], - rules: { - 'prettier/prettier': 'error', - 'react-hooks/rules-of-hooks': 'error', - 'react-hooks/exhaustive-deps': 'error', - '@typescript-eslint/no-explicit-any': 'error', - '@typescript-eslint/no-unused-vars': 'error' - } - } - ] -}; diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..81c05ed14 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.lockb binary diff=lockb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index afcd4303a..b762a1b65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ on: jobs: linters: name: Linters - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 services: postgres: image: postgis/postgis:14-3.3 @@ -33,7 +33,7 @@ jobs: js_tests: name: JavaScript tests - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 @@ -51,9 +51,15 @@ jobs: run: | bun run test + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + unit_tests: name: Unit tests - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 + timeout-minutes: 20 env: RUBY_YJIT_ENABLE: "1" services: @@ -75,7 +81,8 @@ jobs: - name: Install build dependancies # - fonts pickable by ImageMagick # - rust for YJIT support - run: sudo apt-get update && sudo apt-get install -y gsfonts rustc redis-server + # - poppler-utils for pdf previews + run: sudo apt-get update && sudo apt-get install -y gsfonts rustc redis-server poppler-utils - name: Setup the app runtime and dependencies uses: ./.github/actions/ci-setup-rails @@ -100,9 +107,15 @@ jobs: name: rspec-results-${{ github.job }}-${{ strategy.job-index }} path: tmp/rspec_${{ github.job }}_${{ strategy.job-index }}.junit.xml + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + system_tests: name: System tests - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 + timeout-minutes: 20 env: RUBY_YJIT_ENABLE: "1" services: @@ -124,6 +137,9 @@ jobs: - name: Setup the app runtime and dependencies uses: ./.github/actions/ci-setup-rails + - name: Setup playwright + run: bunx playwright install chromium + - name: Pre-compile assets uses: ./.github/actions/ci-setup-assets @@ -144,10 +160,15 @@ jobs: name: rspec-results-${{ github.job }}-${{ strategy.job-index }} path: tmp/rspec_${{ github.job }}_${{ strategy.job-index }}.junit.xml + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + save_test_reports: name: Save test reports needs: [unit_tests, system_tests] - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 180beb90d..e22760720 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,11 @@ public/downloads doc/*.svg uploads/* .byebug_history +.DS_Store +*.swp +.envrc .env +.tool-versions storage/ /node_modules /yarn-error.log @@ -29,6 +33,7 @@ yarn-debug.log* /public/assets /spec/support/spec_config.local.rb /config/initializers/config.local.rb +/coverage # Local Netlify folder .netlify diff --git a/.prettierrc.js b/.prettierrc.js deleted file mode 100644 index f0722fd99..000000000 --- a/.prettierrc.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - singleQuote: true, - trailingComma: 'none' -}; diff --git a/.rubocop.yml b/.rubocop.yml index e4f06ef32..c806ff7c1 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -11,7 +11,7 @@ inherit_mode: - Include AllCops: - TargetRubyVersion: 3.2 + TargetRubyVersion: 3.3 DisabledByDefault: true SuggestExtensions: false NewCops: enable @@ -23,6 +23,8 @@ AllCops: - "bin/*" - "node_modules/**/*" - "vendor/**/*" + - "storage/**/*" + - "tmp/**/*" DS/AddConcurrentIndex: Enabled: true @@ -1175,7 +1177,7 @@ Style/FormatStringToken: EnforcedStyle: template Style/FrozenStringLiteralComment: - Enabled: false + Enabled: true Style/GlobalVars: Enabled: true diff --git a/.ruby-version b/.ruby-version index 15a279981..bea438e9a 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.0 +3.3.1 diff --git a/.scss-lint.yml b/.scss-lint.yml deleted file mode 100644 index 2c32e2bef..000000000 --- a/.scss-lint.yml +++ /dev/null @@ -1,264 +0,0 @@ -exclude: - - 'app/assets/stylesheets/reset.scss' - - 'app/assets/stylesheets/direct_uploads.scss' - - 'app/assets/stylesheets/dsfr_override.scss' - - 'app/assets/stylesheets/manager.scss' - -linters: - BangFormat: - enabled: true - space_before_bang: true - space_after_bang: false - - BemDepth: - enabled: false - max_elements: 1 - - BorderZero: - enabled: true - convention: none - - # To enable later - ChainedClasses: - enabled: false - - ColorKeyword: - enabled: true - - # To enable later - ColorVariable: - enabled: false - - Comment: - enabled: true - style: silent - - DebugStatement: - enabled: true - - DeclarationOrder: - enabled: true - - DisableLinterReason: - enabled: false - - DuplicateProperty: - enabled: true - - ElsePlacement: - enabled: true - style: same_line - - EmptyLineBetweenBlocks: - enabled: true - ignore_single_line_blocks: false - - EmptyRule: - enabled: true - - ExtendDirective: - enabled: false - - FinalNewline: - enabled: true - present: true - - HexLength: - enabled: true - style: long - - HexNotation: - enabled: true - style: uppercase - - HexValidation: - enabled: true - - # To enable later - IdSelector: - enabled: false - - # To enable later - ImportantRule: - enabled: false - - ImportPath: - enabled: false - leading_underscore: false - filename_extension: false - - Indentation: - enabled: true - allow_non_nested_indentation: false - character: space - width: 2 - - LeadingZero: - enabled: true - style: include_zero - - MergeableSelector: - enabled: false - force_nesting: true - - NameFormat: - enabled: true - allow_leading_underscore: false - convention: hyphenated_lowercase - - # To enable later - NestingDepth: - enabled: false - max_depth: 3 - ignore_parent_selectors: false - - # To enable later - PlaceholderInExtend: - enabled: false - - PrivateNamingConvention: - enabled: false - prefix: _ - - PropertyCount: - enabled: false - include_nested: false - max_properties: 10 - - PropertySortOrder: - enabled: false - ignore_unspecified: false - min_properties: 2 - separate_groups: false - - PropertySpelling: - enabled: true - extra_properties: - - scroll-padding - disabled_properties: [] - - # To enable later - PropertyUnits: - enabled: false - global: [ - 'ch', 'em', 'ex', 'rem', # Font-relative lengths - 'cm', 'in', 'mm', 'pc', 'pt', 'px', 'q', # Absolute lengths - 'vh', 'vw', 'vmin', 'vmax', # Viewport-percentage lengths - 'deg', 'grad', 'rad', 'turn', # Angle - 'ms', 's', # Duration - 'Hz', 'kHz', # Frequency - 'dpi', 'dpcm', 'dppx', # Resolution - '%'] # Other - properties: {} - - PseudoElement: - enabled: false # otherwise rules on ::marker fails - - # To enable later - QualifyingElement: - enabled: false - allow_element_with_attribute: false - allow_element_with_class: false - allow_element_with_id: false - - # To enable later - SelectorDepth: - enabled: false - max_depth: 3 - - SelectorFormat: - enabled: true - # hyphenated_lowercase + any dsfr selector which are not hyphenated - convention: ^(?:fr-[^A-Z]+|[^_A-Z]+)$ - - Shorthand: - enabled: false - allowed_shorthands: [1, 2, 3, 4] - - SingleLinePerProperty: - enabled: true - allow_single_line_rule_sets: false - - SingleLinePerSelector: - enabled: true - - SpaceAfterComma: - enabled: true - style: one_space - - SpaceAfterComment: - enabled: true - style: one_space - allow_empty_comments: true - - SpaceAfterPropertyColon: - enabled: true - style: one_space - - SpaceAfterPropertyName: - enabled: true - - SpaceAfterVariableColon: - enabled: true - style: one_space - - SpaceAfterVariableName: - enabled: true - - SpaceAroundOperator: - enabled: true - style: one_space - - SpaceBeforeBrace: - enabled: true - style: space - allow_single_line_padding: false - - SpaceBetweenParens: - enabled: true - spaces: 0 - - StringQuotes: - enabled: true - style: double_quotes - - TrailingSemicolon: - enabled: true - - TrailingWhitespace: - enabled: true - - TrailingZero: - enabled: true - - # To enable later - TransitionAll: - enabled: false - - UnnecessaryMantissa: - enabled: true - - UnnecessaryParentReference: - enabled: true - - UrlFormat: - enabled: true - - UrlQuotes: - enabled: true - - VariableForProperty: - enabled: false - properties: [] - - VendorPrefix: - enabled: true - identifier_list: base - additional_identifiers: [] - excluded_identifiers: [] - - ZeroUnit: - enabled: false - - Compass::*: - enabled: false diff --git a/.simplecov b/.simplecov new file mode 100644 index 000000000..e6c81df21 --- /dev/null +++ b/.simplecov @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +SimpleCov.start "rails" do + enable_coverage :branch + + command_name "RSpec process #{Process.pid}" + + if ENV["CI"] # codecov compatibility + require 'simplecov-cobertura' + formatter SimpleCov::Formatter::CoberturaFormatter + else + formatter SimpleCov::Formatter::MultiFormatter.new([ + SimpleCov::Formatter::SimpleFormatter, + SimpleCov::Formatter::HTMLFormatter + ]) + end + + add_filter "/channels/" # not used + groups.delete("Channels") + + add_filter "/lib/tasks/deployment/" + + add_group "Components", "app/components" + add_group "API", ["app/graphql", "app/serializers"] + add_group "Manager", ["app/dashboards", "app/fields", "app/controllers/manager"] + add_group "Models", ["app/models", "app/validators"] + add_group "Policies", "app/policies" + add_group "Services", "app/services" + add_group "Tasks", ["app/tasks", "lib/tasks"] +end diff --git a/Gemfile b/Gemfile index 03173c548..3e4bbe79f 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,5 @@ +# frozen_string_literal: true + source 'https://rubygems.org' gem 'rails', '~> 7.0.8' # allows update to security fixes at any time @@ -17,6 +19,7 @@ gem 'anchored' gem 'bcrypt' gem 'bootsnap', '>= 1.4.4', require: false # Reduces boot times through caching; required in config/boot.rb gem 'browser' +gem 'capybara-playwright-driver' gem 'charlock_holmes' gem 'chartkick' gem 'chunky_png' @@ -26,7 +29,7 @@ gem 'deep_cloneable' # Enable deep clone of active record models gem 'delayed_cron_job', require: false # Cron jobs gem 'delayed_job_active_record' gem 'delayed_job_web' -gem 'devise', git: 'https://github.com/heartcombo/devise.git', ref: "edffc79bf05d7f1c58ba50ffeda645e2e4ae0cb1" # Gestion des comptes utilisateurs, drop ref on next release: 4.9.4 +gem 'devise' gem 'devise-i18n' gem 'devise-two-factor' gem 'discard' @@ -37,6 +40,7 @@ gem 'flipper' gem 'flipper-active_record' gem 'flipper-active_support_cache_store' gem 'flipper-ui' +gem 'front_matter_parser' gem 'fugit' gem 'geocoder' gem 'geo_coord', require: "geo/coord" @@ -94,6 +98,7 @@ gem 'sidekiq' gem 'sidekiq-cron' gem 'skylight' gem 'spreadsheet_architect' +gem 'string-similarity' gem 'strong_migrations' # lint database migrations gem 'sys-proctable' gem 'turbo-rails' @@ -106,7 +111,7 @@ gem 'webrick', require: false gem 'yabeda-prometheus' gem 'yabeda-sidekiq' gem 'zipline' -gem 'zxcvbn-ruby', require: 'zxcvbn' +gem 'zxcvbn' group :test do gem 'axe-core-rspec' # accessibility rspec matchers @@ -122,6 +127,9 @@ group :test do gem 'selenium-devtools' gem 'selenium-webdriver' gem 'shoulda-matchers', require: false + gem 'simplecov', require: false + gem 'simplecov-cobertura', require: false + gem "test-prof" gem 'timecop' gem 'vcr' gem 'webmock' @@ -139,7 +147,6 @@ group :development do gem 'rubocop-performance', require: false gem 'rubocop-rails', require: false gem 'rubocop-rspec', require: false - gem 'scss_lint', require: false gem 'stackprof' gem 'web-console' end diff --git a/Gemfile.lock b/Gemfile.lock index 9894c3dc0..fa36c264e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -6,65 +6,53 @@ GIT json (>= 2.5) sidekiq (~> 7.0) -GIT - remote: https://github.com/heartcombo/devise.git - revision: edffc79bf05d7f1c58ba50ffeda645e2e4ae0cb1 - ref: edffc79bf05d7f1c58ba50ffeda645e2e4ae0cb1 - specs: - devise (4.9.3) - bcrypt (~> 3.0) - orm_adapter (~> 0.1) - railties (>= 4.1.0) - responders - warden (~> 1.2.3) - GEM remote: https://rubygems.org/ specs: aasm (5.5.0) concurrent-ruby (~> 1.0) acsv (0.0.1) - actioncable (7.0.8.1) - actionpack (= 7.0.8.1) - activesupport (= 7.0.8.1) + actioncable (7.0.8.5) + actionpack (= 7.0.8.5) + activesupport (= 7.0.8.5) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.8.1) - actionpack (= 7.0.8.1) - activejob (= 7.0.8.1) - activerecord (= 7.0.8.1) - activestorage (= 7.0.8.1) - activesupport (= 7.0.8.1) + actionmailbox (7.0.8.5) + actionpack (= 7.0.8.5) + activejob (= 7.0.8.5) + activerecord (= 7.0.8.5) + activestorage (= 7.0.8.5) + activesupport (= 7.0.8.5) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.0.8.1) - actionpack (= 7.0.8.1) - actionview (= 7.0.8.1) - activejob (= 7.0.8.1) - activesupport (= 7.0.8.1) + actionmailer (7.0.8.5) + actionpack (= 7.0.8.5) + actionview (= 7.0.8.5) + activejob (= 7.0.8.5) + activesupport (= 7.0.8.5) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.0) - actionpack (7.0.8.1) - actionview (= 7.0.8.1) - activesupport (= 7.0.8.1) + actionpack (7.0.8.5) + actionview (= 7.0.8.5) + activesupport (= 7.0.8.5) rack (~> 2.0, >= 2.2.4) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (7.0.8.1) - actionpack (= 7.0.8.1) - activerecord (= 7.0.8.1) - activestorage (= 7.0.8.1) - activesupport (= 7.0.8.1) + actiontext (7.0.8.5) + actionpack (= 7.0.8.5) + activerecord (= 7.0.8.5) + activestorage (= 7.0.8.5) + activesupport (= 7.0.8.5) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.0.8.1) - activesupport (= 7.0.8.1) + actionview (7.0.8.5) + activesupport (= 7.0.8.5) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) @@ -79,26 +67,26 @@ GEM activemodel (>= 5.2.0) activestorage (>= 5.2.0) activesupport (>= 5.2.0) - activejob (7.0.8.1) - activesupport (= 7.0.8.1) + activejob (7.0.8.5) + activesupport (= 7.0.8.5) globalid (>= 0.3.6) - activemodel (7.0.8.1) - activesupport (= 7.0.8.1) - activerecord (7.0.8.1) - activemodel (= 7.0.8.1) - activesupport (= 7.0.8.1) - activestorage (7.0.8.1) - actionpack (= 7.0.8.1) - activejob (= 7.0.8.1) - activerecord (= 7.0.8.1) - activesupport (= 7.0.8.1) + activemodel (7.0.8.5) + activesupport (= 7.0.8.5) + activerecord (7.0.8.5) + activemodel (= 7.0.8.5) + activesupport (= 7.0.8.5) + activestorage (7.0.8.5) + actionpack (= 7.0.8.5) + activejob (= 7.0.8.5) + activerecord (= 7.0.8.5) + activesupport (= 7.0.8.5) marcel (~> 1.0) mini_mime (>= 1.1.0) activestorage-openstack (1.6.0) fog-openstack (>= 1.0.9) marcel rails (>= 5.2.2) - activesupport (7.0.8.1) + activesupport (7.0.8.5) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -148,7 +136,7 @@ GEM erubi (~> 1.4) parser (>= 2.4) smart_properties - bigdecimal (3.1.6) + bigdecimal (3.1.7) bindata (2.5.0) bindex (0.8.1) bootsnap (1.18.3) @@ -156,7 +144,7 @@ GEM brakeman (6.1.2) racc browser (5.3.1) - builder (3.2.4) + builder (3.3.0) capybara (3.40.0) addressable matrix @@ -169,6 +157,10 @@ GEM capybara-email (3.0.2) capybara (>= 2.4, < 4.0) mail + capybara-playwright-driver (0.5.2) + addressable + capybara + playwright-ruby-client (>= 1.16.0) capybara-screenshot (1.0.26) capybara (>= 1.0, < 4) launchy @@ -179,14 +171,14 @@ GEM marcel (~> 1.0) nokogiri (~> 1.10, >= 1.10.4) rubyzip (>= 1.3.0, < 3) - charlock_holmes (0.7.7) - chartkick (5.0.5) + charlock_holmes (0.7.9) + chartkick (5.0.6) choice (0.2.0) chunky_png (1.4.0) clamav-client (3.2.0) coercible (1.0.0) descendants_tracker (~> 0.0.1) - concurrent-ruby (1.2.3) + concurrent-ruby (1.3.4) connection_pool (2.4.1) content_disposition (1.0.0) crack (1.0.0) @@ -213,9 +205,15 @@ GEM sinatra (>= 1.4.4) descendants_tracker (0.0.4) thread_safe (~> 0.3, >= 0.3.1) + devise (4.9.4) + bcrypt (~> 3.0) + orm_adapter (~> 0.1) + railties (>= 4.1.0) + responders + warden (~> 1.2.3) devise-i18n (1.12.0) devise (>= 4.9.0) - devise-two-factor (5.0.0) + devise-two-factor (6.0.0) activesupport (~> 7.0) devise (~> 4.0) railties (~> 7.0) @@ -223,6 +221,7 @@ GEM diff-lcs (1.5.1) discard (1.3.0) activerecord (>= 4.2, < 8) + docile (1.4.0) dotenv (2.8.1) dotenv-rails (2.8.1) dotenv (= 2.8.1) @@ -239,8 +238,8 @@ GEM dumb_delegator (1.0.0) email_validator (2.2.4) activemodel - erubi (1.12.0) - et-orbi (1.2.7) + erubi (1.13.0) + et-orbi (1.2.11) tzinfo ethon (0.16.0) ffi (>= 1.15.0) @@ -257,19 +256,20 @@ GEM faraday-net_http (3.1.0) net-http ffi (1.16.3) - flipper (1.2.2) + flipper (1.3.0) concurrent-ruby (< 2) - flipper-active_record (1.2.2) + flipper-active_record (1.3.0) activerecord (>= 4.2, < 8) - flipper (~> 1.2.2) - flipper-active_support_cache_store (1.2.2) + flipper (~> 1.3.0) + flipper-active_support_cache_store (1.3.0) activesupport (>= 4.2, < 8) - flipper (~> 1.2.2) - flipper-ui (1.2.2) + flipper (~> 1.3.0) + flipper-ui (1.3.0) erubi (>= 1.0.0, < 2.0.0) - flipper (~> 1.2.2) + flipper (~> 1.3.0) rack (>= 1.4, < 4) - rack-protection (>= 1.5.3, <= 4.0.0) + rack-protection (>= 1.5.3, < 5.0.0) + rack-session (>= 1.0.2, < 3.0.0) sanitize (< 7) fog-core (2.4.0) builder @@ -283,8 +283,9 @@ GEM fog-core (~> 2.1) fog-json (>= 1.0) formatador (1.1.0) - fugit (1.9.0) - et-orbi (~> 1, >= 1.2.7) + front_matter_parser (1.0.1) + fugit (1.11.1) + et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) geo_coord (0.2.0) geocoder (1.8.2) @@ -333,7 +334,7 @@ GEM highline (3.0.1) htmlentities (4.3.4) http_accept_language (2.1.1) - i18n (1.14.4) + i18n (1.14.6) concurrent-ruby (~> 1.0) i18n-tasks (1.0.13) activesupport (>= 4.0.2) @@ -355,7 +356,7 @@ GEM invisible_captcha (2.2.0) rails (>= 5.2) io-console (0.7.2) - irb (1.11.2) + irb (1.12.0) rdoc reline (>= 0.4.2) job-iteration (1.4.1) @@ -364,7 +365,7 @@ GEM rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.7.1) + json (2.7.2) json-jwt (1.16.6) activesupport (>= 4.2) aes_key_wrap @@ -372,12 +373,15 @@ GEM bindata faraday (~> 2.0) faraday-follow_redirects - json_schemer (2.1.1) + json_schemer (2.2.1) + base64 + bigdecimal hana (~> 1.3) regexp_parser (~> 2.0) simpleidn (~> 0.2) jsonapi-renderer (0.2.2) - jwt (2.7.1) + jwt (2.8.1) + base64 kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -404,9 +408,10 @@ GEM letter_opener (~> 1.7) railties (>= 5.2) rexml - listen (3.8.0) + listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) + logger (1.6.0) lograge (0.14.0) actionpack (>= 4) activesupport (>= 4) @@ -421,17 +426,17 @@ GEM net-imap net-pop net-smtp - maintenance_tasks (2.6.0) + maintenance_tasks (2.7.0) actionpack (>= 6.0) activejob (>= 6.0) activerecord (>= 6.0) job-iteration (>= 1.3.6) railties (>= 6.0) zeitwerk (>= 2.6.2) - marcel (1.0.2) + marcel (1.0.4) matrix (0.4.2) memory_profiler (1.0.1) - method_source (1.0.0) + method_source (1.1.0) mime-types (3.5.2) mime-types-data (~> 3.2015) mime-types-data (3.2024.0206) @@ -439,25 +444,25 @@ GEM rake mini_magick (4.12.0) mini_mime (1.1.5) - mini_portile2 (2.8.5) - minitest (5.22.2) + mini_portile2 (2.8.7) + minitest (5.25.1) msgpack (1.7.2) multi_json (1.15.0) mustermann (3.0.0) ruby2_keywords (~> 0.0.1) net-http (0.4.1) uri - net-imap (0.4.10) + net-imap (0.4.17) date net-protocol net-pop (0.1.2) net-protocol net-protocol (0.2.2) timeout - net-smtp (0.4.0.1) + net-smtp (0.5.0) net-protocol - nio4r (2.7.0) - nokogiri (1.16.2) + nio4r (2.7.3) + nokogiri (1.16.7) mini_portile2 (~> 2.8.2) racc (~> 1.4) openid_connect (2.3.0) @@ -480,8 +485,11 @@ GEM ast (~> 2.4.1) racc pdf-core (0.9.0) - pg (1.5.4) - phonelib (0.8.7) + pg (1.5.6) + phonelib (0.8.8) + playwright-ruby-client (1.46.0) + concurrent-ruby (>= 1.1.6) + mime-types (>= 3.0) prawn (2.4.0) pdf-core (~> 0.9.0) ttfunk (~> 1.7) @@ -503,14 +511,14 @@ GEM promise.rb (0.7.4) psych (5.1.2) stringio - public_suffix (5.0.4) - puma (6.4.2) + public_suffix (5.0.5) + puma (6.4.3) nio4r (~> 2.0) pundit (2.3.1) activesupport (>= 3.0.0) raabro (1.4.0) - racc (1.7.3) - rack (2.2.8.1) + racc (1.8.1) + rack (2.2.10) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-mini-profiler (3.3.1) @@ -527,25 +535,27 @@ GEM rack (~> 2.2, >= 2.2.4) rack-proxy (0.7.7) rack + rack-session (1.0.2) + rack (< 3) rack-test (2.1.0) rack (>= 1.3) rack_session_access (0.2.0) builder (>= 2.0.0) rack (>= 1.0.0) - rails (7.0.8.1) - actioncable (= 7.0.8.1) - actionmailbox (= 7.0.8.1) - actionmailer (= 7.0.8.1) - actionpack (= 7.0.8.1) - actiontext (= 7.0.8.1) - actionview (= 7.0.8.1) - activejob (= 7.0.8.1) - activemodel (= 7.0.8.1) - activerecord (= 7.0.8.1) - activestorage (= 7.0.8.1) - activesupport (= 7.0.8.1) + rails (7.0.8.5) + actioncable (= 7.0.8.5) + actionmailbox (= 7.0.8.5) + actionmailer (= 7.0.8.5) + actionpack (= 7.0.8.5) + actiontext (= 7.0.8.5) + actionview (= 7.0.8.5) + activejob (= 7.0.8.5) + activemodel (= 7.0.8.5) + activerecord (= 7.0.8.5) + activestorage (= 7.0.8.5) + activesupport (= 7.0.8.5) bundler (>= 1.15.0) - railties (= 7.0.8.1) + railties (= 7.0.8.5) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -562,21 +572,21 @@ GEM rails-html-sanitizer (1.6.0) loofah (~> 2.21) nokogiri (~> 1.14) - rails-i18n (7.0.8) + rails-i18n (7.0.9) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) rails-pg-extras (5.3.1) rails ruby-pg-extras (= 5.3.1) - railties (7.0.8.1) - actionpack (= 7.0.8.1) - activesupport (= 7.0.8.1) + railties (7.0.8.5) + actionpack (= 7.0.8.5) + activesupport (= 7.0.8.5) method_source rake (>= 12.2) thor (~> 1.0) zeitwerk (~> 2.5) rainbow (3.1.1) - rake (13.1.0) + rake (13.2.1) rake-progressbar (0.0.5) rb-fsevent (0.11.2) rb-inotify (0.10.1) @@ -584,19 +594,20 @@ GEM rdoc (6.6.3.1) psych (>= 4.0.0) redcarpet (3.6.0) - redis (5.1.0) - redis-client (>= 0.17.0) - redis-client (0.20.0) + redis (5.2.0) + redis-client (>= 0.22.0) + redis-client (0.22.1) connection_pool regexp_parser (2.9.0) - reline (0.4.2) + reline (0.5.3) io-console (~> 0.5) request_store (1.5.1) rack (>= 1.4) responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.2.6) + rexml (3.3.6) + strscan rodf (1.2.0) builder (>= 3.0) rubyzip (>= 1.0) @@ -614,20 +625,20 @@ GEM rspec-mocks (3.13.0) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (6.1.1) + rspec-rails (6.1.2) actionpack (>= 6.1) activesupport (>= 6.1) railties (>= 6.1) - rspec-core (~> 3.12) - rspec-expectations (~> 3.12) - rspec-mocks (~> 3.12) - rspec-support (~> 3.12) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) rspec-retry (0.6.2) rspec-core (> 3.3) - rspec-support (3.13.0) + rspec-support (3.13.1) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.60.2) + rubocop (1.63.3) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) @@ -635,27 +646,30 @@ GEM rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.30.0, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.30.0) - parser (>= 3.2.1.0) + rubocop-ast (1.31.2) + parser (>= 3.3.0.4) rubocop-capybara (2.20.0) rubocop (~> 1.41) rubocop-factory_bot (2.25.1) rubocop (~> 1.41) - rubocop-performance (1.20.2) + rubocop-performance (1.21.0) rubocop (>= 1.48.1, < 2.0) - rubocop-ast (>= 1.30.0, < 2.0) - rubocop-rails (2.23.1) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rails (2.24.1) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) - rubocop-ast (>= 1.30.0, < 2.0) - rubocop-rspec (2.26.1) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rspec (2.29.1) 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) ruby-graphviz (1.2.5) rexml ruby-next-core (1.0.2) @@ -673,14 +687,9 @@ GEM nokogiri (>= 1.6.2) rexml xmlenc (>= 0.7.1) - sanitize (6.1.0) + sanitize (6.1.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) - 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) sassc (2.4.0) ffi (~> 1.9) sassc-rails (2.1.2) @@ -689,34 +698,34 @@ GEM sprockets (> 3.0) sprockets-rails tilt - scss_lint (0.60.0) - sass (~> 3.5, >= 3.5.5) selectize-rails (0.12.6) - selenium-devtools (0.121.0) + selenium-devtools (0.126.0) selenium-webdriver (~> 4.2) - selenium-webdriver (4.17.0) + selenium-webdriver (4.22.0) base64 (~> 0.2) + logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) - sentry-delayed_job (5.16.1) + sentry-delayed_job (5.17.3) delayed_job (>= 4.0) - sentry-ruby (~> 5.16.1) - sentry-rails (5.16.1) + sentry-ruby (~> 5.17.3) + sentry-rails (5.17.3) railties (>= 5.0) - sentry-ruby (~> 5.16.1) - sentry-ruby (5.16.1) + sentry-ruby (~> 5.17.3) + sentry-ruby (5.17.3) + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) - sentry-sidekiq (5.16.1) - sentry-ruby (~> 5.16.1) + sentry-sidekiq (5.17.3) + sentry-ruby (~> 5.17.3) sidekiq (>= 3.0) - shoulda-matchers (6.1.0) + shoulda-matchers (6.2.0) activesupport (>= 5.2.0) sib-api-v3-sdk (9.1.0) addressable (~> 2.3, >= 2.3.0) json (~> 2.1, >= 2.1.0) typhoeus (~> 1.0, >= 1.0.1) - sidekiq (7.2.1) + sidekiq (7.2.4) concurrent-ruby (< 2) connection_pool (>= 2.3.0) rack (>= 2.2.4) @@ -728,6 +737,15 @@ GEM simple_xlsx_reader (1.0.4) nokogiri rubyzip + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-cobertura (2.1.0) + rexml + simplecov (~> 0.19) + simplecov-html (0.12.3) + simplecov_json_formatter (0.1.4) simpleidn (0.2.1) unf (~> 0.1.4) sinatra (3.2.0) @@ -735,13 +753,13 @@ GEM rack (~> 2.2, >= 2.2.4) rack-protection (= 3.2.0) tilt (~> 2.0) - skylight (6.0.3) + skylight (6.0.4) activesupport (>= 5.2.0) smart_properties (1.17.0) spreadsheet_architect (5.0.0) caxlsx (>= 3.3.0, < 4) rodf (>= 1.0.0, < 2) - spring (4.1.3) + spring (4.2.1) spring-commands-rspec (1.0.4) spring (>= 0.9.1) sprockets (4.2.1) @@ -752,9 +770,11 @@ GEM activesupport (>= 5.2) sprockets (>= 3.0.0) stackprof (0.2.26) + string-similarity (2.1.0) stringio (3.1.0) - strong_migrations (1.7.0) + strong_migrations (1.8.0) activerecord (>= 5.2) + strscan (3.1.0) swd (2.0.3) activesupport (>= 3) attr_required (>= 0.0.5) @@ -766,13 +786,14 @@ GEM temple (0.8.2) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) - thor (1.3.0) + test-prof (1.3.3) + thor (1.3.2) thread_safe (0.3.6) tilt (2.3.0) timecop (0.9.8) timeout (0.4.1) ttfunk (1.7.0) - turbo-rails (2.0.2) + turbo-rails (2.0.5) actionpack (>= 6.0.0) activejob (>= 6.0.0) railties (>= 6.0.0) @@ -790,7 +811,7 @@ GEM activemodel (>= 3.0.0) public_suffix vcr (6.2.0) - view_component (3.10.0) + view_component (3.12.1) activesupport (>= 5.2.0, < 8.0) concurrent-ruby (~> 1.0) method_source (~> 1.0) @@ -816,11 +837,11 @@ GEM activesupport faraday (~> 2.0) faraday-follow_redirects - webmock (3.20.0) + webmock (3.23.0) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webrick (1.8.1) + webrick (1.8.2) websocket (1.2.10) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) @@ -846,13 +867,13 @@ GEM anyway_config (>= 1.3, < 3) sidekiq yabeda (~> 0.6) - zeitwerk (2.6.13) + zeitwerk (2.7.0) zip_tricks (5.6.0) zipline (1.5.0) actionpack (>= 6.0, < 8.0) content_disposition (~> 1.0) zip_tricks (>= 4.2.1, < 6.0) - zxcvbn-ruby (1.2.0) + zxcvbn (0.1.11) PLATFORMS ruby @@ -878,6 +899,7 @@ DEPENDENCIES browser capybara capybara-email + capybara-playwright-driver capybara-screenshot charlock_holmes chartkick @@ -888,7 +910,7 @@ DEPENDENCIES delayed_cron_job delayed_job_active_record delayed_job_web - devise! + devise devise-i18n devise-two-factor discard @@ -900,6 +922,7 @@ DEPENDENCIES flipper-active_record flipper-active_support_cache_store flipper-ui + front_matter_parser fugit geo_coord geocoder @@ -967,7 +990,6 @@ DEPENDENCIES rubocop-rspec saml_idp sassc-rails - scss_lint selenium-devtools selenium-webdriver sentry-delayed_job @@ -979,13 +1001,17 @@ DEPENDENCIES sidekiq sidekiq-cron simple_xlsx_reader + simplecov + simplecov-cobertura skylight spreadsheet_architect spring spring-commands-rspec stackprof + string-similarity strong_migrations sys-proctable + test-prof timecop turbo-rails typhoeus @@ -1000,7 +1026,7 @@ DEPENDENCIES yabeda-prometheus yabeda-sidekiq zipline - zxcvbn-ruby + zxcvbn BUNDLED WITH - 2.5.4 + 2.5.9 diff --git a/Guardfile b/Guardfile index 30cfad9f1..e7b21a921 100644 --- a/Guardfile +++ b/Guardfile @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # A sample Guardfile # More info at https://github.com/guard/guard#readme diff --git a/Procfile.dev b/Procfile.dev index 953087cec..1459bc6df 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,3 +1,3 @@ -web: RAILS_QUEUE_ADAPTER=delayed_job bin/rails server -p 3000 +web: bin/rails server -p 3000 jobs: bin/rake jobs:work vite: bin/vite dev diff --git a/README.md b/README.md index 9fedb71e5..0261e2109 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ Pour faire tourner sidekiq, vous aurez besoin de : - redis +- lightgallery : une license a été souscrite pour soutenir le projet, mais elle n'est pas obligatoire si la librairie est utilisée dans le cadre d'une application open source. + #### Développement - rbenv : voir https://github.com/rbenv/rbenv-installer#rbenv-installer--doctor-scripts @@ -62,7 +64,7 @@ Selenium::WebDriver::Chrome.path = "/Applications/Brave Browser.app/Contents/Mac Webdrivers::Chromedriver.required_version = "103.0.5060.53" ``` -Il est également possible de faire une installation et mise à jour automatique lors de l'exécution de `bin/update` en définissant la variable d'environnement `UPDATE_WEBDRIVER`. Les binaires seront installés dans le repertoire `~/.local/bin/` qui doit être rajouté manuellement dans le path. +Il est également possible de faire une installation et mise à jour automatique lors de l'exécution de `bin/update` en définissant la variable d'environnement `UPDATE_WEBDRIVER`. Les binaires seront installés dans le repertoire `~/.local/bin/` qui doit être rajouté manuellement dans le path. ### Création des rôles de la base de données @@ -142,7 +144,7 @@ Pour exécuter les tests de l'application, plusieurs possibilités : - Afficher les logs js en error issus de la console du navigateur `console.error('coucou')` - JS_LOG=error bin/rspec spec/system + JS_LOG=debug,log,error bin/rspec spec/system - Augmenter la latence lors de tests end2end pour déceler des bugs récalcitrants @@ -192,3 +194,5 @@ La compatibilité est testée par Browserstack.
[ \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/centered_marianne.svg.old b/app/assets/images/centered_marianne.svg.old new file mode 100644 index 000000000..5e812262d --- /dev/null +++ b/app/assets/images/centered_marianne.svg.old @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/dgnum.svg b/app/assets/images/dgnum.svg new file mode 100644 index 000000000..d55a032c2 --- /dev/null +++ b/app/assets/images/dgnum.svg @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/faq/administrateur-add-administrateur.png b/app/assets/images/faq/administrateur-add-administrateur.png new file mode 100644 index 000000000..bb8e5d30d Binary files /dev/null and b/app/assets/images/faq/administrateur-add-administrateur.png differ diff --git a/app/assets/images/faq/administrateur-all-procedures.png b/app/assets/images/faq/administrateur-all-procedures.png new file mode 100644 index 000000000..db6ef0bc8 Binary files /dev/null and b/app/assets/images/faq/administrateur-all-procedures.png differ diff --git a/app/assets/images/faq/administrateur-button-copy-procedure.png b/app/assets/images/faq/administrateur-button-copy-procedure.png new file mode 100644 index 000000000..2d2fbcdb3 Binary files /dev/null and b/app/assets/images/faq/administrateur-button-copy-procedure.png differ diff --git a/app/assets/images/faq/administrateur-create-declarative.png b/app/assets/images/faq/administrateur-create-declarative.png new file mode 100644 index 000000000..22d01085f Binary files /dev/null and b/app/assets/images/faq/administrateur-create-declarative.png differ diff --git a/app/assets/images/faq/administrateur-example-markup-preview.png b/app/assets/images/faq/administrateur-example-markup-preview.png new file mode 100644 index 000000000..6b2613757 Binary files /dev/null and b/app/assets/images/faq/administrateur-example-markup-preview.png differ diff --git a/app/assets/images/faq/administrateur-example-markup.png b/app/assets/images/faq/administrateur-example-markup.png new file mode 100644 index 000000000..7890881a7 Binary files /dev/null and b/app/assets/images/faq/administrateur-example-markup.png differ diff --git a/app/assets/images/faq/administrateur-link-all-procedures.png b/app/assets/images/faq/administrateur-link-all-procedures.png new file mode 100644 index 000000000..350d6c811 Binary files /dev/null and b/app/assets/images/faq/administrateur-link-all-procedures.png differ diff --git a/app/assets/images/faq/administrateur-list-champs-repetition.png b/app/assets/images/faq/administrateur-list-champs-repetition.png new file mode 100644 index 000000000..2f8c88bea Binary files /dev/null and b/app/assets/images/faq/administrateur-list-champs-repetition.png differ diff --git a/app/assets/images/faq/administrateur-procedure-action-close.png b/app/assets/images/faq/administrateur-procedure-action-close.png new file mode 100644 index 000000000..e9aa0c1f7 Binary files /dev/null and b/app/assets/images/faq/administrateur-procedure-action-close.png differ diff --git a/app/assets/images/faq/administrateur-procedure-auto-archive.png b/app/assets/images/faq/administrateur-procedure-auto-archive.png new file mode 100644 index 000000000..b67453d38 Binary files /dev/null and b/app/assets/images/faq/administrateur-procedure-auto-archive.png differ diff --git a/app/assets/images/faq/administrateur-procedure-close-message.png b/app/assets/images/faq/administrateur-procedure-close-message.png new file mode 100644 index 000000000..ee6b2b592 Binary files /dev/null and b/app/assets/images/faq/administrateur-procedure-close-message.png differ diff --git a/app/assets/images/faq/administrateur-procedure-close-replace.png b/app/assets/images/faq/administrateur-procedure-close-replace.png new file mode 100644 index 000000000..1ef5f17e5 Binary files /dev/null and b/app/assets/images/faq/administrateur-procedure-close-replace.png differ diff --git a/app/assets/images/faq/administrateur-procedure-test-button.png b/app/assets/images/faq/administrateur-procedure-test-button.png new file mode 100644 index 000000000..593acb38b Binary files /dev/null and b/app/assets/images/faq/administrateur-procedure-test-button.png differ diff --git a/app/assets/images/faq/administrateur-procedure-test-commencer.png b/app/assets/images/faq/administrateur-procedure-test-commencer.png new file mode 100644 index 000000000..942523612 Binary files /dev/null and b/app/assets/images/faq/administrateur-procedure-test-commencer.png differ diff --git a/app/assets/images/faq/administrateur-procedure-test-link.png b/app/assets/images/faq/administrateur-procedure-test-link.png new file mode 100644 index 000000000..e505e7a45 Binary files /dev/null and b/app/assets/images/faq/administrateur-procedure-test-link.png differ diff --git a/app/assets/images/faq/administrateur-procedure-test-publish.png b/app/assets/images/faq/administrateur-procedure-test-publish.png new file mode 100644 index 000000000..3314fd047 Binary files /dev/null and b/app/assets/images/faq/administrateur-procedure-test-publish.png differ diff --git a/app/assets/images/faq/administrateur-procedure-test-thanks.png b/app/assets/images/faq/administrateur-procedure-test-thanks.png new file mode 100644 index 000000000..b241fff64 Binary files /dev/null and b/app/assets/images/faq/administrateur-procedure-test-thanks.png differ diff --git a/app/assets/images/faq/administrateur-procedure-test-usager.png b/app/assets/images/faq/administrateur-procedure-test-usager.png new file mode 100644 index 000000000..ae7d76fe7 Binary files /dev/null and b/app/assets/images/faq/administrateur-procedure-test-usager.png differ diff --git a/app/assets/images/faq/administrateur-procedures-list-header.png b/app/assets/images/faq/administrateur-procedures-list-header.png new file mode 100644 index 000000000..2feab43ac Binary files /dev/null and b/app/assets/images/faq/administrateur-procedures-list-header.png differ diff --git a/app/assets/images/faq/administrateur-procedures-list.png b/app/assets/images/faq/administrateur-procedures-list.png new file mode 100644 index 000000000..b3a58d9c8 Binary files /dev/null and b/app/assets/images/faq/administrateur-procedures-list.png differ diff --git a/app/assets/images/faq/administrateur-profile-switch.png b/app/assets/images/faq/administrateur-profile-switch.png new file mode 100644 index 000000000..148aed96d Binary files /dev/null and b/app/assets/images/faq/administrateur-profile-switch.png differ diff --git a/app/assets/images/faq/administrateur-repetition-create.png b/app/assets/images/faq/administrateur-repetition-create.png new file mode 100644 index 000000000..f86f5bc1e Binary files /dev/null and b/app/assets/images/faq/administrateur-repetition-create.png differ diff --git a/app/assets/images/faq/administrateur-repetition-view-usager-empty.png b/app/assets/images/faq/administrateur-repetition-view-usager-empty.png new file mode 100644 index 000000000..dcd32df08 Binary files /dev/null and b/app/assets/images/faq/administrateur-repetition-view-usager-empty.png differ diff --git a/app/assets/images/faq/administrateur-repetition-view-usager-fill.png b/app/assets/images/faq/administrateur-repetition-view-usager-fill.png new file mode 100644 index 000000000..bfb2c72ce Binary files /dev/null and b/app/assets/images/faq/administrateur-repetition-view-usager-fill.png differ diff --git a/app/assets/images/faq/administrateur-set-auto-close-date.png b/app/assets/images/faq/administrateur-set-auto-close-date.png new file mode 100644 index 000000000..37dfd32e1 Binary files /dev/null and b/app/assets/images/faq/administrateur-set-auto-close-date.png differ diff --git a/app/assets/images/faq/administrateur-test-instruction-dossiers-list.png b/app/assets/images/faq/administrateur-test-instruction-dossiers-list.png new file mode 100644 index 000000000..d53508f60 Binary files /dev/null and b/app/assets/images/faq/administrateur-test-instruction-dossiers-list.png differ diff --git a/app/assets/images/faq/instructeur-accepter-add-justificatif.png b/app/assets/images/faq/instructeur-accepter-add-justificatif.png new file mode 100644 index 000000000..aa46c42a5 Binary files /dev/null and b/app/assets/images/faq/instructeur-accepter-add-justificatif.png differ diff --git a/app/assets/images/faq/instructeur-dossiers-list-header.png b/app/assets/images/faq/instructeur-dossiers-list-header.png new file mode 100644 index 000000000..a4e225099 Binary files /dev/null and b/app/assets/images/faq/instructeur-dossiers-list-header.png differ diff --git a/app/assets/images/faq/instructeur-filtres-and.png b/app/assets/images/faq/instructeur-filtres-and.png new file mode 100644 index 000000000..b602aef38 Binary files /dev/null and b/app/assets/images/faq/instructeur-filtres-and.png differ diff --git a/app/assets/images/faq/instructeur-filtres-date.png b/app/assets/images/faq/instructeur-filtres-date.png new file mode 100644 index 000000000..3d3106a5a Binary files /dev/null and b/app/assets/images/faq/instructeur-filtres-date.png differ diff --git a/app/assets/images/faq/instructeur-filtres-dropdown.png b/app/assets/images/faq/instructeur-filtres-dropdown.png new file mode 100644 index 000000000..610fe2ee1 Binary files /dev/null and b/app/assets/images/faq/instructeur-filtres-dropdown.png differ diff --git a/app/assets/images/faq/instructeur-filtres-list.png b/app/assets/images/faq/instructeur-filtres-list.png new file mode 100644 index 000000000..878f3ddcc Binary files /dev/null and b/app/assets/images/faq/instructeur-filtres-list.png differ diff --git a/app/assets/images/faq/instructeur-filtres-or.png b/app/assets/images/faq/instructeur-filtres-or.png new file mode 100644 index 000000000..bca3a8876 Binary files /dev/null and b/app/assets/images/faq/instructeur-filtres-or.png differ diff --git a/app/assets/images/faq/instructeur-procedure-header.png b/app/assets/images/faq/instructeur-procedure-header.png new file mode 100644 index 000000000..74307bab9 Binary files /dev/null and b/app/assets/images/faq/instructeur-procedure-header.png differ diff --git a/app/assets/images/faq/instructeur-procedure-notifications.png b/app/assets/images/faq/instructeur-procedure-notifications.png new file mode 100644 index 000000000..ea56143c2 Binary files /dev/null and b/app/assets/images/faq/instructeur-procedure-notifications.png differ diff --git a/app/assets/images/faq/instructeur-procedure-show.png b/app/assets/images/faq/instructeur-procedure-show.png new file mode 100644 index 000000000..837cc39c7 Binary files /dev/null and b/app/assets/images/faq/instructeur-procedure-show.png differ diff --git a/app/assets/images/faq/sign-in-page.png b/app/assets/images/faq/sign-in-page.png new file mode 100644 index 000000000..f75d879e2 Binary files /dev/null and b/app/assets/images/faq/sign-in-page.png differ diff --git a/app/assets/images/faq/usager-dossier-accepte-summary.png b/app/assets/images/faq/usager-dossier-accepte-summary.png new file mode 100644 index 000000000..63ac6714a Binary files /dev/null and b/app/assets/images/faq/usager-dossier-accepte-summary.png differ diff --git a/app/assets/images/faq/usager-dossier-actions-menu-clone.png b/app/assets/images/faq/usager-dossier-actions-menu-clone.png new file mode 100644 index 000000000..fac555853 Binary files /dev/null and b/app/assets/images/faq/usager-dossier-actions-menu-clone.png differ diff --git a/app/assets/images/faq/usager-dossier-actions-menu-start-new.png b/app/assets/images/faq/usager-dossier-actions-menu-start-new.png new file mode 100644 index 000000000..f49e9ca86 Binary files /dev/null and b/app/assets/images/faq/usager-dossier-actions-menu-start-new.png differ diff --git a/app/assets/images/faq/usager-dossier-actions-menu-transfer.png b/app/assets/images/faq/usager-dossier-actions-menu-transfer.png new file mode 100644 index 000000000..6f9259230 Binary files /dev/null and b/app/assets/images/faq/usager-dossier-actions-menu-transfer.png differ diff --git a/app/assets/images/faq/usager-dossier-cloned-draft.png b/app/assets/images/faq/usager-dossier-cloned-draft.png new file mode 100644 index 000000000..743a065f5 Binary files /dev/null and b/app/assets/images/faq/usager-dossier-cloned-draft.png differ diff --git a/app/assets/images/faq/usager-dossiers-list.png b/app/assets/images/faq/usager-dossiers-list.png new file mode 100644 index 000000000..fa425f094 Binary files /dev/null and b/app/assets/images/faq/usager-dossiers-list.png differ diff --git a/app/assets/images/faq/usager-dropdown.png b/app/assets/images/faq/usager-dropdown.png new file mode 100644 index 000000000..3f501193b Binary files /dev/null and b/app/assets/images/faq/usager-dropdown.png differ diff --git a/app/assets/images/faq/usager-edit-email.png b/app/assets/images/faq/usager-edit-email.png new file mode 100644 index 000000000..6a8b0b48d Binary files /dev/null and b/app/assets/images/faq/usager-edit-email.png differ diff --git a/app/assets/images/faq/usager-edit-identity-brouillon-1.png b/app/assets/images/faq/usager-edit-identity-brouillon-1.png new file mode 100644 index 000000000..7739aa717 Binary files /dev/null and b/app/assets/images/faq/usager-edit-identity-brouillon-1.png differ diff --git a/app/assets/images/faq/usager-edit-identity-brouillon-2.png b/app/assets/images/faq/usager-edit-identity-brouillon-2.png new file mode 100644 index 000000000..0f49e2c7a Binary files /dev/null and b/app/assets/images/faq/usager-edit-identity-brouillon-2.png differ diff --git a/app/assets/images/faq/usager-edit-identity-construction-1.png b/app/assets/images/faq/usager-edit-identity-construction-1.png new file mode 100644 index 000000000..29de87923 Binary files /dev/null and b/app/assets/images/faq/usager-edit-identity-construction-1.png differ diff --git a/app/assets/images/faq/usager-edit-identity-construction-2.png b/app/assets/images/faq/usager-edit-identity-construction-2.png new file mode 100644 index 000000000..f71ceef24 Binary files /dev/null and b/app/assets/images/faq/usager-edit-identity-construction-2.png differ diff --git a/app/assets/images/faq/usager-email-dossier-repasser-instruction.png b/app/assets/images/faq/usager-email-dossier-repasser-instruction.png new file mode 100644 index 000000000..031804e22 Binary files /dev/null and b/app/assets/images/faq/usager-email-dossier-repasser-instruction.png differ diff --git a/app/assets/images/faq/usager-footer-contact.png b/app/assets/images/faq/usager-footer-contact.png new file mode 100644 index 000000000..51a729760 Binary files /dev/null and b/app/assets/images/faq/usager-footer-contact.png differ diff --git a/app/assets/images/faq/usager-form-footer-submit.png b/app/assets/images/faq/usager-form-footer-submit.png new file mode 100644 index 000000000..27922fe6d Binary files /dev/null and b/app/assets/images/faq/usager-form-footer-submit.png differ diff --git a/app/assets/images/faq/usager-messagerie.png b/app/assets/images/faq/usager-messagerie.png new file mode 100644 index 000000000..a0578bdff Binary files /dev/null and b/app/assets/images/faq/usager-messagerie.png differ diff --git a/app/assets/images/faq/usager-procedure-close-focus-contact.png b/app/assets/images/faq/usager-procedure-close-focus-contact.png new file mode 100644 index 000000000..86affd931 Binary files /dev/null and b/app/assets/images/faq/usager-procedure-close-focus-contact.png differ diff --git a/app/assets/images/faq/usager-transfer-dossier.png b/app/assets/images/faq/usager-transfer-dossier.png new file mode 100644 index 000000000..a578b1f28 Binary files /dev/null and b/app/assets/images/faq/usager-transfer-dossier.png differ diff --git a/app/assets/images/header/logo-dn-wide.png b/app/assets/images/header/logo-dn-wide.png new file mode 100644 index 000000000..37e16e51c Binary files /dev/null and b/app/assets/images/header/logo-dn-wide.png differ diff --git a/app/assets/images/header/logo-ds-narrow.svg b/app/assets/images/header/logo-ds-narrow.svg index 437c271ee..e20eb1fd0 100644 --- a/app/assets/images/header/logo-ds-narrow.svg +++ b/app/assets/images/header/logo-ds-narrow.svg @@ -1 +1,334 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/header/logo-ds-wide.png b/app/assets/images/header/logo-ds-wide.png index a0ac0f353..37e16e51c 100644 Binary files a/app/assets/images/header/logo-ds-wide.png and b/app/assets/images/header/logo-ds-wide.png differ diff --git a/app/assets/images/header/logo-ds-wide.png.old b/app/assets/images/header/logo-ds-wide.png.old new file mode 100644 index 000000000..a0ac0f353 Binary files /dev/null and b/app/assets/images/header/logo-ds-wide.png.old differ diff --git a/app/assets/images/header/logo-ds-wide.svg b/app/assets/images/header/logo-ds-wide.svg index 3fb67e18a..fe68f0999 100644 --- a/app/assets/images/header/logo-ds-wide.svg +++ b/app/assets/images/header/logo-ds-wide.svg @@ -1 +1,365 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/header/logo-ds-wide.svg.old b/app/assets/images/header/logo-ds-wide.svg.old new file mode 100644 index 000000000..3fb67e18a --- /dev/null +++ b/app/assets/images/header/logo-ds-wide.svg.old @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/header/logo-ds-wide_source.svg b/app/assets/images/header/logo-ds-wide_source.svg new file mode 100644 index 000000000..7cdf0acff --- /dev/null +++ b/app/assets/images/header/logo-ds-wide_source.svg @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DGNUM + Délégation GénéraleNumérique + + démarches normaliennes + + diff --git a/app/assets/images/header/logo-ds.svg b/app/assets/images/header/logo-ds.svg index eda8aa5eb..42ae8f7f7 100644 --- a/app/assets/images/header/logo-ds.svg +++ b/app/assets/images/header/logo-ds.svg @@ -1 +1,359 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/instructions_moncomptepro.png b/app/assets/images/instructions_moncomptepro.png new file mode 100644 index 000000000..554d388b5 Binary files /dev/null and b/app/assets/images/instructions_moncomptepro.png differ diff --git a/app/assets/images/mailer/instructeur_mailer/logo-ds-wide.jpg b/app/assets/images/mailer/instructeur_mailer/logo-ds-wide.jpg new file mode 100644 index 000000000..cc3ba3e41 Binary files /dev/null and b/app/assets/images/mailer/instructeur_mailer/logo-ds-wide.jpg differ diff --git a/app/assets/images/mailer/instructeur_mailer/logo-ds-wide.png b/app/assets/images/mailer/instructeur_mailer/logo-ds-wide.png new file mode 100644 index 000000000..f9453b783 Binary files /dev/null and b/app/assets/images/mailer/instructeur_mailer/logo-ds-wide.png differ diff --git a/app/assets/images/mailer/instructeur_mailer/logo-ds-wide.svg b/app/assets/images/mailer/instructeur_mailer/logo-ds-wide.svg new file mode 100644 index 000000000..4a2ee0189 --- /dev/null +++ b/app/assets/images/mailer/instructeur_mailer/logo-ds-wide.svg @@ -0,0 +1,348 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DGNUM + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/mailer/instructeur_mailer/logo.png b/app/assets/images/mailer/instructeur_mailer/logo.png new file mode 100644 index 000000000..0774a7d6a Binary files /dev/null and b/app/assets/images/mailer/instructeur_mailer/logo.png differ diff --git a/app/assets/images/mailer/instructeur_mailer/logo.png.old b/app/assets/images/mailer/instructeur_mailer/logo.png.old new file mode 100644 index 000000000..34a48e8bc Binary files /dev/null and b/app/assets/images/mailer/instructeur_mailer/logo.png.old differ diff --git a/app/assets/images/marianne.png b/app/assets/images/marianne.png index a84a7bc79..468c2c294 100644 Binary files a/app/assets/images/marianne.png and b/app/assets/images/marianne.png differ diff --git a/app/assets/images/marianne.svg b/app/assets/images/marianne.svg index 9690583ea..3cb2174a5 100644 --- a/app/assets/images/marianne.svg +++ b/app/assets/images/marianne.svg @@ -1 +1,201 @@ - \ No newline at end of file + + diff --git a/app/assets/images/marianne.svg.old b/app/assets/images/marianne.svg.old new file mode 100644 index 000000000..9690583ea --- /dev/null +++ b/app/assets/images/marianne.svg.old @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/pdf-placeholder.png b/app/assets/images/pdf-placeholder.png new file mode 100644 index 000000000..58da75f9f Binary files /dev/null and b/app/assets/images/pdf-placeholder.png differ diff --git a/app/assets/images/republique-francaise-logo.svg b/app/assets/images/republique-francaise-logo.svg index 0f1cd3d4c..377882ec5 100644 --- a/app/assets/images/republique-francaise-logo.svg +++ b/app/assets/images/republique-francaise-logo.svg @@ -1 +1,203 @@ - \ No newline at end of file + + diff --git a/app/assets/images/republique-francaise-logo.svg.old b/app/assets/images/republique-francaise-logo.svg.old new file mode 100644 index 000000000..0f1cd3d4c --- /dev/null +++ b/app/assets/images/republique-francaise-logo.svg.old @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/stylesheets/01_common.scss b/app/assets/stylesheets/01_common.scss index 00c777a0c..850bd6c7c 100644 --- a/app/assets/stylesheets/01_common.scss +++ b/app/assets/stylesheets/01_common.scss @@ -1,4 +1,4 @@ -@import "placeholders"; +@import 'placeholders'; html, body { @@ -6,11 +6,21 @@ body { scroll-behavior: smooth; } +// Forces line breaks to prevent buttons from overflowing their container +input[type='submit'] { + white-space: normal; +} + .page-wrapper { position: relative; min-height: 100%; } +// Wrap text in pre tag +pre { + white-space: pre-wrap; +} + // Mobile Safari doesn't bubble mouse events by default, unless: // // - the target element of the event is a link or a form field. @@ -28,3 +38,7 @@ body { .container { @extend %container; } + +react-fragment { + display: block; +} diff --git a/app/assets/stylesheets/02_utils.scss b/app/assets/stylesheets/02_utils.scss index c445acbfa..3d5ae5a13 100644 --- a/app/assets/stylesheets/02_utils.scss +++ b/app/assets/stylesheets/02_utils.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; // floats .pull-left { @@ -37,7 +37,7 @@ } .text-right { - text-align: right; + text-align: right !important; } .text-sm { @@ -142,7 +142,6 @@ } } - // who known .highlighted { background-color: var( @@ -194,13 +193,29 @@ // using $direction.key as css modifier, $direction.values to set css properties // scale it using $steps $directions: ( - "t": ("margin-top"), - "r": ("margin-right"), - "b": ("margin-bottom"), - "l": ("margin-left"), - "x": ("margin-left", "margin-right"), - "y": ("margin-top", "margin-bottom"), - "": ("margin") + 't': ( + 'margin-top' + ), + 'r': ( + 'margin-right' + ), + 'b': ( + 'margin-bottom' + ), + 'l': ( + 'margin-left' + ), + 'x': ( + 'margin-left', + 'margin-right' + ), + 'y': ( + 'margin-top', + 'margin-bottom' + ), + '': ( + 'margin' + ) ); $steps: (0, 1, 2, 3, 4, 5, 6, 7, 8); @@ -215,13 +230,29 @@ $steps: (0, 1, 2, 3, 4, 5, 6, 7, 8); } $directions: ( - "t": ("padding-top"), - "r": ("padding-right"), - "b": ("padding-bottom"), - "l": ("padding-left"), - "x": ("padding-left", "padding-right"), - "y": ("padding-top", "padding-bottom"), - "": ("padding") + 't': ( + 'padding-top' + ), + 'r': ( + 'padding-right' + ), + 'b': ( + 'padding-bottom' + ), + 'l': ( + 'padding-left' + ), + 'x': ( + 'padding-left', + 'padding-right' + ), + 'y': ( + 'padding-top', + 'padding-bottom' + ), + '': ( + 'padding' + ) ); $steps: (0, 1, 2, 3, 4, 5, 6, 7, 8); diff --git a/app/assets/stylesheets/_colors.scss b/app/assets/stylesheets/_colors.scss index 5a3a5838f..dab8b2027 100644 --- a/app/assets/stylesheets/_colors.scss +++ b/app/assets/stylesheets/_colors.scss @@ -1,26 +1,26 @@ -$light-blue: #1C7EC9; -$lighter-blue: #C3D9FF; +$light-blue: #1c7ec9; +$lighter-blue: #c3d9ff; $black: #333333; -$white: #FFFFFF; +$white: #ffffff; $grey: #888888; -$light-grey: #F8F8F8; +$light-grey: #f8f8f8; $dark-grey: #666666; -$border-grey: #CCCCCC; -$dark-red: #A10005; +$border-grey: #cccccc; +$dark-red: #a10005; $medium-red: rgba(161, 0, 5, 0.9); -$light-red: #ED1C24; -$lighter-red: #F52A2A; -$background-red: #FFDFDF; +$light-red: #ed1c24; +$lighter-red: #f52a2a; +$background-red: #ffdfdf; $green: darken(#169862, 5%); -$old-green: #15AD70; +$old-green: #15ad70; $lighter-green: lighten($old-green, 30%); $light-green: lighten($old-green, 25%); $dark-green: darken($old-green, 20%); -$orange: #F28900; +$orange: #f28900; $orange-bg: lighten($orange, 35%); -$yellow: #FEF3B8; -$light-yellow: #FFFFDE; -$blue-france-700: #00006D; +$yellow: #fef3b8; +$light-yellow: #ffffde; +$blue-france-700: #00006d; $blue-france-500: #000091; -$blue-france-400: #7F7FC8; +$blue-france-400: #7f7fc8; $g700: #383838; diff --git a/app/assets/stylesheets/_mixins.scss b/app/assets/stylesheets/_mixins.scss index f561d6d63..8dd06bfe0 100644 --- a/app/assets/stylesheets/_mixins.scss +++ b/app/assets/stylesheets/_mixins.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; @mixin horizontal-padding($value) { padding-left: $value; @@ -22,4 +22,3 @@ background-image: image-url($image-url); } } - diff --git a/app/assets/stylesheets/_placeholders.scss b/app/assets/stylesheets/_placeholders.scss index 5488879d0..e2354708f 100644 --- a/app/assets/stylesheets/_placeholders.scss +++ b/app/assets/stylesheets/_placeholders.scss @@ -1,6 +1,6 @@ -@import "colors"; -@import "mixins"; -@import "constants"; +@import 'colors'; +@import 'mixins'; +@import 'constants'; %horizontal-list { list-style-type: none; @@ -27,7 +27,8 @@ } } -%container { // TODO: switch to new design with preview in two view not in two column https://github.com/betagouv/demarches-simplifiees.fr/issues/7882 +%container { + // TODO: switch to new design with preview in two view not in two column https://github.com/betagouv/demarches-simplifiees.fr/issues/7882 @include horizontal-padding($default-padding); max-width: $page-width + 2 * $default-padding; margin-left: auto; diff --git a/app/assets/stylesheets/actiontext.scss b/app/assets/stylesheets/actiontext.scss index 274f04774..f3c3e970e 100644 --- a/app/assets/stylesheets/actiontext.scss +++ b/app/assets/stylesheets/actiontext.scss @@ -11,9 +11,9 @@ trix-editor { min-height: 10em; - background-color: #FFFFFF; + background-color: #ffffff; } -[data-fr-theme="dark"] .trix-button-group button { +[data-fr-theme='dark'] .trix-button-group button { background: var(--background-action-high-blue-france) !important; } diff --git a/app/assets/stylesheets/add_instructeur.scss b/app/assets/stylesheets/add_instructeur.scss index ba7d7b3cf..9d63b0558 100644 --- a/app/assets/stylesheets/add_instructeur.scss +++ b/app/assets/stylesheets/add_instructeur.scss @@ -1,5 +1,5 @@ -@import "constants"; -@import "colors"; +@import 'constants'; +@import 'colors'; .instructeur-wrapper { .select-instructeurs { diff --git a/app/assets/stylesheets/agentconnect.scss b/app/assets/stylesheets/agentconnect.scss index 350f85d52..3e831839b 100644 --- a/app/assets/stylesheets/agentconnect.scss +++ b/app/assets/stylesheets/agentconnect.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; #agentconnect { .agent { @@ -10,7 +10,7 @@ } .box { - background-color: #F2F2F9; + background-color: #f2f2f9; padding: $default-padding; ul { diff --git a/app/assets/stylesheets/animations.scss b/app/assets/stylesheets/animations.scss index 4cb79aa38..2e2cc0384 100644 --- a/app/assets/stylesheets/animations.scss +++ b/app/assets/stylesheets/animations.scss @@ -1,4 +1,4 @@ -@import "placeholders"; +@import 'placeholders'; @keyframes fade-in-down { 0% { diff --git a/app/assets/stylesheets/archive.scss b/app/assets/stylesheets/archive.scss index 91da1e4ca..09bcb85c5 100644 --- a/app/assets/stylesheets/archive.scss +++ b/app/assets/stylesheets/archive.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; table.archive-table { .text-right { diff --git a/app/assets/stylesheets/attachment.scss b/app/assets/stylesheets/attachment.scss index 3688742e7..dc8300d49 100644 --- a/app/assets/stylesheets/attachment.scss +++ b/app/assets/stylesheets/attachment.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .attachment-error, .attachment-upload-error { @@ -8,7 +8,7 @@ &::before { box-shadow: inset 2px 0 0 0 var(--border-plain-error); height: 100%; - content: ""; + content: ''; left: -0.75rem; position: absolute; width: 2px; @@ -26,7 +26,7 @@ } .attachment-multiple:not(.fr-downloads-group), -.attachment-multiple.fr-downloads-group[data-controller=replace-attachment] { +.attachment-multiple.fr-downloads-group[data-controller='replace-attachment'] { ul { list-style-type: none; padding-inline-start: 0; @@ -49,9 +49,8 @@ } } -.attachment-multiple.fr-downloads-group.destroyable { - ul { - list-style-type: none; - padding-inline-start: 0; - } +.attachment-multiple.fr-downloads-group.destroyable ul, +ul[data-file-input-reset-target='fileList'] { + list-style-type: none; + padding-inline-start: 0; } diff --git a/app/assets/stylesheets/attestation.scss b/app/assets/stylesheets/attestation.scss index 400cfc57f..925b943e2 100644 --- a/app/assets/stylesheets/attestation.scss +++ b/app/assets/stylesheets/attestation.scss @@ -1,20 +1,20 @@ @font-face { - font-family: "Marianne"; - src: url("marianne-regular.ttf"); + font-family: 'Marianne'; + src: url('marianne-regular.ttf'); font-weight: normal; font-style: normal; } @font-face { - font-family: "Marianne"; - src: url("marianne-bold.ttf"); + font-family: 'Marianne'; + src: url('marianne-bold.ttf'); font-weight: bold; font-style: normal; } @font-face { - font-family: "Marianne"; - src: url("marianne-thin.ttf"); + font-family: 'Marianne'; + src: url('marianne-thin.ttf'); font-weight: 100; // weasy print n"accepte pas lighter font-style: normal; } @@ -25,8 +25,9 @@ @bottom-center { font-size: 8pt; - content: counter(page) " / " counter(pages); + content: counter(page) ' / ' counter(pages); margin-top: 17mm; + white-space: nowrap; } @bottom-left { @@ -41,11 +42,12 @@ flex-direction: column; justify-content: space-between; // This will push the footer down max-width: 21cm; - height: 29.7cm; + min-height: 29.7cm; padding: 17mm; margin: 0 auto; - background: #FFFFFF; + background: #ffffff; box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); // Optional: for better visualization + position: relative; } } @@ -140,13 +142,11 @@ h2 { margin: 0; - line-height: 8pt; } h3 { font-size: 10pt; // same as text font-weight: bold; - line-height: 4pt; } li p { @@ -167,9 +167,31 @@ } } - .footer { + footer { position: running(footer); font-size: 7pt; font-weight: 100; + white-space: nowrap; + + @media screen { + position: absolute; + bottom: 0; + } + } + + .tdc-repetition li { + margin-bottom: 5mm; + padding-left: 3mm; + + dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 1mm 10mm; + } + + .invisible { + visibility: hidden; + height: 0; + } } } diff --git a/app/assets/stylesheets/attestation_template_2_edit.scss b/app/assets/stylesheets/attestation_template_2_edit.scss index b726e9a8d..53103046f 100644 --- a/app/assets/stylesheets/attestation_template_2_edit.scss +++ b/app/assets/stylesheets/attestation_template_2_edit.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; #attestation-edit { .attestation-preview { @@ -20,7 +20,7 @@ min-height: 400px; } - .editor { + .tiptap-editor { // Visual zones .header .flex-1, h1 { @@ -63,17 +63,6 @@ li p { margin-bottom: 0; } - - // Tags - .fr-menu__list { - max-height: 500px; - } - - .fr-tag:not(.fr-menu .fr-tag) { - // style span rendered by tiptap like a button/link tag - color: var(--text-action-high-blue-france); - background-color: var(--background-action-low-blue-france); - } } // scss-lint:disable SelectorFormat diff --git a/app/assets/stylesheets/attestation_template_edit.scss b/app/assets/stylesheets/attestation_template_edit.scss index 4c5069655..9829c444c 100644 --- a/app/assets/stylesheets/attestation_template_edit.scss +++ b/app/assets/stylesheets/attestation_template_edit.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; #attestation-template-edit { .text-active { diff --git a/app/assets/stylesheets/auth.scss b/app/assets/stylesheets/auth.scss index af379dcf3..01387789d 100644 --- a/app/assets/stylesheets/auth.scss +++ b/app/assets/stylesheets/auth.scss @@ -1,7 +1,7 @@ -@import "colors"; -@import "constants"; -@import "placeholders"; -@import "mixins"; +@import 'colors'; +@import 'constants'; +@import 'placeholders'; +@import 'mixins'; #auth, #agentconnect { @@ -48,15 +48,15 @@ } .sign-in-form .form { - input[type="email"] { + input[type='email'] { margin-bottom: $default-spacer; } - input[type="password"] { + input[type='password'] { margin-bottom: $default-spacer; } - input[type="checkbox"] { + input[type='checkbox'] { margin-bottom: 0; } } diff --git a/app/assets/stylesheets/autosave.scss b/app/assets/stylesheets/autosave.scss index ed9bf6821..386bedae6 100644 --- a/app/assets/stylesheets/autosave.scss +++ b/app/assets/stylesheets/autosave.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .autosave { position: relative; diff --git a/app/assets/stylesheets/badges.scss b/app/assets/stylesheets/badges.scss deleted file mode 100644 index f1e19da3a..000000000 --- a/app/assets/stylesheets/badges.scss +++ /dev/null @@ -1,34 +0,0 @@ -@import "colors"; -@import "constants"; - -.badge { - padding: 0 5px; - font-size: 14px; - font-weight: bold; - text-align: center; - white-space: nowrap; - border-radius: 100px; - background-color: rgba(0, 0, 0, 0.08); - vertical-align: top; - - &.baseline { - vertical-align: baseline; - } - - &.warning { - background-color: $orange; - color: #FFFFFF; - } -} - -.badge-group { - display: flex; - - .fr-badge { - margin-right: $default-spacer; - } - - .fr-badge:last-child { - margin-right: 0; - } -} diff --git a/app/assets/stylesheets/beta.scss b/app/assets/stylesheets/beta.scss index 13dba967a..2ddfd7026 100644 --- a/app/assets/stylesheets/beta.scss +++ b/app/assets/stylesheets/beta.scss @@ -6,8 +6,8 @@ right: -35px; transform: rotate(45deg); width: 150px; - background-color: #008CBA; - color: #FFFFFF; + background-color: #008cba; + color: #ffffff; padding: 5px; font-size: 15px; font-weight: 700; diff --git a/app/assets/stylesheets/buttons.scss b/app/assets/stylesheets/buttons.scss index edaf01c16..1293eb494 100644 --- a/app/assets/stylesheets/buttons.scss +++ b/app/assets/stylesheets/buttons.scss @@ -1,6 +1,6 @@ -@import "colors"; -@import "constants"; -@import "placeholders"; +@import 'colors'; +@import 'constants'; +@import 'placeholders'; .button { @extend %outline; @@ -11,7 +11,7 @@ border: 1px solid $border-grey; font-size: 14px; line-height: 20px; - background-color: #FFFFFF; + background-color: #ffffff; color: $black; text-align: center; -webkit-appearance: none; @@ -29,7 +29,7 @@ } &.primary { - color: #FFFFFF; + color: #ffffff; border-color: $blue-france-700; background-color: $blue-france-700; @@ -41,10 +41,10 @@ &.secondary { color: $blue-france-700; border-color: $blue-france-700; - background-color: #FFFFFF; + background-color: #ffffff; &:hover:not(:disabled) { - color: #FFFFFF; + color: #ffffff; background: $blue-france-700; } } @@ -52,10 +52,10 @@ &.danger { color: $black; border-color: $border-grey; - background-color: #FFFFFF; + background-color: #ffffff; &:hover:not(:disabled) { - color: #FFFFFF; + color: #ffffff; border-color: $medium-red; background-color: $medium-red; @@ -66,35 +66,35 @@ } &.accepted { - color: #FFFFFF; + color: #ffffff; border-color: $green; background-color: $green; &:hover:not(:disabled) { color: $green; - background-color: #FFFFFF; + background-color: #ffffff; } } &.without-continuation { - color: #FFFFFF; + color: #ffffff; border-color: $black; background-color: $black; &:hover:not(:disabled) { color: $black; - background-color: #FFFFFF; + background-color: #ffffff; } } &.refused { - color: #FFFFFF; + color: #ffffff; border-color: $dark-red; background-color: $dark-red; &:hover:not(:disabled) { color: $dark-red; - background-color: #FFFFFF; + background-color: #ffffff; } } @@ -151,10 +151,8 @@ .dropdown-button { white-space: nowrap; - &::after { - content: "▾"; - margin-left: $default-spacer; - font-weight: bold; + [aria-hidden='true'].fr-ml-2v::after { + content: '▾'; } &.icon-only { @@ -174,13 +172,12 @@ } } - -[data-fr-theme="dark"] .dropdown-content { +[data-fr-theme='dark'] .dropdown-content { border: none; background: var(--background-action-low-blue-france); } -[data-fr-theme="dark"] .dropdown-items { +[data-fr-theme='dark'] .dropdown-items { li { &:not(.inactive) { &:hover, @@ -197,7 +194,7 @@ .dropdown-content { border: 1px solid $border-grey; - background: #FFFFFF; + background: #ffffff; box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); position: absolute; right: 0; @@ -243,6 +240,57 @@ ul.dropdown-items { margin-bottom: 0; } +// Apply custom styles to DSFR fr-translate component +.fr-translate__btn.fr-btn.help-btn::before { + content: none; +} + +.help-content.fr-menu ul.fr-menu__list { + --text-decoration: underline; + text-align: left; + font-size: 1rem; + + @media (min-width: 62em) { + font-size: 0.875rem; + padding: 0; + width: 360px; + } +} + +.help-content.fr-menu ul.fr-menu__list li { + padding: 0.75rem 1rem; + + @media (min-width: 62em) { + padding-right: 1rem; + padding-left: 1rem; + } +} + +.help-content.fr-menu ul.fr-menu__list li:not(:last-child) { + @media (min-width: 62em) { + border-bottom: 1px solid $border-grey; + } +} + +.help-content.fr-menu ul.fr-menu__list { + h1, + p { + font-size: inherit; + line-height: inherit; + } + + dd { + word-break: break-word; + } +} + +.help-content a[href]:hover, +.help-content a[href]:active { + @media (hover: hover) and (pointer: fine) { + --text-decoration: none; + } +} + .dropdown-items { li { display: flex; @@ -294,7 +342,7 @@ ul.dropdown-items { // Make child links fill the whole clickable area > a, - .dropdown-items-link { + .dropdown-items-link { display: flex; flex-grow: 1; margin: -$default-padding; @@ -317,7 +365,7 @@ ul.dropdown-items { } p + h4, - p + p, { + p + p { margin-top: $default-spacer; } } diff --git a/app/assets/stylesheets/card.scss b/app/assets/stylesheets/card.scss index 82b6f7cf1..8e0459a01 100644 --- a/app/assets/stylesheets/card.scss +++ b/app/assets/stylesheets/card.scss @@ -1,8 +1,7 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; - -[data-fr-theme="dark"] .card { +[data-fr-theme='dark'] .card { background: none; border: 1px solid var(--background-action-low-blue-france); } @@ -11,7 +10,7 @@ padding: ($default-spacer * 3) ($default-spacer * 2); border: 1px solid $border-grey; margin-bottom: $default-spacer * 4; - background: #FFFFFF; + background: #ffffff; .card-title { font-weight: bold; @@ -51,6 +50,7 @@ &.no-list { ul { list-style: none !important; + padding-left: 0; } } diff --git a/app/assets/stylesheets/card_admin.scss b/app/assets/stylesheets/card_admin.scss index 8f678d3aa..122f9e734 100644 --- a/app/assets/stylesheets/card_admin.scss +++ b/app/assets/stylesheets/card_admin.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .fr-tile-subtitle { min-height: 7rem; diff --git a/app/assets/stylesheets/carte.scss b/app/assets/stylesheets/carte.scss index ec8dd3c48..355a575bb 100644 --- a/app/assets/stylesheets/carte.scss +++ b/app/assets/stylesheets/carte.scss @@ -1,6 +1,4 @@ -@import "colors"; -@import "constants"; - +@import 'colors'; .areas { margin-bottom: 10px; @@ -10,60 +8,49 @@ } } -.form [data-react-component-value='MapEditor'] [data-reach-combobox-input] { - margin-bottom: 0; -} +.ds-ctrl button { + color: $dark-grey; -.map-style-control { - position: absolute; - bottom: 4px; - left: 10px; - - img { - width: 100%; - } - - button { - padding: 0; - border: none; - cursor: pointer; - - > div { - position: absolute; - bottom: 5px; - left: 5px; - } - } - - .map-style-panel { - z-index: 1; - padding: $default-spacer; - margin-bottom: $default-spacer; - - ul { - list-style: none; - padding: $default-spacer; - padding-bottom: 0; - margin-bottom: -$default-spacer; - - label { - font-size: 12px; - font-weight: normal; - } - } + &.on, + &:hover { + background-color: rgba(0, 0, 0, 0.05); } } -.cadastres-selection-control { - z-index: 1; - position: absolute; - top: 135px; - left: 10px; +.react-aria-popover { + &[data-placement='top'] { + --origin: translateY(8px); + } - button { - &.on, - &:hover { - background-color: rgba(0, 0, 0, 0.05); - } + &[data-placement='bottom'] { + --origin: translateY(-8px); + } + + &[data-placement='right'] { + --origin: translateX(-8px); + } + + &[data-placement='left'] { + --origin: translateX(8px); + } + + &[data-entering] { + animation: popover-slide 200ms; + } + + &[data-exiting] { + animation: popover-slide 200ms reverse ease-in; + } +} + +@keyframes popover-slide { + from { + transform: var(--origin); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; } } diff --git a/app/assets/stylesheets/cnaf.scss b/app/assets/stylesheets/cnaf.scss index 9cbd7ea36..96bb8a0c2 100644 --- a/app/assets/stylesheets/cnaf.scss +++ b/app/assets/stylesheets/cnaf.scss @@ -1,5 +1,5 @@ -@import "constants"; -@import "colors"; +@import 'constants'; +@import 'colors'; table.cnaf { margin: 2 * $default-padding 0 $default-padding $default-padding; @@ -7,7 +7,7 @@ table.cnaf { caption { font-weight: bold; - margin-left: - $default-padding; + margin-left: -$default-padding; margin-bottom: $default-spacer; text-align: left; } diff --git a/app/assets/stylesheets/code_blocks.scss b/app/assets/stylesheets/code_blocks.scss index 8dedf3021..9e3d56f66 100644 --- a/app/assets/stylesheets/code_blocks.scss +++ b/app/assets/stylesheets/code_blocks.scss @@ -1,4 +1,4 @@ -@import "colors"; +@import 'colors'; .code-block { background-color: $black; diff --git a/app/assets/stylesheets/code_example.scss b/app/assets/stylesheets/code_example.scss index 93936618f..503795041 100644 --- a/app/assets/stylesheets/code_example.scss +++ b/app/assets/stylesheets/code_example.scss @@ -1,5 +1,5 @@ -@import "constants"; -@import "colors"; +@import 'constants'; +@import 'colors'; .code-example { background-color: var(--background-contrast-grey); @@ -13,7 +13,6 @@ margin-right: auto; padding: $default-padding; } - } pre { diff --git a/app/assets/stylesheets/commencer.scss b/app/assets/stylesheets/commencer.scss index e8b5f8e3a..4b5982773 100644 --- a/app/assets/stylesheets/commencer.scss +++ b/app/assets/stylesheets/commencer.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; .commencer { @media (max-width: 62em) { diff --git a/app/assets/stylesheets/conditions_component.scss b/app/assets/stylesheets/conditions_component.scss index 055e9b4f9..f1936841f 100644 --- a/app/assets/stylesheets/conditions_component.scss +++ b/app/assets/stylesheets/conditions_component.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; form.form > .conditionnel { .condition-table { @@ -37,7 +37,6 @@ form.form > .conditionnel { th { text-align: left; padding: $default-spacer; - } td { @@ -48,7 +47,7 @@ form.form > .conditionnel { margin-bottom: 0; } - input[type=text] { + input[type='text'] { display: inline-block; margin-bottom: 0; } @@ -57,5 +56,14 @@ form.form > .conditionnel { select.alert { border-color: $dark-red; } + + &:first-child { + padding-left: 0; + } + + &:last-child { + text-align: right; + padding-right: 0; + } } } diff --git a/app/assets/stylesheets/demande.scss b/app/assets/stylesheets/demande.scss index 92be4d4e0..b84008c18 100644 --- a/app/assets/stylesheets/demande.scss +++ b/app/assets/stylesheets/demande.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .dossier-show { .champ-row { diff --git a/app/assets/stylesheets/demarches_index.scss b/app/assets/stylesheets/demarches_index.scss index ab58d5502..ef2e1cf68 100644 --- a/app/assets/stylesheets/demarches_index.scss +++ b/app/assets/stylesheets/demarches_index.scss @@ -1,4 +1,4 @@ -@import "colors"; +@import 'colors'; #demarches-index { margin-bottom: 30px; diff --git a/app/assets/stylesheets/dgfip.scss b/app/assets/stylesheets/dgfip.scss index 2efae24bd..d4e95a4bb 100644 --- a/app/assets/stylesheets/dgfip.scss +++ b/app/assets/stylesheets/dgfip.scss @@ -1,5 +1,5 @@ -@import "constants"; -@import "colors"; +@import 'constants'; +@import 'colors'; table.dgfip { margin: 2 * $default-padding 0 $default-padding $default-padding; @@ -7,7 +7,7 @@ table.dgfip { caption { font-weight: bold; - margin-left: - $default-padding; + margin-left: -$default-padding; margin-bottom: $default-spacer; text-align: left; } diff --git a/app/assets/stylesheets/direct_uploads.scss b/app/assets/stylesheets/direct_uploads.scss index 73785f9cc..595b0ab9b 100644 --- a/app/assets/stylesheets/direct_uploads.scss +++ b/app/assets/stylesheets/direct_uploads.scss @@ -1,4 +1,4 @@ -@import "colors"; +@import 'colors'; .direct-upload { display: inline-block; @@ -20,7 +20,9 @@ left: 0; bottom: 0; background-color: var(--background-contrast-grey); - transition: width 120ms ease-out, opacity 60ms 60ms ease-in; + transition: + width 120ms ease-out, + opacity 60ms 60ms ease-in; transform: translate3d(0, 0, 0); } @@ -36,7 +38,7 @@ border-color: var(--border-plain-error); } -input[type=file][data-direct-upload-url][disabled], -input[type=file][data-auto-attach-url][disabled] { +input[type='file'][data-direct-upload-url][disabled], +input[type='file'][data-auto-attach-url][disabled] { display: none; } diff --git a/app/assets/stylesheets/dossier_annotations_privees.scss b/app/assets/stylesheets/dossier_annotations_privees.scss index 84273ddf5..dd9ea6fdd 100644 --- a/app/assets/stylesheets/dossier_annotations_privees.scss +++ b/app/assets/stylesheets/dossier_annotations_privees.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; #dossier-annotations-privees { h1 { diff --git a/app/assets/stylesheets/dossier_champs.scss b/app/assets/stylesheets/dossier_champs.scss index 86808d340..fbedbee32 100644 --- a/app/assets/stylesheets/dossier_champs.scss +++ b/app/assets/stylesheets/dossier_champs.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .table.dossier-champs { th, diff --git a/app/assets/stylesheets/dossier_edit.scss b/app/assets/stylesheets/dossier_edit.scss index 6c09c38b1..1d4459af8 100644 --- a/app/assets/stylesheets/dossier_edit.scss +++ b/app/assets/stylesheets/dossier_edit.scss @@ -1,9 +1,9 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; $dossier-actions-bar-border-width: 1px; -[data-fr-theme="dark"] .dossier-edit .dossier-edit-sticky-footer { +[data-fr-theme='dark'] .dossier-edit .dossier-edit-sticky-footer { background-color: var(--background-action-low-blue-france); border: none; } @@ -32,7 +32,7 @@ $dossier-actions-bar-border-width: 1px; align-items: baseline; .mandatory-explanation { - flex-grow: 1; // Push the "notice" button to the right + flex-grow: 1; // Push the "notice" button to the right flex-shrink: 1; // Allow the text to shrink margin-bottom: $default-spacer; // Leave space when the "notice" button wraps under the text } @@ -51,11 +51,9 @@ $dossier-actions-bar-border-width: 1px; } .dossier-edit-sticky-footer { - // scss-lint:disable VendorPrefix DuplicateProperty - position: fixed; // Fallback for IE 11, and other browser that don't support sticky - position: -webkit-sticky; // This is needed on Safari (tested on 12.1) + position: fixed; // Fallback for IE 11, and other browser that don't support sticky + position: -webkit-sticky; // This is needed on Safari (tested on 12.1) position: sticky; - // scss-lint:enable VendorPrefix DuplicateProperty // IE 11 uses `position:fixed` – and thus needs an explicit width, content-box for better layout, etc. width: 100%; @@ -72,9 +70,9 @@ $dossier-actions-bar-border-width: 1px; padding-right: $default-padding - $dossier-actions-bar-border-width; padding-left: $default-padding - $dossier-actions-bar-border-width; - background: #FFFFFF; + background: #ffffff; - border: $dossier-actions-bar-border-width solid #CCCCCC; + border: $dossier-actions-bar-border-width solid #cccccc; border-top-left-radius: 5px; border-top-right-radius: 5px; border-bottom: none; diff --git a/app/assets/stylesheets/dossier_link.scss b/app/assets/stylesheets/dossier_link.scss index ebe2c0bc5..8a09234d3 100644 --- a/app/assets/stylesheets/dossier_link.scss +++ b/app/assets/stylesheets/dossier_link.scss @@ -1,9 +1,9 @@ -@import "constants"; -@import "colors"; +@import 'constants'; +@import 'colors'; .dossier-link { .help-block > p { - margin-top: - $default-padding; + margin-top: -$default-padding; margin-bottom: 2 * $default-padding; } diff --git a/app/assets/stylesheets/dossier_views.scss b/app/assets/stylesheets/dossier_views.scss index e24706016..c5d263017 100644 --- a/app/assets/stylesheets/dossier_views.scss +++ b/app/assets/stylesheets/dossier_views.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .dossier-container { .sub-header { @@ -15,7 +15,6 @@ .header-actions { margin-bottom: $default-spacer; display: flex; - } } diff --git a/app/assets/stylesheets/dossiers_table.scss b/app/assets/stylesheets/dossiers_table.scss index de3caa57c..4cea963a4 100644 --- a/app/assets/stylesheets/dossiers_table.scss +++ b/app/assets/stylesheets/dossiers_table.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .table.dossiers-table { font-size: 14px; @@ -22,7 +22,8 @@ // In order to have identical height in the table header and the table rows, // we compensate for the height difference between the biggest element of the header // (the Personnaliser button, 38px) and the biggest cell-link element of the rows (the label, 28px) - padding: calc((2 * #{$default-spacer}) + ((38px - 28px) / 2)) $default-spacer; + padding: calc((2 * #{$default-spacer}) + ((38px - 28px) / 2)) + $default-spacer; display: block; } @@ -53,7 +54,6 @@ width: 110px; } - .follow-col { width: 450px; diff --git a/app/assets/stylesheets/dsfr.scss b/app/assets/stylesheets/dsfr.scss index f1e0344d4..6478ea3c7 100644 --- a/app/assets/stylesheets/dsfr.scss +++ b/app/assets/stylesheets/dsfr.scss @@ -1,4 +1,4 @@ -@import "colors"; +@import 'colors'; // overwrite DSFR style for SimpleFormatComponent, some user use markdown with // ordered list having paragraph between list item @@ -32,36 +32,93 @@ trix-editor.fr-input { } .fr-ds-combobox { - .fr-menu { - width: 100%; - - .fr-menu__list { - width: 100%; - max-height: 300px; - } - } - .fr-autocomplete { background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748Z'%3E%3C/path%3E%3C/svg%3E"); } } +.fr-ds-combobox__multiple { + .fr-tag-list { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + margin-bottom: 0.5rem; + } +} + +.fr-ds-combobox__menu { + &[data-placement='top'] { + --origin: translateY(8px); + } + + &[data-placement='bottom'] { + --origin: translateY(-8px); + } + + &[data-placement='right'] { + --origin: translateX(-8px); + } + + &[data-placement='left'] { + --origin: translateX(8px); + } + + &[data-entering] { + animation: popover-slide 200ms; + } + + &.fr-menu { + width: var(--trigger-width); + top: unset; + + .fr-menu__list { + display: block; + width: unset; + max-height: 300px; + overflow: auto; + } + + .fr-menu__item { + &[data-selected] { + font-weight: bold; + } + + &[data-focused] { + font-weight: bold; + } + } + } +} + +@keyframes popover-slide { + from { + transform: var(--origin); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } +} + @media (max-width: 62em) { - .fr-ds-combobox .fr-menu .fr-menu__list { - z-index: calc(var(--ground) + 1000); - background-color: var(--background-default-grey); - --idle: transparent; - --hover: var(--background-overlap-grey-hover); - --active: var(--background-overlap-grey-active); - filter: drop-shadow(var(--overlap-shadow)); - box-shadow: inset 0 1px 0 0 var(--border-open-blue-france); + .fr-ds-combobox__menu { + &.fr-menu .fr-menu__list { + z-index: calc(var(--ground) + 1000); + background-color: var(--background-default-grey); + --idle: transparent; + --hover: var(--background-overlap-grey-hover); + --active: var(--background-overlap-grey-active); + filter: drop-shadow(var(--overlap-shadow)); + box-shadow: inset 0 1px 0 0 var(--border-open-blue-france); + } } } // Fix firefox < 80, Safari < 15.4, Chrome < 83 not supporting "appearance: auto" on inputs // This rule was set by DSFR for DSFR design, but broke our legacy forms. -// scss-lint:disable DuplicateProperty -input[type="checkbox"] { +input[type='checkbox'] { -moz-appearance: checkbox; -moz-appearance: auto; @@ -69,30 +126,30 @@ input[type="checkbox"] { -webkit-appearance: auto; } -input[type="radio"] { +input[type='radio'] { -moz-appearance: radio; -moz-appearance: auto; -webkit-appearance: radio; -webkit-appearance: auto; } -// scss-lint:enable DuplicateProperty // remove additional calendar icon on date input already handle by Firefox navigator @-moz-document url-prefix() { - .fr-input[type="date"] { + .fr-input[type='date'] { background-image: none; } } -.fr-btn.fr-btn--icon-left[target="_blank"] { +.fr-btn.fr-btn--icon-left[target='_blank'] { &::after { display: none; } } // dans le DSFR il est possible d'avoir un bouton seulement avec une icone mais j'ai du surcharger ici pour eviter d'avoir des marges de l'icone. Je n'ai pas bien compris pourquoi -.fr-btns-group--sm.fr-btns-group--icon-right .fr-btn[class*=" fr-icon-"].icon-only::after { +.fr-btns-group--sm.fr-btns-group--icon-right + .fr-btn[class*=' fr-icon-'].icon-only::after { margin-left: 0; margin-right: 0; } @@ -119,11 +176,11 @@ input[type="radio"] { button.fr-tag-bug { background-color: $blue-france-500; - color: #FFFFFF; + color: #ffffff; &:hover { - background-color: #1212FF; - color: #FFFFFF; + background-color: #1212ff; + color: #ffffff; } .tag-dismiss { @@ -132,6 +189,17 @@ button.fr-tag-bug { } } +// on applique le comportement desktop du sélecteur de langue aux terminaux de toute dimension +.fr-translate .fr-menu__list { + display: grid; + grid-template-rows: repeat(var(--rows), auto); + grid-auto-flow: column; +} + +.fr-translate__language[aria-current]:not([aria-current='false']) { + display: inline-flex; +} + // on veut ajouter un gris plus clair dans le side_menu .fr-sidemenu__item .fr-sidemenu__link.custom-link-grey { color: var(--text-disabled-grey); @@ -152,11 +220,11 @@ button.fr-tag-bug { border: 2px solid var(--border-action-high-grey); } - .fr-radio-group input[type="radio"] { + .fr-radio-group input[type='radio'] { opacity: 1; } - .fr-tabs__tab[aria-selected=true]:not(:disabled) { + .fr-tabs__tab[aria-selected='true']:not(:disabled) { border: 5px solid var(--border-action-high-grey); } @@ -164,3 +232,41 @@ button.fr-tag-bug { border: 2px solid var(--border-action-high-grey); } } + +// On restaure la visibilité des éléments .fr-search-bar .fr-label (en appliquant les valeurs par défaut des différentes propriétés) +// Et on utilise la classe .sr-only pour masquer les éléments souhaités au cas par cas +.fr-search-bar .fr-label { + position: initial; + width: initial; + height: initial; + padding: initial; + margin: initial; + overflow: initial; + clip: initial; + white-space: initial; + border: initial; + display: block; // Pour cette valeur spécifique, on récupère celle de .fr-label +} + +// Fix toggles having labels on 2 lines +// From upstream https://github.com/GouvernementFR/dsfr/pull/928 +// Remove it when PR is merged (v1.12 ?) +.fr-toggle label[data-fr-unchecked-label][data-fr-checked-label]::before { + word-wrap: normal; +} + +// We use the DSFR badge design to highlight the email in France Connect page +// but we don't want it to be uppercase +.fr-badge--lowercase { + text-transform: lowercase; +} + +// We don't want badge to split in two lines +.fr-tag { + white-space: nowrap; +} + +// We remove the line height because it creates unharmonized spaces - most of all in table +.fr-tags-group > li { + line-height: inherit; +} diff --git a/app/assets/stylesheets/errors_summary.scss b/app/assets/stylesheets/errors_summary.scss index 54843e294..e2be31dc5 100644 --- a/app/assets/stylesheets/errors_summary.scss +++ b/app/assets/stylesheets/errors_summary.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .errors-summary { background: $background-red; @@ -9,4 +9,3 @@ padding: $default-spacer; } } - diff --git a/app/assets/stylesheets/exports.scss b/app/assets/stylesheets/exports.scss new file mode 100644 index 000000000..4f16cb599 --- /dev/null +++ b/app/assets/stylesheets/exports.scss @@ -0,0 +1,69 @@ +@import 'constants'; + +.export-template-preview { + // From https://codepen.io/myramoki/pen/xZJjrr + .tree { + margin-left: 0; + } + + .tree, + .tree ul { + padding: 0; + list-style: none; + position: relative; + } + + .tree ul { + margin: 0 0 0 0.5em; // (indentation/2) + } + + .tree:before, + .tree ul:before { + content: ''; + display: block; + width: 0; + position: absolute; + top: 0; + bottom: 0; + left: 4px; + border-left: 1px dashed; + } + + ul.tree:before { + border-left: none; + } + + .tree li { + margin: 0; + padding: 0 1.5em; // indentation + .5em + line-height: 2em; // default list item's `line-height` + position: relative; + } + + .tree > li { + padding-left: 0; // Don't indent first level + } + + .tree li:before { + content: ''; + display: block; + width: 10px; // same with indentation + height: 0; + border-top: 1px dashed; + margin-top: -1px; // border top width + position: absolute; + top: 1em; // (line-height/2) + left: 4px; + } + + ul.tree > li:before { + border-top: none; + } + + .tree li:last-child:before { + background: var(--background-alt-blue-france); // same with body background + height: auto; + top: 1em; // (line-height/2) + bottom: 0; + } +} diff --git a/app/assets/stylesheets/flex.scss b/app/assets/stylesheets/flex.scss index 484fbda11..9e4ccf324 100644 --- a/app/assets/stylesheets/flex.scss +++ b/app/assets/stylesheets/flex.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; .flex { display: flex; diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss index ebc1ad87c..6073283bc 100644 --- a/app/assets/stylesheets/forms.scss +++ b/app/assets/stylesheets/forms.scss @@ -1,6 +1,6 @@ -@import "constants"; -@import "colors"; -@import "placeholders"; +@import 'constants'; +@import 'colors'; +@import 'placeholders'; .form { input.unstyled { @@ -45,14 +45,17 @@ } // Keep only bottom margin in nested (consecutive) header sections, ie. first legend for a same level - .fr-fieldset > .fr-fieldset__legend + .fr-fieldset__element > .fr-fieldset:first-of-type .header-section { + .fr-fieldset + > .fr-fieldset__legend + + .fr-fieldset__element + > .fr-fieldset:first-of-type + .header-section { margin-top: 0 !important; } // Don't cumulate margin-bottoms for inlined elements (radio...), because .fr-fieldset has already its own // This is important because of multilpe conditional hidden elements to not take additional space, // but we need the usual margin when there are an error or conditional spinner is visible. - // scss-lint:disable SingleLinePerSelector .fr-fieldset__element > .fr-fieldset:not(.fr-fieldset--error):not(:has(+ .spinner)) > .fr-fieldset__element.fr-fieldset__element--inline { @@ -82,7 +85,7 @@ &.required { &::after { color: $dark-red; - content: " *"; + content: ' *'; } } } @@ -94,7 +97,7 @@ } .notice { - margin-top: - $default-spacer; + margin-top: -$default-spacer; margin-bottom: $default-padding; color: var(--text-mention-grey); @@ -129,7 +132,7 @@ gap: 0.25rem; // Space before mandatory icon because dsfr set display:flex on checkbox label } - input[type=checkbox] { + input[type='checkbox'] { position: absolute; top: 3px; left: 0px; @@ -169,7 +172,8 @@ } label { - padding: $default-padding $default-padding $default-padding $default-spacer; + padding: $default-padding $default-padding $default-padding + $default-spacer; border: 1px solid $border-grey; border-radius: 4px; font-weight: normal; @@ -198,7 +202,7 @@ font-style: italic; } - input[type=radio] { + input[type='radio'] { margin-bottom: 0; } @@ -208,7 +212,7 @@ } } - .drop_down_other { // scss-lint:disable SelectorFormat + .drop_down_other { label { font-weight: normal; } @@ -223,7 +227,7 @@ padding: inherit; } - input[type=password], + input[type='password'], select:not(.fr-select) { display: block; margin-bottom: 0; @@ -242,14 +246,13 @@ } } - - input[type=checkbox] { + input[type='checkbox'] { &.small-margin { margin-bottom: $default-spacer; } } - input[type=text]:not(.fr-input):not(.fr-select) { + input[type='text']:not(.fr-input):not(.fr-select) { border: solid 1px $border-grey; padding: $default-padding; @@ -279,18 +282,18 @@ } } - div.field_with_errors > input { // scss-lint:disable SelectorFormat + div.field_with_errors > input { border: 1px solid $dark-red; } - input[type=text], - input[type=email], - input[type=password], - input[type=date], - input[type=number], - input[type=datetime-local], + input[type='text'], + input[type='email'], + input[type='password'], + input[type='date'], + input[type='number'], + input[type='datetime-local'], textarea, - input[type=tel] { + input[type='tel'] { @media (max-width: $two-columns-breakpoint) { width: 100%; } @@ -304,17 +307,17 @@ } @media (min-width: $two-columns-breakpoint) { - input[type=email], - input[type=password], - input[type=number], - input[inputmode=numeric], - input[inputmode=decimal], - input[type=tel] { + input[type='email'], + input[type='password'], + input[type='number'], + input[inputmode='numeric'], + input[inputmode='decimal'], + input[type='tel'] { max-width: 500px; } } - input[type=date] { + input[type='date'] { max-width: 180px; } @@ -342,8 +345,8 @@ } } - input[type=checkbox], - input[type=radio] { + input[type='checkbox'], + input[type='radio'] { @extend %outline; // Firefox tends to display some controls smaller than other browsers. @@ -356,41 +359,6 @@ margin-bottom: 0; } - [data-reach-combobox-input] { - &:not([class^='width-']) { - width: 100%; - min-width: 50%; - max-width: 100%; - } - - &:focus { - border-color: $blue-france-500; - } - } - - [data-reach-combobox-token-list] { - padding: $default-spacer; - display: flex; - flex-wrap: wrap; - align-items: center; - list-style: none; - } - - [data-reach-combobox-token] button { - border: solid 1px $border-grey; - border-radius: 4px; - padding: $default-spacer; - margin-right: $default-spacer; - cursor: pointer; - display: flex; - align-items: center; - } - - [data-reach-combobox-token] button:focus { - background-color: $black; - color: $white; - } - .editable-champ { &:not(.editable-champ-carte) .algolia-autocomplete { margin-bottom: 2 * $default-padding; @@ -414,12 +382,14 @@ } } - .utils-repetition-required .row:first-child .utils-repetition-required-destroy-button { + .utils-repetition-required + .row:first-child + .utils-repetition-required-destroy-button { display: none; } } - .editable-champ-titre_identite { // scss-lint:disable SelectorFormat + .editable-champ-titre_identite { margin-bottom: 2 * $default-padding; } @@ -520,95 +490,12 @@ &:before, &:after { font-weight: bold; - content: "/"; + content: '/'; } } -[data-react-component-value^="ComboMultiple"] { +.fr-ds-combobox__multiple { margin-bottom: $default-fields-spacer; - - [data-reach-combobox-input] { - flex-grow: 1; - background-image: image-url("icons/chevron-down"); - background-size: 14px; - background-repeat: no-repeat; - background-position: right 10px center; - border-radius: 4px; - border: solid 1px $border-grey; - padding: $default-padding; - margin: $default-spacer; - margin-top: 0; - width: 100%; - } - - ul { - list-style: none; - - li { - margin-right: $default-spacer; - display: inline-block; - } - } -} - -[data-reach-combobox-token-label] { - border: 1px solid #CCCCCC; - border-radius: 4px; - display: flex; - flex-wrap: wrap; -} - -[data-reach-combobox-option] { - font-size: 16px; - list-style-type: none; -} - -[data-reach-combobox-option][aria-selected="true"] { - background: $light-blue !important; - color: $white; -} - -[data-reach-combobox-separator] { - font-size: 16px; - color: $dark-grey; - background: $light-grey; - padding: $default-spacer; -} - -[data-reach-combobox-no-results] { - font-size: 16px; - color: $dark-grey; - background: $light-grey; - padding: $default-spacer; -} - -[data-reach-combobox-token] button { - cursor: pointer; - background-color: transparent; - background-image: none; - border: none; - line-height: 1; - padding: 0; - margin-right: 4px; - display: flex; - align-items: center !important; -} - -[data-reach-combobox-input] button:focus { - outline-color: $light-blue; -} - -[data-fr-theme="dark"] [data-reach-combobox-popover] { - border: none; - background: var(--background-action-low-blue-france); -} - -[data-fr-theme="dark"] [data-reach-combobox-option]:hover { - background: var(--background-action-low-blue-france-hover); -} - -[data-reach-combobox-popover] { - z-index: 20; } .fconnect-form { @@ -623,51 +510,17 @@ textarea::placeholder { color: $dark-grey; } -@media (max-width: 62em) { - - .padded-fixed-footer { - padding-top: 120px; - } -} - -@media (min-width: 62em) { - - .padded-fixed-footer { - padding-top: 60px; - } -} - -[data-fr-theme="dark"] .fixed-footer { - border-top: 2px solid var(--background-action-low-blue-france-hover); - background-color: var(--background-action-low-blue-france); -} - .mandatory { fill: currentColor; } -.fixed-footer { - border-top: 2px solid $blue-france-500; - position: fixed; - bottom: 0; - left: 0; - right: 0; - padding-top: $default-padding; - background-color: $white; - z-index: 2; -} - -.fr-menu__list { +:not(.fr-translate) .fr-menu__list { padding: $default-spacer; overflow-y: auto; .fr-menu__item { list-style-type: none; margin-bottom: $default-spacer; - - &[aria-selected] { - font-weight: bold; - } } } @@ -698,3 +551,22 @@ textarea::placeholder { .resize-y { resize: vertical; } + +.checkbox-group-bordered { + border: 1px solid var(--border-default-grey); + flex: 1 1 100%; // copied from fr-fieldset-element + max-width: 100%; // copied from fr-fieldset-element +} + +.fieldset-bordered { + position: relative; +} + +.fieldset-bordered::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + border-left: 2px solid var(--border-default-blue-france); +} diff --git a/app/assets/stylesheets/gallery.scss b/app/assets/stylesheets/gallery.scss new file mode 100644 index 000000000..cf7dad075 --- /dev/null +++ b/app/assets/stylesheets/gallery.scss @@ -0,0 +1,104 @@ +.gallery { + a { + background-image: none; + } + + .champ-libelle { + margin-top: 0.5rem; + } + + img { + height: 200px; + width: 200px; + object-fit: cover; + } + + .thumbnail { + position: relative; + display: flex; + justify-content: center; + align-items: center; + border: 1px solid var(--border-default-grey); + background-color: var(--border-default-grey); + + img { + position: relative; + z-index: 1; + } + + .fr-btn { + position: absolute; + z-index: 10; + background-color: var(--background-default-grey); + color: var(--text-active-blue-france); + border: 1px solid var(--border-active-blue-france); + padding: 0.25rem 0.75rem; + display: none; + } + } + + a.gallery-link:hover .fr-btn, + a.gallery-link:active .fr-btn { + display: flex; + } + + a.gallery-link:active .fr-btn { + background-color: var(--hover-tint); + } +} + +.gallery-pieces-jointes { + display: flex; + flex-wrap: wrap; + + .gallery-item { + margin: 0 2rem 3rem 0; + + .fr-download { + margin-bottom: 0; + } + + .fr-text--sm { + margin-bottom: 0; + } + } +} + +.gallery-demande { + .gallery-items-list { + display: flex; + flex-wrap: wrap; + } + + .gallery-item { + margin: 0.5rem 2rem 1rem 0; + } + + .fr-download { + margin-bottom: 0.5rem; + } + + .thumbnail { + width: fit-content; + margin-bottom: 0.5rem; + } +} + +.lg-has-iframe { + width: 80% !important; + margin-top: 50px; +} + +.lg-icon { + --hover-tint: none; + --active-tint: none; +} + +.lg-sub-html { + background-image: linear-gradient( + 180deg, + rgba(0, 0, 0, 0), + rgba(0, 0, 0, 1) + ) !important; + padding: 30px 40px 0 40px !important; +} diff --git a/app/assets/stylesheets/gaps.scss b/app/assets/stylesheets/gaps.scss index 0d2dc2226..34a57b9d1 100644 --- a/app/assets/stylesheets/gaps.scss +++ b/app/assets/stylesheets/gaps.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; .gap-left { margin-left: $default-spacer; diff --git a/app/assets/stylesheets/groupe_gestionnaire_cards.scss b/app/assets/stylesheets/groupe_gestionnaire_cards.scss index 4ddeefdf3..17eeeaca4 100644 --- a/app/assets/stylesheets/groupe_gestionnaire_cards.scss +++ b/app/assets/stylesheets/groupe_gestionnaire_cards.scss @@ -3,7 +3,7 @@ padding-left: 20px; padding-right: 20px; position: relative; -} + } .notifications { top: 3px; diff --git a/app/assets/stylesheets/help_dropdown.scss b/app/assets/stylesheets/help_dropdown.scss index f20b13a02..46c7fabd0 100644 --- a/app/assets/stylesheets/help_dropdown.scss +++ b/app/assets/stylesheets/help_dropdown.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .help-dropdown { .dropdown-content { @@ -7,10 +7,6 @@ } } -.help-dropdown-title { - font-weight: bold; -} - .dropdown-items li.help-dropdown-service { cursor: default; @@ -23,14 +19,3 @@ color: $blue-france-500; } } - -.help-dropdown-service-action { - margin-top: $default-padding; - margin-bottom: $default-spacer; -} - -.help-dropdown-service-item { - margin-top: $default-spacer; - line-height: 18px; -} - diff --git a/app/assets/stylesheets/icons.scss b/app/assets/stylesheets/icons.scss index aea716925..74562860d 100644 --- a/app/assets/stylesheets/icons.scss +++ b/app/assets/stylesheets/icons.scss @@ -12,114 +12,114 @@ } &.follow { - background-image: image-url("icons/follow-folder.svg"); + background-image: image-url('icons/follow-folder.svg'); } &.unfollow { - background-image: image-url("icons/unfollow-folder.svg"); + background-image: image-url('icons/unfollow-folder.svg'); } &.standby { - background-image: image-url("icons/standby.svg"); + background-image: image-url('icons/standby.svg'); } &.unarchive { - background-image: image-url("icons/unarchive.svg"); + background-image: image-url('icons/unarchive.svg'); } &.edit { - background-image: image-url("icons/edit-folder-blue.svg"); + background-image: image-url('icons/edit-folder-blue.svg'); } &.bubble { - background-image: image-url("icons/bubble.svg"); + background-image: image-url('icons/bubble.svg'); } &.attached { - background-image: image-url("icons/attached.svg"); + background-image: image-url('icons/attached.svg'); } &.preview { - background-image: image-url("icons/preview.svg"); + background-image: image-url('icons/preview.svg'); } &.retry { - background-image: image-url("icons/retry.svg"); + background-image: image-url('icons/retry.svg'); } &.download { - background-image: image-url("icons/download.svg"); + background-image: image-url('icons/download.svg'); } &.lock { - background-image: image-url("icons/lock.svg"); + background-image: image-url('icons/lock.svg'); } &.add { - background-image: image-url("icons/add.svg"); + background-image: image-url('icons/add.svg'); margin-left: -5px; margin-right: 0px; } &.justificatif { - background-image: image-url("icons/justificatif.svg"); + background-image: image-url('icons/justificatif.svg'); } &.printer { - background-image: image-url("icons/printer.svg"); + background-image: image-url('icons/printer.svg'); } &.account { - background-image: image-url("icons/account-circle.svg"); + background-image: image-url('icons/account-circle.svg'); } &.super-admin { - background-image: image-url("icons/super-admin.svg"); + background-image: image-url('icons/super-admin.svg'); } &.mail { - background-image: image-url("icons/mail.svg"); + background-image: image-url('icons/mail.svg'); } &.reply { - background-image: image-url("icons/reply.svg"); + background-image: image-url('icons/reply.svg'); } &.search { - background-image: image-url("icons/search-blue.svg"); + background-image: image-url('icons/search-blue.svg'); } &.sign-out { - background-image: image-url("icons/sign-out.svg"); + background-image: image-url('icons/sign-out.svg'); } &.info { - background-image: image-url("icons/info-blue.svg"); + background-image: image-url('icons/info-blue.svg'); object-fit: contain; } &.help { - background-image: image-url("icons/help.svg"); + background-image: image-url('icons/help.svg'); } &.phone { - background-image: image-url("icons/phone.svg"); + background-image: image-url('icons/phone.svg'); } &.clock { - background-image: image-url("icons/clock.svg"); + background-image: image-url('icons/clock.svg'); } &.smile { - background-image: image-url("icons/smile-regular.svg"); + background-image: image-url('icons/smile-regular.svg'); } &.frown { - background-image: image-url("icons/frown-regular.svg"); + background-image: image-url('icons/frown-regular.svg'); } &.meh { - background-image: image-url("icons/meh-regular.svg"); + background-image: image-url('icons/meh-regular.svg'); } &.mandatory { @@ -134,7 +134,6 @@ // 3. Follow the first example : create the class then add the mask-image property with data url you copied // 4. Keep this list alphabetic :) .fr-icon { - // scss-lint:disable VendorPrefix &-align-center { &:before, &:after { @@ -159,6 +158,14 @@ } } + &-eye { + &:before, + &:after { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M12.0003 3C17.3924 3 21.8784 6.87976 22.8189 12C21.8784 17.1202 17.3924 21 12.0003 21C6.60812 21 2.12215 17.1202 1.18164 12C2.12215 6.87976 6.60812 3 12.0003 3ZM12.0003 19C16.2359 19 19.8603 16.052 20.7777 12C19.8603 7.94803 16.2359 5 12.0003 5C7.7646 5 4.14022 7.94803 3.22278 12C4.14022 16.052 7.7646 19 12.0003 19ZM12.0003 16.5C9.51498 16.5 7.50026 14.4853 7.50026 12C7.50026 9.51472 9.51498 7.5 12.0003 7.5C14.4855 7.5 16.5003 9.51472 16.5003 12C16.5003 14.4853 14.4855 16.5 12.0003 16.5ZM12.0003 14.5C13.381 14.5 14.5003 13.3807 14.5003 12C14.5003 10.6193 13.381 9.5 12.0003 9.5C10.6196 9.5 9.50026 10.6193 9.50026 12C9.50026 13.3807 10.6196 14.5 12.0003 14.5Z'%3E%3C/path%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M12.0003 3C17.3924 3 21.8784 6.87976 22.8189 12C21.8784 17.1202 17.3924 21 12.0003 21C6.60812 21 2.12215 17.1202 1.18164 12C2.12215 6.87976 6.60812 3 12.0003 3ZM12.0003 19C16.2359 19 19.8603 16.052 20.7777 12C19.8603 7.94803 16.2359 5 12.0003 5C7.7646 5 4.14022 7.94803 3.22278 12C4.14022 16.052 7.7646 19 12.0003 19ZM12.0003 16.5C9.51498 16.5 7.50026 14.4853 7.50026 12C7.50026 9.51472 9.51498 7.5 12.0003 7.5C14.4855 7.5 16.5003 9.51472 16.5003 12C16.5003 14.4853 14.4855 16.5 12.0003 16.5ZM12.0003 14.5C13.381 14.5 14.5003 13.3807 14.5003 12C14.5003 10.6193 13.381 9.5 12.0003 9.5C10.6196 9.5 9.50026 10.6193 9.50026 12C9.50026 13.3807 10.6196 14.5 12.0003 14.5Z'%3E%3C/path%3E%3C/svg%3E"); + } + } + &-file-copy-line { &:before, &:after { @@ -167,6 +174,30 @@ } } + &-file-image-line { + &:before, + &:after { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M15 8V4H5V20H19V8H15ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H16L20.9997 7L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918ZM11 9.5C11 10.3284 10.3284 11 9.5 11C8.67157 11 8 10.3284 8 9.5C8 8.67157 8.67157 8 9.5 8C10.3284 8 11 8.67157 11 9.5ZM17.5 17L13.5 10L8 17H17.5Z'%3E%3C/path%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M15 8V4H5V20H19V8H15ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H16L20.9997 7L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918ZM11 9.5C11 10.3284 10.3284 11 9.5 11C8.67157 11 8 10.3284 8 9.5C8 8.67157 8.67157 8 9.5 8C10.3284 8 11 8.67157 11 9.5ZM17.5 17L13.5 10L8 17H17.5Z'%3E%3C/path%3E%3C/svg%3E"); + } + } + + &-folder-line { + &:before, + &:after { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M4 5V19H20V7H11.5858L9.58579 5H4ZM12.4142 5H21C21.5523 5 22 5.44772 22 6V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5Z'%3E%3C/path%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M4 5V19H20V7H11.5858L9.58579 5H4ZM12.4142 5H21C21.5523 5 22 5.44772 22 6V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5Z'%3E%3C/path%3E%3C/svg%3E"); + } + } + + &-folder-zip-line { + &:before, + &:after { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M10.4142 3L12.4142 5H21C21.5523 5 22 5.44772 22 6V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142ZM18 18H14V15H16V13H14V11H16V9H14V7H11.5858L9.58579 5H4V19H20V7H16V9H18V11H16V13H18V18Z'%3E%3C/path%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M10.4142 3L12.4142 5H21C21.5523 5 22 5.44772 22 6V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142ZM18 18H14V15H16V13H14V11H16V9H14V7H11.5858L9.58579 5H4V19H20V7H16V9H18V11H16V13H18V18Z'%3E%3C/path%3E%3C/svg%3E"); + } + } + &-intermediate-circle-fill { &:before, &:after { @@ -183,6 +214,14 @@ } } + &-pdf-2-line { + &:before, + &:after { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M5 4H15V8H19V20H5V4ZM3.9985 2C3.44749 2 3 2.44405 3 2.9918V21.0082C3 21.5447 3.44476 22 3.9934 22H20.0066C20.5551 22 21 21.5489 21 20.9925L20.9997 7L16 2H3.9985ZM10.4999 7.5C10.4999 9.07749 10.0442 10.9373 9.27493 12.6534C8.50287 14.3757 7.46143 15.8502 6.37524 16.7191L7.55464 18.3321C10.4821 16.3804 13.7233 15.0421 16.8585 15.49L17.3162 13.5513C14.6435 12.6604 12.4999 9.98994 12.4999 7.5H10.4999ZM11.0999 13.4716C11.3673 12.8752 11.6042 12.2563 11.8037 11.6285C12.2753 12.3531 12.8553 13.0182 13.5101 13.5953C12.5283 13.7711 11.5665 14.0596 10.6352 14.4276C10.7999 14.1143 10.9551 13.7948 11.0999 13.4716Z'%3E%3C/path%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M5 4H15V8H19V20H5V4ZM3.9985 2C3.44749 2 3 2.44405 3 2.9918V21.0082C3 21.5447 3.44476 22 3.9934 22H20.0066C20.5551 22 21 21.5489 21 20.9925L20.9997 7L16 2H3.9985ZM10.4999 7.5C10.4999 9.07749 10.0442 10.9373 9.27493 12.6534C8.50287 14.3757 7.46143 15.8502 6.37524 16.7191L7.55464 18.3321C10.4821 16.3804 13.7233 15.0421 16.8585 15.49L17.3162 13.5513C14.6435 12.6604 12.4999 9.98994 12.4999 7.5H10.4999ZM11.0999 13.4716C11.3673 12.8752 11.6042 12.2563 11.8037 11.6285C12.2753 12.3531 12.8553 13.0182 13.5101 13.5953C12.5283 13.7711 11.5665 14.0596 10.6352 14.4276C10.7999 14.1143 10.9551 13.7948 11.0999 13.4716Z'%3E%3C/path%3E%3C/svg%3E"); + } + } + &-underline { &:before, &:after { @@ -190,5 +229,4 @@ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M8 3V12C8 14.2091 9.79086 16 12 16C14.2091 16 16 14.2091 16 12V3H18V12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12V3H8ZM4 20H20V22H4V20Z' fill='currentColor'%3E%3C/path%3E%3C/svg%3E"); } } - // scss-lint:enable VendorPrefix } diff --git a/app/assets/stylesheets/instructeur.scss b/app/assets/stylesheets/instructeur.scss index 9227d5063..1b8105aed 100644 --- a/app/assets/stylesheets/instructeur.scss +++ b/app/assets/stylesheets/instructeur.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .page-title { font-size: 30px; @@ -44,8 +44,16 @@ position: relative; } -.dropdown-export .dropdown-content { +.dropdown-export.dropdown-content { width: 450px; + + a { + text-decoration: underline; + } +} + +.dropdown-label.dropdown-content { + min-width: 390px; } .print-menu { @@ -54,7 +62,7 @@ right: 0; top: 45px; font-size: 14px; - background: #FFFFFF; + background: #ffffff; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); border: 1px solid $border-grey; min-width: 270px; diff --git a/app/assets/stylesheets/invites_form.scss b/app/assets/stylesheets/invites_form.scss index b0fc6c938..16263e917 100644 --- a/app/assets/stylesheets/invites_form.scss +++ b/app/assets/stylesheets/invites_form.scss @@ -1,4 +1,3 @@ - #invites-form { @media (min-width: 48em) { min-width: 400px; diff --git a/app/assets/stylesheets/labels.scss b/app/assets/stylesheets/labels.scss index cdfa65a5e..ab6e4bd82 100644 --- a/app/assets/stylesheets/labels.scss +++ b/app/assets/stylesheets/labels.scss @@ -1,12 +1,12 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .label { display: inline-block; padding: 4px $default-spacer; background: $dark-grey; border: 1px solid transparent; - color: #FFFFFF; + color: #ffffff; border-radius: 4px; font-size: 12px; line-height: 18px; diff --git a/app/assets/stylesheets/landing.scss b/app/assets/stylesheets/landing.scss index c0795cde8..9d42ad829 100644 --- a/app/assets/stylesheets/landing.scss +++ b/app/assets/stylesheets/landing.scss @@ -1,7 +1,7 @@ -@import "constants"; -@import "colors"; -@import "mixins"; -@import "placeholders"; +@import 'constants'; +@import 'colors'; +@import 'mixins'; +@import 'placeholders'; $landing-breakpoint: 1040px; @@ -90,7 +90,7 @@ $landing-breakpoint: 1040px; @extend %horizontal-list-item; max-width: 500px; width: 100%; - background-color: #FFFFFF; + background-color: #ffffff; box-shadow: 0 4px 16px 0 rgba(153, 153, 153, 0.2); padding: 24px; display: flex; @@ -125,7 +125,6 @@ $landing-breakpoint: 1040px; } .landing { - .numbers { @extend %horizontal-list; justify-content: space-around; @@ -133,7 +132,8 @@ $landing-breakpoint: 1040px; } .number { - @extend %horizontal-list-item; + display: flex; + flex-direction: column; width: 320px; text-align: center; @@ -143,6 +143,8 @@ $landing-breakpoint: 1040px; } .number-value { + padding-bottom: 0; + padding-inline-start: 0; color: var(--text-action-high-blue-france); font-size: 2rem; line-height: 2rem; @@ -150,8 +152,9 @@ $landing-breakpoint: 1040px; } .number-label { + order: 2; max-width: 10rem; - margin: auto; + margin: 0 auto; font-weight: 600; font-size: 1.25rem; line-height: 1.5rem; @@ -163,6 +166,10 @@ $landing-breakpoint: 1040px; } } +html[lang='fr'] .landing .number-label-third::before { + content: 'de '; +} + $users-breakpoint: 950px; .users { @@ -240,7 +247,6 @@ $users-breakpoint: 950px; } } - .role-administrations-image { text-align: right; @@ -281,22 +287,22 @@ $cta-panel-button-border-size: 2px; .cta-panel-button-white { @include cta-panel-button; - border: $cta-panel-button-border-size solid #FFFFFF; - color: #FFFFFF; + border: $cta-panel-button-border-size solid #ffffff; + color: #ffffff; &:hover { - color: #FFFFFF; + color: #ffffff; text-decoration: none; background-color: rgba(255, 255, 255, 0.2); } &:focus { - color: #FFFFFF; + color: #ffffff; text-decoration: none; } &:active, &:focus { - outline: 3px solid #FFFFFF; + outline: 3px solid #ffffff; } } diff --git a/app/assets/stylesheets/layouts.scss b/app/assets/stylesheets/layouts.scss index a9961e7cd..27dfa40ff 100644 --- a/app/assets/stylesheets/layouts.scss +++ b/app/assets/stylesheets/layouts.scss @@ -1,14 +1,20 @@ -@import "colors"; -@import "constants"; -@import "placeholders"; +@import 'colors'; +@import 'constants'; +@import 'placeholders'; .two-columns { - @media (min-width: $two-columns-breakpoint) { - background: linear-gradient(to right, transparent 0%, transparent 50%, var(--background-alt-blue-france) 50%, var(--background-alt-blue-france) 100%); + background: linear-gradient( + to right, + transparent 0%, + transparent 50%, + var(--background-alt-blue-france) 50%, + var(--background-alt-blue-france) 100% + ); } - .columns-container { // TODO: https://github.com/betagouv/demarches-simplifiees.fr/issues/7882, once implemented, we won't need container anymore + .columns-container { + // TODO: https://github.com/betagouv/demarches-simplifiees.fr/issues/7882, once implemented, we won't need container anymore @extend %container; display: flex; flex-direction: column; @@ -54,17 +60,13 @@ .sticky--top { position: sticky; - // scss-lint:disable VendorPrefix position: -webkit-sticky; // This is needed on Safari (tested on 12.1) - // scss-lint:enable VendorPrefix top: 1rem; } .sticky--bottom { position: sticky; - // scss-lint:disable VendorPrefix position: -webkit-sticky; // This is needed on Safari (tested on 12.1) - // scss-lint:enable VendorPrefix bottom: 0; z-index: 10; // above DSFR btn which are at 1 diff --git a/app/assets/stylesheets/link-sent.scss b/app/assets/stylesheets/link-sent.scss deleted file mode 100644 index c22d6d6f4..000000000 --- a/app/assets/stylesheets/link-sent.scss +++ /dev/null @@ -1,42 +0,0 @@ -@import "constants"; -@import "colors"; - -#link-sent { - padding-top: 2 * $default-padding; - padding-bottom: 2 * $default-padding; - text-align: center; - max-width: 700px; - - section { - text-align: left; - margin: 3 * $default-padding auto; - } - - p, - ol { - margin-top: $default-padding; - margin-bottom: $default-padding; - } - - .link-sent-info { - color: #000000; - background-color: $yellow; - padding: 0 $default-padding; - border: 1px solid transparent; // prevent margin collapse of first paragraph - } - - .link-sent-help { - border-top: 1px solid $grey; - padding-top: $default-padding; - margin-bottom: $default-padding; - } - - .link-sent-help-title { - font-weight: bold; - } - - .link-sent-help-list { - list-style-position: outside; - padding-left: $default-padding; - } -} diff --git a/app/assets/stylesheets/manager.scss b/app/assets/stylesheets/manager.scss index 7480fddd7..a81bf77d7 100644 --- a/app/assets/stylesheets/manager.scss +++ b/app/assets/stylesheets/manager.scss @@ -1,35 +1,4 @@ -@import "constants"; - -[data-reach-combobox-token-label] { - border: 1px solid #CCCCCC; - border-radius: 4px; - display: flex; - flex-wrap: wrap; -} - -.form [data-reach-combobox-token-list] { - padding: 8px; - display: flex; - align-items: center; - list-style: none; -} - -.form [data-reach-combobox-input]:not([class^='width-']) { - width: 100%; - min-width: 50%; - max-width: 100%; -} - -.form [data-reach-combobox-token] button { - border: solid 1px #CCCCCC; - background-color: transparent; - border-radius: 4px; - padding: 8px; - margin-right: 8px; - cursor: pointer; - display: flex; - align-items: center; -} +@import 'constants'; .hidden { display: none; @@ -52,7 +21,7 @@ } .manager-mandatory { - color: #A10005; + color: #a10005; font-size: 18px; } @@ -70,4 +39,79 @@ margin-bottom: 4px; } } + + .fr-ds-combobox { + .fr-autocomplete { + background-repeat: no-repeat; + background-position: right; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748Z'%3E%3C/path%3E%3C/svg%3E"); + } + } + + .fr-ds-combobox__multiple { + .fr-tag-list { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + margin-bottom: 0.3rem; + } + + .fr-tag { + font-size: small; + padding: 0.5rem; + display: flex; + align-items: center; + border: solid 1px #dcdcdc; + + button { + margin-left: 0.3rem; + } + } + } + + .fr-ds-combobox__menu { + &[data-placement='top'] { + --origin: translateY(8px); + } + + &[data-placement='bottom'] { + --origin: translateY(-8px); + } + + &[data-placement='right'] { + --origin: translateX(-8px); + } + + &[data-placement='left'] { + --origin: translateX(8px); + } + + &[data-entering] { + animation: popover-slide 200ms; + } + + &.fr-menu { + width: var(--trigger-width); + top: unset; + background-color: white; + border: solid 1px #dcdcdc; + + .fr-menu__list { + display: block; + width: unset; + max-height: 300px; + overflow: auto; + } + + .fr-menu__item { + &[data-selected] { + font-weight: bold; + } + + &[data-focused] { + font-weight: bold; + } + } + } + } } diff --git a/app/assets/stylesheets/map_info.scss b/app/assets/stylesheets/map_info.scss index 00f7442c7..f1c7ee819 100644 --- a/app/assets/stylesheets/map_info.scss +++ b/app/assets/stylesheets/map_info.scss @@ -1,10 +1,10 @@ -@import "colors"; +@import 'colors'; -$dep-nothing: #E3E3FD; // blue-france-925 -$dep-small: #CACAFB; // blue-france-850 -$dep-medium: #8585F6; // blue-france-625 -$dep-large: #313178; // blue-france-200 -$dep-xlarge: #272747; // blue-france-125 +$dep-nothing: #e3e3fd; // blue-france-925 +$dep-small: #cacafb; // blue-france-850 +$dep-medium: #8585f6; // blue-france-625 +$dep-large: #313178; // blue-france-200 +$dep-xlarge: #272747; // blue-france-125 #map-svg { max-width: 100%; diff --git a/app/assets/stylesheets/markdown-content.scss b/app/assets/stylesheets/markdown-content.scss new file mode 100644 index 000000000..c166078e8 --- /dev/null +++ b/app/assets/stylesheets/markdown-content.scss @@ -0,0 +1,20 @@ +.markdown-content { + img { + max-width: 100%; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); + + display: block; + + // In markdown img are always wrapped in p, + // which already contains vertical margin. + // We only add margin when there are siblings. + // NOTE: CSS consider the img is only-child even + // when there are only text node siblings, but it's still fine for us. + margin: 1.5rem auto; + + &:only-child { + margin-top: 0; + margin-bottom: 0; + } + } +} diff --git a/app/assets/stylesheets/menu_component.scss b/app/assets/stylesheets/menu_component.scss index 32438e088..378b23163 100644 --- a/app/assets/stylesheets/menu_component.scss +++ b/app/assets/stylesheets/menu_component.scss @@ -1,4 +1,4 @@ -@import "colors"; +@import 'colors'; .menu-component-header { font-size: 12px; diff --git a/app/assets/stylesheets/merci.scss b/app/assets/stylesheets/merci.scss deleted file mode 100644 index b73c2d8e6..000000000 --- a/app/assets/stylesheets/merci.scss +++ /dev/null @@ -1,7 +0,0 @@ -@import "constants"; - -.merci .monavis { - img { - margin-top: 2 * $default-padding; - } -} diff --git a/app/assets/stylesheets/mesri.scss b/app/assets/stylesheets/mesri.scss index 62f3891c1..37ac9f7dc 100644 --- a/app/assets/stylesheets/mesri.scss +++ b/app/assets/stylesheets/mesri.scss @@ -1,5 +1,5 @@ -@import "constants"; -@import "colors"; +@import 'constants'; +@import 'colors'; table.mesri { margin: 2 * $default-padding 0 $default-padding $default-padding; @@ -7,7 +7,7 @@ table.mesri { caption { font-weight: bold; - margin-left: - $default-padding; + margin-left: -$default-padding; margin-bottom: $default-spacer; text-align: left; } diff --git a/app/assets/stylesheets/message.scss b/app/assets/stylesheets/message.scss index 8595b0348..ccc3a1608 100644 --- a/app/assets/stylesheets/message.scss +++ b/app/assets/stylesheets/message.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .message { display: flex; diff --git a/app/assets/stylesheets/messagerie.scss b/app/assets/stylesheets/messagerie.scss index ded8156aa..58e6a0518 100644 --- a/app/assets/stylesheets/messagerie.scss +++ b/app/assets/stylesheets/messagerie.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .messages-list { max-height: 350px; @@ -23,7 +23,7 @@ margin-bottom: $default-spacer; } - .form input[type="file"] { + .form input[type='file'] { margin-bottom: 0; } } diff --git a/app/assets/stylesheets/motivation.scss b/app/assets/stylesheets/motivation.scss index a9199e682..caf904150 100644 --- a/app/assets/stylesheets/motivation.scss +++ b/app/assets/stylesheets/motivation.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .motivation { width: 450px; diff --git a/app/assets/stylesheets/new_alert.scss b/app/assets/stylesheets/new_alert.scss index 6ab745363..5fc14fff0 100644 --- a/app/assets/stylesheets/new_alert.scss +++ b/app/assets/stylesheets/new_alert.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .alert { padding: 15px; @@ -8,10 +8,10 @@ .alert-danger { background-color: $medium-red; - color: #FFFFFF; + color: #ffffff; a { - color: #FFFFFF; + color: #ffffff; } } diff --git a/app/assets/stylesheets/new_footer.scss b/app/assets/stylesheets/new_footer.scss index 68cfeffba..656cb0e1f 100644 --- a/app/assets/stylesheets/new_footer.scss +++ b/app/assets/stylesheets/new_footer.scss @@ -1,4 +1,4 @@ -@import "mixins"; +@import 'mixins'; .landing-footer { @include vertical-padding(72px); diff --git a/app/assets/stylesheets/new_header.scss b/app/assets/stylesheets/new_header.scss index 176b1432e..62804bdcf 100644 --- a/app/assets/stylesheets/new_header.scss +++ b/app/assets/stylesheets/new_header.scss @@ -5,3 +5,42 @@ filter: none; } } + +// Target mobile version +.fr-header__menu.fr-modal { + // Avoid overflow due to button width + overflow-x: hidden; + + .fr-btn { + width: auto; + } + + // Align links & buttons + .fr-translate { + margin-left: 0; + margin-right: 0; + } + + // Align list content with other items + .fr-menu__list { + margin-left: 0.5rem; + } + + // Add space between button edge and content + .fr-container + .fr-header__menu-links + .fr-btns-group.flex.align-center + .fr-translate.fr-nav + .fr-nav__item + .fr-translate__btn.fr-btn { + margin-right: 0; + margin-left: 0; + padding-right: 0.5rem; + padding-left: 0.5rem; + } + + // Remove border on when there is one item only + .fr-nav__item:only-child::before { + box-shadow: none; + } +} diff --git a/app/assets/stylesheets/notifications.scss b/app/assets/stylesheets/notifications.scss index fb3923cfc..7097b9368 100644 --- a/app/assets/stylesheets/notifications.scss +++ b/app/assets/stylesheets/notifications.scss @@ -1,11 +1,9 @@ -@import "colors"; - span.notifications { position: absolute; width: 8px; height: 8px; border-radius: 4px; - background-color: $orange; + background-color: var(--background-flat-warning); } .fr-tabs__list span.notifications { diff --git a/app/assets/stylesheets/pagination.scss b/app/assets/stylesheets/pagination.scss index 464ed74ab..b9faa9bf4 100644 --- a/app/assets/stylesheets/pagination.scss +++ b/app/assets/stylesheets/pagination.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; .pagination { text-align: center; diff --git a/app/assets/stylesheets/password_complexity.scss b/app/assets/stylesheets/password_complexity.scss index ee521b0b3..6e68056ce 100644 --- a/app/assets/stylesheets/password_complexity.scss +++ b/app/assets/stylesheets/password_complexity.scss @@ -1,11 +1,11 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; -$complexity-bg: #EEEEEE; +$complexity-bg: #eeeeee; $complexity-color-0: $lighter-red; -$complexity-color-1: #FF5000; +$complexity-color-1: #ff5000; $complexity-color-2: $orange; -$complexity-color-3: #FFD000; +$complexity-color-3: #ffd000; $complexity-color-4: $green; .password-complexity { @@ -17,19 +17,35 @@ $complexity-color-4: $green; border-radius: 8px; &.complexity-0 { - background: linear-gradient(to right, $complexity-color-0 00%, $complexity-bg 20%); + background: linear-gradient( + to right, + $complexity-color-0 00%, + $complexity-bg 20% + ); } &.complexity-1 { - background: linear-gradient(to right, $complexity-color-1 20%, $complexity-bg 40%); + background: linear-gradient( + to right, + $complexity-color-1 20%, + $complexity-bg 40% + ); } &.complexity-2 { - background: linear-gradient(to right, $complexity-color-2 40%, $complexity-bg 60%); + background: linear-gradient( + to right, + $complexity-color-2 40%, + $complexity-bg 60% + ); } &.complexity-3 { - background: linear-gradient(to right, $complexity-color-3 60%, $complexity-bg 80%); + background: linear-gradient( + to right, + $complexity-color-3 60%, + $complexity-bg 80% + ); } &.complexity-4 { diff --git a/app/assets/stylesheets/patron.scss b/app/assets/stylesheets/patron.scss index 22f745a69..148340966 100644 --- a/app/assets/stylesheets/patron.scss +++ b/app/assets/stylesheets/patron.scss @@ -1,4 +1,4 @@ -@import "colors"; +@import 'colors'; .patron { .patron-section { diff --git a/app/assets/stylesheets/personnes_impliquees.scss b/app/assets/stylesheets/personnes_impliquees.scss index d47aa2755..ec404fcb0 100644 --- a/app/assets/stylesheets/personnes_impliquees.scss +++ b/app/assets/stylesheets/personnes_impliquees.scss @@ -1,5 +1,5 @@ -@import "constants"; -@import "colors"; +@import 'constants'; +@import 'colors'; .personnes-impliquees { padding-bottom: 50px; @@ -9,41 +9,7 @@ margin-left: 16px; } - [data-react-component-value^="ComboMultiple"] { + .fr-ds-combobox__multiple { margin-bottom: 0; - - [data-reach-combobox-token-list] { - padding: 0.5 * $default-padding; - display: flex; - } - - [data-reach-combobox-token] button { - border: solid 1px $border-grey; - margin-top: 0.5 * $default-padding; - margin-bottom: 0.5 * $default-padding; - margin-right: 0.5 * $default-padding; - border-radius: 4px; - padding: 0.5 * $default-padding; - cursor: pointer; - list-style: none; - } - - [data-reach-combobox-token] button:focus { - background-color: $black; - color: $white; - } - - - [data-reach-combobox-input] { - outline: none; - border: none; - flex-grow: 1; - margin: 0.25rem; - } - - [data-reach-combobox-input]:focus { - outline: solid; - outline-color: $light-blue; - } } } diff --git a/app/assets/stylesheets/pole_emploi.scss b/app/assets/stylesheets/pole_emploi.scss index c51ee0103..9a09dfa3d 100644 --- a/app/assets/stylesheets/pole_emploi.scss +++ b/app/assets/stylesheets/pole_emploi.scss @@ -1,5 +1,5 @@ -@import "constants"; -@import "colors"; +@import 'constants'; +@import 'colors'; table.pole-emploi { margin: 2 * $default-padding 0 $default-padding $default-padding; @@ -7,7 +7,7 @@ table.pole-emploi { caption { font-weight: bold; - margin-left: - $default-padding; + margin-left: -$default-padding; margin-bottom: $default-spacer; text-align: left; } diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss index ec327f818..4997c6825 100644 --- a/app/assets/stylesheets/print.scss +++ b/app/assets/stylesheets/print.scss @@ -1,4 +1,4 @@ -@import "colors"; +@import 'colors'; @media print { .new-header, @@ -16,7 +16,7 @@ } body { - font-family: "Marianne"; + font-family: 'Marianne'; } .subtitle { diff --git a/app/assets/stylesheets/procedure_admin.scss b/app/assets/stylesheets/procedure_admin.scss index 7f9b4c8c1..39f18528a 100644 --- a/app/assets/stylesheets/procedure_admin.scss +++ b/app/assets/stylesheets/procedure_admin.scss @@ -13,19 +13,11 @@ } .procedure-admin-listing-container { - display: flex; - justify-content: flex-end; - padding-left: 16px; - padding-right: 16px; - max-width: 1072px; margin-left: auto; - margin-right: auto; - margin-top: 10px; } .container { a { - cursor: pointer; overflow-wrap: break-word; } } @@ -37,5 +29,4 @@ li { font-size: 14px; } - } diff --git a/app/assets/stylesheets/procedure_champs_editor.scss b/app/assets/stylesheets/procedure_champs_editor.scss index 6f1db8ac1..ab4db4560 100644 --- a/app/assets/stylesheets/procedure_champs_editor.scss +++ b/app/assets/stylesheets/procedure_champs_editor.scss @@ -1,6 +1,6 @@ -@import "colors"; -@import "constants"; -@import "placeholders"; +@import 'colors'; +@import 'constants'; +@import 'placeholders'; .types-de-champ-editor { > .types-de-champ-block { @@ -21,11 +21,19 @@ .type-de-champ-container { width: 100%; border: 1px solid var(--border-default-grey); + padding-top: 12px; + border-left-width: 4px; border-radius: 5px; - margin-bottom: $default-padding; + margin-bottom: 3 * $default-spacer; box-shadow: 0px 2px 4px -4px; } + &.type-header-section { + .type-de-champ-container { + border-left: 4px solid var(--background-action-high-blue-france); + } + } + .handle { cursor: grab; @@ -49,31 +57,18 @@ display: none; } - .head { - select { - margin-bottom: 0px; - } - } - - &.type-header-section { - .head { - background-color: var(--background-contrast-blue-cumulus); - } - } - .flex { &.flex-gap { column-gap: $default-spacer * 2; } &.section { - margin-bottom: 8px; - padding: $default-spacer / 2 $default-spacer * 2; - } + padding: $default-spacer $default-spacer * 2; - &.head { - border-bottom: 1px solid var(--border-default-grey); - padding: $default-spacer / 2 $default-spacer; // due to no-outline button horizontal padding, don't add twice the padding so it's aligned with section + &.footer { + padding: 1.5 * $default-spacer $default-spacer * 2; + border-top: 1px solid var(--border-default-grey); + } } } @@ -113,7 +108,7 @@ a { // Remove the icon indicating an external link (for less visual noise) - &[target="_blank"]::after { + &[target='_blank']::after { display: none; } } diff --git a/app/assets/stylesheets/procedure_context.scss b/app/assets/stylesheets/procedure_context.scss index 6033fce0d..a07e39e37 100644 --- a/app/assets/stylesheets/procedure_context.scss +++ b/app/assets/stylesheets/procedure_context.scss @@ -1,18 +1,9 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; $procedure-context-breakpoint: $two-columns-breakpoint; .procedure-preview { - .paperless-logo { - width: 100%; - margin-bottom: 60px; - - @media (max-width: $procedure-context-breakpoint) { - display: none; - } - } - .simple { margin-bottom: 0.2rem; font-size: 1.5rem; @@ -46,7 +37,7 @@ $procedure-context-breakpoint: $two-columns-breakpoint; html[data-fr-theme='dark'] & { box-sizing: content-box; padding: $default-padding / 2; - background: #FFFFFF; + background: #ffffff; } @media (min-width: $procedure-context-breakpoint) { @@ -56,17 +47,9 @@ $procedure-context-breakpoint: $two-columns-breakpoint; } } -.no-procedure-presentation { - margin-bottom: 1.6rem; - - p { - margin: 0; - } -} - .procedure-context-content { @media (max-width: $procedure-context-breakpoint) { - input[type=submit] { + input[type='submit'] { margin-bottom: 2 * $default-padding; } } diff --git a/app/assets/stylesheets/procedure_form.scss b/app/assets/stylesheets/procedure_form.scss index 68c7839c8..3e3b2e0a6 100644 --- a/app/assets/stylesheets/procedure_form.scss +++ b/app/assets/stylesheets/procedure_form.scss @@ -1,7 +1,5 @@ -@import "colors"; -@import "constants"; - -// scss-lint:disable SelectorFormat +@import 'colors'; +@import 'constants'; .procedure-form .page-title { text-align: left; @@ -23,7 +21,7 @@ flex: 10; padding: 0 $default-padding; - input[type=file] { + input[type='file'] { background-color: transparent; // Remove white bg set by DSFR } @@ -66,7 +64,7 @@ } } -[data-fr-theme="dark"] .procedure-form__actions { +[data-fr-theme='dark'] .procedure-form__actions { background: var(--background-action-low-blue-france); border-top: 1px solid var(--background-action-low-blue-france-hover); } diff --git a/app/assets/stylesheets/procedure_list.scss b/app/assets/stylesheets/procedure_list.scss index 3fa1094d2..d677f11cf 100644 --- a/app/assets/stylesheets/procedure_list.scss +++ b/app/assets/stylesheets/procedure_list.scss @@ -1,6 +1,6 @@ -@import "colors"; -@import "constants"; -@import "mixins"; +@import 'colors'; +@import 'constants'; +@import 'mixins'; .procedure-list { .procedure-logo-link { @@ -16,7 +16,6 @@ background-position: 95% 50%; } - .procedure-stats { list-style-type: none; padding-inline-start: 0; @@ -36,7 +35,6 @@ background-color: rgba(0, 0, 0, 0.05); } - .stats-number, .stats-legend { text-align: center; diff --git a/app/assets/stylesheets/procedure_logo.scss b/app/assets/stylesheets/procedure_logo.scss index 073e9c78f..bf709a613 100644 --- a/app/assets/stylesheets/procedure_logo.scss +++ b/app/assets/stylesheets/procedure_logo.scss @@ -1,11 +1,11 @@ -@import "colors"; -@import "constants"; -@import "mixins"; +@import 'colors'; +@import 'constants'; +@import 'mixins'; .procedure-logo { @include ie-compatible-background-image(); - background-color: #FFFFFF; // also in dark mode: logos assume transparent pixels are white + background-color: #ffffff; // also in dark mode: logos assume transparent pixels are white background-position: 95% 50%; color: #000000; // alt text when image is not loaded height: 84px; diff --git a/app/assets/stylesheets/procedure_show.scss b/app/assets/stylesheets/procedure_show.scss index 5100162cb..84ed620fb 100644 --- a/app/assets/stylesheets/procedure_show.scss +++ b/app/assets/stylesheets/procedure_show.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .procedure-header { a.header-link { @@ -33,7 +33,7 @@ padding-right: 10px; background-color: $light-blue; border-radius: 4px; - color: #FFFFFF; + color: #ffffff; height: 36px; line-height: 36px; } @@ -45,45 +45,8 @@ display: inline-block; } - [data-react-component-value^="ComboMultiple"] { + .fr-ds-combobox__multiple { margin-bottom: $default-fields-spacer; - - [data-reach-combobox-token-list] { - padding: 0.25 * $default-padding; - display: inline-block; - width: 100%; - } - - [data-reach-combobox-token] button { - border: solid 1px $border-grey; - margin: 0.25 * $default-padding; - border-radius: 2px; - padding: 0.25 * $default-padding; - cursor: pointer; - list-style: none; - display: flex; - align-items: center; - } - - [data-reach-combobox-token] button:focus { - background-color: $black; - color: $white; - } - - - [data-reach-combobox-input] { - outline: none; - flex-grow: 1; - margin: $default-spacer; - padding: $default-spacer; - border-radius: 4px; - border: solid 1px $border-grey; - margin-top: 0; - } - - [data-reach-combobox-input]:focus { - border-color: $blue-france-500; - } } // fix/dsfr diff --git a/app/assets/stylesheets/profil.scss b/app/assets/stylesheets/profil.scss index 1a3abdeb9..a7a385e9c 100644 --- a/app/assets/stylesheets/profil.scss +++ b/app/assets/stylesheets/profil.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; #profil-page { b { diff --git a/app/assets/stylesheets/rich_text.scss b/app/assets/stylesheets/rich_text.scss index 5b6d2e359..f851d75cf 100644 --- a/app/assets/stylesheets/rich_text.scss +++ b/app/assets/stylesheets/rich_text.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; .rich-text:not(.piece_justificative):not(.titre_identite) { i { diff --git a/app/assets/stylesheets/routing_rules_component.scss b/app/assets/stylesheets/routing_rules_component.scss index ca5985cd3..7b2efbb6e 100644 --- a/app/assets/stylesheets/routing_rules_component.scss +++ b/app/assets/stylesheets/routing_rules_component.scss @@ -1,8 +1,7 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; #routing-rules { - .routing-rules-table { table-layout: fixed; @@ -39,7 +38,6 @@ th { text-align: left; padding: $default-spacer; - } td { @@ -50,7 +48,7 @@ margin-bottom: 0; } - input[type=text] { + input[type='text'] { display: inline-block; margin-bottom: 0; } diff --git a/app/assets/stylesheets/sections.scss b/app/assets/stylesheets/sections.scss index 7b08a1c26..8d44c8465 100644 --- a/app/assets/stylesheets/sections.scss +++ b/app/assets/stylesheets/sections.scss @@ -31,32 +31,34 @@ .header-section.section-2::before { counter-increment: h2; - content: counter(h2) ". "; + content: counter(h2) '. '; } .header-section.section-3::before { counter-increment: h3; - content: counter(h2) "." counter(h3) ". "; + content: counter(h2) '.' counter(h3) '. '; } .header-section.section-4::before { counter-increment: h4; - content: counter(h2) "." counter(h3) "." counter(h4) ". "; + content: counter(h2) '.' counter(h3) '.' counter(h4) '. '; } .header-section.section-5::before { counter-increment: h5; - content: counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) ". "; + content: counter(h2) '.' counter(h3) '.' counter(h4) '.' counter(h5) '. '; } .header-section.section-6::before { counter-increment: h6; - content: counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) "." counter(h6) ". "; + content: counter(h2) '.' counter(h3) '.' counter(h4) '.' counter(h5) '.' + counter(h6) '. '; } .header-section.section-7::before { counter-increment: h7; - content: counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) "." counter(h6) "." counter(h7) ". "; + content: counter(h2) '.' counter(h3) '.' counter(h4) '.' counter(h5) '.' + counter(h6) '.' counter(h7) '. '; } .repetition { diff --git a/app/assets/stylesheets/services_index.scss b/app/assets/stylesheets/services_index.scss index 840be9ff3..73e97f9ea 100644 --- a/app/assets/stylesheets/services_index.scss +++ b/app/assets/stylesheets/services_index.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; #services-index { h1 { diff --git a/app/assets/stylesheets/site_banner.scss b/app/assets/stylesheets/site_banner.scss index 6b4448781..47813ceb7 100644 --- a/app/assets/stylesheets/site_banner.scss +++ b/app/assets/stylesheets/site_banner.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .site-banner { width: 100%; diff --git a/app/assets/stylesheets/spinner.scss b/app/assets/stylesheets/spinner.scss index d0be31b8a..1d2f6d8cb 100644 --- a/app/assets/stylesheets/spinner.scss +++ b/app/assets/stylesheets/spinner.scss @@ -2,7 +2,7 @@ vertical-align: middle; &::before { - content: ""; + content: ''; display: inline-block; width: 1.5rem; height: 1.5rem; diff --git a/app/assets/stylesheets/stats.scss b/app/assets/stylesheets/stats.scss index 01721ec38..759768e6e 100644 --- a/app/assets/stylesheets/stats.scss +++ b/app/assets/stylesheets/stats.scss @@ -1,162 +1,13 @@ -@import "colors"; -@import "constants"; - -$dark-grey: #333333; -$light-grey: #999999; - -$default-space: 15px; - -$new-h1-margin-bottom: 4 * $default-space; -$new-h2-margin-bottom: 3 * $default-space; - -.new-h1, -.new-h2 { - color: $dark-grey; - text-align: center; - font-weight: bold; -} - -.new-h1 { - margin-bottom: 3.75rem; - font-size: 2.5rem; -} - -.new-h2 { - margin-bottom: $new-h2-margin-bottom; - font-size: 36px; -} - -$statistiques-padding-top: $default-space * 2; - -.statistiques { - width: 1040px; - margin: 0 auto; - padding-top: $statistiques-padding-top; -} - -.stat-cards { - .stat-card:nth-of-type(even) { - margin-right: 0px; - } -} - -$stat-card-margin-bottom: 3 * $default-space; - -.stat-card { - padding: 15px; - margin-bottom: $stat-card-margin-bottom; - border-radius: 5px; - box-shadow: none; - border: 1px solid rgba(0, 0, 0, 0.15); -} - -$stat-card-half-horizontal-spacing: 4 * $default-space; - -.stat-card-half { - width: calc((100% - #{$stat-card-half-horizontal-spacing}) / 2); - margin-right: $stat-card-half-horizontal-spacing; -} - -.stat-card-title { - color: $dark-grey; - font-size: 26px; - font-weight: bold; - width: 200px; - text-transform: uppercase; -} - -.stat-card-details { - font-size: 13px; - font-style: italic; -} - -$segmented-control-margin-top: $default-space; - -.segmented-control { - border-radius: 36px; - height: 36px; - line-height: 36px; - font-size: 0; - padding: 0; - display: inline-block; - margin-top: $segmented-control-margin-top; -} - -$segmented-control-item-horizontal-padding: $default-space; -$segmented-control-item-border-radius: 2 * $default-space; - -.segmented-control-item { - display: inline-block; - font-size: 15px; - border: 2px solid $blue-france-700; - margin-right: -2px; - padding-top: var(--li-bottom); - padding-left: $segmented-control-item-horizontal-padding; - padding-right: $segmented-control-item-horizontal-padding; - color: $blue-france-700; - - &:first-of-type { - border-radius: $segmented-control-item-border-radius 0px 0px $segmented-control-item-border-radius; - } - - &:last-of-type { - border-radius: 0px $segmented-control-item-border-radius $segmented-control-item-border-radius 0px; - margin-right: 0; - } - - &:hover { - background-color: $blue-france-500; - color: #FFFFFF; - cursor: pointer; - } -} - -.segmented-control-item-active { - background-color: $blue-france-700; - color: #FFFFFF; -} - -.chart-container { - margin-top: 36px; -} - .chart { width: 100%; } -$big-number-card-padding: 2 * $segmented-control-item-border-radius; - -.big-number-card { - padding: $big-number-card-padding $segmented-control-item-horizontal-padding; -} - -.big-number-card-title { - display: block; - text-align: center; - margin: 0 auto; - margin-bottom: 20px; - color: $light-grey; - text-transform: uppercase; - - &.long-title { - margin-left: -30px; - margin-right: -30px; - } -} - .big-number-card-number { display: block; text-align: center; - font-size: 80px; - line-height: 1em; + font-size: 4.5rem; + line-height: 1.5em; font-weight: bold; - color: $blue-france-500; + color: var(--text-title-blue-france); white-space: nowrap; } - -.big-number-card-detail { - display: block; - margin-top: $default-padding; - text-align: center; - color: $blue-france-500; -} diff --git a/app/assets/stylesheets/status_overview.scss b/app/assets/stylesheets/status_overview.scss index 088885146..2c85570e0 100644 --- a/app/assets/stylesheets/status_overview.scss +++ b/app/assets/stylesheets/status_overview.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .status-timeline { display: inline-block; @@ -29,7 +29,7 @@ // Arrows &:not(:last-child)::after { - content: "▸"; + content: '▸'; display: inline-block; margin-left: 10px; margin-right: 10px; @@ -59,7 +59,7 @@ } blockquote { - quotes: "« " " »" "‘" "’"; + quotes: '« ' ' »' '‘' '’'; margin-bottom: $default-padding * 3; } diff --git a/app/assets/stylesheets/sticky.scss b/app/assets/stylesheets/sticky.scss new file mode 100644 index 000000000..3600e41c7 --- /dev/null +++ b/app/assets/stylesheets/sticky.scss @@ -0,0 +1,49 @@ +@import 'constants'; + +.fixed-footer { + border-top: 2px solid var(--border-plain-blue-france); + position: fixed; + bottom: 0; + left: 0; + right: 0; + padding-top: $default-padding; + background-color: var(--background-default-grey); + z-index: 2; +} + +@media (max-width: 62em) { + .padded-fixed-footer { + padding-top: 120px; + } +} + +@media (min-width: 62em) { + .padded-fixed-footer { + padding-top: 60px; + } +} + +[data-fr-theme='dark'] .fixed-footer { + background-color: var(--background-action-low-blue-france); +} + +.sticky-header { + padding-top: $default-padding; + padding-bottom: $default-padding; + + &-container { + position: sticky; + top: 0; + left: 0; + right: 0; + z-index: 800; + } + + &-warning { + background-color: var(--background-contrast-warning); + } + + p { + margin: 0; + } +} diff --git a/app/assets/stylesheets/sub_header.scss b/app/assets/stylesheets/sub_header.scss index 533ae2138..70c9fb1bd 100644 --- a/app/assets/stylesheets/sub_header.scss +++ b/app/assets/stylesheets/sub_header.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .sub-header { background-color: var(--background-alt-blue-france); diff --git a/app/assets/stylesheets/super_admin.scss b/app/assets/stylesheets/super_admin.scss index 949f1aa4d..302e57639 100644 --- a/app/assets/stylesheets/super_admin.scss +++ b/app/assets/stylesheets/super_admin.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; .super-admin { margin-top: 40px; diff --git a/app/assets/stylesheets/table.scss b/app/assets/stylesheets/table.scss index aef1079e6..8a22ede37 100644 --- a/app/assets/stylesheets/table.scss +++ b/app/assets/stylesheets/table.scss @@ -1,8 +1,9 @@ -@import "colors"; -@import "constants"; -@import "mixins"; +@import 'colors'; +@import 'constants'; +@import 'mixins'; -.table { // TODO : tester de remplacer par l'élément table uniquement +.table { + // TODO : tester de remplacer par l'élément table uniquement width: 100%; tbody tr { @@ -10,7 +11,7 @@ } td, - th[scope="row"] { + th[scope='row'] { @include vertical-padding($default-spacer); vertical-align: middle; } diff --git a/app/assets/stylesheets/table_service.scss b/app/assets/stylesheets/table_service.scss deleted file mode 100644 index 6d03c1f30..000000000 --- a/app/assets/stylesheets/table_service.scss +++ /dev/null @@ -1,5 +0,0 @@ -@import "constants"; - -.change { - width: 300px; -} diff --git a/app/assets/stylesheets/tags.scss b/app/assets/stylesheets/tags.scss new file mode 100644 index 000000000..6dc4c668a --- /dev/null +++ b/app/assets/stylesheets/tags.scss @@ -0,0 +1,22 @@ +@import 'colors'; +@import 'constants'; + +$colors: 'green-tilleul-verveine', 'green-bourgeon', 'green-emeraude', + 'green-menthe', 'blue-ecume', 'purple-glycine', 'pink-macaron', + 'yellow-tournesol', 'brown-cafe-creme', 'beige-gris-galet'; + +@each $color in $colors { + .fr-tag--#{$color}, + a.fr-tag--#{$color}, + button.fr-tag--#{$color}, + input[type='button'].fr-tag--#{$color}, + input[type='image'].fr-tag--#{$color}, + input[type='reset'].fr-tag--#{$color}, + input[type='submit'].fr-tag--#{$color} { + --idle: transparent; + --hover: var(--background-action-low-#{$color}-hover); + --active: var(--background-action-low-#{$color}-active); + background-color: var(--background-action-low-#{$color}); + color: var(--text-action-high-#{$color}); + } +} diff --git a/app/assets/stylesheets/tiptap_editor.scss b/app/assets/stylesheets/tiptap_editor.scss new file mode 100644 index 000000000..a7a7497bd --- /dev/null +++ b/app/assets/stylesheets/tiptap_editor.scss @@ -0,0 +1,14 @@ +@import 'constants'; + +.tiptap-editor { + // Tags + .fr-menu__list { + max-height: 500px; + } + + .fr-tag:not(.fr-menu .fr-tag) { + // style span rendered by tiptap like a button/link tag + color: var(--text-action-high-blue-france); + background-color: var(--background-action-low-blue-france); + } +} diff --git a/app/assets/stylesheets/title.scss b/app/assets/stylesheets/title.scss index d02120e2f..761ee51e1 100644 --- a/app/assets/stylesheets/title.scss +++ b/app/assets/stylesheets/title.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; .huge-title { text-align: center; diff --git a/app/assets/stylesheets/toggle-switch.scss b/app/assets/stylesheets/toggle-switch.scss index e25a5390d..bf1b6a73d 100644 --- a/app/assets/stylesheets/toggle-switch.scss +++ b/app/assets/stylesheets/toggle-switch.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; // Toggle-switch // The switch - the box around @@ -22,7 +22,7 @@ } // Hide default HTML checkbox -.form label.toggle-switch input[type="checkbox"] { +.form label.toggle-switch input[type='checkbox'] { opacity: 0; width: 0; height: 0; @@ -45,7 +45,7 @@ .toggle-switch-control::before { position: absolute; - content: ""; + content: ''; height: 20px; width: 20px; left: 1px; diff --git a/app/components/application_component.rb b/app/components/application_component.rb index da1e79249..7b7873bb6 100644 --- a/app/components/application_component.rb +++ b/app/components/application_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ApplicationComponent < ViewComponent::Base include ViewComponent::Translatable include FlipperHelper @@ -8,6 +10,10 @@ class ApplicationComponent < ViewComponent::Base controller.current_user end + def current_instructeur + controller.current_instructeur + end + def current_administrateur controller.current_administrateur end diff --git a/app/components/attachment/edit_component.rb b/app/components/attachment/edit_component.rb index 05ddb7b18..926027ee2 100644 --- a/app/components/attachment/edit_component.rb +++ b/app/components/attachment/edit_component.rb @@ -1,7 +1,10 @@ +# frozen_string_literal: true + # Display a widget for uploading, editing and deleting a file attachment class Attachment::EditComponent < ApplicationComponent attr_reader :champ attr_reader :attachment + attr_reader :attachments attr_reader :user_can_destroy alias user_can_destroy? user_can_destroy attr_reader :as_multiple @@ -9,24 +12,25 @@ class Attachment::EditComponent < ApplicationComponent EXTENSIONS_ORDER = ['jpeg', 'png', 'pdf', 'zip'].freeze - def initialize(champ: nil, auto_attach_url: nil, attached_file:, direct_upload: true, index: 0, as_multiple: false, view_as: :link, user_can_destroy: true, **kwargs) - @as_multiple = as_multiple - @attached_file = attached_file - @auto_attach_url = auto_attach_url + def initialize(champ: nil, auto_attach_url: nil, attached_file:, direct_upload: true, index: 0, as_multiple: false, view_as: :link, user_can_destroy: true, user_can_replace: false, attachments: [], max: nil, **kwargs) @champ = champ + @attached_file = attached_file @direct_upload = direct_upload @index = index @view_as = view_as @user_can_destroy = user_can_destroy + @user_can_replace = user_can_replace + @as_multiple = as_multiple + @auto_attach_url = auto_attach_url - # attachment passed by kwarg because we don't want a default (nil) value. - @attachment = if kwargs.key?(:attachment) - kwargs.delete(:attachment) - elsif attached_file.respond_to?(:attachment) - attached_file.attachment - else - fail ArgumentError, "You must pass an `attachment` kwarg when not using as single attachment like in #{attached_file.name}. Set it to nil for a new attachment." - end + # Adaptation pour la gestion des pièces jointes multiples + @attachments = attachments.presence || (kwargs.key?(:attachment) ? [kwargs.delete(:attachment)] : []) + @attachments << attached_file.attachment if attached_file.respond_to?(:attachment) && @attachments.empty? + @attachments.compact! + @max = max + + # Utilisation du premier attachement comme référence pour la rétrocompatibilité + @attachment = @attachments.first # When parent form has nested attributes, pass the form builder object_name # to correctly infer the input attribute name. @@ -54,7 +58,11 @@ class Attachment::EditComponent < ApplicationComponent end def destroy_attachment_path - attachment_path(champ_id: champ&.public_id) + if champ.present? + attachment_path(dossier_id: champ&.dossier_id, stable_id: champ&.stable_id, row_id: champ&.row_id) + else + attachment_path(auto_attach_url: @auto_attach_url, view_as: @view_as, direct_upload: @direct_upload) + end end def attachment_input_class @@ -63,16 +71,23 @@ class Attachment::EditComponent < ApplicationComponent def file_field_options track_issue_with_missing_validators if missing_validators? - { - class: class_names("fr-upload attachment-input": true, "#{attachment_input_class}": true, "hidden": persisted?), + + options = { + class: class_names("fr-upload attachment-input": true, "#{attachment_input_class}": true), direct_upload: @direct_upload, id: input_id, aria: { describedby: champ&.describedby_id }, data: { auto_attach_url:, turbo_force: :server - }.merge(has_file_size_validator? ? { max_file_size: } : {}) - }.merge(has_content_type_validator? ? { accept: accept_content_type } : {}) + }.merge(has_file_size_validator? ? { max_file_size: max_file_size } : {}) + } + + options.merge!(has_content_type_validator? ? { accept: accept_content_type } : {}) + options[:multiple] = true if as_multiple? + options[:disabled] = true if (@max && @index >= @max) || persisted? + + options end def poll_url @@ -90,7 +105,8 @@ class Attachment::EditComponent < ApplicationComponent end def field_name(object_name = nil, method_name = nil, *method_names, multiple: false, index: nil) - helpers.field_name(@form_object_name || ActiveModel::Naming.param_key(@attached_file.record), attribute_name) + field_name = @form_object_name || ActiveModel::Naming.param_key(@attached_file.record) + "#{field_name}[#{attribute_name}]#{'[]' if as_multiple?}" end def attribute_name @@ -126,24 +142,24 @@ class Attachment::EditComponent < ApplicationComponent !!attachment&.persisted? end - def downloadable? + def downloadable?(attachment) return false unless @view_as == :download - viewable? + viewable?(attachment) end - def viewable? + def viewable?(attachment) return false if attachment.virus_scanner_error? return false if attachment.watermark_pending? true end - def error? + def error?(attachment) attachment.virus_scanner_error? end - def error_message + def error_message(attachment) case when attachment.virus_scanner.infected? t(".errors.virus_infected") @@ -157,10 +173,10 @@ class Attachment::EditComponent < ApplicationComponent def input_id if champ.present? # There is always a single input by champ, its id must match the label "for" attribute. - return champ.input_id + champ.input_id + else + dom_id(@attached_file.record, attribute_name) end - - helpers.field_id(@form_object_name || @attached_file.record, attribute_name) end def auto_attach_url diff --git a/app/components/attachment/edit_component/edit_component.en.yml b/app/components/attachment/edit_component/edit_component.en.yml index b5ae8544f..09d8cd176 100644 --- a/app/components/attachment/edit_component/edit_component.en.yml +++ b/app/components/attachment/edit_component/edit_component.en.yml @@ -5,6 +5,7 @@ en: retry: Retry delete: Delete delete_file: Delete file %{filename} + multiple_files: Multiple files possible. replace: Replace replace_file: Replace file %{filename} open_file: Open file %{filename} diff --git a/app/components/attachment/edit_component/edit_component.fr.yml b/app/components/attachment/edit_component/edit_component.fr.yml index d4e5b6811..67e2dd519 100644 --- a/app/components/attachment/edit_component/edit_component.fr.yml +++ b/app/components/attachment/edit_component/edit_component.fr.yml @@ -5,6 +5,7 @@ fr: retry: Réessayer delete: Supprimer delete_file: Supprimer le fichier %{filename} + multiple_files: Plusieurs fichiers possibles. replace: Remplacer replace_file: Remplacer le fichier %{filename} open_file: Ouvrir le fichier %{filename} diff --git a/app/components/attachment/edit_component/edit_component.html.haml b/app/components/attachment/edit_component/edit_component.html.haml index 5845e8dad..423490e50 100644 --- a/app/components/attachment/edit_component/edit_component.html.haml +++ b/app/components/attachment/edit_component/edit_component.html.haml @@ -1,39 +1,61 @@ -.attachment.fr-upload-group{ { id: attachment ? dom_id(attachment, :edit) : nil, class: class_names("fr-mb-1w": !(as_multiple? && downloadable?)) }.compact } - - if persisted? - %div{ id: dom_id(attachment, :persisted_row) } - .flex.flex-gap-2{ class: class_names("attachment-error": attachment.virus_scanner_error?) } - - if user_can_destroy? - = render NestedForms::OwnedButtonComponent.new(formaction: destroy_attachment_path, http_method: :delete, opt: {class: "fr-btn fr-btn--tertiary fr-btn--sm fr-icon-delete-line", title: t(".delete_file", filename: attachment.filename)}) do - = t('.delete') +.attachment.fr-upload-group{ id: (attachment ? dom_id(attachment, :edit) : nil), class: class_names("fr-mb-1w": !(as_multiple? && attachments.any?(&:persisted?))) } + - if as_multiple? + - attachments.each do |attachment| + - if attachment.persisted? + %div{ id: dom_id(attachment, :persisted_row) } + .flex.flex-gap-2{ class: class_names("attachment-error": attachment.virus_scanner_error?) } + - if user_can_destroy? + = render NestedForms::OwnedButtonComponent.new(formaction: destroy_attachment_path, http_method: :delete, opt: {class: "fr-btn fr-btn--tertiary fr-btn--sm fr-icon-delete-line", title: t(".delete_file", filename: attachment.filename)}) do + = t('.delete_file', filename: attachment.filename) - - if downloadable? - = render Dsfr::DownloadComponent.new(attachment:) - - else - .fr-py-1v - %span.attachment-filename.fr-mr-1w= link_to_if(viewable?, attachment.filename.to_s, helpers.url_for(attachment.blob), title: t(".open_file", filename: attachment.filename), **helpers.external_link_attributes) + - if downloadable?(attachment) + = render Dsfr::DownloadComponent.new(attachment: attachment) + - else + .fr-py-1v + %span.attachment-filename.fr-mr-1w= link_to_if(viewable?(attachment), attachment.filename.to_s, helpers.url_for(attachment.blob), title: t(".open_file", filename: attachment.filename), **helpers.external_link_attributes) - = render Attachment::ProgressComponent.new(attachment: attachment, ignore_antivirus: true) + = render Attachment::ProgressComponent.new(attachment: attachment, ignore_antivirus: true) - - if error? - %p.fr-error-text= error_message + - if error?(attachment) + %p.fr-error-text= error_message(attachment) + - else + - if persisted? + %div{ id: dom_id(attachment, :persisted_row) } + .flex.flex-gap-2{ class: class_names("attachment-error": attachment.virus_scanner_error?) } + - if user_can_destroy? + = render NestedForms::OwnedButtonComponent.new(formaction: destroy_attachment_path, http_method: :delete, opt: {class: "fr-btn fr-btn--tertiary fr-btn--sm fr-icon-delete-line", title: t(".delete_file", filename: attachment.filename)}) do + = t('.delete_file', filename: attachment.filename) - - elsif first? + - if downloadable?(attachment) + = render Dsfr::DownloadComponent.new(attachment:) + - else + .fr-py-1v + %span.attachment-filename.fr-mr-1w= link_to_if(viewable?(attachment), attachment.filename.to_s, helpers.url_for(attachment.blob), title: t(".open_file", filename: attachment.filename), **helpers.external_link_attributes) + + = render Attachment::ProgressComponent.new(attachment: attachment, ignore_antivirus: true) + + - if error?(attachment) + %p.fr-error-text= error_message(attachment) + + - if first? && !persisted? %p.fr-hint-text.fr-mb-1w - if max_file_size.present? = t('.max_file_size', max_file_size: number_to_human_size(max_file_size)) - if allowed_formats.present? = t('.allowed_formats', formats: allowed_formats.join(', ')) + - if as_multiple? + = t('.multiple_files') - - - if !as_multiple? + - if !persisted? || champ.present? && champ.titre_identite? = file_field(champ, field_name, **file_field_options) - - if persisted? - - Attachment::PendingPollComponent.new(attachment: attachment, poll_url:, context: poll_context).then do |component| - .fr-mt-2w - = render component + - attachments.filter(&:persisted?).each do |attachment| + - if attachment.persisted? + - Attachment::PendingPollComponent.new(attachment: attachment, poll_url: poll_url, context: poll_context).then do |component| + .fr-mt-2w + = render component - .attachment-upload-error.hidden - %p.fr-error-text= t('.errors.uploading') - = button_tag(**retry_button_options) do - = t(".retry") + .attachment-upload-error.hidden + %p.fr-error-text= t('.errors.uploading') + = button_tag(**retry_button_options) do + = t(".retry") diff --git a/app/components/attachment/gallery_item_component.rb b/app/components/attachment/gallery_item_component.rb new file mode 100644 index 000000000..5c4cb8233 --- /dev/null +++ b/app/components/attachment/gallery_item_component.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +class Attachment::GalleryItemComponent < ApplicationComponent + include GalleryHelper + attr_reader :attachment, :seen_at + + def initialize(attachment:, gallery_demande: false, seen_at: nil) + @attachment = attachment + @gallery_demande = gallery_demande + @seen_at = seen_at + end + + def blob + attachment.blob + end + + def gallery_demande? = @gallery_demande + + def libelle + if from_champ? + attachment.record.libelle + elsif from_messagerie? + 'Pièce jointe au message' + elsif from_avis_externe? + 'Pièce jointe à l’avis' + elsif from_justificatif_motivation? + 'Pièce jointe à la décision' + end + end + + def origin + case + when from_public_champ? + 'Dossier usager' + when from_private_champ? + 'Annotation privée' + when from_messagerie_expert? + 'Messagerie (expert)' + when from_messagerie_instructeur? + 'Messagerie (instructeur)' + when from_messagerie_usager? + 'Messagerie (usager)' + when from_avis_externe_instructeur? + 'Avis externe (instructeur)' + when from_avis_externe_expert? + 'Avis externe (expert)' + when from_justificatif_motivation? + 'Justificatif de décision' + end + end + + def title + "#{libelle} -- #{sanitize(blob.filename.to_s)}" + end + + def gallery_link(blob, &block) + if displayable_image?(blob) + link_to image_url(blob_url(attachment)), title: title, data: { src: blob.url }, class: 'gallery-link' do + yield + end + elsif displayable_pdf?(blob) + link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: title do + yield + end + end + end + + def created_at + attachment.record.created_at + end + + def updated? + from_public_champ? && updated_at > attachment.record.dossier.depose_at + end + + def updated_at + blob.created_at + end + + def badge_updated_class + class_names( + "fr-badge fr-badge--sm" => true, + "highlighted" => seen_at.present? && updated_at&.>(seen_at) + ) + end + + private + + def from_champ? + attachment.record.class.in?([Champs::PieceJustificativeChamp, Champs::TitreIdentiteChamp]) + end + + def from_public_champ? + from_champ? && !attachment.record.private? + end + + def from_private_champ? + from_champ? && attachment.record.private? + end + + def from_messagerie? + attachment.record.is_a?(Commentaire) + end + + def from_messagerie_instructeur? + from_messagerie? && attachment.record.instructeur.present? + end + + def from_messagerie_expert? + from_messagerie? && attachment.record.expert.present? + end + + def from_messagerie_usager? + from_messagerie? && attachment.record.instructeur.nil? && attachment.record.expert.nil? + end + + def from_avis_externe? + attachment.record.is_a?(Avis) + end + + def from_avis_externe_instructeur? + from_avis_externe? && attachment.name == 'introduction_file' + end + + def from_avis_externe_expert? + from_avis_externe? && attachment.name == 'piece_justificative_file' + end + + def from_justificatif_motivation? + attachment.name == 'justificatif_motivation' + end +end diff --git a/app/components/attachment/gallery_item_component/gallery_item_component.en.yml b/app/components/attachment/gallery_item_component/gallery_item_component.en.yml new file mode 100644 index 000000000..7e273db89 --- /dev/null +++ b/app/components/attachment/gallery_item_component/gallery_item_component.en.yml @@ -0,0 +1,3 @@ +en: + created_at: "Added on %{datetime}" + updated_at: "Updated on %{datetime}" diff --git a/app/components/attachment/gallery_item_component/gallery_item_component.fr.yml b/app/components/attachment/gallery_item_component/gallery_item_component.fr.yml new file mode 100644 index 000000000..788df7462 --- /dev/null +++ b/app/components/attachment/gallery_item_component/gallery_item_component.fr.yml @@ -0,0 +1,3 @@ +fr: + created_at: "Ajoutée le %{datetime}" + updated_at: "Modifiée le %{datetime}" diff --git a/app/components/attachment/gallery_item_component/gallery_item_component.html.haml b/app/components/attachment/gallery_item_component/gallery_item_component.html.haml new file mode 100644 index 000000000..74fa9f176 --- /dev/null +++ b/app/components/attachment/gallery_item_component/gallery_item_component.html.haml @@ -0,0 +1,26 @@ +.gallery-item + - if !gallery_demande? + %p.fr-tag.fr-tag--sm.fr-mb-3v= origin + - if displayable_pdf?(blob) || displayable_image?(blob) + = gallery_link(blob) do + .thumbnail + = image_tag(representation_url_for(attachment), loading: :lazy) + .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } + Visualiser + - if !gallery_demande? + .fr-text--sm.fr-mt-2v.fr-mb-1v + = libelle.truncate(30) + = render Attachment::ShowComponent.new(attachment:, truncate: true, new_tab: gallery_demande?) + - if !gallery_demande? + .fr-mt-2v.fr-mb-2v{ class: badge_updated_class } + = t(updated? ? '.updated_at' : '.created_at', datetime: helpers.try_format_datetime(updated_at, format: :veryshort)) + - else + .thumbnail + = image_tag('apercu-indisponible.png') + - if !gallery_demande? + .fr-text--sm.fr-mt-2v.fr-mb-1v + = libelle.truncate(30) + = render Attachment::ShowComponent.new(attachment:, truncate: true, new_tab: gallery_demande?) + - if !gallery_demande? + .fr-mt-2v.fr-mb-2v{ class: badge_updated_class } + = t(updated? ? '.updated_at' : '.created_at', datetime: helpers.try_format_datetime(updated_at, format: :veryshort)) diff --git a/app/components/attachment/multiple_component.rb b/app/components/attachment/multiple_component.rb index b5900cd13..050a3b5da 100644 --- a/app/components/attachment/multiple_component.rb +++ b/app/components/attachment/multiple_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Display a widget for uploading, editing and deleting a file attachment class Attachment::MultipleComponent < ApplicationComponent DEFAULT_MAX_ATTACHMENTS = 10 @@ -15,7 +17,7 @@ class Attachment::MultipleComponent < ApplicationComponent delegate :count, :empty?, to: :attachments, prefix: true - def initialize(champ:, attached_file:, form_object_name: nil, view_as: :link, user_can_destroy: true, max: nil) + def initialize(champ: nil, attached_file:, form_object_name: nil, view_as: :link, user_can_destroy: true, user_can_replace: false, max: nil) @champ = champ @attached_file = attached_file @form_object_name = form_object_name @@ -30,16 +32,12 @@ class Attachment::MultipleComponent < ApplicationComponent @attachments.each_with_index(&block) end - def can_attach_next? - @attachments.count < @max - end - def empty_component_id - "attachment-multiple-empty-#{champ.public_id}" + champ.present? ? "attachment-multiple-empty-#{champ.public_id}" : "attachment-multiple-empty-generic" end def auto_attach_url - helpers.auto_attach_url(champ) + champ.present? ? helpers.auto_attach_url(champ) : '#' end alias poll_url auto_attach_url diff --git a/app/components/attachment/multiple_component/multiple_component.html.haml b/app/components/attachment/multiple_component/multiple_component.html.haml index 545387758..40b94577f 100644 --- a/app/components/attachment/multiple_component/multiple_component.html.haml +++ b/app/components/attachment/multiple_component/multiple_component.html.haml @@ -5,10 +5,10 @@ %ul.fr-my-1v - each_attachment do |attachment, index| %li{ id: dom_id(attachment) } - = render Attachment::EditComponent.new(champ:, attached_file:, attachment:, index:, as_multiple: true, view_as:, user_can_destroy:, form_object_name:) + = render Attachment::EditComponent.new(champ:, attached_file:, attachment:, index:, view_as:, user_can_destroy:, form_object_name:) - %div{ id: empty_component_id, class: class_names("hidden": !can_attach_next?), data: { turbo_force: :server } } - = render Attachment::EditComponent.new(champ:, attached_file:, attachment: nil, index: attachments_count, user_can_destroy:, form_object_name:) + %div{ id: empty_component_id, data: { turbo_force: :server } } + = render Attachment::EditComponent.new(champ:, as_multiple: champ.nil?, attached_file:, attachment: nil, index: attachments_count, user_can_destroy:, form_object_name:, max: @max) // single poll and refresh message for all attachments = render Attachment::PendingPollComponent.new(attachments: attachments, poll_url:, context: poll_context) diff --git a/app/components/attachment/pending_poll_component.rb b/app/components/attachment/pending_poll_component.rb index ab13a847b..6c1ec3c57 100644 --- a/app/components/attachment/pending_poll_component.rb +++ b/app/components/attachment/pending_poll_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Attachment::PendingPollComponent < ApplicationComponent attr_reader :attachments diff --git a/app/components/attachment/progress_bar_component.rb b/app/components/attachment/progress_bar_component.rb index 8e4605246..a7a745555 100644 --- a/app/components/attachment/progress_bar_component.rb +++ b/app/components/attachment/progress_bar_component.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class Attachment::ProgressBarComponent < ApplicationComponent end diff --git a/app/components/attachment/progress_component.rb b/app/components/attachment/progress_component.rb index 311605091..3446a4161 100644 --- a/app/components/attachment/progress_component.rb +++ b/app/components/attachment/progress_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Attachment::ProgressComponent < ApplicationComponent attr_reader :attachment attr_reader :ignore_antivirus diff --git a/app/components/attachment/show_component.rb b/app/components/attachment/show_component.rb index 4bc8416e7..6ea8ad7f8 100644 --- a/app/components/attachment/show_component.rb +++ b/app/components/attachment/show_component.rb @@ -1,10 +1,13 @@ +# frozen_string_literal: true + class Attachment::ShowComponent < ApplicationComponent - def initialize(attachment:, new_tab: false) + def initialize(attachment:, new_tab: false, truncate: false) @attachment = attachment @new_tab = new_tab + @truncate = truncate end - attr_reader :attachment, :new_tab + attr_reader :attachment, :new_tab, :truncate def should_display_link? (attachment.virus_scanner.safe? || !attachment.virus_scanner.started?) && !attachment.watermark_pending? diff --git a/app/components/attachment/show_component/show_component.html.haml b/app/components/attachment/show_component/show_component.html.haml index 49b9768df..f8bd81f44 100644 --- a/app/components/attachment/show_component/show_component.html.haml +++ b/app/components/attachment/show_component/show_component.html.haml @@ -1,6 +1,6 @@ %div{ id: dom_id(attachment, :show), class: class_names("attachment-error": error?, "fr-mb-2w": !should_display_link?) } - if should_display_link? - = render Dsfr::DownloadComponent.new(attachment: attachment, virus_not_analyzed: !attachment.virus_scanner.started?, new_tab: new_tab) + = render Dsfr::DownloadComponent.new(attachment: attachment, virus_not_analyzed: !attachment.virus_scanner.started?, new_tab: new_tab, truncate: truncate) - else .attachment-filename.fr-mb-1w.fr-mr-1w= attachment.filename.to_s diff --git a/app/components/autosave_notice_component.rb b/app/components/autosave_notice_component.rb new file mode 100644 index 000000000..7d78c119d --- /dev/null +++ b/app/components/autosave_notice_component.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class AutosaveNoticeComponent < ApplicationComponent + attr_reader :label_scope + + def initialize(success:, label_scope:) + @success = success + @label_scope = label_scope + end + + def success? = @success + + def label + success? ? t(".#{label_scope}.saved") : t(".#{label_scope}.error") + end +end diff --git a/app/components/autosave_notice_component/autosave_notice_component.en.yml b/app/components/autosave_notice_component/autosave_notice_component.en.yml new file mode 100644 index 000000000..ec721bd1a --- /dev/null +++ b/app/components/autosave_notice_component/autosave_notice_component.en.yml @@ -0,0 +1,8 @@ +--- +en: + form: + saved: 'Form saved' + error: 'Form in error' + attestation: + saved: 'Attestation saved' + error: 'Attestation in error' diff --git a/app/components/autosave_notice_component/autosave_notice_component.fr.yml b/app/components/autosave_notice_component/autosave_notice_component.fr.yml new file mode 100644 index 000000000..9f56b2f82 --- /dev/null +++ b/app/components/autosave_notice_component/autosave_notice_component.fr.yml @@ -0,0 +1,8 @@ +--- +fr: + form: + saved: 'Formulaire enregistré' + error: 'Formulaire en erreur' + attestation: + saved: 'Attestation enregistrée' + error: 'Attestation en erreur' diff --git a/app/components/autosave_notice_component/autosave_notice_component.html.haml b/app/components/autosave_notice_component/autosave_notice_component.html.haml new file mode 100644 index 000000000..2c59662fa --- /dev/null +++ b/app/components/autosave_notice_component/autosave_notice_component.html.haml @@ -0,0 +1,2 @@ +#autosave-notice.fr-badge.fr-badge--sm{ class: class_names("fr-badge--success" => success?, "fr-badge--error" => !success?) } + = label diff --git a/app/components/conditions/champs_conditions_component.rb b/app/components/conditions/champs_conditions_component.rb index 27f85275c..b80685cb6 100644 --- a/app/components/conditions/champs_conditions_component.rb +++ b/app/components/conditions/champs_conditions_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Conditions::ChampsConditionsComponent < Conditions::ConditionsComponent def initialize(tdc:, upper_tdcs:, procedure_id:) @tdc, @condition, @source_tdcs = tdc, tdc.condition, upper_tdcs diff --git a/app/components/conditions/conditions_component.rb b/app/components/conditions/conditions_component.rb index 01f081a85..658da1b22 100644 --- a/app/components/conditions/conditions_component.rb +++ b/app/components/conditions/conditions_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Conditions::ConditionsComponent < ApplicationComponent include Logic @@ -61,7 +63,7 @@ class Conditions::ConditionsComponent < ApplicationComponent def available_targets_for_select @source_tdcs - .filter { |tdc| ChampValue::MANAGED_TYPE_DE_CHAMP.values.include?(tdc.type_champ) } + .filter(&:conditionable?) .map { |tdc| [tdc.libelle, champ_value(tdc.stable_id).to_json] } end @@ -97,7 +99,7 @@ class Conditions::ConditionsComponent < ApplicationComponent [t('is', scope: 'logic'), Eq.name], [t('is_not', scope: 'logic'), NotEq.name] ] - when ChampValue::CHAMP_VALUE_TYPE.fetch(:commune_enum), ChampValue::CHAMP_VALUE_TYPE.fetch(:epci_enum) + when ChampValue::CHAMP_VALUE_TYPE.fetch(:commune_enum), ChampValue::CHAMP_VALUE_TYPE.fetch(:epci_enum), ChampValue::CHAMP_VALUE_TYPE.fetch(:address) [ [t(InDepartementOperator.name, scope: 'logic.operators'), InDepartementOperator.name], [t(NotInDepartementOperator.name, scope: 'logic.operators'), NotInDepartementOperator.name], @@ -149,7 +151,7 @@ class Conditions::ConditionsComponent < ApplicationComponent id: input_id_for('value', row_index), class: 'fr-select' ) - when :enum, :enums, :commune_enum, :epci_enum, :departement_enum + when :enum, :enums, :commune_enum, :epci_enum, :departement_enum, :address enums_for_select = left.options(@source_tdcs, operator_name) if right_invalid diff --git a/app/components/conditions/conditions_errors_component.rb b/app/components/conditions/conditions_errors_component.rb index 335716c97..0be954008 100644 --- a/app/components/conditions/conditions_errors_component.rb +++ b/app/components/conditions/conditions_errors_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Conditions::ConditionsErrorsComponent < ApplicationComponent def initialize(conditions:, source_tdcs:) @conditions, @source_tdcs = conditions, source_tdcs diff --git a/app/components/conditions/ineligibilite_rules_component.rb b/app/components/conditions/ineligibilite_rules_component.rb new file mode 100644 index 000000000..05dc3fe4c --- /dev/null +++ b/app/components/conditions/ineligibilite_rules_component.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class Conditions::IneligibiliteRulesComponent < Conditions::ConditionsComponent + include Logic + + def initialize(draft_revision:) + @draft_revision = draft_revision + @published_revision = draft_revision.procedure.published_revision + @condition = draft_revision.ineligibilite_rules + @source_tdcs = draft_revision.types_de_champ_for(scope: :public) + end + + def pending_changes? + return false if !@published_revision + + !@published_revision.compare_ineligibilite_rules(@draft_revision).empty? + end + + private + + def input_prefix + 'procedure_revision[condition_form]' + end + + def input_id_for(name, row_index) + "#{@draft_revision.id}-#{name}-#{row_index}" + end + + def delete_condition_path(row_index) + delete_row_admin_procedure_ineligibilite_rules_path(@draft_revision.procedure_id, revision_id: @draft_revision.id, row_index:) + end + + def add_condition_path + add_row_admin_procedure_ineligibilite_rules_path(@draft_revision.procedure_id, revision_id: @draft_revision.id) + end +end diff --git a/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.fr.yml b/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.fr.yml new file mode 100644 index 000000000..b646c3019 --- /dev/null +++ b/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.fr.yml @@ -0,0 +1,6 @@ +--- +fr: + display_if: Bloquer si + select: Sélectionner + add_condition: Ajouter une règle d’inéligibilité + remove_a_row: Supprimer une règle diff --git a/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml b/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml new file mode 100644 index 000000000..a012dc339 --- /dev/null +++ b/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml @@ -0,0 +1,49 @@ +%div{ id: dom_id(@draft_revision, :ineligibilite_rules) } + = render Procedure::PendingRepublishComponent.new(procedure: @draft_revision.procedure, render_if: pending_changes?) + = render Conditions::ConditionsErrorsComponent.new(conditions: condition_per_row, source_tdcs: @source_tdcs) + .fr-fieldset + = form_for(@draft_revision, url: change_admin_procedure_ineligibilite_rules_path(@draft_revision.procedure_id), html: { id: 'ineligibilite_form', class: 'width-100', novalidate: true }) do |f| + .fr-fieldset__element + .fr-toggle.fr-toggle--label-left + = f.check_box :ineligibilite_enabled, class: 'fr-toggle__input', data: @opt + = f.label :ineligibilite_enabled, "Bloquer le dépôt des dossiers répondant à des conditions d’inéligibilité", data: { 'fr-checked-label': "Activé", 'fr-unchecked-label': "Désactivé" }, class: 'fr-toggle__label' + + .fr-fieldset__element= render Dsfr::InputComponent.new(form: f, attribute: :ineligibilite_message, input_type: :text_area, opts: {rows: 5}) + + .fr-mx-1w.fr-label.fr-py-0.fr-mb-1w.fr-mt-2w + Conditions d’inéligibilité + %span.fr-hint-text Vous pouvez utiliser une ou plusieurs condtions pour bloquer le dépot. + .fr-fieldset__element + = form_tag admin_procedure_ineligibilite_rules_path(@draft_revision.procedure_id), method: :patch, data: { turbo: true, controller: 'autosave' }, class: 'form width-100' do + .conditionnel.width-100 + %table.condition-table + - if rows.size > 0 + %thead + %tr + %th.fr-pt-0.far-left + %th.fr-pt-0.target Champ Cible + %th.fr-pt-0.operator Opérateur + %th.fr-pt-0.value Valeur + %th.fr-pt-0.delete-column + %tbody + - rows.each.with_index do |(targeted_champ, operator_name, value), row_index| + %tr + %td.far-left= far_left_tag(row_index) + %td.target= left_operand_tag(targeted_champ, row_index) + %td.operator= operator_tag(operator_name, targeted_champ, row_index) + %td.value= right_operand_tag(targeted_champ, value, row_index, operator_name) + %td.delete-column= delete_condition_tag(row_index) + %tfoot + %tr + %td.text-right{ colspan: 5 }= add_condition_tag + + .padded-fixed-footer + .fixed-footer + .fr-container + .fr-grid-row.fr-col-offset-md-2.fr-col-md-8 + .fr-col-12 + %ul.fr-btns-group.fr-btns-group--inline-md + %li + = link_to "Annuler et revenir à l’écran de gestion", admin_procedure_path(id: @draft_revision.procedure), class: 'fr-btn fr-btn--secondary', data: { confirm: 'Si vous avez fait des modifications elles ne seront pas sauvegardées.'} + %li + = button_tag "Enregistrer", class: "fr-btn", form: 'ineligibilite_form' diff --git a/app/components/conditions/routing_rules_component.rb b/app/components/conditions/routing_rules_component.rb index 4db0f0d19..74a957bcc 100644 --- a/app/components/conditions/routing_rules_component.rb +++ b/app/components/conditions/routing_rules_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Conditions::RoutingRulesComponent < Conditions::ConditionsComponent include Logic diff --git a/app/components/dossiers/accuse_lecture_component.rb b/app/components/dossiers/accuse_lecture_component.rb index 78fe76f7f..6e3c8c375 100644 --- a/app/components/dossiers/accuse_lecture_component.rb +++ b/app/components/dossiers/accuse_lecture_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dossiers::AccuseLectureComponent < ApplicationComponent def initialize(dossier:) @dossier = dossier diff --git a/app/components/dossiers/autosave_footer_component.rb b/app/components/dossiers/autosave_footer_component.rb index e0162bed5..52b8ed534 100644 --- a/app/components/dossiers/autosave_footer_component.rb +++ b/app/components/dossiers/autosave_footer_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dossiers::AutosaveFooterComponent < ApplicationComponent include ApplicationHelper attr_reader :dossier diff --git a/app/components/dossiers/autosave_footer_component/autosave_footer_component.html.haml b/app/components/dossiers/autosave_footer_component/autosave_footer_component.html.haml index 28039d90c..3a244233a 100644 --- a/app/components/dossiers/autosave_footer_component/autosave_footer_component.html.haml +++ b/app/components/dossiers/autosave_footer_component/autosave_footer_component.html.haml @@ -7,8 +7,6 @@ = t('.en_construction.explanation') - else = t('.brouillon.explanation') - - if !annotation? - = link_to t('.more_information'), t("links.common.faq.autosave_url"), class: 'autosave-more-infos fr-link fr-link--sm', **external_link_attributes %p.autosave-status.succeeded.fr-mb-0 = dsfr_icon('fr-icon-checkbox-circle-fill fr-text-default--success autosave-icon') @@ -19,8 +17,6 @@ = t('.en_construction.confirmation') - else = t('.brouillon.confirmation') - - if !annotation? - = link_to t('.more_information'), t("links.common.faq.autosave_url"), class: 'autosave-more-infos fr-link fr-link--sm', **external_link_attributes %p.autosave-status.failed.fr-mb-0 %span.autosave-icon ⚠️ diff --git a/app/components/dossiers/batch_alert_component.rb b/app/components/dossiers/batch_alert_component.rb index 67d5b9f3f..594b97f0f 100644 --- a/app/components/dossiers/batch_alert_component.rb +++ b/app/components/dossiers/batch_alert_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dossiers::BatchAlertComponent < ApplicationComponent attr_reader :batch diff --git a/app/components/dossiers/batch_operation_component.rb b/app/components/dossiers/batch_operation_component.rb index 7f090feaa..3ee024f69 100644 --- a/app/components/dossiers/batch_operation_component.rb +++ b/app/components/dossiers/batch_operation_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dossiers::BatchOperationComponent < ApplicationComponent attr_reader :statut, :procedure @@ -7,7 +9,7 @@ class Dossiers::BatchOperationComponent < ApplicationComponent end def render? - ['a-suivre', 'traites', 'suivis', 'archives', 'supprimes_recemment', 'expirant'].include?(@statut) + ['a-suivre', 'traites', 'suivis', 'archives', 'supprimes', 'expirant'].include?(@statut) end def operations_for_dossier(dossier) @@ -81,7 +83,7 @@ class Dossiers::BatchOperationComponent < ApplicationComponent } ] } - when 'supprimes_recemment' then + when 'supprimes' then { options: [ diff --git a/app/components/dossiers/batch_operation_component/batch_operation_component.html.haml b/app/components/dossiers/batch_operation_component/batch_operation_component.html.haml index f75cbc6da..c8357feca 100644 --- a/app/components/dossiers/batch_operation_component/batch_operation_component.html.haml +++ b/app/components/dossiers/batch_operation_component/batch_operation_component.html.haml @@ -13,6 +13,7 @@ -# Dropdown button title %button#batch_operation_others.fr-btn.fr-btn--sm.fr-btn--secondary.fr-ml-1w.dropdown-button{ disabled: true, data: { menu_button_target: 'button', batch_operation_target: 'dropdown' } } = t('.operations.other') + %span.fr-ml-2v{ 'aria-hidden': 'true' } #state-menu.dropdown-content.fade-in-down{ data: { menu_button_target: 'menu' }, "aria-labelledby" => "batch_operation_others" } %ul.dropdown-items diff --git a/app/components/dossiers/batch_operation_inline_buttons_component.rb b/app/components/dossiers/batch_operation_inline_buttons_component.rb index bba90741f..85327a882 100644 --- a/app/components/dossiers/batch_operation_inline_buttons_component.rb +++ b/app/components/dossiers/batch_operation_inline_buttons_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dossiers::BatchOperationInlineButtonsComponent < ApplicationComponent attr_reader :opt, :icons, :form diff --git a/app/components/dossiers/batch_select_more_component.rb b/app/components/dossiers/batch_select_more_component.rb index 7ed36b3d9..3ba807c96 100644 --- a/app/components/dossiers/batch_select_more_component.rb +++ b/app/components/dossiers/batch_select_more_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dossiers::BatchSelectMoreComponent < ApplicationComponent def initialize(dossiers_count:, filtered_sorted_ids:) @dossiers_count = dossiers_count diff --git a/app/components/dossiers/champs_rows_show_component.rb b/app/components/dossiers/champs_rows_show_component.rb index 838fb30cd..b4d5f9e27 100644 --- a/app/components/dossiers/champs_rows_show_component.rb +++ b/app/components/dossiers/champs_rows_show_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dossiers::ChampsRowsShowComponent < ApplicationComponent attr_reader :profile attr_reader :seen_at @@ -24,7 +26,7 @@ class Dossiers::ChampsRowsShowComponent < ApplicationComponent def blank_key(champ) key = ".blank_optional" - key += "_attachment" if champ.type_de_champ.piece_justificative? + key += "_attachment" if champ.type_de_champ.piece_justificative_or_titre_identite? key end diff --git a/app/components/dossiers/champs_rows_show_component/champs_rows_show_component.html.haml b/app/components/dossiers/champs_rows_show_component/champs_rows_show_component.html.haml index 2ff8db51a..4edba264e 100644 --- a/app/components/dossiers/champs_rows_show_component/champs_rows_show_component.html.haml +++ b/app/components/dossiers/champs_rows_show_component/champs_rows_show_component.html.haml @@ -23,7 +23,7 @@ - when TypeDeChamp.type_champs.fetch(:multiple_drop_down_list) = render partial: "shared/champs/multiple_drop_down_list/show", locals: { champ: champ } - when TypeDeChamp.type_champs.fetch(:piece_justificative), TypeDeChamp.type_champs.fetch(:titre_identite) - = render partial: "shared/champs/piece_justificative/show", locals: { champ: champ } + = render partial: "shared/champs/piece_justificative/show", locals: { champ: champ, profile: @profile } - when TypeDeChamp.type_champs.fetch(:siret) = render partial: "shared/champs/siret/show", locals: { champ: champ, profile: @profile } - when TypeDeChamp.type_champs.fetch(:iban) diff --git a/app/components/dossiers/deleted_dossiers_component.rb b/app/components/dossiers/deleted_dossiers_component.rb new file mode 100644 index 000000000..4e99cc402 --- /dev/null +++ b/app/components/dossiers/deleted_dossiers_component.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Dossiers::DeletedDossiersComponent < ApplicationComponent + include DossierHelper + + def initialize(deleted_dossiers:) + @deleted_dossiers = deleted_dossiers + end + + def role + controller.try(:nav_bar_profile) + end +end diff --git a/app/components/dossiers/deleted_dossiers_component/deleted_dossiers_component.en.yml b/app/components/dossiers/deleted_dossiers_component/deleted_dossiers_component.en.yml new file mode 100644 index 000000000..a3b9fdb95 --- /dev/null +++ b/app/components/dossiers/deleted_dossiers_component/deleted_dossiers_component.en.yml @@ -0,0 +1,7 @@ +en: + deleted_explanation: "The folders have been deleted. You can no longer recover them for the following reasons:" + deleted_explanation_first_instructor: The user intentionally deleted their folder. + deleted_explanation_second_instructor: The maximum retention period has expired. In accordance with GDPR regulations, the application cannot continue to host them. + deleted_explanation_first_user: You have deleted your folder. + deleted_explanation_second_user: The maximum retention period has expired. In accordance with GDPR regulations, the application cannot continue to host them. + no_deleted_folders: You have no permanently deleted folders. diff --git a/app/components/dossiers/deleted_dossiers_component/deleted_dossiers_component.fr.yml b/app/components/dossiers/deleted_dossiers_component/deleted_dossiers_component.fr.yml new file mode 100644 index 000000000..d29d35120 --- /dev/null +++ b/app/components/dossiers/deleted_dossiers_component/deleted_dossiers_component.fr.yml @@ -0,0 +1,7 @@ +fr: + deleted_explanation: "Les dossiers ont été supprimés. Vous ne pouvez plus les récupérer pour les raisons suivantes :" + deleted_explanation_first_instructeur: L’utilisateur a intentionnellement supprimé son dossier. + deleted_explanation_second_instructeur: Le délai de conservation maximal a expiré. Conformément au règlement RGPD, l'application ne peut continuer à les héberger. + deleted_explanation_first_user: Vous avez supprimé votre dossier. + deleted_explanation_second_user: Le délai de conservation maximal a expiré. Conformément au règlement RGPD, l'application ne peut continuer à les héberger. + no_deleted_dossiers: Vous n'avez pas de dossiers supprimés définitivement. diff --git a/app/components/dossiers/deleted_dossiers_component/deleted_dossiers_component.html.haml b/app/components/dossiers/deleted_dossiers_component/deleted_dossiers_component.html.haml new file mode 100644 index 000000000..1fc51132a --- /dev/null +++ b/app/components/dossiers/deleted_dossiers_component/deleted_dossiers_component.html.haml @@ -0,0 +1,47 @@ + +.fr-container + %h1.fr-h2 + Historique des dossiers supprimés + +.fr-container + - if @deleted_dossiers.present? + = render Dsfr::CalloutComponent.new(title: nil) do |c| + - c.with_body do + %p + = t('.deleted_explanation') + + %ul + %li + = t(".deleted_explanation_first_#{role}") + %li + = t(".deleted_explanation_second_#{role}") + + .fr-table.fr-table--layout-fixed.fr-mt-3w + %table + %thead + %tr + %th.number-col N° dossier + %th Libellé de la démarche + %th Raison de suppression + %th Date de suppression + %tbody + - @deleted_dossiers.each do |deleted_dossier| + %tr + %td.number-col + = deleted_dossier.dossier_id + + %td.number-col + = deleted_dossier.procedure.libelle.truncate_words(10) + + %td + = deletion_reason_badge(deleted_dossier.reason) + -# .fr-badge + -# = t("activerecord.attributes.deleted_dossier.reason.#{deleted_dossier.reason}") + %td.deleted-cell + = l(deleted_dossier.deleted_at, format: '%d/%m/%y') + + = paginate @deleted_dossiers, views_prefix: 'shared' + + - else + %p + = t('.no_deleted_dossiers') diff --git a/app/components/dossiers/edit_footer_component.rb b/app/components/dossiers/edit_footer_component.rb index fca7fab45..b5acc51f2 100644 --- a/app/components/dossiers/edit_footer_component.rb +++ b/app/components/dossiers/edit_footer_component.rb @@ -1,4 +1,8 @@ +# frozen_string_literal: true + class Dossiers::EditFooterComponent < ApplicationComponent + delegate :can_passer_en_construction?, to: :@dossier + def initialize(dossier:, annotation:) @dossier = dossier @annotation = annotation @@ -14,20 +18,29 @@ class Dossiers::EditFooterComponent < ApplicationComponent @annotation.present? end + def disabled_submit_buttons_options + { + class: 'fr-text--sm fr-mb-0 fr-mr-2w', + data: { 'fr-opened': "true" }, + aria: { controls: 'modal-eligibilite-rules-dialog' } + } + end + def submit_draft_button_options { class: 'fr-btn fr-btn--sm', - disabled: !owner?, + disabled: !owner? || !can_passer_en_construction?, method: :post, - data: { 'disable-with': t('.submitting'), controller: 'autosave-submit' } + data: { 'disable-with': t('.submitting'), controller: 'autosave-submit', turbo_force: :server } } end def submit_en_construction_button_options { class: 'fr-btn fr-btn--sm', + disabled: !can_passer_en_construction?, method: :post, - data: { 'disable-with': t('.submitting'), controller: 'autosave-submit' }, + data: { 'disable-with': t('.submitting'), controller: 'autosave-submit', turbo_force: :server }, form: { id: "form-submit-en-construction" } } end diff --git a/app/components/dossiers/edit_footer_component/edit_footer_component.en.yml b/app/components/dossiers/edit_footer_component/edit_footer_component.en.yml index 098e6ec0b..b6de7d121 100644 --- a/app/components/dossiers/edit_footer_component/edit_footer_component.en.yml +++ b/app/components/dossiers/edit_footer_component/edit_footer_component.en.yml @@ -2,5 +2,6 @@ en: submit: Submit the file submit_changes: Submit file changes + submit_disabled: File submission disabled submitting: Submitting… invite_notice: You are invited to make amendments to this file but only the owner themselves can submit it. diff --git a/app/components/dossiers/edit_footer_component/edit_footer_component.fr.yml b/app/components/dossiers/edit_footer_component/edit_footer_component.fr.yml index 33937aed6..8ffd062db 100644 --- a/app/components/dossiers/edit_footer_component/edit_footer_component.fr.yml +++ b/app/components/dossiers/edit_footer_component/edit_footer_component.fr.yml @@ -2,5 +2,6 @@ fr: submit: Déposer le dossier submit_changes: Déposer les modifications + submit_disabled: Pourquoi je ne peux pas déposer mon dossier ? submitting: Envoi en cours… invite_notice: En tant qu’invité, vous pouvez remplir ce formulaire – mais le titulaire du dossier doit le déposer lui-même. diff --git a/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml b/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml index 77540bd16..2f0f59b2b 100644 --- a/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml +++ b/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml @@ -3,8 +3,13 @@ = render Dossiers::AutosaveFooterComponent.new(dossier: @dossier, annotation: annotation?) - if !annotation? && @dossier.can_transition_to_en_construction? + - if !can_passer_en_construction? + = link_to t('.submit_disabled'), "#", disabled_submit_buttons_options = button_to t('.submit'), brouillon_dossier_url(@dossier), submit_draft_button_options - - elsif @dossier.forked_with_changes? + + - if @dossier.forked_with_changes? + - if !can_passer_en_construction? + = link_to t('.submit_disabled'), "#", disabled_submit_buttons_options = button_to t('.submit_changes'), modifier_dossier_url(@dossier.editing_fork_origin), submit_en_construction_button_options diff --git a/app/components/dossiers/errors_full_messages_component.rb b/app/components/dossiers/errors_full_messages_component.rb index fd8bafd94..01710ed7f 100644 --- a/app/components/dossiers/errors_full_messages_component.rb +++ b/app/components/dossiers/errors_full_messages_component.rb @@ -3,17 +3,14 @@ class Dossiers::ErrorsFullMessagesComponent < ApplicationComponent ErrorDescriptor = Data.define(:anchor, :label, :error_message) - def initialize(dossier:, errors:) + def initialize(dossier:) @dossier = dossier - @errors = errors end def dedup_and_partitioned_errors - formated_errors = @errors.to_enum # ActiveModel::Errors.to_a is an alias to full_messages, we don't want that + @dossier.errors.to_enum # ActiveModel::Errors.to_a is an alias to full_messages, we don't want that .to_a # but enum.to_a gives back an array - .uniq { |error| [error.inner_error.base] } # dedup cumulated errors from dossier.champs, dossier.champs_public, dossier.champs_private which run the validator one time per association .map { |error| to_error_descriptor(error) } - yield(Array(formated_errors[0..2]), Array(formated_errors[3..])) end def to_error_descriptor(error) @@ -27,6 +24,6 @@ class Dossiers::ErrorsFullMessagesComponent < ApplicationComponent end def render? - !@errors.empty? + !@dossier.errors.empty? end end diff --git a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.en.yml b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.en.yml index 3fab8164d..99235b8b1 100644 --- a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.en.yml +++ b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.en.yml @@ -1,8 +1,13 @@ --- en: sumup_html: - one: | - Your file has 1 error. Fix-it to continue : - other: | - Your file has %{count} errors. Fix-them to continue : - see_more: Show all errors + title: + one: | + Your file has 1 error + other: | + Your file has %{count} errors + content: + one: | + Fix-it to continue: + other: | + Fix-them to continue: diff --git a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.fr.yml b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.fr.yml index 1fd0e7f8c..8ed2c3f63 100644 --- a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.fr.yml +++ b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.fr.yml @@ -1,8 +1,13 @@ --- fr: sumup_html: - one: | - Votre dossier contient 1 champ en erreur. Corrigez-la pour poursuivre : - other: | - Votre dossier contient %{count} champs en erreurs. Corrigez-les pour poursuivre : - see_more: Afficher toutes les erreurs + title: + one: | + Votre dossier contient 1 champ en erreur + other: | + Votre dossier contient %{count} champs en erreur + content: + one: | + Corrigez-la pour poursuivre : + other: | + Corrigez-les pour poursuivre : diff --git a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml index 58d76cb56..01ffd40bf 100644 --- a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml +++ b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml @@ -1,15 +1,7 @@ -.fr-alert.fr-alert--error.fr-mb-3w{ role: "alertdialog" } - - dedup_and_partitioned_errors do |head, tail| - %p#sumup-errors= t('.sumup_html', count: head.size + tail.size, url: head.first.anchor) - %ul.fr-mb-0#head-errors - - head.each do |error_descriptor| - %li - = link_to error_descriptor.label, error_descriptor.anchor, class: 'error-anchor' - = error_descriptor.error_message - - if tail.size > 0 - %button{ type: "button", "aria-controls": 'tail-errors', "aria-expanded": "false", class: "fr-btn fr-btn--sm fr-btn--tertiary-no-outline" }= t('.see_more') - %ul#tail-errors.fr-collapse.fr-mt-0 - - tail.each do |error_descriptor| - %li - = link_to error_descriptor.label, error_descriptor.anchor, class: 'error-anchor' - = "(#{error_descriptor.error_message})" +.fr-alert.fr-alert--error.fr-mb-3w{ role: 'alert' } + + - if dedup_and_partitioned_errors.size > 0 + %h3#sumup-errors.fr-alert__title{ data: { controller: 'autofocus' }, tabindex: '-1' } + = t('.sumup_html.title', count: dedup_and_partitioned_errors.size) + %p= t('.sumup_html.content', count: dedup_and_partitioned_errors.size) + = render ExpandableErrorList.new(errors: dedup_and_partitioned_errors) diff --git a/app/components/dossiers/export_dropdown_component.rb b/app/components/dossiers/export_dropdown_component.rb index 098dae369..9710c3704 100644 --- a/app/components/dossiers/export_dropdown_component.rb +++ b/app/components/dossiers/export_dropdown_component.rb @@ -1,12 +1,16 @@ +# frozen_string_literal: true + class Dossiers::ExportDropdownComponent < ApplicationComponent include ApplicationHelper - def initialize(procedure:, statut: nil, count: nil, class_btn: nil, export_url: nil) + def initialize(procedure:, export_templates: nil, statut: nil, count: nil, class_btn: nil, export_url: nil, show_export_template_tab: true) @procedure = procedure + @export_templates = export_templates @statut = statut @count = count @class_btn = class_btn @export_url = export_url + @show_export_template_tab = show_export_template_tab end def formats @@ -21,10 +25,15 @@ class Dossiers::ExportDropdownComponent < ApplicationComponent item.fetch(:format) != :json || @procedure.active_revision.carte? end - def download_export_path(export_format:, no_progress_notification: nil) + def download_export_path(export_format: nil, export_template_id: nil, no_progress_notification: nil) @export_url.call(@procedure, - export_format: export_format, + export_format:, + export_template_id:, statut: @statut, no_progress_notification: no_progress_notification) end + + def export_templates + @export_templates + end end diff --git a/app/components/dossiers/export_dropdown_component/export_dropdown_component.en.yml b/app/components/dossiers/export_dropdown_component/export_dropdown_component.en.yml index d48aa0879..b0f1c0313 100644 --- a/app/components/dossiers/export_dropdown_component/export_dropdown_component.en.yml +++ b/app/components/dossiers/export_dropdown_component/export_dropdown_component.en.yml @@ -12,4 +12,4 @@ en: other: Download %{count} files macros_doc: title: "Macros documentation" - url: "https://doc.demarches-simplifiees.fr/pour-aller-plus-loin/exports-et-macros" + url: "https://docs.dgnum.eu/s/demarches-normaliennes/doc/exports-et-macros-sOxubsFKJd" diff --git a/app/components/dossiers/export_dropdown_component/export_dropdown_component.fr.yml b/app/components/dossiers/export_dropdown_component/export_dropdown_component.fr.yml index d5646ce34..8121125e3 100644 --- a/app/components/dossiers/export_dropdown_component/export_dropdown_component.fr.yml +++ b/app/components/dossiers/export_dropdown_component/export_dropdown_component.fr.yml @@ -12,4 +12,4 @@ fr: other: Télécharger %{count} dossiers macros_doc: title: "documentation sur les macros" - url: "https://doc.demarches-simplifiees.fr/pour-aller-plus-loin/exports-et-macros" + url: "https://docs.dgnum.eu/s/demarches-normaliennes/doc/exports-et-macros-sOxubsFKJd" diff --git a/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml b/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml index 12e064ae0..cbde910f2 100644 --- a/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml +++ b/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml @@ -1,16 +1,74 @@ -= render Dropdown::MenuComponent.new(wrapper: :span, button_options: { class: ['fr-btn--sm', @class_btn.present? ? @class_btn : 'fr-btn--secondary']}, menu_options: { id: @count.nil? ? "download_menu" : "download_all_menu", class: ['dropdown-export'] }) do |menu| - - menu.with_menu_header_html do - %p.menu-component-header.fr-px-2w.fr-pt-2w.fr-mb-0 - %span.fr-icon-info-line{ aria: { hidden: true } } - Des macros ? Lisez la - = link_to('doc', t('.macros_doc.url'), - title: t('.macros_doc.title'), - **external_link_attributes) - += render Dropdown::MenuComponent.new(wrapper: :div, button_options: { class: ['fr-btn--sm', @class_btn.present? ? @class_btn : 'fr-btn--secondary']}, menu_options: { id: @count.nil? ? "download_menu" : "download_all_menu", class: ['dropdown-export'] }) do |menu| - menu.with_button_inner_html do = @count.nil? ? t(".download_all") : t(".download", count: @count) - - formats.each do |format| - - menu.with_item do - = link_to download_export_path(export_format: format), role: 'menuitem', data: { turbo_method: :post, turbo: true } do - = t(".everything_#{format}_html") + - menu.with_form do + .fr-container + .fr-tabs.fr-my-3w + %ul.fr-tabs__list{ role: 'tablist' } + %li{ role: 'presentation' } + %button.fr-tabs__tab.fr-tabs__tab--icon-left{ id: "tabpanel-standard#{@count}", tabindex: "0", role: "tab", "aria-selected": "true", "aria-controls": "tabpanel-standard#{@count}-panel" } Standard + + - if @show_export_template_tab + %li{ role: 'presentation' } + %button.fr-tabs__tab.fr-tabs__tab--icon-left{ id: "tabpanel-template#{@count}", tabindex: "-1", role: "tab", "aria-selected": "false", "aria-controls": "tabpanel-template#{@count}-panel" } A partir d'un modèle + + .fr-tabs__panel.fr-pb-8w.fr-tabs__panel--selected{ id: "tabpanel-standard#{@count}-panel", role: "tabpanel", "aria-labelledby": "tabpanel-standard#{@count}", tabindex: "0" } + = form_with url: download_export_path, namespace: "export#{@count}", data: { turbo_method: :post, turbo: true } do |f| + = f.hidden_field :statut, value: @statut + %fieldset.fr-fieldset#radio-hint{ "aria-labelledby": "radio-hint-legend" } + %legend.fr-fieldset__legend--regular.fr-fieldset__legend#radio-hint-legend Séletionner le format de l'export + .fr-fieldset__element + .fr-radio-group + = f.radio_button :export_format, 'xlsx' + = f.label :export_format_xlsx, 'Fichier xlsx' + .fr-fieldset__element + .fr-radio-group + = f.radio_button :export_format, 'ods' + = f.label :export_format_ods, 'Fichier ods' + .fr-fieldset__element + .fr-radio-group + = f.radio_button :export_format, 'csv' + = f.label :export_format_csv do + Fichier csv + %span.fr-hint-text Uniquement les dossiers, sans les champs répétables + .fr-fieldset__element + .fr-radio-group + = f.radio_button :export_format, 'zip' + = f.label :export_format_zip do + Fichier zip + %span.fr-hint-text ne contient pas l'horodatage ni le journal de log + - if allowed_format?({format: :json}) + .fr-fieldset__element + .fr-radio-group + = f.radio_button :export_format, 'json' + = f.label :export_format_json do + Fichier geojson + + .fr-fieldset__element + %ul.fr-btns-group.fr-btns-group--sm.fr-btns-group--inline + %li + %button.fr-btn.fr-btn--secondary{ type: 'button', "data-action": "click->menu-button#close" } Annuler + %li + = f.submit "Demander l'export", "data-action": "click->menu-button#close", class: 'fr-btn' + + + - if @show_export_template_tab + .fr-tabs__panel.fr-pr-3w.fr-pb-8w{ id: "tabpanel-template#{@count}-panel", role: "tabpanel", "aria-labelledby": "tabpanel-template", tabindex: "0" } + = form_with url: download_export_path, namespace: "export_template_#{@count}", data: { turbo_method: :post, turbo: true } do |f| + = f.hidden_field :statut, value: @statut + .fr-select-group + - if export_templates.present? + %label.fr-label{ for: 'select' } + Sélectionner le modèle d'export + = f.collection_select :export_template_id, export_templates, :id, :name, {}, { class: "fr-select fr-mb-2w" } + - else + %p + %i Aucun modèle configuré + %p + = link_to "Configurer les modèles d'export", exports_instructeur_procedure_path(procedure_id: params[:procedure_id]), class: 'fr-link' + %ul.fr-btns-group.fr-btns-group--sm.fr-btns-group--inline + %li + %button.fr-btn.fr-btn--secondary{ type: 'button', "data-action": "click->menu-button#close" } Annuler + %li + = f.submit "Demander l'export", "data-action": "click->menu-button#close", class: 'fr-btn' diff --git a/app/components/dossiers/export_link_component.rb b/app/components/dossiers/export_link_component.rb index 0fe2967aa..f3ce43e34 100644 --- a/app/components/dossiers/export_link_component.rb +++ b/app/components/dossiers/export_link_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dossiers::ExportLinkComponent < ApplicationComponent include ApplicationHelper include TabsHelper @@ -11,9 +13,10 @@ class Dossiers::ExportLinkComponent < ApplicationComponent @export_url = export_url end - def download_export_path(export_format:, statut:, no_progress_notification: nil) + def download_export_path(export_format:, statut:, export_template_id: nil, no_progress_notification: nil) @export_url.call(@procedure, export_format: export_format, + export_template_id:, statut: statut, no_progress_notification: no_progress_notification) end @@ -27,7 +30,7 @@ class Dossiers::ExportLinkComponent < ApplicationComponent end def export_title(export) - if export.procedure_presentation_id.nil? + if !export.built_from_procedure_presentation? t(".export_title_everything", export_format: export.format) elsif export.tous? t(".export_title", export_format: export.format, count: export.count) diff --git a/app/components/dossiers/export_link_component/export_link_component.html.haml b/app/components/dossiers/export_link_component/export_link_component.html.haml index 0ed8d34a2..32d631e33 100644 --- a/app/components/dossiers/export_link_component/export_link_component.html.haml +++ b/app/components/dossiers/export_link_component/export_link_component.html.haml @@ -7,6 +7,9 @@ = export_title(export) %span.fr-text-mention--grey.fr-mb-1w = time_info(export) + - if export.export_template + %span.fr-tag.fr-tag--sm.fr-ml-1w + = export.export_template.name .fr-ml-auto = badge(export) @@ -14,4 +17,4 @@ = export_button(export) - if export.failed? - = button_to refresh_button_options(export)[:title], download_export_path(export_format: export.format, statut: export.statut), refresh_button_options(export) + = button_to refresh_button_options(export)[:title], download_export_path(export_template_id: export.export_template&.id, export_format: export.format, statut: export.statut), refresh_button_options(export) diff --git a/app/components/dossiers/geo_area_component.rb b/app/components/dossiers/geo_area_component.rb index 6e004d635..7e39fa2ed 100644 --- a/app/components/dossiers/geo_area_component.rb +++ b/app/components/dossiers/geo_area_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dossiers::GeoAreaComponent < ApplicationComponent attr_reader :geo_area, :editing diff --git a/app/components/dossiers/geo_areas_component.rb b/app/components/dossiers/geo_areas_component.rb index 51e0b93ab..dd7277f13 100644 --- a/app/components/dossiers/geo_areas_component.rb +++ b/app/components/dossiers/geo_areas_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dossiers::GeoAreasComponent < ApplicationComponent attr_reader :champ, :editing diff --git a/app/components/dossiers/individual_form_component.rb b/app/components/dossiers/individual_form_component.rb new file mode 100644 index 000000000..3dbe2d035 --- /dev/null +++ b/app/components/dossiers/individual_form_component.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Dossiers::IndividualFormComponent < ApplicationComponent + delegate :for_tiers?, to: :@dossier + + def initialize(dossier:) + @dossier = dossier + end + + def email_notifications?(individual) + individual.object.notification_method == Individual.notification_methods[:email] + end +end diff --git a/app/components/dossiers/individual_form_component/individual_form_component.en.yml b/app/components/dossiers/individual_form_component/individual_form_component.en.yml new file mode 100644 index 000000000..fbaffd2e5 --- /dev/null +++ b/app/components/dossiers/individual_form_component/individual_form_component.en.yml @@ -0,0 +1,7 @@ +--- +en: + self_title: Your identity + callout_text: "You are acting as a proxy for a principal, either professionally (such as accountant, lawyer, civil servant…) or personally (family). Make sure to respect the conditions of" + callout_link: Articles 1984 and following of the Civil Code. + callout_link_title: Articles 1984 and following of the Civil Code + beneficiaire_title: "Identity of the beneficiary" diff --git a/app/components/dossiers/individual_form_component/individual_form_component.fr.yml b/app/components/dossiers/individual_form_component/individual_form_component.fr.yml new file mode 100644 index 000000000..86512c87b --- /dev/null +++ b/app/components/dossiers/individual_form_component/individual_form_component.fr.yml @@ -0,0 +1,7 @@ +--- +fr: + self_title: Votre identité + callout_text: Vous agissez en tant que mandataire, soit professionnellement (comme expert-comptable, avocat, agent public…) soit personnellement (famille). Assurez-vous de respecter les conditions + callout_link: des Articles 1984 et suivants du Code civil. + callout_link_title: Articles 1984 et suivants du Code civil + beneficiaire_title: Identité du bénéficiaire diff --git a/app/components/dossiers/individual_form_component/individual_form_component.html.haml b/app/components/dossiers/individual_form_component/individual_form_component.html.haml new file mode 100644 index 000000000..961e9844a --- /dev/null +++ b/app/components/dossiers/individual_form_component/individual_form_component.html.haml @@ -0,0 +1,83 @@ += form_for @dossier, url: update_identite_dossier_path(@dossier), html: { id: 'identite-form', class: "form", "data-controller" => "for-tiers" } do |f| + = f.hidden_field :for_tiers + - if for_tiers? + .fr-alert.fr-alert--info.fr-mb-2w + %p.fr-notice__text + = t('.callout_text') + = link_to(t('.callout_link'), + 'https://www.legifrance.gouv.fr/codes/section_lc/LEGITEXT000006070721/LEGISCTA000006136404/#LEGISCTA000006136404', + title: helpers.new_tab_suffix(t('.callout_link_title')), + **helpers.external_link_attributes) + + %fieldset.fr-fieldset.mandataire-infos + %legend.fr-fieldset__legend--regular.fr-fieldset__legend + %h2.fr-h4 + = t('.self_title') + + .fr-fieldset__element.fr-fieldset__element--short-text + = render Dsfr::InputComponent.new(form: f, attribute: :mandataire_first_name, opts: { autocomplete: 'given-name' }) + + .fr-fieldset__element.fr-fieldset__element--short-text + = render Dsfr::InputComponent.new(form: f, attribute: :mandataire_last_name, opts: { autocomplete: 'family-name' }) + + = f.fields_for :individual, include_id: false do |individual| + %fieldset.fr-fieldset.fr-mb-0.individual-infos + %legend.fr-fieldset__legend--regular.fr-fieldset__legend + %h2.fr-h4 + - if for_tiers? + = t('.beneficiaire_title') + - else + = t('.self_title') + + .fr-fieldset__element.fr-mb-0 + %fieldset.fr-fieldset + %legend.fr-fieldset__legend--regular.fr-fieldset__legend + = t('activerecord.attributes.individual.gender') + = render EditableChamp::AsteriskMandatoryComponent.new + + .fr-fieldset__element + .fr-radio-group + = individual.radio_button :gender, Individual::GENDER_FEMALE, required: true, id: "identite_champ_radio_#{Individual::GENDER_FEMALE}" + %label.fr-label{ for: "identite_champ_radio_#{Individual::GENDER_FEMALE}" } + = Individual.human_attribute_name('gender.female') + .fr-fieldset__element + .fr-radio-group + = individual.radio_button :gender, Individual::GENDER_MALE, required: true, id: "identite_champ_radio_#{Individual::GENDER_MALE}" + %label.fr-label{ for: "identite_champ_radio_#{Individual::GENDER_MALE}" } + = Individual.human_attribute_name('gender.male') + .fr-fieldset__element.fr-mb-0 + .fr-fieldset.width-100 + .fr-fieldset__element.fr-fieldset__element--short-text + - if for_tiers? + = render Dsfr::InputComponent.new(form: individual, attribute: :prenom) + - else + = render Dsfr::InputComponent.new(form: individual, attribute: :prenom, opts: { autocomplete: 'given-name' }) + .fr-fieldset__element.fr-fieldset__element--short-text + - if for_tiers? + = render Dsfr::InputComponent.new(form: individual, attribute: :nom) + - else + = render Dsfr::InputComponent.new(form: individual, attribute: :nom, opts: { autocomplete: 'family-name' }) + + - if @dossier.procedure.ask_birthday? + .fr-fieldset__element + = render Dsfr::InputComponent.new(form: individual, attribute: :birthdate, input_type: :date_field, + opts: { placeholder: 'Format : AAAA-MM-JJ', max: Date.today.iso8601, min: "1900-01-01", autocomplete: 'bday' }) + + - if for_tiers? + %fieldset.fr-fieldset + %legend.fr-fieldset__legend--regular.fr-fieldset__legend + = t('activerecord.attributes.individual.notification_method') + = render EditableChamp::AsteriskMandatoryComponent.new + + - Individual.notification_methods.each do |method, _| + .fr-fieldset__element + .fr-radio-group + = individual.radio_button :notification_method, method, required: true, id: "notification_method_#{method}", "data-action" => "for-tiers#toggleEmailInput", "data-for-tiers-target" => "notificationMethod" + %label.fr-label{ for: "notification_method_#{method}" } + = t("activerecord.attributes.individual.notification_methods.#{method}") + + + .fr-fieldset__element.fr-fieldset__element--short-text{ "data-for-tiers-target" => "emailContainer", class: class_names(hidden: !email_notifications?(individual)) } + = render Dsfr::InputComponent.new(form: individual, attribute: :email, input_type: :email_field, opts: { "data-for-tiers-target" => "emailInput" }) + + = f.submit t('views.users.dossiers.identite.continue'), class: "fr-btn", "data-email-input-target": "next" diff --git a/app/components/dossiers/instructeur_filter_component.rb b/app/components/dossiers/instructeur_filter_component.rb deleted file mode 100644 index 5f55441b1..000000000 --- a/app/components/dossiers/instructeur_filter_component.rb +++ /dev/null @@ -1,23 +0,0 @@ -class Dossiers::InstructeurFilterComponent < ApplicationComponent - def initialize(procedure:, procedure_presentation:, statut:, field_id: nil) - @procedure = procedure - @procedure_presentation = procedure_presentation - @statut = statut - @field_id = field_id - end - - attr_reader :procedure, :procedure_presentation, :statut, :field_id - - def filterable_fields_for_select - procedure_presentation.filterable_fields_options - end - - def field_type - return :text if field_id.nil? - procedure_presentation.field_type(field_id) - end - - def options_for_select_of_field - procedure_presentation.field_enum(field_id) - end -end diff --git a/app/components/dossiers/instructeur_filter_component/instructeur_filter_component.html.haml b/app/components/dossiers/instructeur_filter_component/instructeur_filter_component.html.haml deleted file mode 100644 index 8afad1b45..000000000 --- a/app/components/dossiers/instructeur_filter_component/instructeur_filter_component.html.haml +++ /dev/null @@ -1,19 +0,0 @@ -= form_tag add_filter_instructeur_procedure_path(procedure), method: :post, class: 'dropdown-form large', id: 'filter-component', data: { turbo: true, controller: 'autosubmit' } do - .fr-select-group - = label_tag :field, t('.column'), class: 'fr-label fr-m-0', id: 'instructeur-filter-combo-label', for: 'search-filter' - = render Dsfr::ComboboxComponent.new form: nil, - options: filterable_fields_for_select, - selected: field_id, - input_html_options: { name: :field, id: 'search-filter', class: 'fr-select', describedby: 'instructeur-filter-combo-label', allows_custom_value: false, form_id: 'filter-component' }, - hidden_html_options: { data: { no_autosubmit: ['input', 'blur'].join(' '), no_autosubmit_on_empty: "true", autosubmit_target: 'input' } } - - %input.hidden{ type: 'submit', formaction: update_filter_instructeur_procedure_path(procedure), data: { autosubmit_target: 'submitter' } } - - = label_tag :value, t('.value'), for: 'value', class: 'fr-label' - - if field_type == :enum - = select_tag :value, options_for_select(options_for_select_of_field), id: 'value', name: 'value', class: 'fr-select', data: { no_autosubmit: true } - - else - %input#value.fr-input{ type: field_type, name: :value, maxlength: ProcedurePresentation::FILTERS_VALUE_MAX_LENGTH, disabled: field_id.nil? ? true : false, data: { no_autosubmit: true } } - - = hidden_field_tag :statut, statut - = submit_tag t('.add_filter'), class: 'fr-btn fr-btn--secondary fr-mt-2w' diff --git a/app/components/dossiers/invalid_ineligibilite_rules_component.rb b/app/components/dossiers/invalid_ineligibilite_rules_component.rb new file mode 100644 index 000000000..78047042f --- /dev/null +++ b/app/components/dossiers/invalid_ineligibilite_rules_component.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Dossiers::InvalidIneligibiliteRulesComponent < ApplicationComponent + delegate :can_passer_en_construction?, to: :@dossier + + def initialize(dossier:) + @dossier = dossier + @revision = dossier.revision + end + + def render? + !can_passer_en_construction? + end + + def error_message + @dossier.revision.ineligibilite_message + end +end diff --git a/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.en.yml b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.en.yml new file mode 100644 index 000000000..1a377763c --- /dev/null +++ b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.en.yml @@ -0,0 +1,6 @@ +fr: + modal: + title: "Your file does not match submission criteria" + close: "Close" + close_alt: "Close this modal" + body: "The procedure « %{procedure_libelle} » have submission criteria, unfortunately your file does not match them. You can not submit your file" \ No newline at end of file diff --git a/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.fr.yml b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.fr.yml new file mode 100644 index 000000000..d191f03d4 --- /dev/null +++ b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.fr.yml @@ -0,0 +1,5 @@ +fr: + modal: + title: "Vous ne pouvez pas déposer votre dossier" + close: "Fermer" + close_alt: "Fermer la fenêtre modale" \ No newline at end of file diff --git a/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.html.haml b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.html.haml new file mode 100644 index 000000000..dd39925cd --- /dev/null +++ b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.html.haml @@ -0,0 +1,16 @@ +%div{ id: dom_id(@dossier, :ineligibilite_rules_broken), data: { controller: 'ineligibilite-rules-match', turbo_force: :server } } + %button.fr-sr-only{ aria: {controls: 'modal-eligibilite-rules-dialog' }, data: {'fr-opened': "false" } } + show modal + + %dialog.fr-modal{ "aria-labelledby" => "fr-modal-title-modal-1", role: "dialog", id: 'modal-eligibilite-rules-dialog', data: { 'ineligibilite-rules-match-target' => 'dialog' } } + .fr-container.fr-container--fluid.fr-container-md + .fr-grid-row.fr-grid-row--center + .fr-col-12.fr-col-md-8.fr-col-lg-6 + .fr-modal__body + .fr-modal__header + %button.fr-btn--close.fr-btn{ aria: { controls: 'modal-eligibilite-rules-dialog' }, title: t('.modal.close_alt') }= t('.modal.close') + .fr-modal__content + %h1#fr-modal-title-modal-1.fr-modal__title + %span.fr-icon-arrow-right-line.fr-icon--lg> + = t('.modal.title') + %p= error_message diff --git a/app/components/dossiers/message_component.rb b/app/components/dossiers/message_component.rb index a67c72e44..1a1329c3c 100644 --- a/app/components/dossiers/message_component.rb +++ b/app/components/dossiers/message_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dossiers::MessageComponent < ApplicationComponent def initialize(commentaire:, connected_user:, messagerie_seen_at: nil, show_reply_button: false, groupe_gestionnaire: nil) @commentaire = commentaire diff --git a/app/components/dossiers/message_component/message_component.html.haml b/app/components/dossiers/message_component/message_component.html.haml index 1f944d151..4631c5d0d 100644 --- a/app/components/dossiers/message_component/message_component.html.haml +++ b/app/components/dossiers/message_component/message_component.html.haml @@ -27,7 +27,9 @@ - if groupe_gestionnaire.nil? && commentaire.piece_jointe.attached? .fr-ml-2w - = render Attachment::ShowComponent.new(attachment: commentaire.piece_jointe.attachment, new_tab: true) + - commentaire.piece_jointe.each do |attachment| + = render Attachment::ShowComponent.new(attachment: attachment, new_tab: true) + - if show_reply_button? = button_tag type: 'button', class: 'fr-btn fr-btn--sm fr-btn--secondary fr-icon-arrow-go-back-line fr-btn--icon-left', onclick: 'document.querySelector("#commentaire_body").focus()' do diff --git a/app/components/dossiers/notified_toggle_component.rb b/app/components/dossiers/notified_toggle_component.rb index 6b2f14473..bd3c3d9ec 100644 --- a/app/components/dossiers/notified_toggle_component.rb +++ b/app/components/dossiers/notified_toggle_component.rb @@ -1,37 +1,9 @@ +# frozen_string_literal: true + class Dossiers::NotifiedToggleComponent < ApplicationComponent - def initialize(procedure:, procedure_presentation:) - @procedure = procedure + def initialize(procedure_presentation:) @procedure_presentation = procedure_presentation - @current_sort = procedure_presentation.sort - end - - private - - def opposite_order - @procedure_presentation.opposite_order_for(current_table, current_column) - end - - def active? - sorted_by_notifications? && order_desc? - end - - def order_desc? - current_order == 'desc' - end - - def current_order - @current_sort['order'] - end - - def current_table - @current_sort['table'] - end - - def current_column - @current_sort['column'] - end - - def sorted_by_notifications? - current_table == 'notifications' && current_column == 'notifications' + @procedure = procedure_presentation.procedure + @sorted_column = procedure_presentation.sorted_column end end diff --git a/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml b/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml index 4f6eca594..1c1d44a4c 100644 --- a/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml +++ b/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml @@ -1,6 +1,9 @@ -= form_tag update_sort_instructeur_procedure_path(procedure_id: @procedure.id, table: 'notifications', column: 'notifications', order: opposite_order), method: :get, data: { controller: 'autosubmit' } do += form_with model: [:instructeur, @procedure_presentation], + data: { controller: 'autosubmit' } do .fr-fieldset__element.fr-m-0 .fr-checkbox-group.fr-checkbox-group--sm - = check_box_tag :order, opposite_order, active? - = label_tag :order, t('.show_notified_first'), class: 'fr-label' + = hidden_field_tag 'sorted_column[id]', @procedure.notifications_column.id + = hidden_field_tag 'sorted_column[order]', 'asc', id: nil + = check_box_tag 'sorted_column[order]', 'desc', @sorted_column.sort_by_notifications? + = label_tag 'sorted_column[order]', t('.show_notified_first'), class: 'fr-label' = submit_tag t('.show_notified_first'), data: {"checkbox-target": 'submit' }, class: 'visually-hidden' diff --git a/app/components/dossiers/row_show_component.rb b/app/components/dossiers/row_show_component.rb index eb08a1bbb..e4ef1cfcd 100644 --- a/app/components/dossiers/row_show_component.rb +++ b/app/components/dossiers/row_show_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dossiers::RowShowComponent < ApplicationComponent attr_reader :label attr_reader :profile diff --git a/app/components/dossiers/user_filter_component.rb b/app/components/dossiers/user_filter_component.rb index acbb62b66..c3bb83b00 100644 --- a/app/components/dossiers/user_filter_component.rb +++ b/app/components/dossiers/user_filter_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dossiers::UserFilterComponent < ApplicationComponent include DossierHelper diff --git a/app/components/dossiers/user_filter_component/user_filter_component.html.haml b/app/components/dossiers/user_filter_component/user_filter_component.html.haml index 52e270d4b..fe2338166 100644 --- a/app/components/dossiers/user_filter_component/user_filter_component.html.haml +++ b/app/components/dossiers/user_filter_component/user_filter_component.html.haml @@ -1,6 +1,6 @@ .fr-grid-row .fr-col-12 - %nav.fr-translate.fr-nav + .fr-translate.fr-nav .fr-nav__item.custom-fr-translate-flex-end %button.fr-translate__btn.translate-no-icon.fr-btn.fr-btn--tertiary.custom-fr-translate-no-icon{ "aria-controls" => "filters", "aria-expanded" => "false", title: t('.button.select_filters') } = t('.button.select_filters') diff --git a/app/components/dossiers/user_procedure_filter_component.rb b/app/components/dossiers/user_procedure_filter_component.rb index a0e349c97..e0cb0970a 100644 --- a/app/components/dossiers/user_procedure_filter_component.rb +++ b/app/components/dossiers/user_procedure_filter_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dossiers::UserProcedureFilterComponent < ApplicationComponent include DossierHelper diff --git a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.en.yml b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.en.yml index d6e123c4a..8577aa87d 100644 --- a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.en.yml +++ b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.en.yml @@ -1,4 +1,5 @@ -fr: +en: procedures: - label: Filter by procedure - prompt: All procedures + label: Show files by procedure + prompt: Select a procedure + button: Show diff --git a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.fr.yml b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.fr.yml index ae421496a..5bad01303 100644 --- a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.fr.yml +++ b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.fr.yml @@ -1,4 +1,5 @@ fr: procedures: - label: Filtrer par démarche - prompt: Toutes les démarches + label: Afficher les dossiers par démarche + prompt: Sélectionner une démarche + button: Afficher diff --git a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.html.haml b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.html.haml index 570ec76ff..4d0a003ac 100644 --- a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.html.haml +++ b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.html.haml @@ -1,5 +1,7 @@ -= form_with(url: dossiers_path, method: :get, data: { controller: 'autosubmit' } ) do |f| += form_with(url: dossiers_path, method: :get, class: "fr-mb-5w") do |f| = f.hidden_field :q, value: params[:q], id: nil - = f.label :procedure_id, t('.procedure.label'), class: 'sr-only' - .fr-input-group - = f.select :procedure_id, options_for_select(@procedures_for_select, params[:procedure_id]), { prompt: t('.procedures.prompt') }, class: 'fr-select' + = f.label :procedure_id, t('.procedures.label'), class: 'fr-label fr-mb-1w', for: 'procedure_select' + .flex + = f.select :procedure_id, options_for_select(@procedures_for_select, params[:procedure_id]), { prompt: t('.procedures.prompt') }, class: 'fr-select fr-mr-1w', id: 'procedure_select' + %button.fr-btn.fr-btn--sm{ 'aria-label': t('.procedures.label') } + = t('.procedures.button') diff --git a/app/components/dropdown/menu_component.rb b/app/components/dropdown/menu_component.rb index 964478d34..60dff5382 100644 --- a/app/components/dropdown/menu_component.rb +++ b/app/components/dropdown/menu_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dropdown::MenuComponent < ApplicationComponent renders_one :button_inner_html renders_one :menu_header_html diff --git a/app/components/dropdown/menu_component/menu_component.html.haml b/app/components/dropdown/menu_component/menu_component.html.haml index b1a98d8bc..7b6f16b4f 100644 --- a/app/components/dropdown/menu_component/menu_component.html.haml +++ b/app/components/dropdown/menu_component/menu_component.html.haml @@ -1,8 +1,9 @@ = content_tag(@wrapper, wrapper_options) do %button{ class: button_class_names, id: button_id, disabled: disabled?, data: data, "aria-expanded": "false", 'aria-haspopup': 'true', 'aria-controls': menu_id } = button_inner_html + %span.fr-ml-2v{ 'aria-hidden': 'true' } - %div{ data: { menu_button_target: 'menu' }, id: menu_id, 'aria-labelledby': button_id, role: menu_role, 'tab-index': -1, class: menu_class_names } + %div{ data: { menu_button_target: 'menu' }, id: menu_id, 'aria-labelledby': button_id, role: menu_role, 'tabindex': -1, class: menu_class_names } = menu_header_html -# the dropdown can be a menu with a list of item diff --git a/app/components/dsfr/alert_component.rb b/app/components/dsfr/alert_component.rb index 7323f639b..6324d3f7d 100644 --- a/app/components/dsfr/alert_component.rb +++ b/app/components/dsfr/alert_component.rb @@ -1,7 +1,20 @@ +# frozen_string_literal: true + # see: https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/alerte class Dsfr::AlertComponent < ApplicationComponent renders_one :body + attr_reader :state, :title, :size, :block, :extra_class_names, :heading_level + + def initialize(state:, title: '', size: '', extra_class_names: nil, heading_level: 'h3') + @state = state + @title = title + @size = size + @block = block + @extra_class_names = extra_class_names + @heading_level = heading_level + end + def prefix_for_state case state when :error then "Erreur : " @@ -19,19 +32,4 @@ class Dsfr::AlertComponent < ApplicationComponent extra_class_names => true ) end - - private - - def initialize(state:, title: '', size: '', extra_class_names: nil, heading_level: 'h3') - @state = state - @title = title - @size = size - @block = block - @extra_class_names = extra_class_names - @heading_level = heading_level - end - - attr_reader :state, :title, :size, :block, :extra_class_names, :heading_level - - private end diff --git a/app/components/dsfr/callout_component.rb b/app/components/dsfr/callout_component.rb index 2d394d939..b2e587d8f 100644 --- a/app/components/dsfr/callout_component.rb +++ b/app/components/dsfr/callout_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # see: https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/mise-en-avant class Dsfr::CalloutComponent < ApplicationComponent renders_one :body @@ -26,6 +28,8 @@ class Dsfr::CalloutComponent < ApplicationComponent "fr-callout--brown-caramel" when :success "fr-callout--green-emeraude" + when :neutral + # default else "fr-background-alt--blue-france" end diff --git a/app/components/dsfr/card_vertical_component.rb b/app/components/dsfr/card_vertical_component.rb index 01412fcb5..7627ec0bf 100644 --- a/app/components/dsfr/card_vertical_component.rb +++ b/app/components/dsfr/card_vertical_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dsfr::CardVerticalComponent < ApplicationComponent renders_many :footer_buttons diff --git a/app/components/dsfr/combobox_component.rb b/app/components/dsfr/combobox_component.rb index a90548ff7..c94849c35 100644 --- a/app/components/dsfr/combobox_component.rb +++ b/app/components/dsfr/combobox_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dsfr::ComboboxComponent < ApplicationComponent def initialize(form: nil, options: nil, url: nil, selected: nil, allows_custom_value: false, limit: nil, input_html_options: {}, hidden_html_options: {}) @form, @options, @url, @selected, @allows_custom_value, @limit, @input_html_options, @hidden_html_options = form, options, url, selected, allows_custom_value, limit, input_html_options, hidden_html_options diff --git a/app/components/dsfr/combobox_component/combobox_component.en.yml b/app/components/dsfr/combobox_component/combobox_component.en.yml deleted file mode 100644 index e24b49f92..000000000 --- a/app/components/dsfr/combobox_component/combobox_component.en.yml +++ /dev/null @@ -1,10 +0,0 @@ -en: - sr: - results: - zero: No result - one: 1 result - other: "{count} results" - results_with_label: - one: "1 result. {label} is the top result – press Enter to activate" - other: "{count} results. {label} is the top result – press Enter to activate" - selected: "{label} selected" diff --git a/app/components/dsfr/combobox_component/combobox_component.fr.yml b/app/components/dsfr/combobox_component/combobox_component.fr.yml deleted file mode 100644 index dc76ad006..000000000 --- a/app/components/dsfr/combobox_component/combobox_component.fr.yml +++ /dev/null @@ -1,10 +0,0 @@ -fr: - sr: - results: - zero: Aucun résultat - one: 1 résultat - other: "{count} résultats" - results_with_label: - one: "1 résultat. {label} est le premier résultat – appuyez sur Entrée pour sélectionner" - other: "{count} résultats. {label} est le premier résultat – appuyez sur Entrée pour sélectionner" - selected: "{label} sélectionné" diff --git a/app/components/dsfr/combobox_component/combobox_component.html.haml b/app/components/dsfr/combobox_component/combobox_component.html.haml deleted file mode 100644 index 47dc64b3b..000000000 --- a/app/components/dsfr/combobox_component/combobox_component.html.haml +++ /dev/null @@ -1,14 +0,0 @@ -.fr-ds-combobox{ data: { controller: 'combobox', allows_custom_value: allows_custom_value, limit: limit } } - .fr-ds-combobox-input - %input{ value: selected_option_label_input_value, **html_input_options } - - if form - = form.hidden_field name, value: selected_option_value_input_value, form: form_id, **@hidden_html_options - - else - %input{ type: 'hidden', name: name, value: selected_option_value_input_value, form: form_id, **@hidden_html_options } - .fr-menu - %ul.fr-menu__list.hidden{ role: 'listbox', hidden: true, id: list_id, data: { turbo_force: :browser, options: options_json, url:, hints: hints_json }.compact } - .sr-only{ aria: { live: 'polite', atomic: 'true' }, data: { turbo_force: :browser } } - %template - %li.fr-menu__item{ role: 'option' } - %slot{ name: 'label' } - = content diff --git a/app/components/dsfr/copy_button_component.rb b/app/components/dsfr/copy_button_component.rb index 7de513bae..d9afdbd40 100644 --- a/app/components/dsfr/copy_button_component.rb +++ b/app/components/dsfr/copy_button_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dsfr::CopyButtonComponent < ApplicationComponent def initialize(text:, title:, success: nil) @text = text diff --git a/app/components/dsfr/download_component.rb b/app/components/dsfr/download_component.rb index ef1e8862f..b576c843e 100644 --- a/app/components/dsfr/download_component.rb +++ b/app/components/dsfr/download_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dsfr::DownloadComponent < ApplicationComponent attr_reader :attachment attr_reader :html_class @@ -5,18 +7,21 @@ class Dsfr::DownloadComponent < ApplicationComponent attr_reader :ephemeral_link attr_reader :virus_not_analyzed attr_reader :new_tab + attr_reader :truncate - def initialize(attachment:, name: nil, url: nil, ephemeral_link: false, virus_not_analyzed: false, new_tab: false) + def initialize(attachment:, name: nil, url: nil, ephemeral_link: false, virus_not_analyzed: false, new_tab: false, truncate: false, title: nil) @attachment = attachment @name = name || attachment.filename.to_s @url = url @ephemeral_link = ephemeral_link @virus_not_analyzed = virus_not_analyzed @new_tab = new_tab + @truncate = truncate + @title = title end def title - t(".title", filename: attachment.filename.to_s) + @title || t(".title", filename: attachment.filename.to_s) end def url diff --git a/app/components/dsfr/download_component/download_component.html.haml b/app/components/dsfr/download_component/download_component.html.haml index 71d53624a..cd30a9f57 100644 --- a/app/components/dsfr/download_component/download_component.html.haml +++ b/app/components/dsfr/download_component/download_component.html.haml @@ -1,7 +1,7 @@ .fr-download %p = link_to url, {class: "fr-download__link", title: title}.merge(new_tab ? { target: '_blank' } : { download: '' }) do - = name + = truncate ? name.truncate(20) : name %span.fr-download__detail = helpers.download_details(attachment) - if ephemeral_link diff --git a/app/components/dsfr/input_component.rb b/app/components/dsfr/input_component.rb index 3ee07149b..39ddd0020 100644 --- a/app/components/dsfr/input_component.rb +++ b/app/components/dsfr/input_component.rb @@ -1,9 +1,13 @@ +# frozen_string_literal: true + class Dsfr::InputComponent < ApplicationComponent include Dsfr::InputErrorable delegate :object, to: :@form delegate :errors, to: :object + attr_reader :attribute + # use it to indicate detailed about the inputs, ex: https://www.systeme-de-design.gouv.fr/elements-d-interface/modeles-et-blocs-fonctionnels/demande-de-mot-de-passe # it uses aria-describedby on input and link it to yielded content renders_one :describedby @@ -32,7 +36,7 @@ class Dsfr::InputComponent < ApplicationComponent }.merge(input_group_error_class_names)) } if email? - opts[:data] = { controller: 'email-input' } + opts[:data] = { controller: 'email-input', email_input_url_value: show_email_suggestions_path } end opts end diff --git a/app/components/dsfr/input_component/input_component.en.yml b/app/components/dsfr/input_component/input_component.en.yml index dcade2424..579c001ee 100644 --- a/app/components/dsfr/input_component/input_component.en.yml +++ b/app/components/dsfr/input_component/input_component.en.yml @@ -4,4 +4,5 @@ en: aria_label: "Show password" label: "Show" email_suggest: - wanna_say: 'Do you mean to say ?' + mistake: "The address seems wrong" + wanna_say: "Do you mean to say:" diff --git a/app/components/dsfr/input_component/input_component.fr.yml b/app/components/dsfr/input_component/input_component.fr.yml index b6cd7a3b8..6681e4f05 100644 --- a/app/components/dsfr/input_component/input_component.fr.yml +++ b/app/components/dsfr/input_component/input_component.fr.yml @@ -4,4 +4,5 @@ fr: aria_label: "Afficher le mot de passe" label: "Afficher" email_suggest: - wanna_say: 'Voulez-vous dire ?' + mistake: "L'adresse semble erronée" + wanna_say: "Vouliez-vous écrire :" diff --git a/app/components/dsfr/input_component/input_component.html.haml b/app/components/dsfr/input_component/input_component.html.haml index bbf1255cd..b9debdc9a 100644 --- a/app/components/dsfr/input_component/input_component.html.haml +++ b/app/components/dsfr/input_component/input_component.html.haml @@ -29,10 +29,14 @@ %label.fr--password__checkbox.fr-label{ for: show_password_id }= t('.show_password.label') - if email? - .suspect-email.hidden{ data: { "email-input-target": 'ariaRegion'}, aria: { live: 'off' } } - = render Dsfr::AlertComponent.new(title: t('.email_suggest.wanna_say'), state: :info, heading_level: :div) do |c| + .suspect-email.hidden{ data: { "email-input-target": 'ariaRegion' }, tabindex: '-1' } + = render Dsfr::AlertComponent.new(title: t('.email_suggest.mistake'), state: '', extra_class_names: 'fr-alert--info' ) do |c| - c.with_body do - %p{ data: { "email-input-target": 'suggestion'} } exemple@gmail.com  ? + %p + = t('.email_suggest.wanna_say') + %span{ data: { "email-input-target": 'suggestion'} } + exemple@gmail.com + = "?" %p = button_tag type: 'button', class: 'fr-btn fr-btn--sm fr-mr-3w', data: { action: 'click->email-input#accept'} do = t('utils.yes') diff --git a/app/components/dsfr/input_errorable.rb b/app/components/dsfr/input_errorable.rb index 836ab9c1b..91fcfcde2 100644 --- a/app/components/dsfr/input_errorable.rb +++ b/app/components/dsfr/input_errorable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Dsfr module InputErrorable extend ActiveSupport::Concern @@ -21,7 +23,7 @@ module Dsfr { "#{dsfr_group_classname}--error" => errors_on_attribute?, - "#{dsfr_group_classname}--valid" => !errors_on_attribute? && errors_on_another_attribute? + "#{dsfr_group_classname}--valid" => !errors_on_attribute? && errors_on_another_attribute? && object.public_send(attribute).present? } end @@ -49,9 +51,9 @@ module Dsfr def attribute_or_rich_body case @input_type when :rich_text_area - @attribute.to_s.sub(/\Arich_/, '').to_sym + attribute.to_s.sub(/\Arich_/, '').to_sym else - @attribute + attribute end end @@ -73,22 +75,26 @@ module Dsfr } end - def input_opts(other_opts = {}) + def react_input_opts(other_opts = {}) + input_opts(other_opts, true) + end + + def input_opts(other_opts = {}, react = false) @opts = @opts.deep_merge!(other_opts) - @opts[:class] = class_names(map_array_to_hash_with_true(@opts[:class]) + @opts[react ? :class_name : :class] = class_names(map_array_to_hash_with_true(@opts[:class]) .merge({ 'fr-password__input': password?, - 'fr-input': true, + 'fr-input': !react, 'fr-mb-0': true }.merge(input_error_class_names))) if errors_on_attribute? - @opts.deep_merge!(aria: { describedby: describedby_id }) + @opts.deep_merge!('aria-describedby': describedby_id) elsif hintable? - @opts.deep_merge!(aria: { describedby: hint_id }) + @opts.deep_merge!('aria-describedby': hint_id) end if @required - @opts[:required] = true + @opts[react ? :is_required : :required] = true end if email? @@ -98,9 +104,7 @@ module Dsfr }) end - if autoresize? - @opts.deep_merge!(data: { controller: 'autoresize' }) - end + @opts.deep_merge!(data: { controller: token_list(@opts.dig(:data, :controller), 'autoresize' => autoresize?) }) @opts end @@ -125,6 +129,8 @@ module Dsfr end end + def hint? = hint.present? + def password? false end @@ -140,15 +146,6 @@ module Dsfr def hintable? false end - - def hint? - return true if get_slot(:hint).present? - - maybe_hint = I18n.exists?("activerecord.attributes.#{object.class.name.underscore}.hints.#{@attribute}") - maybe_hint_html = I18n.exists?("activerecord.attributes.#{object.class.name.underscore}.hints.#{@attribute}_html") - - maybe_hint || maybe_hint_html - end end end end diff --git a/app/components/dsfr/input_status_message_component.rb b/app/components/dsfr/input_status_message_component.rb index 545a6a68e..78a88a0da 100644 --- a/app/components/dsfr/input_status_message_component.rb +++ b/app/components/dsfr/input_status_message_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Dsfr class InputStatusMessageComponent < ApplicationComponent def initialize(errors_on_attribute:, error_full_messages:, describedby_id:, champ:) diff --git a/app/components/dsfr/input_status_message_component/input_status_message_component.html.haml b/app/components/dsfr/input_status_message_component/input_status_message_component.html.haml index 305d2d07f..9edef67b1 100644 --- a/app/components/dsfr/input_status_message_component/input_status_message_component.html.haml +++ b/app/components/dsfr/input_status_message_component/input_status_message_component.html.haml @@ -1,4 +1,4 @@ -.fr-messages-group{ id: @describedby_id, aria: { live: :assertive } } +.fr-messages-group{ id: @describedby_id } - if @error_full_messages.size > 0 %p{ class: class_names('fr-message' => true, "fr-message--#{@errors_on_attribute ? 'error' : 'valid'}" => true) } = "« #{@champ.libelle} » " diff --git a/app/components/dsfr/list_component.rb b/app/components/dsfr/list_component.rb index 19bcd83b9..93b567397 100644 --- a/app/components/dsfr/list_component.rb +++ b/app/components/dsfr/list_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dsfr::ListComponent < ApplicationComponent renders_many :items renders_one :empty diff --git a/app/components/dsfr/notice_component.rb b/app/components/dsfr/notice_component.rb index 44cae0b70..fdb1aa3a2 100644 --- a/app/components/dsfr/notice_component.rb +++ b/app/components/dsfr/notice_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # see: https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/bandeau-d-information-importante/ class Dsfr::NoticeComponent < ApplicationComponent renders_one :title diff --git a/app/components/dsfr/radio_button_list_component.rb b/app/components/dsfr/radio_button_list_component.rb index fc67c3625..df84138a3 100644 --- a/app/components/dsfr/radio_button_list_component.rb +++ b/app/components/dsfr/radio_button_list_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dsfr::RadioButtonListComponent < ApplicationComponent attr_reader :error @@ -15,8 +17,8 @@ class Dsfr::RadioButtonListComponent < ApplicationComponent end def each_button - @buttons.each do |button| - yield(*button.values_at(:label, :value, :hint), **button.except(:label, :value, :hint)) + @buttons.each.with_index do |button, index| + yield(*button.values_at(:label, :value, :hint, :tooltip), **button.merge!(index:).except(:label, :value, :hint, :tooltip)) end end end diff --git a/app/components/dsfr/radio_button_list_component/radio_button_list_component.html.haml b/app/components/dsfr/radio_button_list_component/radio_button_list_component.html.haml index 8776b11af..8c58448ad 100644 --- a/app/components/dsfr/radio_button_list_component/radio_button_list_component.html.haml +++ b/app/components/dsfr/radio_button_list_component/radio_button_list_component.html.haml @@ -1,18 +1,22 @@ %fieldset{ class: class_names("fr-fieldset": true, "fr-fieldset--error": error?), 'aria-labelledby': 'radio-hint-element-legend radio-hint-element-messages', role: error? ? :group : nil } %legend.fr-fieldset__legend--regular.fr-fieldset__legend = content - - - each_button do |label, value, hint, **button_options| + - each_button do |label, value, hint, tooltip, **button_options| .fr-fieldset__element .fr-radio-group - = @form.radio_button @target, value, **button_options + = @form.radio_button @target, value, **button_options.except(:index) = @form.label @target, value: value, class: 'fr-label' do - capture do = label = button_options[:after_label] if button_options[:after_label] - %span.fr-hint-text= hint if hint + - if hint.present? + .flex + .fr-hint-text= hint + - if tooltip.present? && button_options[:index] + .fr-icon-information-line.fr-icon--sm.ml-1{ 'aria-describedby': "tooltip-#{button_options[:index]}" } + %span.fr-tooltip.fr-placement{ id: "tooltip-#{button_options[:index]}", role: 'tooltip', 'aria-hidden': 'true' }= tooltip .fr-messages-group{ 'aria-live': 'assertive' } - if error? diff --git a/app/components/dsfr/sidemenu_component.rb b/app/components/dsfr/sidemenu_component.rb index 31c2730e4..930b117ca 100644 --- a/app/components/dsfr/sidemenu_component.rb +++ b/app/components/dsfr/sidemenu_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Dsfr::SidemenuComponent < ApplicationComponent renders_many :links, "LinkComponent" diff --git a/app/components/dsfr/toggle_component.rb b/app/components/dsfr/toggle_component.rb index 20c328e9b..7e7f3ca40 100644 --- a/app/components/dsfr/toggle_component.rb +++ b/app/components/dsfr/toggle_component.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class Dsfr::ToggleComponent < ApplicationComponent - def initialize(form:, target:, title:, disabled: nil, hint: nil, toggle_labels: { checked: 'Activé', unchecked: 'Désactivé' }, opt: nil) + def initialize(form:, target:, title:, disabled: nil, hint: nil, toggle_labels: { checked: 'Activé', unchecked: 'Désactivé' }, opt: nil, extra_class_names: nil) @form = form @target = target @title = title @@ -7,7 +9,8 @@ class Dsfr::ToggleComponent < ApplicationComponent @disabled = disabled @toggle_labels = toggle_labels @opt = opt + @extra_class_names = extra_class_names end - attr_reader :toggle_labels + attr_reader :toggle_labels, :extra_class_names end diff --git a/app/components/dsfr/toggle_component/toggle_component.html.haml b/app/components/dsfr/toggle_component/toggle_component.html.haml index 7769a3724..18bde573d 100644 --- a/app/components/dsfr/toggle_component/toggle_component.html.haml +++ b/app/components/dsfr/toggle_component/toggle_component.html.haml @@ -1,4 +1,4 @@ -.fr-toggle.fr-toggle--label-left +%div{ class: "fr-toggle fr-toggle--label-left #{extra_class_names}" } = @form.check_box @target, class: 'fr-toggle__input', disabled: @disabled, data: @opt = @form.label @target, diff --git a/app/components/editable_champ/address_component.rb b/app/components/editable_champ/address_component.rb index 0bcefb5e0..94ed6d9eb 100644 --- a/app/components/editable_champ/address_component.rb +++ b/app/components/editable_champ/address_component.rb @@ -1,5 +1,18 @@ +# frozen_string_literal: true + class EditableChamp::AddressComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname 'fr-select' end + + def react_props + react_input_opts(id: @champ.input_id, + class: 'fr-mt-1w', + name: @form.field_name(:value), + selected_key: @champ.value, + items: @champ.selected_items, + loader: data_sources_data_source_adresse_path, + minimum_input_length: 2, + allows_custom_value: true) + end end diff --git a/app/components/editable_champ/address_component/address_component.html.haml b/app/components/editable_champ/address_component/address_component.html.haml index e1029f05a..6df764db4 100644 --- a/app/components/editable_champ/address_component/address_component.html.haml +++ b/app/components/editable_champ/address_component/address_component.html.haml @@ -1,3 +1,3 @@ -= render Dsfr::ComboboxComponent.new form: @form, url: data_sources_data_source_adresse_path, selected: @champ.value, allows_custom_value: true, input_html_options: { name: :value, id: @champ.input_id, class: 'fr-select', describedby: @champ.describedby_id } do - = @form.hidden_field :external_id, data: { value_slot: 'value' } - = @form.hidden_field :feature, data: { value_slot: 'data' } +%react-fragment + = render ReactComponent.new "ComboBox/RemoteComboBox", **react_props do + = render ReactComponent.new "ComboBox/ComboBoxValueSlot", field: :data, name: @form.field_name(:feature) diff --git a/app/components/editable_champ/annuaire_education_component.rb b/app/components/editable_champ/annuaire_education_component.rb index 847b6cc21..94e6e8ead 100644 --- a/app/components/editable_champ/annuaire_education_component.rb +++ b/app/components/editable_champ/annuaire_education_component.rb @@ -1,12 +1,19 @@ -class EditableChamp::AnnuaireEducationComponent < EditableChamp::ComboSearchComponent +# frozen_string_literal: true + +class EditableChamp::AnnuaireEducationComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname - 'fr-input' + 'fr-select' end - def react_input_opts - opts = input_opts(id: @champ.input_id, required: @champ.required?, aria: { describedby: @champ.describedby_id }) - opts[:className] = "#{opts.delete(:class)} fr-mt-1w" - - opts + def react_props + react_input_opts(id: @champ.input_id, + class: "fr-mt-1w", + name: @form.field_name(:external_id), + selected_key: @champ.external_id, + items: @champ.selected_items, + loader: 'https://data.education.gouv.fr/api/records/1.0/search?dataset=fr-en-annuaire-education&rows=5', + coerce: 'AnnuaireEducation', + debounce: 500, + minimum_input_length: 5) end end diff --git a/app/components/editable_champ/annuaire_education_component/annuaire_education_component.html.haml b/app/components/editable_champ/annuaire_education_component/annuaire_education_component.html.haml index 00ce7bbba..a3a489e4d 100644 --- a/app/components/editable_champ/annuaire_education_component/annuaire_education_component.html.haml +++ b/app/components/editable_champ/annuaire_education_component/annuaire_education_component.html.haml @@ -1,7 +1,3 @@ -- render_parent - -= @form.hidden_field :value -= @form.hidden_field :external_id -= react_component("ComboAnnuaireEducationSearch", - **react_input_opts, - **react_combo_props) +%react-fragment + = render ReactComponent.new "ComboBox/RemoteComboBox", **react_props do + = render ReactComponent.new "ComboBox/ComboBoxValueSlot", field: :label, name: @form.field_name(:value) diff --git a/app/components/editable_champ/asterisk_mandatory_component.rb b/app/components/editable_champ/asterisk_mandatory_component.rb index d71d395a0..59f4e3d0b 100644 --- a/app/components/editable_champ/asterisk_mandatory_component.rb +++ b/app/components/editable_champ/asterisk_mandatory_component.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class EditableChamp::AsteriskMandatoryComponent < ApplicationComponent end diff --git a/app/components/editable_champ/carte_component.rb b/app/components/editable_champ/carte_component.rb index 864887f15..fbbe6d6f7 100644 --- a/app/components/editable_champ/carte_component.rb +++ b/app/components/editable_champ/carte_component.rb @@ -1,12 +1,33 @@ +# frozen_string_literal: true + class EditableChamp::CarteComponent < EditableChamp::EditableChampBaseComponent include ApplicationHelper def dsfr_champ_container :fieldset end - def initialize(**args) - super(**args) + def react_props + { + feature_collection: @champ.to_feature_collection, + champ_id: @champ.input_id, + url: update_path, + adresse_source: data_sources_data_source_adresse_path, + options: @champ.render_options, + translations: { + address_input_label: t(".address_input_label"), + address_input_description: t(".address_input_description"), + pin_input_label: t(".pin_input_label"), + pin_input_description: t(".pin_input_description"), + show_pin: t(".show_pin"), + add_pin: t(".add_pin"), + add_file: t(".add_file"), + choose_file: t(".choose_file"), + delete_file: t(".delete_file") + } + } + end - @autocomplete_component = EditableChamp::ComboSearchComponent.new(**args) + def update_path + champs_carte_features_path(@champ.dossier, @champ.stable_id, row_id: @champ.row_id) end end diff --git a/app/components/editable_champ/carte_component/carte_component.en.yml b/app/components/editable_champ/carte_component/carte_component.en.yml new file mode 100644 index 000000000..1ec1620cf --- /dev/null +++ b/app/components/editable_champ/carte_component/carte_component.en.yml @@ -0,0 +1,11 @@ +--- +en: + address_input_label: "Find an address" + address_input_description: "Enter at least 2 characters" + pin_input_label: "Add a point to the map" + pin_input_description: "Example: " + show_pin: "Show your location on the map" + add_pin: "Add the point with the coordinates entered on the map" + add_file: "Add a GPX or KML file" + choose_file: "Choose a GPX or KML file" + delete_file: "Delete the file" diff --git a/app/components/editable_champ/carte_component/carte_component.fr.yml b/app/components/editable_champ/carte_component/carte_component.fr.yml new file mode 100644 index 000000000..e54f53112 --- /dev/null +++ b/app/components/editable_champ/carte_component/carte_component.fr.yml @@ -0,0 +1,11 @@ +--- +fr: + address_input_label: "Rechercher une adresse" + address_input_description: "Saisissez au moins 2 caractères" + pin_input_label: "Ajouter un point sur la carte" + pin_input_description: "Exemple : " + show_pin: "Afficher votre position sur la carte" + add_pin: "Ajouter le point avec les coordonnées saisies sur la carte" + add_file: "Ajouter un fichier GPX ou KML" + choose_file: "Choisir un fichier GPX ou KML" + delete_file: "Supprimer le fichier" diff --git a/app/components/editable_champ/carte_component/carte_component.html.haml b/app/components/editable_champ/carte_component/carte_component.html.haml index ad054669d..052e979a9 100644 --- a/app/components/editable_champ/carte_component/carte_component.html.haml +++ b/app/components/editable_champ/carte_component/carte_component.html.haml @@ -1,14 +1,6 @@ .fr-fieldset__element - = render @autocomplete_component - - = react_component("MapEditor", - { featureCollection: @champ.to_feature_collection, - champId: @champ.input_id, - url: champs_carte_features_path(@champ), - options: @champ.render_options, - autocompleteAnnounceTemplateId: @autocomplete_component.announce_template_id, - autocompleteScreenReaderInstructions: t("combo_search_component.screen_reader_instructions") }, - {class: 'width-100'}) + %react-fragment.width-100 + = render ReactComponent.new "MapEditor", **react_props .geo-areas{ id: dom_id(@champ, :geo_areas) } = render Dossiers::GeoAreasComponent.new(champ: @champ, editing: true) diff --git a/app/components/editable_champ/champ_label_component.rb b/app/components/editable_champ/champ_label_component.rb index 967a95584..3143ad276 100644 --- a/app/components/editable_champ/champ_label_component.rb +++ b/app/components/editable_champ/champ_label_component.rb @@ -1,6 +1,10 @@ +# frozen_string_literal: true + class EditableChamp::ChampLabelComponent < ApplicationComponent include Dsfr::InputErrorable + attr_reader :attribute + def initialize(form:, champ:, seen_at: nil) @form, @champ, @seen_at = form, champ, seen_at @attribute = :value diff --git a/app/components/editable_champ/champ_label_component/champ_label_component.html.haml b/app/components/editable_champ/champ_label_component/champ_label_component.html.haml index 0be774df0..5dffbf6cd 100644 --- a/app/components/editable_champ/champ_label_component/champ_label_component.html.haml +++ b/app/components/editable_champ/champ_label_component/champ_label_component.html.haml @@ -5,9 +5,3 @@ - render EditableChamp::ChampLabelContentComponent.new form: @form, champ: @champ, seen_at: @seen_at - elsif @champ.legend_label? %legend.fr-fieldset__legend.fr-text--regular{ id: @champ.labelledby_id }= render EditableChamp::ChampLabelContentComponent.new form: @form, champ: @champ, seen_at: @seen_at -- elsif @champ.single_checkbox? - -# no label to add -- else - -# champ civilite (and other?) - .fr-label.fr-mb-1w{ id: @champ.labelledby_id } - = render EditableChamp::ChampLabelContentComponent.new form: @form, champ: @champ, seen_at: @seen_at diff --git a/app/components/editable_champ/champ_label_content_component.rb b/app/components/editable_champ/champ_label_content_component.rb index 21069fe98..94978c7b0 100644 --- a/app/components/editable_champ/champ_label_content_component.rb +++ b/app/components/editable_champ/champ_label_content_component.rb @@ -1,7 +1,11 @@ +# frozen_string_literal: true + class EditableChamp::ChampLabelContentComponent < ApplicationComponent include ApplicationHelper include Dsfr::InputErrorable + attr_reader :attribute + def initialize(form:, champ:, seen_at: nil) @form, @champ, @seen_at = form, champ, seen_at @attribute = :value diff --git a/app/components/editable_champ/champ_label_content_component/champ_label_content_component.en.yml b/app/components/editable_champ/champ_label_content_component/champ_label_content_component.en.yml index f761f5f52..ec1a56453 100644 --- a/app/components/editable_champ/champ_label_content_component/champ_label_content_component.en.yml +++ b/app/components/editable_champ/champ_label_content_component/champ_label_content_component.en.yml @@ -3,5 +3,4 @@ en: changes_to_save: "modifications to submit" modified_at: "modified on %{datetime}" check_content_rebased: "Information: field updated by administration. Check its content." - optional_champ: (optional) recommended_size: The recommended maximum size is %{size} characters. diff --git a/app/components/editable_champ/champ_label_content_component/champ_label_content_component.fr.yml b/app/components/editable_champ/champ_label_content_component/champ_label_content_component.fr.yml index 4e983b829..60cf6d066 100644 --- a/app/components/editable_champ/champ_label_content_component/champ_label_content_component.fr.yml +++ b/app/components/editable_champ/champ_label_content_component/champ_label_content_component.fr.yml @@ -3,5 +3,4 @@ fr: changes_to_save: "modification à déposer" modified_at: "modifié le %{datetime}" check_content_rebased: "Information : champ actualisé par l'administration. Vérifier son contenu." - optional_champ: (facultatif) recommended_size: La taille maximale conseillée est de %{size} caractères. diff --git a/app/components/editable_champ/champ_label_content_component/champ_label_content_component.html.haml b/app/components/editable_champ/champ_label_content_component/champ_label_content_component.html.haml index 09fdd72f9..5a65163b7 100644 --- a/app/components/editable_champ/champ_label_content_component/champ_label_content_component.html.haml +++ b/app/components/editable_champ/champ_label_content_component/champ_label_content_component.html.haml @@ -2,8 +2,6 @@ - if @champ.public? - if @champ.mandatory? = render EditableChamp::AsteriskMandatoryComponent.new - - else - %span.sr-only= t('.optional_champ') - if @champ.forked_with_changes? %span.updated-at.highlighted @@ -12,7 +10,6 @@ %span.updated-at{ class: highlight_if_unseen_class } = t('.modified_at', datetime: try_format_datetime(@champ.updated_at)) - - if @champ.rebased_at.present? && @champ.rebased_at > (@seen_at || @champ.updated_at) && current_user.owns_or_invite?(@champ.dossier) %span.updated-at.highlighted = t('.check_content_rebased') @@ -21,7 +18,7 @@ %span.fr-hint-text{ data: { controller: 'date-input-hint' } }= hint - if @champ.description.present? - %span.fr-hint-text{ id: @champ.describedby_id }= render SimpleFormatComponent.new(@champ.description, allow_a: true) + %span.fr-hint-text= render SimpleFormatComponent.new(@champ.description, allow_a: true) -- if @champ.textarea? - %span.sr-only= t('.recommended_size', size: @champ.character_limit_base) +- if @champ.textarea? && @champ.character_limit_base&.positive? + %span.fr-hint-text= t('.recommended_size', size: @champ.character_limit_base) diff --git a/app/components/editable_champ/checkbox_component.rb b/app/components/editable_champ/checkbox_component.rb index af649c013..118bf823d 100644 --- a/app/components/editable_champ/checkbox_component.rb +++ b/app/components/editable_champ/checkbox_component.rb @@ -1,9 +1,7 @@ -class EditableChamp::CheckboxComponent < EditableChamp::EditableChampBaseComponent - def dsfr_champ_container - :fieldset - end +# frozen_string_literal: true +class EditableChamp::CheckboxComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname - 'fr-radio' + 'fr-checkbox' end end diff --git a/app/components/editable_champ/checkbox_component/checkbox_component.html.haml b/app/components/editable_champ/checkbox_component/checkbox_component.html.haml index 998e25d8a..44e1afae3 100644 --- a/app/components/editable_champ/checkbox_component/checkbox_component.html.haml +++ b/app/components/editable_champ/checkbox_component/checkbox_component.html.haml @@ -1,9 +1,8 @@ -.fr-fieldset__element - .fr-checkbox-group - = @form.check_box :value, - { required: @champ.required?, id: @champ.input_id, checked: @champ.true?, aria: { describedby: @champ.describedby_id }, class: class_names('required' => @champ.required?)}, - 'true', - 'false' - %label.fr-label{ for: @champ.input_id, id: @champ.labelledby_id } - %span - = render EditableChamp::ChampLabelContentComponent.new form: @form, champ: @champ, seen_at: @seen_at +.fr-checkbox-group + = @form.check_box :value, + { required: @champ.required?, id: @champ.input_id, checked: @champ.true?, aria: { describedby: @champ.describedby_id }, class: class_names('required' => @champ.required?)}, + 'true', + 'false' + %label.fr-label{ for: @champ.input_id, id: @champ.labelledby_id } + %span + = render EditableChamp::ChampLabelContentComponent.new form: @form, champ: @champ, seen_at: @seen_at diff --git a/app/components/editable_champ/civilite_component.rb b/app/components/editable_champ/civilite_component.rb index 6f3b61705..d6eed462d 100644 --- a/app/components/editable_champ/civilite_component.rb +++ b/app/components/editable_champ/civilite_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::CiviliteComponent < EditableChamp::EditableChampBaseComponent def dsfr_champ_container :fieldset diff --git a/app/components/editable_champ/cnaf_component.rb b/app/components/editable_champ/cnaf_component.rb index 4e3fc65cc..77a24df0b 100644 --- a/app/components/editable_champ/cnaf_component.rb +++ b/app/components/editable_champ/cnaf_component.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class EditableChamp::CnafComponent < EditableChamp::EditableChampBaseComponent end diff --git a/app/components/editable_champ/cojo_component.rb b/app/components/editable_champ/cojo_component.rb index e49c44b3d..67ad6fd20 100644 --- a/app/components/editable_champ/cojo_component.rb +++ b/app/components/editable_champ/cojo_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::COJOComponent < EditableChamp::EditableChampBaseComponent def input_group_class if @champ.accreditation_success? diff --git a/app/components/editable_champ/cojo_component/cojo_component.en.yml b/app/components/editable_champ/cojo_component/cojo_component.en.yml index 88069f927..9b2436729 100644 --- a/app/components/editable_champ/cojo_component/cojo_component.en.yml +++ b/app/components/editable_champ/cojo_component/cojo_component.en.yml @@ -3,5 +3,6 @@ en: accreditation_number_label: Accreditation number accreditation_number_notice: Identification number issued by Paris 2024 accreditation_birthdate_label: Date of birth + accreditation_birthdate_hint: 'Format: MM/JJ/AAAA' accreditation_number_error: Invalid accreditation number accreditation_number_verification_pending: Accreditation number verification in progress diff --git a/app/components/editable_champ/cojo_component/cojo_component.fr.yml b/app/components/editable_champ/cojo_component/cojo_component.fr.yml index 914b94595..165d43525 100644 --- a/app/components/editable_champ/cojo_component/cojo_component.fr.yml +++ b/app/components/editable_champ/cojo_component/cojo_component.fr.yml @@ -3,5 +3,6 @@ fr: accreditation_number_label: Numéro d‘accréditation accreditation_number_notice: Numéro d‘identification délivré par Paris 2024 accreditation_birthdate_label: Date de naissance + accreditation_birthdate_hint: 'Format : JJ/MM/AAAA' accreditation_number_error: Le numéro d‘accréditation est incorrect accreditation_number_verification_pending: Vérification du numéro d‘accréditation en cours diff --git a/app/components/editable_champ/cojo_component/cojo_component.html.haml b/app/components/editable_champ/cojo_component/cojo_component.html.haml index 80ac7ccf3..b8df81585 100644 --- a/app/components/editable_champ/cojo_component/cojo_component.html.haml +++ b/app/components/editable_champ/cojo_component/cojo_component.html.haml @@ -17,6 +17,7 @@ .fr-input-group{ class: input_group_class } = @form.label :accreditation_birthdate, for: @champ.accreditation_birthdate_input_id, class: 'fr-label' do - safe_join [t('.accreditation_birthdate_label'), @champ.required? ? render(EditableChamp::AsteriskMandatoryComponent.new) : ''], ' ' + %p.fr-hint-text{ data: { controller: 'date-input-hint' } }= t('.accreditation_birthdate_hint') = @form.date_field :accreditation_birthdate, required: @champ.required?, aria: { describedby: dom_id(@champ, :accreditation_birthdate) }, diff --git a/app/components/editable_champ/combo_search_component.rb b/app/components/editable_champ/combo_search_component.rb deleted file mode 100644 index bbad7a600..000000000 --- a/app/components/editable_champ/combo_search_component.rb +++ /dev/null @@ -1,17 +0,0 @@ -class EditableChamp::ComboSearchComponent < EditableChamp::EditableChampBaseComponent - include ApplicationHelper - - def announce_template_id - @announce_template_id ||= dom_id(@champ, "aria-announce-template") - end - - # NOTE: because this template is called by `render_parent` from a child template, - # as of ViewComponent 2.x translations virtual paths are not properly propagated - # and we can't use the usual component namespacing. Instead we use global translations. - def react_combo_props - { - screenReaderInstructions: t("combo_search_component.screen_reader_instructions"), - announceTemplateId: announce_template_id - } - end -end diff --git a/app/components/editable_champ/combo_search_component/combo_search_component.html.haml b/app/components/editable_champ/combo_search_component/combo_search_component.html.haml deleted file mode 100644 index 9b2e14a56..000000000 --- a/app/components/editable_champ/combo_search_component/combo_search_component.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -%template{ id: announce_template_id } - %slot{ "name": "0" }= t("combo_search_component.result_slot_html", count: 0) - %slot{ "name": "1" }= t("combo_search_component.result_slot_html", count: 1) - %slot{ "name": "many" }= t("combo_search_component.result_slot_html", count: 2) diff --git a/app/components/editable_champ/communes_component.rb b/app/components/editable_champ/communes_component.rb index 80fbb70e9..d29e295c0 100644 --- a/app/components/editable_champ/communes_component.rb +++ b/app/components/editable_champ/communes_component.rb @@ -1,7 +1,20 @@ +# frozen_string_literal: true + class EditableChamp::CommunesComponent < EditableChamp::EditableChampBaseComponent include ApplicationHelper def dsfr_input_classname 'fr-select' end + + def react_props + react_input_opts(id: @champ.input_id, + class: 'fr-mt-1w', + name: @form.field_name(:code), + selected_key: @champ.selected, + items: @champ.selected_items, + loader: data_sources_data_source_commune_path(with_combined_code: true), + limit: 20, + minimum_input_length: 2) + end end diff --git a/app/components/editable_champ/communes_component/communes_component.html.haml b/app/components/editable_champ/communes_component/communes_component.html.haml index 709c87d93..5c2f652de 100644 --- a/app/components/editable_champ/communes_component/communes_component.html.haml +++ b/app/components/editable_champ/communes_component/communes_component.html.haml @@ -1,2 +1,2 @@ -= render Dsfr::ComboboxComponent.new form: @form, url: data_sources_data_source_commune_path, selected: [@champ.to_s, @champ.selected], limit: 20, input_html_options: { name: :external_id, id: @champ.input_id, class: 'fr-select', describedby: @champ.describedby_id } do - = @form.hidden_field :code_postal, data: { value_slot: 'data:string' } +%react-fragment + = render ReactComponent.new "ComboBox/RemoteComboBox", **react_props diff --git a/app/components/editable_champ/date_component.rb b/app/components/editable_champ/date_component.rb index 5cf98d4ac..e5e0cfa47 100644 --- a/app/components/editable_champ/date_component.rb +++ b/app/components/editable_champ/date_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::DateComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname 'fr-input' diff --git a/app/components/editable_champ/datetime_component.rb b/app/components/editable_champ/datetime_component.rb index c77af3004..a21a20953 100644 --- a/app/components/editable_champ/datetime_component.rb +++ b/app/components/editable_champ/datetime_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::DatetimeComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname 'fr-input' diff --git a/app/components/editable_champ/decimal_number_component.rb b/app/components/editable_champ/decimal_number_component.rb index f4ebe6b46..a6fbeb52d 100644 --- a/app/components/editable_champ/decimal_number_component.rb +++ b/app/components/editable_champ/decimal_number_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::DecimalNumberComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname 'fr-input' diff --git a/app/components/editable_champ/departements_component.rb b/app/components/editable_champ/departements_component.rb index f61a3be05..6a7ef21ee 100644 --- a/app/components/editable_champ/departements_component.rb +++ b/app/components/editable_champ/departements_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::DepartementsComponent < EditableChamp::EditableChampBaseComponent include ApplicationHelper @@ -8,7 +10,7 @@ class EditableChamp::DepartementsComponent < EditableChamp::EditableChampBaseCom end def options - APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } + APIGeoService.departement_options end def select_options diff --git a/app/components/editable_champ/dgfip_component.rb b/app/components/editable_champ/dgfip_component.rb index 0bb07fa3a..879e83608 100644 --- a/app/components/editable_champ/dgfip_component.rb +++ b/app/components/editable_champ/dgfip_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::DgfipComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname 'fr-input' diff --git a/app/components/editable_champ/dossier_link_component.rb b/app/components/editable_champ/dossier_link_component.rb index 188c96b33..4fcef378c 100644 --- a/app/components/editable_champ/dossier_link_component.rb +++ b/app/components/editable_champ/dossier_link_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::DossierLinkComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname 'fr-input' diff --git a/app/components/editable_champ/dossier_link_component/dossier_link_component.html.haml b/app/components/editable_champ/dossier_link_component/dossier_link_component.html.haml index 5389f084d..da3cc176a 100644 --- a/app/components/editable_champ/dossier_link_component/dossier_link_component.html.haml +++ b/app/components/editable_champ/dossier_link_component/dossier_link_component.html.haml @@ -1,8 +1,4 @@ = @form.text_field(:value, input_opts(id: @champ.input_id, aria: { describedby: @champ.describedby_id }, inputmode: :numeric, min: 1, pattern: "[0-9]{1,12}", autocomplete: 'off', required: @champ.required?, class: "width-33-desktop #{@champ.blank? ? '' : 'small-margin'}")) -- if !@champ.blank? - - if dossier.blank? - .fr-error-text.fr-mb-4w - = t('.not_found') - - else - .fr-info-text.fr-mb-4w= sanitize(dossier.text_summary) +- if !@champ.blank? && !dossier.blank? + .fr-info-text.fr-mb-4w= sanitize(dossier.text_summary) diff --git a/app/components/editable_champ/drop_down_list_component.rb b/app/components/editable_champ/drop_down_list_component.rb index 0f92a95bc..8d1ac7204 100644 --- a/app/components/editable_champ/drop_down_list_component.rb +++ b/app/components/editable_champ/drop_down_list_component.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + class EditableChamp::DropDownListComponent < EditableChamp::EditableChampBaseComponent def render? - @champ.options? + @champ.drop_down_options.any? end def select_class_names @@ -21,6 +23,17 @@ class EditableChamp::DropDownListComponent < EditableChamp::EditableChampBaseCom def contains_long_option? max_length = 100 - @champ.enabled_non_empty_options.any? { _1.size > max_length } + @champ.drop_down_options.any? { _1.size > max_length } + end + + def react_props + react_input_opts(id: @champ.input_id, + class: 'fr-mt-1w', + name: @form.field_name(:value), + selected_key: @champ.selected, + items: @champ.drop_down_options_with_other.map { _1.is_a?(Array) ? _1 : [_1, _1] }, + empty_filter_key: @champ.drop_down_other? ? Champs::DropDownListChamp::OTHER : nil, + 'aria-describedby': @champ.describedby_id, + 'aria-labelledby': @champ.labelledby_id) end end diff --git a/app/components/editable_champ/drop_down_list_component/drop_down_list_component.html.haml b/app/components/editable_champ/drop_down_list_component/drop_down_list_component.html.haml index c2268eba0..3d38158c9 100644 --- a/app/components/editable_champ/drop_down_list_component/drop_down_list_component.html.haml +++ b/app/components/editable_champ/drop_down_list_component/drop_down_list_component.html.haml @@ -1,6 +1,6 @@ - if @champ.render_as_radios? .fr-fieldset__content - - @champ.enabled_non_empty_options.each_with_index do |option, index| + - @champ.drop_down_options.each_with_index do |option, index| .fr-radio-group = @form.radio_button :value, option, id: dom_id(@champ, "radio_option_#{index}") %label.fr-label{ for: dom_id(@champ, "radio_option_#{index}") } @@ -18,10 +18,11 @@ %label.fr-label{ for: dom_id(@champ, "radio_option_other") } = t('shared.champs.drop_down_list.other') - elsif @champ.render_as_combobox? - = render Dsfr::ComboboxComponent.new form: @form, options: @champ.enabled_non_empty_options(other: true), selected: @champ.selected, input_html_options: { name: :value, id: @champ.input_id, class: select_class_names, describedby: @champ.describedby_id } + %react-fragment + = render ReactComponent.new "ComboBox/SingleComboBox", **react_props - else = @form.select :value, - @champ.enabled_non_empty_options(other: true), + @champ.drop_down_options_with_other, { selected: @champ.selected, include_blank: true }, required: @champ.required?, id: @champ.input_id, diff --git a/app/components/editable_champ/drop_down_other_input_component.rb b/app/components/editable_champ/drop_down_other_input_component.rb index bb8f5665e..2e1a9bc0e 100644 --- a/app/components/editable_champ/drop_down_other_input_component.rb +++ b/app/components/editable_champ/drop_down_other_input_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::DropDownOtherInputComponent < EditableChamp::EditableChampBaseComponent def render? @champ.other? diff --git a/app/components/editable_champ/drop_down_other_input_component/drop_down_other_input_component.html.haml b/app/components/editable_champ/drop_down_other_input_component/drop_down_other_input_component.html.haml index 609e236d2..67987679c 100644 --- a/app/components/editable_champ/drop_down_other_input_component/drop_down_other_input_component.html.haml +++ b/app/components/editable_champ/drop_down_other_input_component/drop_down_other_input_component.html.haml @@ -1,5 +1,5 @@ .drop_down_other.fr-mt-2w .fr-input-group - %label.fr-label{ for: dom_id(@champ, :value_other) } Veuillez saisir votre autre choix - + %label.fr-label{ for: dom_id(@champ, :value_other) } + = t('shared.champs.drop_down_list.other_label') = @form.text_field :value_other, maxlength: 200, size: nil, id: dom_id(@champ, :value_other), class: 'fr-input' diff --git a/app/components/editable_champ/editable_champ_base_component.rb b/app/components/editable_champ/editable_champ_base_component.rb index 85a2edef4..5acb853c1 100644 --- a/app/components/editable_champ/editable_champ_base_component.rb +++ b/app/components/editable_champ/editable_champ_base_component.rb @@ -1,6 +1,15 @@ +# frozen_string_literal: true + class EditableChamp::EditableChampBaseComponent < ApplicationComponent include Dsfr::InputErrorable + attr_reader :attribute + + def initialize(form:, champ:, seen_at: nil, opts: {}) + @form, @champ, @seen_at, @opts = form, champ, seen_at, opts + @attribute = :value + end + def dsfr_champ_container :div end @@ -12,9 +21,4 @@ class EditableChamp::EditableChampBaseComponent < ApplicationComponent def describedby_id @champ.describedby_id end - - def initialize(form:, champ:, seen_at: nil, opts: {}) - @form, @champ, @seen_at, @opts = form, champ, seen_at, opts - @attribute = :value - end end diff --git a/app/components/editable_champ/editable_champ_component.rb b/app/components/editable_champ/editable_champ_component.rb index 3e3177a19..20f0a7fd6 100644 --- a/app/components/editable_champ/editable_champ_component.rb +++ b/app/components/editable_champ/editable_champ_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::EditableChampComponent < ApplicationComponent def initialize(form:, champ:, seen_at: nil) @form, @champ, @seen_at = form, champ, seen_at @@ -54,9 +56,9 @@ class EditableChamp::EditableChampComponent < ApplicationComponent def turbo_poll_url_value if @champ.private? - annotation_instructeur_dossier_path(@champ.dossier.procedure, @champ.dossier, @champ) + annotation_instructeur_dossier_path(@champ.dossier.procedure, @champ.dossier, @champ.stable_id, row_id: @champ.row_id, with_public_id: true) else - champ_dossier_path(@champ.dossier, @champ) + champ_dossier_path(@champ.dossier, @champ.stable_id, row_id: @champ.row_id, with_public_id: true) end end @@ -82,6 +84,6 @@ class EditableChamp::EditableChampComponent < ApplicationComponent end def autosave_enabled? - !@champ.carte? && !@champ.block? && @champ.fillable? + !@champ.carte? && !@champ.repetition? && @champ.fillable? end end diff --git a/app/components/editable_champ/editable_champ_component/editable_champ_component.html.haml b/app/components/editable_champ/editable_champ_component/editable_champ_component.html.haml index 84c45bb17..06ff21348 100644 --- a/app/components/editable_champ/editable_champ_component/editable_champ_component.html.haml +++ b/app/components/editable_champ/editable_champ_component/editable_champ_component.html.haml @@ -6,5 +6,3 @@ = render champ_component = render Dsfr::InputStatusMessageComponent.new(errors_on_attribute: champ_component.errors_on_attribute?, error_full_messages: champ_component.error_full_messages, describedby_id: @champ.describedby_id, champ: @champ) - - = @form.hidden_field :id, value: @champ.id diff --git a/app/components/editable_champ/email_component.rb b/app/components/editable_champ/email_component.rb index 57788aa26..efdcca7e6 100644 --- a/app/components/editable_champ/email_component.rb +++ b/app/components/editable_champ/email_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::EmailComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname 'fr-input' diff --git a/app/components/editable_champ/engagement_juridique_component.rb b/app/components/editable_champ/engagement_juridique_component.rb index 223cc0e9e..96ffb2869 100644 --- a/app/components/editable_champ/engagement_juridique_component.rb +++ b/app/components/editable_champ/engagement_juridique_component.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class EditableChamp::EngagementJuridiqueComponent < EditableChamp::EditableChampBaseComponent end diff --git a/app/components/editable_champ/epci_component.rb b/app/components/editable_champ/epci_component.rb index 8bcf38437..497a930be 100644 --- a/app/components/editable_champ/epci_component.rb +++ b/app/components/editable_champ/epci_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::EpciComponent < EditableChamp::EditableChampBaseComponent include ApplicationHelper diff --git a/app/components/editable_champ/etablissement_titre_component.rb b/app/components/editable_champ/etablissement_titre_component.rb index 13e59358a..a6c781d31 100644 --- a/app/components/editable_champ/etablissement_titre_component.rb +++ b/app/components/editable_champ/etablissement_titre_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::EtablissementTitreComponent < ApplicationComponent include EtablissementHelper diff --git a/app/components/editable_champ/explication_component.rb b/app/components/editable_champ/explication_component.rb index daffa3bf9..aa7ef3b92 100644 --- a/app/components/editable_champ/explication_component.rb +++ b/app/components/editable_champ/explication_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::ExplicationComponent < EditableChamp::EditableChampBaseComponent delegate :type_de_champ, to: :@champ delegate :notice_explicative, to: :type_de_champ diff --git a/app/components/editable_champ/expression_reguliere_component.rb b/app/components/editable_champ/expression_reguliere_component.rb index 7e6a6d52c..a0844d824 100644 --- a/app/components/editable_champ/expression_reguliere_component.rb +++ b/app/components/editable_champ/expression_reguliere_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::ExpressionReguliereComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname 'fr-input' diff --git a/app/components/editable_champ/header_section_component.rb b/app/components/editable_champ/header_section_component.rb index a4f7a6560..83c9bfdca 100644 --- a/app/components/editable_champ/header_section_component.rb +++ b/app/components/editable_champ/header_section_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::HeaderSectionComponent < ApplicationComponent def initialize(form: nil, champ:, seen_at: nil, html_class: {}) @champ = champ diff --git a/app/components/editable_champ/iban_component.rb b/app/components/editable_champ/iban_component.rb index 5a5646305..a41933e2f 100644 --- a/app/components/editable_champ/iban_component.rb +++ b/app/components/editable_champ/iban_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::IbanComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname 'fr-input' diff --git a/app/components/editable_champ/integer_number_component.rb b/app/components/editable_champ/integer_number_component.rb index 81b5348cb..44076580f 100644 --- a/app/components/editable_champ/integer_number_component.rb +++ b/app/components/editable_champ/integer_number_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::IntegerNumberComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname 'fr-input' diff --git a/app/components/editable_champ/linked_drop_down_list_component.rb b/app/components/editable_champ/linked_drop_down_list_component.rb index 1d09e19fc..f2ae949de 100644 --- a/app/components/editable_champ/linked_drop_down_list_component.rb +++ b/app/components/editable_champ/linked_drop_down_list_component.rb @@ -1,8 +1,14 @@ +# frozen_string_literal: true + class EditableChamp::LinkedDropDownListComponent < EditableChamp::EditableChampBaseComponent def dsfr_champ_container :fieldset end + def render? + @champ.drop_down_options.any? + end + private def secondary_label diff --git a/app/components/editable_champ/linked_drop_down_list_component/linked_drop_down_list_component.html.haml b/app/components/editable_champ/linked_drop_down_list_component/linked_drop_down_list_component.html.haml index cd466d386..9d8b7856c 100644 --- a/app/components/editable_champ/linked_drop_down_list_component/linked_drop_down_list_component.html.haml +++ b/app/components/editable_champ/linked_drop_down_list_component/linked_drop_down_list_component.html.haml @@ -1,19 +1,18 @@ -- if @champ.options? - .fr-fieldset__element.fr-mb-0 - .fr-select-group - = render EditableChamp::ChampLabelComponent.new form: @form, champ: @champ, seen_at: @seen_at +.fr-fieldset__element.fr-mb-0 + .fr-select-group + = render EditableChamp::ChampLabelComponent.new form: @form, champ: @champ, seen_at: @seen_at - = @form.select :primary_value, @champ.primary_options, {}, required: @champ.required?, class: 'fr-select fr-mb-3v', id: @champ.input_id, aria: { describedby: @champ.describedby_id } + = @form.select :primary_value, @champ.primary_options, {}, required: @champ.required?, class: 'fr-select fr-mb-3v', id: @champ.input_id, aria: { describedby: @champ.describedby_id } - - if @champ.has_secondary_options_for_primary? - .secondary - .fr-fieldset__element - .fr-select-group - = @form.label :secondary_value, for: "#{@champ.input_id}-secondary", class: 'fr-label' do - - sanitize(secondary_label) - - if @champ.drop_down_secondary_description.present? - .notice{ id: "#{@champ.describedby_id}-secondary" } - = render SimpleFormatComponent.new(@champ.drop_down_secondary_description, allow_a: true) - = @form.select :secondary_value, @champ.secondary_options[@champ.primary_value], {}, required: @champ.required?, class: 'fr-select', id: "#{@champ.input_id}-secondary", aria: { describedby: "#{@champ.describedby_id}-secondary" } - - else - = @form.hidden_field :secondary_value, value: '' +- if @champ.has_secondary_options_for_primary? + .secondary + .fr-fieldset__element + .fr-select-group + = @form.label :secondary_value, for: "#{@champ.input_id}-secondary", class: 'fr-label' do + - sanitize(secondary_label) + - if @champ.drop_down_secondary_description.present? + .notice{ id: "#{@champ.describedby_id}-secondary" } + = render SimpleFormatComponent.new(@champ.drop_down_secondary_description, allow_a: true) + = @form.select :secondary_value, @champ.secondary_options[@champ.primary_value], {}, required: @champ.required?, class: 'fr-select', id: "#{@champ.input_id}-secondary", aria: { describedby: "#{@champ.describedby_id}-secondary" } +- else + = @form.hidden_field :secondary_value, value: '' diff --git a/app/components/editable_champ/mesri_component.rb b/app/components/editable_champ/mesri_component.rb index 69aac9824..0da7475f7 100644 --- a/app/components/editable_champ/mesri_component.rb +++ b/app/components/editable_champ/mesri_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::MesriComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname 'fr-input' diff --git a/app/components/editable_champ/multiple_drop_down_list_component.rb b/app/components/editable_champ/multiple_drop_down_list_component.rb index 291ed3e77..aec59c019 100644 --- a/app/components/editable_champ/multiple_drop_down_list_component.rb +++ b/app/components/editable_champ/multiple_drop_down_list_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::MultipleDropDownListComponent < EditableChamp::EditableChampBaseComponent include ApplicationHelper @@ -8,4 +10,16 @@ class EditableChamp::MultipleDropDownListComponent < EditableChamp::EditableCham def dsfr_champ_container @champ.render_as_checkboxes? ? :fieldset : :div end + + def react_props + react_input_opts(id: @champ.input_id, + class: 'fr-mt-1w', + name: @form.field_name(:value, multiple: true), + selected_keys: @champ.selected_options, + items: @champ.drop_down_options, + value_separator: false, + 'aria-label': @champ.libelle, + 'aria-describedby': @champ.describedby_id, + 'aria-labelledby': @champ.labelledby_id) + end end diff --git a/app/components/editable_champ/multiple_drop_down_list_component/multiple_drop_down_list_component.html.haml b/app/components/editable_champ/multiple_drop_down_list_component/multiple_drop_down_list_component.html.haml index f3d8007b9..9faa09692 100644 --- a/app/components/editable_champ/multiple_drop_down_list_component/multiple_drop_down_list_component.html.haml +++ b/app/components/editable_champ/multiple_drop_down_list_component/multiple_drop_down_list_component.html.haml @@ -1,19 +1,12 @@ -- if @champ.options? - - if @champ.render_as_checkboxes? - = @form.collection_check_boxes :value, @champ.enabled_non_empty_options, :to_s, :to_s do |b| - - capture do - .fr-fieldset__element - .fr-checkbox-group - = b.check_box(checked: @champ.selected_options.include?(b.value), aria: { describedby: @champ.describedby_id }, id: @champ.checkbox_id(b.value), class: 'fr-checkbox-group__checkbox') - %label.fr-label{ for: @champ.checkbox_id(b.value) } - = b.text +- if @champ.render_as_checkboxes? + = @form.collection_check_boxes :value, @champ.drop_down_options, :to_s, :to_s do |b| + - capture do + .fr-fieldset__element + .fr-checkbox-group + = b.check_box(checked: @champ.selected_options.include?(b.value), aria: { describedby: @champ.describedby_id }, id: @champ.checkbox_id(b.value), class: 'fr-checkbox-group__checkbox') + %label.fr-label{ for: @champ.checkbox_id(b.value) } + = b.text - - else - %div{ 'data-turbo-focus-group': true } - - if @champ.selected_options.present? - .fr-mb-2w.fr-mt-2w{ "data-turbo": "true" } - - @champ.selected_options.each do |option| - = render NestedForms::OwnedButtonComponent.new(formaction: champs_options_path(@champ.id, option:), http_method: :delete, opt: { aria: {pressed: true }, class: 'fr-tag fr-tag-bug fr-mb-1w fr-mr-1w', id: @champ.checkbox_id(option) }) do - = option - - if @champ.unselected_options.present? - = @form.select :value, @champ.unselected_options, { selected: '', include_blank: false, prompt: t('.prompt') }, id: @champ.input_id, aria: { describedby: @champ.describedby_id }, class: 'fr-select fr-mt-2v' +- else + %react-fragment + = render ReactComponent.new "ComboBox/MultiComboBox", **react_props diff --git a/app/components/editable_champ/number_component.rb b/app/components/editable_champ/number_component.rb index adf2814bc..310fa325b 100644 --- a/app/components/editable_champ/number_component.rb +++ b/app/components/editable_champ/number_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::NumberComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname 'fr-input' diff --git a/app/components/editable_champ/pays_component.rb b/app/components/editable_champ/pays_component.rb index fee1ce157..bd61a9350 100644 --- a/app/components/editable_champ/pays_component.rb +++ b/app/components/editable_champ/pays_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::PaysComponent < EditableChamp::EditableChampBaseComponent include ApplicationHelper diff --git a/app/components/editable_champ/phone_component.rb b/app/components/editable_champ/phone_component.rb index c8aa8a0ab..ee464b812 100644 --- a/app/components/editable_champ/phone_component.rb +++ b/app/components/editable_champ/phone_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::PhoneComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname 'fr-input' diff --git a/app/components/editable_champ/piece_justificative_component.rb b/app/components/editable_champ/piece_justificative_component.rb index 95b3ff21a..47b873e8d 100644 --- a/app/components/editable_champ/piece_justificative_component.rb +++ b/app/components/editable_champ/piece_justificative_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::PieceJustificativeComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname 'fr-input' diff --git a/app/components/editable_champ/pole_emploi_component.rb b/app/components/editable_champ/pole_emploi_component.rb index 49479bbf9..b82189b1b 100644 --- a/app/components/editable_champ/pole_emploi_component.rb +++ b/app/components/editable_champ/pole_emploi_component.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class EditableChamp::PoleEmploiComponent < EditableChamp::EditableChampBaseComponent end diff --git a/app/components/editable_champ/regions_component.rb b/app/components/editable_champ/regions_component.rb index 730cc0c53..05fbb16e9 100644 --- a/app/components/editable_champ/regions_component.rb +++ b/app/components/editable_champ/regions_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::RegionsComponent < EditableChamp::EditableChampBaseComponent include ApplicationHelper @@ -8,7 +10,7 @@ class EditableChamp::RegionsComponent < EditableChamp::EditableChampBaseComponen private def options - APIGeoService.regions.map { [_1[:name], _1[:code]] } + APIGeoService.region_options end def select_options diff --git a/app/components/editable_champ/repetition_component.rb b/app/components/editable_champ/repetition_component.rb index bd077dd9b..685d890e1 100644 --- a/app/components/editable_champ/repetition_component.rb +++ b/app/components/editable_champ/repetition_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::RepetitionComponent < EditableChamp::EditableChampBaseComponent def legend_params @champ.description.present? ? { describedby: dom_id(@champ, :repetition) } : {} diff --git a/app/components/editable_champ/repetition_row_component.rb b/app/components/editable_champ/repetition_row_component.rb index cb9925286..f3368d525 100644 --- a/app/components/editable_champ/repetition_row_component.rb +++ b/app/components/editable_champ/repetition_row_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::RepetitionRowComponent < ApplicationComponent def initialize(form:, dossier:, type_de_champ:, row_id:, row_number:, seen_at: nil) @form, @dossier, @type_de_champ, @row_id, @row_number, @seen_at = form, dossier, type_de_champ, row_id, row_number, seen_at diff --git a/app/components/editable_champ/rna_component.rb b/app/components/editable_champ/rna_component.rb index 1742676eb..c4d0e15ca 100644 --- a/app/components/editable_champ/rna_component.rb +++ b/app/components/editable_champ/rna_component.rb @@ -1,5 +1,11 @@ +# frozen_string_literal: true + class EditableChamp::RNAComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname 'fr-input' - end + end + + def update_path + champs_rna_path(@champ.dossier, @champ.stable_id, row_id: @champ.row_id) + end end diff --git a/app/components/editable_champ/rna_component/rna_component.html.haml b/app/components/editable_champ/rna_component/rna_component.html.haml index 0543591ad..3a7672892 100644 --- a/app/components/editable_champ/rna_component/rna_component.html.haml +++ b/app/components/editable_champ/rna_component/rna_component.html.haml @@ -1,4 +1,4 @@ -= @form.text_field(:value, input_opts( id: @champ.input_id, aria: { describedby: @champ.describedby_id }, data: { controller: 'turbo-input', turbo_input_load_on_connect_value: @champ.prefilled? && @champ.value.present? && @champ.data.blank?, turbo_input_url_value: champs_rna_path(@champ.id) }, required: @champ.required?, pattern: "W[0-9]{9}", class: "width-33-desktop", maxlength: 10)) += @form.text_field(:value, input_opts( id: @champ.input_id, aria: { describedby: @champ.describedby_id }, data: { controller: 'turbo-input format', format: 'deleteSpace', turbo_input_load_on_connect_value: @champ.prefilled? && @champ.value.present? && @champ.data.blank?, turbo_input_url_value: update_path }, required: @champ.required?, pattern: "W[0-9A-Z]{9}", class: "width-33-desktop", maxlength: 10)) .rna-info{ id: dom_id(@champ, :rna_info) } = render 'shared/champs/rna/association', champ: @champ, error: nil diff --git a/app/components/editable_champ/rnf_component.rb b/app/components/editable_champ/rnf_component.rb index 8638f1a64..267aabc04 100644 --- a/app/components/editable_champ/rnf_component.rb +++ b/app/components/editable_champ/rnf_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::RNFComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname 'fr-input' diff --git a/app/components/editable_champ/rnf_component/rnf_component.en.yml b/app/components/editable_champ/rnf_component/rnf_component.en.yml index 24faeae48..5221ee0d0 100644 --- a/app/components/editable_champ/rnf_component/rnf_component.en.yml +++ b/app/components/editable_champ/rnf_component/rnf_component.en.yml @@ -2,4 +2,4 @@ en: rnf_info_error: No foundation found rnf_info_pending: RNF verification pending - rnf_info_success: "This RNF matches %{title}, %{address}" + rnf_info_success: "This RNF matches: %{title}, %{address}" diff --git a/app/components/editable_champ/rnf_component/rnf_component.fr.yml b/app/components/editable_champ/rnf_component/rnf_component.fr.yml index 6c1441985..0a334be79 100644 --- a/app/components/editable_champ/rnf_component/rnf_component.fr.yml +++ b/app/components/editable_champ/rnf_component/rnf_component.fr.yml @@ -2,4 +2,4 @@ fr: rnf_info_error: Aucune fondation trouvée rnf_info_pending: Vérification du RNF en cours - rnf_info_success: "Ce RNF correspond à %{title}, %{address}" + rnf_info_success: "Ce RNF correspond à : %{title}, %{address}" diff --git a/app/components/editable_champ/rnf_component/rnf_component.html.haml b/app/components/editable_champ/rnf_component/rnf_component.html.haml index c8685d8ca..ea5da960c 100644 --- a/app/components/editable_champ/rnf_component/rnf_component.html.haml +++ b/app/components/editable_champ/rnf_component/rnf_component.html.haml @@ -1,6 +1,6 @@ -= @form.text_field :external_id, input_opts(id: @champ.input_id, required: @champ.required?, class: "width-33-desktop fr-input small-margin", aria: { describedby: @champ.describedby_id }) += @form.text_field :external_id, input_opts(id: @champ.input_id, required: @champ.required?, class: "width-33-desktop fr-input", aria: { describedby: @champ.describedby_id }) -.rnf-info{ id: dom_id(@champ, :rnf_info) } +.rnf-info{ id: dom_id(@champ, :rnf_info), role: 'status' } - if @champ.fetch_external_data_error? %p.fr-error-text= t('.rnf_info_error') - elsif @champ.fetch_external_data_pending? diff --git a/app/components/editable_champ/section_component.rb b/app/components/editable_champ/section_component.rb index 69b3c9dcc..9798b9e70 100644 --- a/app/components/editable_champ/section_component.rb +++ b/app/components/editable_champ/section_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::SectionComponent < ApplicationComponent include ApplicationHelper include TreeableConcern diff --git a/app/components/editable_champ/section_component/section_component.html.haml b/app/components/editable_champ/section_component/section_component.html.haml index f7a718781..285f3db39 100644 --- a/app/components/editable_champ/section_component/section_component.html.haml +++ b/app/components/editable_champ/section_component/section_component.html.haml @@ -10,13 +10,12 @@ = fields_for champ.input_name, champ do |form| = render EditableChamp::EditableChampComponent.new form:, champ: - else - %fieldset.fr-fieldset.fr-my-0 - - if header_section - %legend.fr-fieldset__legend.fr-my-0{ class: "reset-#{tag_for_depth}" } - = render EditableChamp::HeaderSectionComponent.new(champ: header_section) - - splitted_tail.each do |section, champ| - - if section.present? - = render section - - else - = fields_for champ.input_name, champ do |form| - = render EditableChamp::EditableChampComponent.new form:, champ: + - if header_section + .fr-fieldset__legend.fr-my-0{ class: "reset-#{tag_for_depth}" } + = render EditableChamp::HeaderSectionComponent.new(champ: header_section) + - splitted_tail.each do |section, champ| + - if section.present? + = render section + - else + = fields_for champ.input_name, champ do |form| + = render EditableChamp::EditableChampComponent.new form:, champ: diff --git a/app/components/editable_champ/siret_component.rb b/app/components/editable_champ/siret_component.rb index 8997b7c4e..8a7d3d1b3 100644 --- a/app/components/editable_champ/siret_component.rb +++ b/app/components/editable_champ/siret_component.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + class EditableChamp::SiretComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname 'fr-input' - end + end def hint_id dom_id(@champ, :siret_info) @@ -10,4 +12,8 @@ class EditableChamp::SiretComponent < EditableChamp::EditableChampBaseComponent def hintable? true end + + def update_path + champs_siret_path(@champ.dossier, @champ.stable_id, row_id: @champ.row_id) + end end diff --git a/app/components/editable_champ/siret_component/siret_component.html.haml b/app/components/editable_champ/siret_component/siret_component.html.haml index ae999e84a..456afd069 100644 --- a/app/components/editable_champ/siret_component/siret_component.html.haml +++ b/app/components/editable_champ/siret_component/siret_component.html.haml @@ -1,4 +1,4 @@ -= @form.text_field(:value, input_opts(id: @champ.input_id, aria: { describedby: @champ.describedby_id }, data: { controller: 'turbo-input', turbo_input_load_on_connect_value: @champ.prefilled? && @champ.value.present? && @champ.etablissement.blank?, turbo_input_url_value: champs_siret_path(@champ.id) }, required: @champ.required?, pattern: "[0-9]{14}", class: "width-33-desktop", maxlength: 14)) += @form.text_field(:value, input_opts(id: @champ.input_id, aria: { describedby: @champ.describedby_id }, data: { controller: 'turbo-input format', format: 'siret', turbo_input_load_on_connect_value: @champ.prefilled? && @champ.value.present? && @champ.etablissement.blank?, turbo_input_url_value: update_path }, required: @champ.required?, class: "width-33-desktop")) .siret-info{ id: dom_id(@champ, :siret_info) } - if @champ.etablissement.present? = render EditableChamp::EtablissementTitreComponent.new(etablissement: @champ.etablissement) diff --git a/app/components/editable_champ/text_component.rb b/app/components/editable_champ/text_component.rb index d2930ea81..702b4f72a 100644 --- a/app/components/editable_champ/text_component.rb +++ b/app/components/editable_champ/text_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::TextComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname 'fr-input' diff --git a/app/components/editable_champ/textarea_component.rb b/app/components/editable_champ/textarea_component.rb index 2942bd97a..c1daff884 100644 --- a/app/components/editable_champ/textarea_component.rb +++ b/app/components/editable_champ/textarea_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::TextareaComponent < EditableChamp::EditableChampBaseComponent include HtmlToStringHelper def dsfr_input_classname diff --git a/app/components/editable_champ/textarea_component/textarea_component.en.yml b/app/components/editable_champ/textarea_component/textarea_component.en.yml index 8e9b49901..760b46d14 100644 --- a/app/components/editable_champ/textarea_component/textarea_component.en.yml +++ b/app/components/editable_champ/textarea_component/textarea_component.en.yml @@ -1,3 +1,3 @@ en: remaining_characters: You have %{remaining_words} characters remaining. - excess_characters: You have %{excess_words} characters too many. + excess_characters: You have exceeded the recommended size of %{excess_words} characters. Please reduce the number of characters. diff --git a/app/components/editable_champ/textarea_component/textarea_component.fr.yml b/app/components/editable_champ/textarea_component/textarea_component.fr.yml index fa8fafdc1..c2f36291b 100644 --- a/app/components/editable_champ/textarea_component/textarea_component.fr.yml +++ b/app/components/editable_champ/textarea_component/textarea_component.fr.yml @@ -1,3 +1,3 @@ fr: remaining_characters: Il vous reste %{remaining_words} caractères. - excess_characters: Vous avez dépassé la taille conseillée de %{excess_words} caractères. Réduire le nombre de caractères. + excess_characters: Vous avez dépassé la taille conseillée de %{excess_words} caractères. Veuillez réduire le nombre de caractères. diff --git a/app/components/editable_champ/textarea_component/textarea_component.html.haml b/app/components/editable_champ/textarea_component/textarea_component.html.haml index e6c7f20cd..cc76ab438 100644 --- a/app/components/editable_champ/textarea_component/textarea_component.html.haml +++ b/app/components/editable_champ/textarea_component/textarea_component.html.haml @@ -1,9 +1,10 @@ ~ @form.text_area(:value, input_opts(id: @champ.input_id, aria: { describedby: @champ.describedby_id }, rows: 6, required: @champ.required?, value: html_to_string(@champ.value), data: { controller: 'autoresize' })) -- if @champ.character_limit_info? - %p.fr-info-text - = t('.remaining_characters', remaining_words: @champ.remaining_characters) +%div{ role: 'status' } + - if @champ.character_limit_info? + %p.fr-info-text + = t('.remaining_characters', remaining_words: @champ.remaining_characters) -- if @champ.character_limit_warning? - %p.fr-icon--sm.fr-mt-4v.fr-mb-0.fr-hint-text.fr-icon-warning-fill.fr-text-default--warning.characters-count - = t('.excess_characters', excess_words: @champ.excess_characters) + - if @champ.character_limit_warning? + %p.fr-icon--sm.fr-mt-4v.fr-mb-0.fr-hint-text.fr-icon-warning-fill.fr-text-default--warning + = t('.excess_characters', excess_words: @champ.excess_characters) diff --git a/app/components/editable_champ/titre_identite_component.rb b/app/components/editable_champ/titre_identite_component.rb index c1b916517..afab922ec 100644 --- a/app/components/editable_champ/titre_identite_component.rb +++ b/app/components/editable_champ/titre_identite_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::TitreIdentiteComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname 'fr-input' diff --git a/app/components/editable_champ/yes_no_component.rb b/app/components/editable_champ/yes_no_component.rb index f9a0e92e6..4d4ff9f29 100644 --- a/app/components/editable_champ/yes_no_component.rb +++ b/app/components/editable_champ/yes_no_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EditableChamp::YesNoComponent < EditableChamp::EditableChampBaseComponent def dsfr_champ_container :fieldset diff --git a/app/components/expandable_error_list.rb b/app/components/expandable_error_list.rb new file mode 100644 index 000000000..9dc1360ca --- /dev/null +++ b/app/components/expandable_error_list.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class ExpandableErrorList < ApplicationComponent + def initialize(errors:) + @errors = errors + end + + def splitted_errors + yield(Array(@errors[0..2]), Array(@errors[3..])) + end +end diff --git a/app/components/expandable_error_list/expandable_error_list.html.en.yml b/app/components/expandable_error_list/expandable_error_list.html.en.yml new file mode 100644 index 000000000..b21ee7d8a --- /dev/null +++ b/app/components/expandable_error_list/expandable_error_list.html.en.yml @@ -0,0 +1,3 @@ +--- +en: + see_more: Show all errors diff --git a/app/components/expandable_error_list/expandable_error_list.html.fr.yml b/app/components/expandable_error_list/expandable_error_list.html.fr.yml new file mode 100644 index 000000000..755d13886 --- /dev/null +++ b/app/components/expandable_error_list/expandable_error_list.html.fr.yml @@ -0,0 +1,3 @@ +--- +fr: + see_more: Afficher toutes les erreurs diff --git a/app/components/expandable_error_list/expandable_error_list.html.haml b/app/components/expandable_error_list/expandable_error_list.html.haml new file mode 100644 index 000000000..b9c958b31 --- /dev/null +++ b/app/components/expandable_error_list/expandable_error_list.html.haml @@ -0,0 +1,14 @@ +- splitted_errors do |head, tail| + %ul#head-errors.fr-mb-0 + - head.each do |error_descriptor| + %li + = link_to error_descriptor.label, error_descriptor.anchor, class: 'error-anchor' + = error_descriptor.error_message + + - if tail.size > 0 + %button.fr-mt-0.fr-btn.fr-btn--sm.fr-btn--tertiary-no-outline{ type: "button", "aria-controls": 'tail-errors', "aria-expanded": "false", class: "" }= t('.see_more') + %ul#tail-errors.fr-collapse.fr-mt-0 + - tail.each do |error_descriptor| + %li + = link_to error_descriptor.label, error_descriptor.anchor, class: 'error-anchor' + = error_descriptor.error_message diff --git a/app/components/export_template/champs_component.rb b/app/components/export_template/champs_component.rb new file mode 100644 index 000000000..e12b2d5d9 --- /dev/null +++ b/app/components/export_template/champs_component.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class ExportTemplate::ChampsComponent < ApplicationComponent + attr_reader :export_template, :title + + def initialize(title, export_template, types_de_champ) + @title = title + @export_template = export_template + @types_de_champ = types_de_champ + end + + def historical_libelle(column) + historical_exported_column = export_template.exported_columns.find { _1.column == column } + if historical_exported_column + historical_exported_column.libelle + else + column.label + end + end + + def sections + @types_de_champ + .reject { _1.header_section? && _1.header_section_level_value > 1 } + .slice_before(&:header_section?) + .filter_map do |(head, *rest)| + libelle = head.libelle if head.header_section? + columns = [head.header_section? ? nil : head, *rest].compact.map { tdc_to_columns(_1) } + { libelle:, columns: } if columns.present? + end + end + + def component_prefix + title.parameterize + end + + private + + def tdc_to_columns(type_de_champ) + prefix = type_de_champ.repetition? ? "Bloc répétable" : nil + type_de_champ.columns(procedure: export_template.procedure, prefix:).map do |column| + ExportedColumn.new(column:, + libelle: historical_libelle(column)) + end + end +end diff --git a/app/components/export_template/champs_component/champs_component.html.haml b/app/components/export_template/champs_component/champs_component.html.haml new file mode 100644 index 000000000..ea6f603cc --- /dev/null +++ b/app/components/export_template/champs_component/champs_component.html.haml @@ -0,0 +1,23 @@ +%fieldset.fr-fieldset{ id: "#{component_prefix}-fieldset", data: { controller: 'checkbox-select-all' } } + %legend.fr-fieldset__legend--regular.fr-fieldset__legend.fr-h5.fr-pb-0 + = title + .checkbox-group-bordered.fr-mx-1w.fr-mb-2w + .fr-fieldset__element.fr-background-contrast--grey.fr-py-2w.fr-px-4w + .fr-checkbox-group + = check_box_tag "#{component_prefix}-select-all", "select-all", false, data: { "checkbox-select-all-target": 'checkboxAll' } + = label_tag "#{component_prefix}-select-all", "Tout sélectionner" + - sections.each.with_index do |section, idx| + - if section[:libelle] + .fr-fieldset__element.fr-text--bold.fr-px-4w{ class: idx > 0 ? "fr-pt-1w" : "" }= section[:libelle] + + - section[:columns].each do |grouped_columns| + - if grouped_columns.many? + .fr-fieldset__element + .fieldset-bordered.fr-ml-3v + - grouped_columns.each do |exported_column| + .fr-fieldset__element.fr-px-3v + .fr-checkbox-group= render ExportTemplate::CheckboxComponent.new(export_template:, exported_column:) + - else + - grouped_columns.each do |exported_column| + .fr-fieldset__element.fr-px-4w + .fr-checkbox-group= render ExportTemplate::CheckboxComponent.new(export_template:, exported_column:) diff --git a/app/components/export_template/checkbox_component.rb b/app/components/export_template/checkbox_component.rb new file mode 100644 index 000000000..c5617e71d --- /dev/null +++ b/app/components/export_template/checkbox_component.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class ExportTemplate::CheckboxComponent < ApplicationComponent + attr_reader :exported_column, :export_template + + def initialize(export_template:, exported_column:) + @export_template = export_template + @exported_column = exported_column + end + + def call + safe_join([ + check_box, + label_tag(label_id, exported_column.libelle) + ]) + end + + def check_box + check_box_tag( + 'export_template[exported_columns][]', + exported_column.id, + export_template.in_export?(exported_column), + class: 'fr-checkbox', + id: sanitize_to_id(label_id), # sanitize_to_id is used by rails in label_tag + data: { "checkbox-select-all-target": 'checkbox' } + ) + end + + def label_id + exported_column.column.id + end +end diff --git a/app/components/groupe_gestionnaire/card/administrateurs_component.rb b/app/components/groupe_gestionnaire/card/administrateurs_component.rb index 231cdd593..e075b0afe 100644 --- a/app/components/groupe_gestionnaire/card/administrateurs_component.rb +++ b/app/components/groupe_gestionnaire/card/administrateurs_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GroupeGestionnaire::Card::AdministrateursComponent < ApplicationComponent def initialize(groupe_gestionnaire:, path:, is_gestionnaire: true) @groupe_gestionnaire = groupe_gestionnaire diff --git a/app/components/groupe_gestionnaire/card/children_component.rb b/app/components/groupe_gestionnaire/card/children_component.rb index 4fac51981..c6b79f6ea 100644 --- a/app/components/groupe_gestionnaire/card/children_component.rb +++ b/app/components/groupe_gestionnaire/card/children_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GroupeGestionnaire::Card::ChildrenComponent < ApplicationComponent def initialize(groupe_gestionnaire:, path:) @groupe_gestionnaire = groupe_gestionnaire diff --git a/app/components/groupe_gestionnaire/card/commentaires_component.rb b/app/components/groupe_gestionnaire/card/commentaires_component.rb index c3bbb7434..d9fcf0e2a 100644 --- a/app/components/groupe_gestionnaire/card/commentaires_component.rb +++ b/app/components/groupe_gestionnaire/card/commentaires_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GroupeGestionnaire::Card::CommentairesComponent < ApplicationComponent def initialize(groupe_gestionnaire:, administrateur:, path:, unread_commentaires: nil) @groupe_gestionnaire = groupe_gestionnaire diff --git a/app/components/groupe_gestionnaire/card/gestionnaires_component.rb b/app/components/groupe_gestionnaire/card/gestionnaires_component.rb index 43b0c40a7..992eb766d 100644 --- a/app/components/groupe_gestionnaire/card/gestionnaires_component.rb +++ b/app/components/groupe_gestionnaire/card/gestionnaires_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GroupeGestionnaire::Card::GestionnairesComponent < ApplicationComponent def initialize(groupe_gestionnaire:, path:, is_gestionnaire: true) @groupe_gestionnaire = groupe_gestionnaire diff --git a/app/components/groupe_gestionnaire/groupe_gestionnaire_administrateurs/administrateur_component.rb b/app/components/groupe_gestionnaire/groupe_gestionnaire_administrateurs/administrateur_component.rb index ef36aea6b..a8e30e84c 100644 --- a/app/components/groupe_gestionnaire/groupe_gestionnaire_administrateurs/administrateur_component.rb +++ b/app/components/groupe_gestionnaire/groupe_gestionnaire_administrateurs/administrateur_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GroupeGestionnaire::GroupeGestionnaireAdministrateurs::AdministrateurComponent < ApplicationComponent include ApplicationHelper diff --git a/app/components/groupe_gestionnaire/groupe_gestionnaire_children/child_component.rb b/app/components/groupe_gestionnaire/groupe_gestionnaire_children/child_component.rb index 3afa79352..e8c10c684 100644 --- a/app/components/groupe_gestionnaire/groupe_gestionnaire_children/child_component.rb +++ b/app/components/groupe_gestionnaire/groupe_gestionnaire_children/child_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GroupeGestionnaire::GroupeGestionnaireChildren::ChildComponent < ApplicationComponent include ApplicationHelper diff --git a/app/components/groupe_gestionnaire/groupe_gestionnaire_gestionnaires/gestionnaire_component.rb b/app/components/groupe_gestionnaire/groupe_gestionnaire_gestionnaires/gestionnaire_component.rb index a1c5ba7b5..d358d0d7b 100644 --- a/app/components/groupe_gestionnaire/groupe_gestionnaire_gestionnaires/gestionnaire_component.rb +++ b/app/components/groupe_gestionnaire/groupe_gestionnaire_gestionnaires/gestionnaire_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GroupeGestionnaire::GroupeGestionnaireGestionnaires::GestionnaireComponent < ApplicationComponent include ApplicationHelper diff --git a/app/components/groupe_gestionnaire/groupe_gestionnaire_list_commentaires/commentaire_component.rb b/app/components/groupe_gestionnaire/groupe_gestionnaire_list_commentaires/commentaire_component.rb index 91d1d5ca4..9de6dc646 100644 --- a/app/components/groupe_gestionnaire/groupe_gestionnaire_list_commentaires/commentaire_component.rb +++ b/app/components/groupe_gestionnaire/groupe_gestionnaire_list_commentaires/commentaire_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GroupeGestionnaire::GroupeGestionnaireListCommentaires::CommentaireComponent < ApplicationComponent include ApplicationHelper diff --git a/app/components/groupe_gestionnaire/groupe_gestionnaire_tree_structures/tree_structure_component.rb b/app/components/groupe_gestionnaire/groupe_gestionnaire_tree_structures/tree_structure_component.rb index a24c6c487..3279c1809 100644 --- a/app/components/groupe_gestionnaire/groupe_gestionnaire_tree_structures/tree_structure_component.rb +++ b/app/components/groupe_gestionnaire/groupe_gestionnaire_tree_structures/tree_structure_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GroupeGestionnaire::GroupeGestionnaireTreeStructures::TreeStructureComponent < ApplicationComponent include ApplicationHelper diff --git a/app/components/instructeurs/activate_account_form_component.rb b/app/components/instructeurs/activate_account_form_component.rb index 3cd66c102..614f308b8 100644 --- a/app/components/instructeurs/activate_account_form_component.rb +++ b/app/components/instructeurs/activate_account_form_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Instructeurs::ActivateAccountFormComponent < ApplicationComponent attr_reader :user def initialize(user:) diff --git a/app/components/instructeurs/activate_account_form_component/activate_account_form_component.html.haml b/app/components/instructeurs/activate_account_form_component/activate_account_form_component.html.haml index 83c8155ce..c92729a6f 100644 --- a/app/components/instructeurs/activate_account_form_component/activate_account_form_component.html.haml +++ b/app/components/instructeurs/activate_account_form_component/activate_account_form_component.html.haml @@ -11,13 +11,16 @@ .fr-fieldset__element %p.fr-text--sm= t('utils.mandatory_champs') - .fr-fieldset__element= render Dsfr::InputComponent.new(form: f, attribute: :email, input_type: :email_field, opts: { disabled: :disabled, class: 'fr-input-group--disabled', value: t('.email_disabled') }) + .fr-fieldset__element + = render Dsfr::InputComponent.new(form: f, attribute: :email, input_type: :email_field, opts: { disabled: :disabled, class: 'fr-input-group--disabled' }) - .fr-fieldset__element= render Dsfr::InputComponent.new(form: f, attribute: :password, input_type: :password_field, opts: { autocomplete: 'current-password' }) + .fr-fieldset__element + = render Dsfr::InputComponent.new(form: f, attribute: :password, input_type: :password_field, + opts: { autofocus: 'true', autocomplete: 'new-password', data: { controller: 'turbo-input', turbo_input_url_value: show_password_complexity_path }, aria: {describedby: 'password_hint'}}) + + #password_complexity + = render PasswordComplexityComponent.new = f.hidden_field :reset_password_token, value: params[:token] - .fr-fieldset__element - .fr-btns-group--right.fr-btns-group.fr-btns-group--inline.fr-btns-group.fr-btns-group--inline - %ul - %li= f.submit t('.submit'), class: 'fr-mt-2v fr-btn fr-btn' + = f.submit t('.submit'), id: 'submit-password', disabled: :disabled, class: "fr-btn fr-btn--lg fr-mt-2w", data: { disabled: :disabled, disable_with: t('views.users.passwords.edit.submit_loading') } diff --git a/app/components/instructeurs/column_filter_component.rb b/app/components/instructeurs/column_filter_component.rb new file mode 100644 index 000000000..f92a3c623 --- /dev/null +++ b/app/components/instructeurs/column_filter_component.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class Instructeurs::ColumnFilterComponent < ApplicationComponent + attr_reader :procedure, :procedure_presentation, :statut, :column + + def initialize(procedure_presentation:, statut:, column: nil) + @procedure_presentation = procedure_presentation + @procedure = procedure_presentation.procedure + @statut = statut + @column = column + end + + def filter_react_props + { + selected_key: column.present? ? column.id : '', + items: filterable_columns_options, + name: "filters[][id]", + id: 'search-filter', + 'aria-describedby': 'instructeur-filter-combo-label', + form: 'filter-component', + data: { no_autosubmit: 'input blur', no_autosubmit_on_empty: 'true', autosubmit_target: 'input' } + } + end + + def filterable_columns_options + @procedure.columns.filter(&:filterable).map { [_1.label, _1.id] } + end + + def current_filter_tags + @procedure_presentation.filters_for(@statut).flat_map do + [ + hidden_field_tag("filters[][id]", _1.column.id, id: nil), + hidden_field_tag("filters[][filter]", _1.filter, id: nil) + ] + end.reduce(&:concat) + end +end diff --git a/app/components/dossiers/instructeur_filter_component/instructeur_filter_component.en.yml b/app/components/instructeurs/column_filter_component/column_filter_component.en.yml similarity index 100% rename from app/components/dossiers/instructeur_filter_component/instructeur_filter_component.en.yml rename to app/components/instructeurs/column_filter_component/column_filter_component.en.yml diff --git a/app/components/dossiers/instructeur_filter_component/instructeur_filter_component.fr.yml b/app/components/instructeurs/column_filter_component/column_filter_component.fr.yml similarity index 100% rename from app/components/dossiers/instructeur_filter_component/instructeur_filter_component.fr.yml rename to app/components/instructeurs/column_filter_component/column_filter_component.fr.yml diff --git a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml new file mode 100644 index 000000000..c8b94293f --- /dev/null +++ b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml @@ -0,0 +1,24 @@ += form_with model: [:instructeur, @procedure_presentation], + class: 'dropdown-form large', + id: 'filter-component', + data: { turbo: true, controller: 'autosubmit' } do + = current_filter_tags + + .fr-select-group + = label_tag :column, t('.column'), class: 'fr-label fr-m-0', id: 'instructeur-filter-combo-label', for: 'search-filter' + %react-fragment + = render ReactComponent.new "ComboBox/SingleComboBox", **filter_react_props + + %input.hidden{ + type: 'submit', + formmethod: 'get', + formaction: url_for([:refresh_column_filter, :instructeur, @procedure_presentation]), + formnovalidate: 'true', + data: { autosubmit_target: 'submitter' } + } + + = label_tag :value, t('.value'), for: 'value', class: 'fr-label' + = render Instructeurs::ColumnFilterValueComponent.new(column:) + + = hidden_field_tag :statut, statut + = submit_tag t('.add_filter'), class: 'fr-btn fr-btn--secondary fr-mt-2w' diff --git a/app/components/instructeurs/column_filter_value_component.rb b/app/components/instructeurs/column_filter_value_component.rb new file mode 100644 index 000000000..61bd4df4b --- /dev/null +++ b/app/components/instructeurs/column_filter_value_component.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class Instructeurs::ColumnFilterValueComponent < ApplicationComponent + attr_reader :column + + def initialize(column:) + @column = column + end + + def call + if column.nil? + tag.input(id: 'value', class: 'fr-input', disabled: true) + elsif column.type.in?([:enum, :enums, :boolean]) + select_tag 'filters[][filter]', + options_for_select(column.options_for_select), + id: 'value', + class: 'fr-select', + data: { no_autosubmit: true }, + required: true + else + tag.input( + name: "filters[][filter]", + id: 'value', + class: 'fr-input', + type:, + maxlength: FilteredColumn::FILTERS_VALUE_MAX_LENGTH, + data: { no_autosubmit: true }, + required: true + ) + end + end + + private + + def type + case column.type + when :datetime, :date + 'date' + when :integer, :decimal + 'number' + else + 'text' + end + end +end diff --git a/app/components/instructeurs/column_picker_component.rb b/app/components/instructeurs/column_picker_component.rb new file mode 100644 index 000000000..f8c1b0fba --- /dev/null +++ b/app/components/instructeurs/column_picker_component.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Instructeurs::ColumnPickerComponent < ApplicationComponent + attr_reader :procedure, :procedure_presentation + + def initialize(procedure:, procedure_presentation:) + @procedure = procedure + @procedure_presentation = procedure_presentation + @displayable_columns_for_select, @displayable_columns_selected = displayable_columns_for_select + end + + def displayable_columns_for_select + [ + procedure.columns.filter(&:displayable).map { |column| [column.label, column.id] }, + procedure_presentation.displayed_columns.map(&:id) + ] + end +end diff --git a/app/components/instructeurs/column_picker_component/column_picker_component.en.yml b/app/components/instructeurs/column_picker_component/column_picker_component.en.yml new file mode 100644 index 000000000..4c5d618b4 --- /dev/null +++ b/app/components/instructeurs/column_picker_component/column_picker_component.en.yml @@ -0,0 +1,3 @@ +--- +en: + save: 'Save' diff --git a/app/components/instructeurs/column_picker_component/column_picker_component.fr.yml b/app/components/instructeurs/column_picker_component/column_picker_component.fr.yml new file mode 100644 index 000000000..8403f9691 --- /dev/null +++ b/app/components/instructeurs/column_picker_component/column_picker_component.fr.yml @@ -0,0 +1,3 @@ +--- +fr: + save: 'Enregistrer' diff --git a/app/components/instructeurs/column_picker_component/column_picker_component.html.haml b/app/components/instructeurs/column_picker_component/column_picker_component.html.haml new file mode 100644 index 000000000..717ac143e --- /dev/null +++ b/app/components/instructeurs/column_picker_component/column_picker_component.html.haml @@ -0,0 +1,5 @@ += form_with model: [:instructeur, @procedure_presentation], class: 'dropdown-form large columns-form' do + %react-fragment + = render ReactComponent.new "ComboBox/MultiComboBox", items: @displayable_columns_for_select, selected_keys: @displayable_columns_selected, name: 'displayed_columns[]', 'aria-label': 'Colonne à afficher', value_separator: false + + = submit_tag t('.save'), class: 'fr-btn fr-btn--secondary' diff --git a/app/components/instructeurs/column_table_header_component.rb b/app/components/instructeurs/column_table_header_component.rb new file mode 100644 index 000000000..4cb90dd93 --- /dev/null +++ b/app/components/instructeurs/column_table_header_component.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class Instructeurs::ColumnTableHeaderComponent < ApplicationComponent + def initialize(procedure_presentation:) + @procedure_presentation = procedure_presentation + @columns = procedure_presentation.displayed_fields_for_headers + @sorted_column = procedure_presentation.sorted_column + end + + private + + def classname(column) + return 'status-col' if column.dossier_state? + return 'number-col' if column.dossier_id? + return 'sva-col' if column.column == 'sva_svr_decision_on' + end + + def column_header(column) + id = column.id + order = opposite_order_for(column) + + button_to( + label_and_arrow(column), + [:instructeur, @procedure_presentation], + params: { sorted_column: { id: id, order: order } }, + class: 'fr-text--bold' + ) + end + + def opposite_order_for(column) + @sorted_column.column == column ? @sorted_column.opposite_order : 'asc' + end + + def label_and_arrow(column) + return column.label if @sorted_column.column != column + + @sorted_column.ascending? ? "#{column.label} ↑" : "#{column.label} ↓" + end + + def aria_sort(column) + return {} if @sorted_column.column != column + + @sorted_column.ascending? ? { "aria-sort": "ascending" } : { "aria-sort": "descending" } + end +end diff --git a/app/components/instructeurs/column_table_header_component/column_table_header_component.html.haml b/app/components/instructeurs/column_table_header_component/column_table_header_component.html.haml new file mode 100644 index 000000000..0a15f2350 --- /dev/null +++ b/app/components/instructeurs/column_table_header_component/column_table_header_component.html.haml @@ -0,0 +1,3 @@ +- @columns.each do |column| + %th{ aria_sort(column), scope: "col", class: classname(column) } + = column_header(column) diff --git a/app/components/instructeurs/filter_buttons_component.rb b/app/components/instructeurs/filter_buttons_component.rb new file mode 100644 index 000000000..d8917c277 --- /dev/null +++ b/app/components/instructeurs/filter_buttons_component.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +class Instructeurs::FilterButtonsComponent < ApplicationComponent + def initialize(filters:, procedure_presentation:, statut:) + @filters = filters + @procedure_presentation = procedure_presentation + @statut = statut + end + + def call + safe_join(filters_by_family, ' et ') + end + + private + + def filters_by_family + @filters + .group_by { _1.column.id } + .values + .map { |group| group.map { |f| filter_form(f) } } + .map { |group| safe_join(group, ' ou ') } + end + + def filter_form(filter) + form_with(model: [:instructeur, @procedure_presentation], class: 'inline') do + safe_join([ + hidden_field_tag('filters[]', ''), # to ensure the filters is not empty + *other_hidden_fields(filter), # other filters to keep + hidden_field_tag('statut', @statut), # collection to set + button_tag(button_content(filter), class: 'fr-tag fr-tag--dismiss fr-my-1w') + ]) + end + end + + def other_hidden_fields(filter) + @filters.reject { _1 == filter }.flat_map do |f| + [ + hidden_field_tag("filters[][id]", f.column.id), + hidden_field_tag("filters[][filter]", f.filter) + ] + end + end + + def button_content(filter) + "#{filter.label.truncate(50)} : #{human_value(filter)}" + end + + def human_value(filter_column) + column, filter = filter_column.column, filter_column.filter + + if column.type_de_champ? + find_type_de_champ(column.stable_id).dynamic_type.filter_to_human(filter) + elsif column.dossier_state? + if filter == 'pending_correction' + Dossier.human_attribute_name("pending_correction.for_instructeur") + else + Dossier.human_attribute_name("state.#{filter}") + end + elsif column.groupe_instructeur? + current_instructeur.groupe_instructeurs + .find { _1.id == filter.to_i }&.label || filter + elsif column.dossier_labels? + Label.find(filter)&.name || filter + elsif column.type == :date + helpers.try_parse_format_date(filter) + else + filter + end + end + + def find_type_de_champ(stable_id) + TypeDeChamp + .joins(:revision_types_de_champ) + .where(revision_types_de_champ: { revision_id: @procedure_presentation.procedure.revisions }) + .order(created_at: :desc) + .find_by(stable_id:) + end +end diff --git a/app/components/main_navigation/instructeur_expert_navigation_component/instructeur_expert_navigation_component.html.haml b/app/components/main_navigation/instructeur_expert_navigation_component/instructeur_expert_navigation_component.html.haml index 70f647517..47c160cf1 100644 --- a/app/components/main_navigation/instructeur_expert_navigation_component/instructeur_expert_navigation_component.html.haml +++ b/app/components/main_navigation/instructeur_expert_navigation_component/instructeur_expert_navigation_component.html.haml @@ -1,4 +1,4 @@ -%nav#header-navigation.fr-nav{ role: :navigation, "aria-label" => t('main_menu', scope: [:layouts, :header]) } +#header-navigation.fr-nav %ul.fr-nav__list - if instructeur? %li.fr-nav__item @@ -9,6 +9,6 @@ = link_to expert_all_avis_path, class: 'fr-nav__link', aria: aria_current_for(:avis) do = Avis.model_name.human(count: 10) - if helpers.current_expert.avis_summary[:unanswered] > 0 - %span.badge.warning= helpers.current_expert.avis_summary[:unanswered] + %span.fr-badge.fr-badge--new.fr-badge--no-icon= helpers.current_expert.avis_summary[:unanswered] = render MainNavigation::AnnouncesLinkComponent.new diff --git a/app/components/manager/dossier_champ_row_component.rb b/app/components/manager/dossier_champ_row_component.rb index d3bedece7..123eefe9f 100644 --- a/app/components/manager/dossier_champ_row_component.rb +++ b/app/components/manager/dossier_champ_row_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Manager::DossierChampRowComponent < ApplicationComponent with_collection_parameter :row diff --git a/app/components/nested_forms/form_owner_component.rb b/app/components/nested_forms/form_owner_component.rb index ddd125e88..893d9cf68 100644 --- a/app/components/nested_forms/form_owner_component.rb +++ b/app/components/nested_forms/form_owner_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # context: https://github.com/demarches-simplifiees/demarches-simplifiees.fr/issues/8661 # a11y: a post/delete/patch/put action must be wrapped in a + + + {children} + + + ); +} + +export function ComboBoxItem(props: ListBoxItemProps) { + return ; +} + +export function SingleComboBox({ + children, + ...maybeProps +}: SingleComboBoxProps) { + const { + 'aria-labelledby': ariaLabelledby, + items: defaultItems, + selectedKey: defaultSelectedKey, + emptyFilterKey, + name, + formValue, + form, + data, + ...props + } = useMemo(() => s.create(maybeProps, SingleComboBoxProps), [maybeProps]); + + const labelledby = useLabelledBy(props.id, ariaLabelledby); + const { ref, dispatch } = useDispatchChangeEvent(); + + const { selectedItem, onReset, ...comboBoxProps } = useSingleList({ + defaultItems, + defaultSelectedKey, + emptyFilterKey, + onChange: dispatch + }); + + return ( + <> + + {(item) => {item.label}} + + {children || name ? ( + + + {name ? ( + + ) : null} + {children} + + + ) : null} + + ); +} + +export function MultiComboBox(maybeProps: MultiComboBoxProps) { + const { + 'aria-labelledby': ariaLabelledby, + items: defaultItems, + selectedKeys: defaultSelectedKeys, + name, + form, + formValue, + allowsCustomValue, + valueSeparator, + className, + ...props + } = useMemo(() => s.create(maybeProps, MultiComboBoxProps), [maybeProps]); + + const labelledby = useLabelledBy(props.id, ariaLabelledby); + const { ref, dispatch } = useDispatchChangeEvent(); + const inputRef = useRef(null); + + const { + selectedItems, + hiddenInputValues, + onRemove, + onReset, + ...comboBoxProps + } = useMultiList({ + defaultItems, + defaultSelectedKeys, + onChange: dispatch, + formValue, + allowsCustomValue, + valueSeparator, + focusInput: () => { + inputRef.current?.focus(); + } + }); + const formResetRef = useOnFormReset(onReset); + + return ( +
+ {selectedItems.length > 0 ? ( + + + {selectedItems.map((item) => ( + + {item.label} + + + ))} + + + ) : null} + + {(item) => {item.label}} + + {name ? ( + + {hiddenInputValues.length == 0 ? ( + + ) : ( + hiddenInputValues.map((value, i) => ( + + )) + )} + + ) : null} +
+ ); +} + +export function RemoteComboBox({ + loader, + onChange, + children, + ...maybeProps +}: RemoteComboBoxProps) { + const { + 'aria-labelledby': ariaLabelledby, + items: defaultItems, + selectedKey: defaultSelectedKey, + allowsCustomValue, + minimumInputLength, + limit, + debounce, + coerce, + formValue, + name, + form, + data, + ...props + } = useMemo(() => s.create(maybeProps, RemoteComboBoxProps), [maybeProps]); + + const labelledby = useLabelledBy(props.id, ariaLabelledby); + const { ref, dispatch } = useDispatchChangeEvent(); + + const load = useMemo( + () => + typeof loader == 'string' + ? createLoader(loader, { minimumInputLength, limit, coerce }) + : loader, + [loader, minimumInputLength, limit, coerce] + ); + const { selectedItem, onReset, ...comboBoxProps } = useRemoteList({ + allowsCustomValue, + defaultItems, + defaultSelectedKey, + debounce, + load, + onChange: (item) => { + onChange?.(item); + dispatch(); + } + }); + + return ( + <> + 0} + allowsCustomValue={allowsCustomValue} + aria-labelledby={labelledby} + {...comboBoxProps} + {...props} + > + {(item) => {item.label}} + + {children || name ? ( + + + {name ? ( + + ) : null} + {children} + + + ) : null} + + ); +} + +export function ComboBoxValueSlot({ + field, + name, + form, + onReset, + data +}: { + field: 'label' | 'value' | 'data'; + name: string; + form?: string; + onReset?: () => void; + data?: Record; +}) { + const selectedItem = useContext(SelectedItemContext); + const value = getSelectedValue(selectedItem, field); + const dataProps = Object.fromEntries( + Object.entries(data ?? {}).map(([key, value]) => [ + `data-${key.replace(/_/g, '-')}`, + value + ]) + ); + const ref = useOnFormReset(onReset); + return ( + + ); +} + +const SelectedItemContext = createContext(null); +const SelectedItemProvider = SelectedItemContext.Provider; + +function getSelectedValue( + selectedItem: Item | null, + field: 'label' | 'value' | 'data' +): string { + if (selectedItem == null) { + return ''; + } else if (field == 'data') { + if (typeof selectedItem.data == 'string') { + return selectedItem.data; + } else if (!selectedItem.data) { + return ''; + } + return JSON.stringify(selectedItem.data); + } + return selectedItem[field]; +} diff --git a/app/javascript/components/ComboMultiple.tsx b/app/javascript/components/ComboMultiple.tsx deleted file mode 100644 index 713e64a8c..000000000 --- a/app/javascript/components/ComboMultiple.tsx +++ /dev/null @@ -1,374 +0,0 @@ -import React, { - useMemo, - useState, - useRef, - useContext, - createContext, - useId, - ReactNode, - ChangeEventHandler, - KeyboardEventHandler -} from 'react'; -import { - Combobox, - ComboboxInput, - ComboboxList, - ComboboxOption, - ComboboxPopover -} from '@reach/combobox'; -import '@reach/combobox/styles.css'; -import { matchSorter } from 'match-sorter'; -import { XIcon } from '@heroicons/react/outline'; -import isHotkey from 'is-hotkey'; -import invariant from 'tiny-invariant'; - -import { useDeferredSubmit, useHiddenField } from './shared/hooks'; - -const Context = createContext<{ - onRemove: (value: string) => void; -} | null>(null); - -type Option = [label: string, value: string]; - -function isOptions(options: string[] | Option[]): options is Option[] { - return Array.isArray(options[0]); -} - -const optionLabelByValue = ( - values: string[], - options: Option[], - value: string -): string => { - const maybeOption: Option | undefined = values.includes(value) - ? [value, value] - : options.find(([, optionValue]) => optionValue == value); - return maybeOption ? maybeOption[0] : ''; -}; - -export type ComboMultipleProps = { - options: string[] | Option[]; - id: string; - labelledby: string; - describedby: string; - label: string; - group: string; - name?: string; - selected: string[]; - acceptNewValues?: boolean; -}; - -export default function ComboMultiple({ - options, - id, - labelledby, - describedby, - label, - group, - name = 'value', - selected, - acceptNewValues = false -}: ComboMultipleProps) { - invariant(id || label, 'ComboMultiple: `id` or a `label` are required'); - invariant(group, 'ComboMultiple: `group` is required'); - - const inputRef = useRef(null); - const [term, setTerm] = useState(''); - const [selections, setSelections] = useState(selected); - const [newValues, setNewValues] = useState([]); - const internalId = useId(); - const inputId = id ?? internalId; - const removedLabelledby = `${inputId}-remove`; - const selectedLabelledby = `${inputId}-selected`; - - const optionsWithLabels = useMemo( - () => - isOptions(options) - ? options - : options.filter((o) => o).map((o) => [o, o]), - [options] - ); - - const extraOptions = useMemo( - () => - acceptNewValues && - term && - term.length > 2 && - !optionLabelByValue(newValues, optionsWithLabels, term) - ? [[term, term]] - : [], - [acceptNewValues, term, optionsWithLabels, newValues] - ); - - const extraListOptions = useMemo( - () => - acceptNewValues && term && term.length > 2 && term.includes(';') - ? term.split(';').map((val) => [val.trim(), val.trim()]) - : [], - [acceptNewValues, term] - ); - - const results = useMemo( - () => - [ - ...extraOptions, - ...(term - ? matchSorter( - optionsWithLabels.filter(([label]) => !label.startsWith('--')), - term - ) - : optionsWithLabels) - ].filter(([, value]) => !selections.includes(value)), - [term, selections, extraOptions, optionsWithLabels] - ); - const [, setHiddenFieldValue, hiddenField] = useHiddenField(group, name); - const awaitFormSubmit = useDeferredSubmit(hiddenField); - - const handleChange: ChangeEventHandler = (event) => { - setTerm(event.target.value); - }; - - const saveSelection = (fn: (selections: string[]) => string[]) => { - setSelections((selections) => { - selections = fn(selections); - setHiddenFieldValue(JSON.stringify(selections)); - return selections; - }); - }; - - const onSelect = (value: string) => { - const maybeValue = [...extraOptions, ...optionsWithLabels].find( - ([, val]) => val == value - ); - - const maybeValueFromListOptions = extraListOptions.find( - ([, val]) => val == value - ); - - const selectedValue = - term.includes(';') && acceptNewValues - ? maybeValueFromListOptions && maybeValueFromListOptions[1] - : maybeValue && maybeValue[1]; - - if (selectedValue) { - if ( - (acceptNewValues && - extraOptions[0] && - extraOptions[0][0] == selectedValue) || - (acceptNewValues && extraListOptions[0]) - ) { - setNewValues((newValues) => { - const set = new Set(newValues); - set.add(selectedValue); - return [...set]; - }); - } - saveSelection((selections) => { - const set = new Set(selections); - set.add(selectedValue); - return [...set]; - }); - } - setTerm(''); - awaitFormSubmit.done(); - hidePopover(); - }; - - const onRemove = (optionValue: string) => { - if (optionValue) { - saveSelection((selections) => - selections.filter((value) => value != optionValue) - ); - setNewValues((newValues) => - newValues.filter((value) => value != optionValue) - ); - } - inputRef.current?.focus(); - }; - - const onKeyDown: KeyboardEventHandler = (event) => { - if ( - isHotkey('enter', event) || - isHotkey(' ', event) || - isHotkey(',', event) || - isHotkey(';', event) - ) { - if (term.includes(';')) { - for (const val of term.split(';')) { - event.preventDefault(); - onSelect(val.trim()); - } - } else if ( - term && - [...extraOptions, ...optionsWithLabels] - .map(([label]) => label) - .includes(term) - ) { - event.preventDefault(); - onSelect(term); - } - } - }; - - const hidePopover = () => { - document - .querySelector(`[data-reach-combobox-popover-id="${inputId}"]`) - ?.setAttribute('hidden', 'true'); - }; - - const showPopover = () => { - document - .querySelector(`[data-reach-combobox-popover-id="${inputId}"]`) - ?.removeAttribute('hidden'); - }; - - const onBlur = () => { - const shouldSelect = - term && - [...extraOptions, ...optionsWithLabels] - .map(([label]) => label) - .includes(term); - - awaitFormSubmit(() => { - if (term.includes(';')) { - for (const val of term.split(';')) { - onSelect(val.trim()); - } - } else if (shouldSelect) { - onSelect(term); - } else { - hidePopover(); - } - }); - }; - - return ( - - - - désélectionner - -
    - {selections.map((selection) => ( - - {optionLabelByValue(newValues, optionsWithLabels, selection)} - - ))} -
- -
- {results && (results.length > 0 || !acceptNewValues) && ( - - - {results.length === 0 && ( -
  • - Aucun résultat{' '} - -
  • - )} - {results.map(([label, value], index) => { - if (label.startsWith('--')) { - return ; - } - return ( - - {label} - - ); - })} -
    -
    - )} -
    - ); -} - -function ComboboxTokenLabel({ - onRemove, - children -}: { - onRemove: (value: string) => void; - children: ReactNode; -}) { - return ( - -
    {children}
    -
    - ); -} - -function ComboboxSeparator({ value }: { value: string }) { - return ( -
  • - {value.slice(2, -2)} -
  • - ); -} - -function ComboboxToken({ - value, - describedby, - children, - ...props -}: { - value: string; - describedby: string; - children: ReactNode; -}) { - const context = useContext(Context); - invariant(context, 'invalid context'); - const { onRemove } = context; - - return ( -
  • - -
  • - ); -} diff --git a/app/javascript/components/ComboSearch.tsx b/app/javascript/components/ComboSearch.tsx deleted file mode 100644 index 2115d6be6..000000000 --- a/app/javascript/components/ComboSearch.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import React, { - useState, - useEffect, - useRef, - useId, - ChangeEventHandler -} from 'react'; -import { useDebounce } from 'use-debounce'; -import { useQuery } from 'react-query'; -import { - Combobox, - ComboboxInput, - ComboboxPopover, - ComboboxList, - ComboboxOption -} from '@reach/combobox'; -import '@reach/combobox/styles.css'; -import invariant from 'tiny-invariant'; - -import { useDeferredSubmit, useHiddenField, groupId } from './shared/hooks'; - -type TransformResults = (term: string, results: unknown) => Result[]; -type TransformResult = ( - result: Result -) => [key: string, value: string, label?: string]; - -export type ComboSearchProps = { - onChange?: (value: string | null, result?: Result) => void; - value?: string; - scope: string; - scopeExtra?: string; - minimumInputLength: number; - transformResults?: TransformResults; - transformResult: TransformResult; - allowInputValues?: boolean; - id?: string; - describedby?: string; - className?: string; - placeholder?: string; - debounceDelay?: number; - screenReaderInstructions: string; - announceTemplateId: string; -}; - -type QueryKey = readonly [ - scope: string, - term: string, - extra: string | undefined -]; - -function ComboSearch({ - onChange, - value: controlledValue, - scope, - scopeExtra, - minimumInputLength, - transformResult, - allowInputValues = false, - transformResults = (_, results) => results as Result[], - id, - describedby, - screenReaderInstructions, - announceTemplateId, - debounceDelay = 0, - ...props -}: ComboSearchProps) { - invariant(id || onChange, 'ComboSearch: `id` or `onChange` are required'); - - const group = !onChange && id ? groupId(id) : undefined; - const [externalValue, setExternalValue, hiddenField] = useHiddenField(group); - const [, setExternalId] = useHiddenField(group, 'external_id'); - const initialValue = externalValue ? externalValue : controlledValue; - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm] = useDebounce(searchTerm, debounceDelay); - const [value, setValue] = useState(initialValue); - const resultsMap = useRef< - Record - >({}); - const getLabel = (result: Result) => { - const [, value, label] = transformResult(result); - return label ?? value; - }; - const setExternalValueAndId = (label: string) => { - const { key, value, result } = resultsMap.current[label]; - if (onChange) { - onChange(value, result); - } else { - setExternalId(key); - setExternalValue(value); - } - }; - const awaitFormSubmit = useDeferredSubmit(hiddenField); - - const handleOnChange: ChangeEventHandler = ({ - target: { value } - }) => { - setValue(value); - if (!value) { - if (onChange) { - onChange(null); - } else { - setExternalId(''); - setExternalValue(''); - } - } else if (value.length >= minimumInputLength) { - setSearchTerm(value.trim()); - if (allowInputValues) { - setExternalId(''); - setExternalValue(value); - } - } - }; - - const handleOnSelect = (value: string) => { - setExternalValueAndId(value); - setValue(value); - setSearchTerm(''); - awaitFormSubmit.done(); - }; - - const { isSuccess, data } = useQuery( - [scope, debouncedSearchTerm, scopeExtra], - { - enabled: !!debouncedSearchTerm, - refetchOnMount: false - } - ); - const results = - isSuccess && data ? transformResults(debouncedSearchTerm, data) : []; - - const onBlur = () => { - if (!allowInputValues && isSuccess && results[0]) { - const label = getLabel(results[0]); - awaitFormSubmit(() => { - handleOnSelect(label); - }); - } - }; - - const [announceLive, setAnnounceLive] = useState(''); - const announceTimeout = useRef>(); - const announceTemplate = document.querySelector( - `#${announceTemplateId}` - ); - invariant(announceTemplate, `Missing #${announceTemplateId}`); - - const announceFragment = useRef( - announceTemplate.content.cloneNode(true) as DocumentFragment - ).current; - - useEffect(() => { - if (isSuccess) { - const slot = announceFragment.querySelector( - 'slot[name="' + (results.length <= 1 ? results.length : 'many') + '"]' - ); - - if (!slot) { - return; - } - - const countSlot = - slot.querySelector('slot[name="count"]'); - if (countSlot) { - countSlot.replaceWith(String(results.length)); - } - - setAnnounceLive(slot.textContent ?? ''); - } - - announceTimeout.current = setTimeout(() => { - setAnnounceLive(''); - }, 3000); - - return () => clearTimeout(announceTimeout.current); - }, [announceFragment, results.length, isSuccess]); - - const initInstrId = useId(); - const resultsId = useId(); - - return ( - - - {isSuccess && ( - - {results.length > 0 ? ( - - {results.map((result, index) => { - const label = getLabel(result); - const [key, value] = transformResult(result); - resultsMap.current[label] = { key, value, result }; - return ; - })} - - ) : ( - - Aucun résultat trouvé - - )} - - )} - {!describedby && ( - - {screenReaderInstructions} - - )} -
    - {announceLive} -
    -
    - ); -} - -export default ComboSearch; diff --git a/app/javascript/components/Layout.tsx b/app/javascript/components/Layout.tsx new file mode 100644 index 000000000..39a33aa9e --- /dev/null +++ b/app/javascript/components/Layout.tsx @@ -0,0 +1,12 @@ +import { I18nProvider } from 'react-aria-components'; +import { StrictMode, type ReactNode } from 'react'; + +export function Layout({ children }: { children: ReactNode }) { + const locale = document.documentElement.lang; + console.debug(`locale: ${locale}`); + return ( + + {children} + + ); +} diff --git a/app/javascript/components/MapEditor/components/AddressInput.tsx b/app/javascript/components/MapEditor/components/AddressInput.tsx index 0e2d2d2a5..8f250adc6 100644 --- a/app/javascript/components/MapEditor/components/AddressInput.tsx +++ b/app/javascript/components/MapEditor/components/AddressInput.tsx @@ -1,33 +1,35 @@ -import React from 'react'; import { fire } from '@utils'; import type { FeatureCollection } from 'geojson'; -import ComboAdresseSearch from '../../ComboAdresseSearch'; -import { ComboSearchProps } from '~/components/ComboSearch'; +import { RemoteComboBox } from '../../ComboBox'; -export function AddressInput( - comboProps: Pick< - ComboSearchProps, - 'screenReaderInstructions' | 'announceTemplateId' - > & { featureCollection: FeatureCollection; champId: string } -) { +export function AddressInput({ + source, + featureCollection, + champId, + translations +}: { + source: string; + featureCollection: FeatureCollection; + champId: string; + translations: Record; +}) { return ( -
    - { - fire(document, 'map:zoom', { - featureCollection: comboProps.featureCollection, - feature - }); +
    + { + if (item && item.data) { + fire(document, 'map:zoom', { + featureCollection, + feature: item.data + }); + } }} - {...comboProps} />
    ); diff --git a/app/javascript/components/MapEditor/components/CadastreLayer.tsx b/app/javascript/components/MapEditor/components/CadastreLayer.tsx index f2f0b7086..9b1ac8b5f 100644 --- a/app/javascript/components/MapEditor/components/CadastreLayer.tsx +++ b/app/javascript/components/MapEditor/components/CadastreLayer.tsx @@ -1,7 +1,9 @@ -import { useCallback, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import type { Feature, FeatureCollection } from 'geojson'; +import { CursorClickIcon } from '@heroicons/react/outline'; -import { useMapLibre } from '../../shared/maplibre/MapLibre'; +import { useMapLibre, ReactControl } from '../../shared/maplibre/MapLibre'; import { useEvent, useMapEvent, @@ -18,15 +20,31 @@ export function CadastreLayer({ featureCollection, createFeatures, deleteFeatures, + toggle, enabled }: { featureCollection: FeatureCollection; createFeatures: CreateFeatures; deleteFeatures: DeleteFeatures; + toggle: () => void; enabled: boolean; }) { const map = useMapLibre(); const selectedCadastresRef = useRef(new Set()); + const [controlElement, setControlElement] = useState( + null + ); + + useEffect(() => { + const control = new ReactControl(); + map.addControl(control, 'top-left'); + setControlElement(control.container); + + return () => { + map.removeControl(control); + setControlElement(null); + }; + }, [map, enabled]); const highlightFeature = useCallback( (cid: string, highlight: boolean) => { @@ -87,7 +105,7 @@ export function CadastreLayer({ }); const onHighlight = useCallback( - ({ detail }) => { + ({ detail }: CustomEvent<{ cid: string; highlight: boolean }>) => { highlightFeature(detail.cid, detail.highlight); }, [highlightFeature] @@ -95,7 +113,35 @@ export function CadastreLayer({ useEvent('map:internal:cadastre:highlight', onHighlight); - return null; + return ( + <> + {controlElement != null + ? createPortal( + , + controlElement + ) + : null} + + ); +} + +function CadastreSwitch({ + enabled, + toggle +}: { + enabled: boolean; + toggle: () => void; +}) { + return ( + + ); } function useCadastres( diff --git a/app/javascript/components/MapEditor/components/DrawLayer.tsx b/app/javascript/components/MapEditor/components/DrawLayer.tsx index 82f6223ef..cffb87486 100644 --- a/app/javascript/components/MapEditor/components/DrawLayer.tsx +++ b/app/javascript/components/MapEditor/components/DrawLayer.tsx @@ -1,7 +1,7 @@ import { useCallback, useRef, useEffect } from 'react'; -import type { LngLatBoundsLike } from 'maplibre-gl'; +import type { LngLatBoundsLike, LngLatLike, IControl } from 'maplibre-gl'; import DrawControl from '@mapbox/mapbox-gl-draw'; -import type { FeatureCollection } from 'geojson'; +import type { FeatureCollection, Feature, Point } from 'geojson'; import { useMapLibre } from '../../shared/maplibre/MapLibre'; import { @@ -52,18 +52,14 @@ export function DrawLayer({ }); // We use mapbox-draw plugin with maplibre. They are compatible but types are not. // eslint-disable-next-line @typescript-eslint/no-explicit-any - map.addControl(draw as any, 'top-left'); + const control = draw as any as IControl; + map.addControl(control, 'top-left'); draw.set( filterFeatureCollection(featureCollection, SOURCE_SELECTION_UTILISATEUR) ); drawRef.current = draw; - for (const [selector, translation] of translations) { - const element = document.querySelector(selector); - if (element) { - element.setAttribute('title', translation); - } - } + patchDrawControl(); } return () => { @@ -78,15 +74,24 @@ export function DrawLayer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [map, enabled]); - const onSetId = useCallback(({ detail }) => { - drawRef.current?.setFeatureProperty(detail.lid, 'id', detail.id); - }, []); - const onAddFeature = useCallback(({ detail }) => { - drawRef.current?.add(detail.feature); - }, []); - const onDeleteFature = useCallback(({ detail }) => { - drawRef.current?.delete(detail.id); - }, []); + const onSetId = useCallback( + ({ detail }: CustomEvent<{ lid: string; id: string }>) => { + drawRef.current?.setFeatureProperty(detail.lid, 'id', detail.id); + }, + [] + ); + const onAddFeature = useCallback( + ({ detail }: CustomEvent<{ feature: Feature }>) => { + drawRef.current?.add(detail.feature); + }, + [] + ); + const onDeleteFature = useCallback( + ({ detail }: CustomEvent<{ id: string }>) => { + drawRef.current?.delete(detail.id); + }, + [] + ); useMapEvent('draw.create', createFeatures); useMapEvent('draw.update', updateFeatures); @@ -122,7 +127,7 @@ function useExternalEvents( const flyTo = useFlyTo(); const onFeatureFocus = useCallback( - ({ detail }) => { + ({ detail }: CustomEvent<{ id: string; bbox: LngLatBoundsLike }>) => { const { id, bbox } = detail; if (id) { const feature = findFeature(featureCollection, id); @@ -137,16 +142,26 @@ function useExternalEvents( ); const onZoomFocus = useCallback( - ({ detail }) => { + ({ + detail + }: CustomEvent<{ + feature: Feature; + featureCollection: FeatureCollection; + }>) => { if (detail.feature && detail.featureCollection == featureCollection) { - flyTo(17, detail.feature.geometry.coordinates); + flyTo(17, detail.feature.geometry.coordinates as LngLatLike); } }, [flyTo, featureCollection] ); const onFeatureCreate = useCallback( - ({ detail }) => { + ({ + detail + }: CustomEvent<{ + feature: Feature; + featureCollection: FeatureCollection; + }>) => { const { feature } = detail; const { geometry, properties } = feature; if ( @@ -164,7 +179,9 @@ function useExternalEvents( ); const onFeatureUpdate = useCallback( - ({ detail }) => { + ({ + detail + }: CustomEvent<{ id: string; properties: Feature['properties'] }>) => { const { id, properties } = detail; const feature = findFeature(featureCollection, id); @@ -177,7 +194,7 @@ function useExternalEvents( ); const onFeatureDelete = useCallback( - ({ detail }) => { + ({ detail }: CustomEvent<{ id: string }>) => { const { id } = detail; const feature = findFeature(featureCollection, id); @@ -207,3 +224,15 @@ const translations = [ ['.mapbox-gl-draw_point', 'Ajouter un point'], ['.mapbox-gl-draw_trash', 'Supprimer'] ]; + +function patchDrawControl() { + document.querySelectorAll('.mapboxgl-ctrl').forEach((control) => { + control.classList.add('maplibregl-ctrl', 'maplibregl-ctrl-group'); + + for (const [selector, translation] of translations) { + for (const button of control.querySelectorAll(selector)) { + button.setAttribute('title', translation); + } + } + }); +} diff --git a/app/javascript/components/MapEditor/components/ImportFileInput.tsx b/app/javascript/components/MapEditor/components/ImportFileInput.tsx index 68be216e2..98cff6c2b 100644 --- a/app/javascript/components/MapEditor/components/ImportFileInput.tsx +++ b/app/javascript/components/MapEditor/components/ImportFileInput.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, MouseEvent, ChangeEvent } from 'react'; +import { useState, useCallback, MouseEvent, ChangeEvent } from 'react'; import type { FeatureCollection } from 'geojson'; import invariant from 'tiny-invariant'; @@ -9,11 +9,13 @@ import { CreateFeatures, DeleteFeatures } from '../hooks'; export function ImportFileInput({ featureCollection, createFeatures, - deleteFeatures + deleteFeatures, + translations }: { featureCollection: FeatureCollection; createFeatures: CreateFeatures; deleteFeatures: DeleteFeatures; + translations: Record; }) { const { inputs, addInputFile, removeInputFile, onFileChange } = useImportFiles(featureCollection, { createFeatures, deleteFeatures }); @@ -24,14 +26,14 @@ export function ImportFileInput({ className="fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-circle-line" onClick={addInputFile} > - Ajouter un fichier GPX ou KML + {translations.add_file}
    {inputs.map((input) => (
    {input.hasValue && ( ; }) { const inputId = useId(); const [value, setValue] = useState(''); @@ -35,9 +37,10 @@ export function PointInput({ return (
    @@ -46,11 +49,9 @@ export function PointInput({ type="button" className="fr-btn fr-btn--secondary fr-icon-map-pin-2-line" onClick={getCurrentPosition} - title="Afficher votre position sur la carte" + title={translations.show_pin} > - - Afficher votre position sur la carte - + {translations.show_pin} ) : null} - - Ajouter le point avec les coordonnées saisies sur la carte - + {translations.add_pin}
    diff --git a/app/javascript/components/MapEditor/hooks.ts b/app/javascript/components/MapEditor/hooks.ts index eae70cb52..78b6a9267 100644 --- a/app/javascript/components/MapEditor/hooks.ts +++ b/app/javascript/components/MapEditor/hooks.ts @@ -137,7 +137,7 @@ export function useFeatureCollection( for (const feature of features) { const id = feature.properties?.id; if (id) { - await httpRequest(`${url}/${id}`, { + await httpRequest(endpointWithId(url, id), { method: 'patch', json: { feature } }).json(); @@ -174,7 +174,9 @@ export function useFeatureCollection( const deletedFeatures = []; for (const feature of features) { const id = feature.properties?.id; - await httpRequest(`${url}/${id}`, { method: 'delete' }).json(); + await httpRequest(endpointWithId(url, id), { + method: 'delete' + }).json(); deletedFeatures.push(feature); } removeFeatures(deletedFeatures, external); @@ -212,3 +214,11 @@ function useError(): [string | undefined, (message: string) => void] { return [error, onError]; } + +// We need this because endoint can have query params. For example with /champs/123?row_id=abc we can't juste concatanate id. +// We want /champs/123/456?row_id=abc not /champs/123?row_id=abc/456 +function endpointWithId(endpoint: string, id: string) { + const url = new URL(endpoint, document.baseURI); + url.pathname = `${url.pathname}/${id}`; + return url.toString(); +} diff --git a/app/javascript/components/MapEditor/index.tsx b/app/javascript/components/MapEditor/index.tsx index 0a918c981..08bad1c61 100644 --- a/app/javascript/components/MapEditor/index.tsx +++ b/app/javascript/components/MapEditor/index.tsx @@ -1,6 +1,4 @@ -import React, { useState } from 'react'; -import { CursorClickIcon } from '@heroicons/react/outline'; -import 'maplibre-gl/dist/maplibre-gl.css'; +import { useState } from 'react'; import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css'; import type { FeatureCollection } from 'geojson'; @@ -12,22 +10,21 @@ import { AddressInput } from './components/AddressInput'; import { PointInput } from './components/PointInput'; import { ImportFileInput } from './components/ImportFileInput'; import { FlashMessage } from '../shared/FlashMessage'; -import { ComboSearchProps } from '../ComboSearch'; export default function MapEditor({ featureCollection: initialFeatureCollection, url, + adresseSource, options, - autocompleteAnnounceTemplateId, - autocompleteScreenReaderInstructions, - champId + champId, + translations }: { featureCollection: FeatureCollection; url: string; + adresseSource: string; options: { layers: string[] }; - autocompleteAnnounceTemplateId: ComboSearchProps['announceTemplateId']; - autocompleteScreenReaderInstructions: ComboSearchProps['screenReaderInstructions']; champId: string; + translations: Record; }) { const [cadastreEnabled, setCadastreEnabled] = useState(false); @@ -40,16 +37,16 @@ export default function MapEditor({ <> {error && } - - + @@ -59,28 +56,18 @@ export default function MapEditor({ enabled={!cadastreEnabled} /> {options.layers.includes('cadastres') ? ( - <> - -
    - -
    - + setCadastreEnabled((enabled) => !enabled)} + enabled={cadastreEnabled} + /> ) : null}
    - + ); } diff --git a/app/javascript/components/MapReader/components/GeoJSONLayer.tsx b/app/javascript/components/MapReader/components/GeoJSONLayer.tsx index 50024e16a..807730cb3 100644 --- a/app/javascript/components/MapReader/components/GeoJSONLayer.tsx +++ b/app/javascript/components/MapReader/components/GeoJSONLayer.tsx @@ -1,6 +1,6 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; -import { Popup, LngLatBoundsLike } from 'maplibre-gl'; -import type { Feature, FeatureCollection } from 'geojson'; +import { useCallback, useEffect, useMemo } from 'react'; +import { Popup, LngLatBoundsLike, LngLatLike } from 'maplibre-gl'; +import type { Feature, FeatureCollection, Point } from 'geojson'; import { useMapLibre } from '../../shared/maplibre/MapLibre'; import { @@ -102,7 +102,7 @@ function useExternalEvents(featureCollection: FeatureCollection) { const fitBounds = useFitBounds(); const flyTo = useFlyTo(); const onFeatureFocus = useCallback( - ({ detail }) => { + ({ detail }: CustomEvent<{ id: string }>) => { const { id } = detail; const feature = findFeature(featureCollection, id); if (feature) { @@ -112,10 +112,10 @@ function useExternalEvents(featureCollection: FeatureCollection) { [featureCollection, fitBounds] ); const onZoomFocus = useCallback( - ({ detail }) => { + ({ detail }: CustomEvent<{ feature: Feature }>) => { const { feature } = detail; if (feature) { - flyTo(17, feature.geometry.coordinates); + flyTo(17, feature.geometry.coordinates as LngLatLike); } }, [flyTo] diff --git a/app/javascript/components/MapReader/index.tsx b/app/javascript/components/MapReader/index.tsx index 1f9fb07d4..62652708e 100644 --- a/app/javascript/components/MapReader/index.tsx +++ b/app/javascript/components/MapReader/index.tsx @@ -1,5 +1,3 @@ -import React from 'react'; -import 'maplibre-gl/dist/maplibre-gl.css'; import type { FeatureCollection } from 'geojson'; import { MapLibre } from '../shared/maplibre/MapLibre'; diff --git a/app/javascript/components/react-aria/hooks.ts b/app/javascript/components/react-aria/hooks.ts new file mode 100644 index 000000000..5df65dda7 --- /dev/null +++ b/app/javascript/components/react-aria/hooks.ts @@ -0,0 +1,553 @@ +import type { + ComboBoxProps as AriaComboBoxProps, + TagGroupProps +} from 'react-aria-components'; +import { useAsyncList, type AsyncListOptions } from 'react-stately'; +import { useMemo, useRef, useState, useEffect } from 'react'; +import type { Key } from 'react'; +import { matchSorter } from 'match-sorter'; +import { useDebounceCallback } from 'usehooks-ts'; +import { useEvent } from 'react-use-event-hook'; +import isEqual from 'react-fast-compare'; +import * as s from 'superstruct'; + +import { Item } from './props'; + +export type Loader = AsyncListOptions['load']; + +export interface ComboBoxProps + extends Omit, 'children'> { + children: React.ReactNode | ((item: Item) => React.ReactNode); + label?: string; + description?: string; +} + +const inputMap = new WeakMap(); +const inputCountMap = new WeakMap(); +export function useDispatchChangeEvent() { + const ref = useRef(null); + + return { + ref, + dispatch: () => { + requestAnimationFrame(() => { + if (ref.current) { + const container = ref.current; + const inputs = Array.from(container.querySelectorAll('input')); + const input = inputs.at(0); + if (input && inputChanged(container, inputs)) { + inputCountMap.set(container, inputs.length); + for (const input of inputs) { + inputMap.set(input, input.value.trim()); + } + input.dispatchEvent(new Event('change', { bubbles: true })); + } + } + }); + } + }; +} + +// I am not proude of this code. We have to tack values and number of values to deal with multi select combobox. +// I have a plan to remove this code. Soon. +function inputChanged(container: HTMLSpanElement, inputs: HTMLInputElement[]) { + const prevCount = inputCountMap.get(container) ?? 0; + if (prevCount != inputs.length) { + return true; + } + for (const input of inputs) { + const value = input.value.trim(); + const prevValue = inputMap.get(input); + if (prevValue == null || prevValue != value) { + return true; + } + } + return false; +} + +export function useSingleList({ + defaultItems, + defaultSelectedKey, + emptyFilterKey, + onChange +}: { + defaultItems?: Item[]; + defaultSelectedKey?: string | null; + emptyFilterKey?: string | null; + onChange?: (item: Item | null) => void; +}) { + const [selectedKey, setSelectedKey] = useState(defaultSelectedKey); + const items = useMemo( + () => (defaultItems ? distinctBy(defaultItems, 'value') : []), + [defaultItems] + ); + const selectedItem = useMemo( + () => items.find((item) => item.value == selectedKey) ?? null, + [items, selectedKey] + ); + const [inputValue, setInputValue] = useState(() => selectedItem?.label ?? ''); + // show fallback item when input value is not matching any items + const fallbackItem = useMemo( + () => items.find((item) => item.value == emptyFilterKey), + [items, emptyFilterKey] + ); + const filteredItems = useMemo(() => { + if (inputValue == '') { + return items; + } + const filteredItems = matchSorter(items, inputValue, { keys: ['label'] }); + if (filteredItems.length == 0 && fallbackItem) { + return [fallbackItem]; + } else { + return filteredItems; + } + }, [items, inputValue, fallbackItem]); + + const initialSelectedKeyRef = useRef(defaultSelectedKey); + + const setSelection = useEvent((key?: string | null) => { + const inputValue = key + ? items.find((item) => item.value == key)?.label + : ''; + setSelectedKey(key); + setInputValue(inputValue ?? ''); + }); + const onSelectionChange = useEvent< + NonNullable + >((key) => { + setSelection(key ? String(key) : null); + const item = + typeof key != 'string' + ? null + : selectedItem?.value == key + ? selectedItem + : items.find((item) => item.value == key) ?? null; + onChange?.(item); + }); + const onInputChange = useEvent>( + (value) => { + setInputValue(value); + if (value == '') { + onSelectionChange(null); + } + } + ); + const onReset = useEvent(() => { + setSelectedKey(null); + setInputValue(''); + }); + + // reset default selected key when props change + useEffect(() => { + if (initialSelectedKeyRef.current != defaultSelectedKey) { + initialSelectedKeyRef.current = defaultSelectedKey; + setSelection(defaultSelectedKey); + } + }, [defaultSelectedKey, setSelection]); + + return { + selectedItem, + selectedKey, + onSelectionChange, + inputValue, + onInputChange, + items: filteredItems, + onReset + }; +} + +export function useMultiList({ + defaultItems, + defaultSelectedKeys, + allowsCustomValue, + valueSeparator, + onChange, + focusInput, + formValue +}: { + defaultItems?: Item[]; + defaultSelectedKeys?: string[]; + allowsCustomValue?: boolean; + valueSeparator?: string | false; + onChange?: () => void; + focusInput?: () => void; + formValue?: 'text' | 'key'; +}) { + const valueSeparatorRegExp = useMemo( + () => + valueSeparator === false + ? false + : valueSeparator + ? new RegExp(valueSeparator) + : /\s|,|;/, + [valueSeparator] + ); + const [selectedKeys, setSelectedKeys] = useState( + () => new Set(defaultSelectedKeys ?? []) + ); + const [inputValue, setInputValue] = useState(''); + const items = useMemo( + () => (defaultItems ? distinctBy(defaultItems, 'value') : []), + [defaultItems] + ); + const itemsIndex = useMemo(() => { + const index = new Map(); + for (const item of items) { + index.set(item.value, item); + } + return index; + }, [items]); + const filteredItems = useMemo( + () => + inputValue.length == 0 + ? items.filter((item) => !selectedKeys.has(item.value)) + : matchSorter( + items.filter((item) => !selectedKeys.has(item.value)), + inputValue, + { keys: ['label'] } + ), + [items, inputValue, selectedKeys] + ); + const selectedItems = useMemo(() => { + const selectedItems: Item[] = []; + for (const key of selectedKeys) { + const item = itemsIndex.get(key); + if (item) { + selectedItems.push(item); + } else if (allowsCustomValue) { + selectedItems.push({ label: key, value: key }); + } + } + return selectedItems; + }, [itemsIndex, selectedKeys, allowsCustomValue]); + const hiddenInputValues = useMemo(() => { + const values = selectedItems.map((item) => + formValue == 'text' || allowsCustomValue ? item.label : item.value + ); + if (!valueSeparatorRegExp || !allowsCustomValue || inputValue == '') { + return values; + } + return [ + ...new Set([ + ...values, + ...inputValue.split(valueSeparatorRegExp).filter(Boolean) + ]) + ]; + }, [ + selectedItems, + inputValue, + valueSeparatorRegExp, + allowsCustomValue, + formValue + ]); + const isSelectionSetRef = useRef(false); + const initialSelectedKeysRef = useRef(defaultSelectedKeys); + + // reset default selected keys when props change + useEffect(() => { + if (!isEqual(initialSelectedKeysRef.current, defaultSelectedKeys)) { + initialSelectedKeysRef.current = defaultSelectedKeys; + setSelectedKeys(new Set(defaultSelectedKeys)); + } + }, [defaultSelectedKeys]); + + const onSelectionChange = useEvent< + NonNullable + >((key) => { + if (key) { + isSelectionSetRef.current = true; + setSelectedKeys((keys) => { + const selectedKeys = new Set(keys.values()); + selectedKeys.add(String(key)); + return selectedKeys; + }); + setInputValue(''); + onChange?.(); + } + }); + + const onInputChange = useEvent>( + (value) => { + const isSelectionSet = isSelectionSetRef.current; + isSelectionSetRef.current = false; + if (isSelectionSet) { + setInputValue(''); + return; + } + + if (!valueSeparatorRegExp) { + setInputValue(value); + return; + } + + const values = value.split(valueSeparatorRegExp); + if (values.length < 2) { + setInputValue(value); + return; + } + + // if input contains a separator, add all values + const addedKeys = allowsCustomValue + ? values.filter(Boolean) + : values + .filter(Boolean) + .map((value) => items.find((item) => item.label == value)?.value) + .filter((key) => key != null); + setSelectedKeys((keys) => { + const selectedKeys = new Set(keys.values()); + for (const key of addedKeys) { + selectedKeys.add(key); + } + return selectedKeys; + }); + onChange?.(); + setInputValue(''); + } + ); + + const onRemove = useEvent>( + (removedKeys) => { + setSelectedKeys((keys) => { + const selectedKeys = new Set(keys.values()); + for (const key of removedKeys) { + selectedKeys.delete(String(key)); + } + // focus input when all items are removed + if (selectedKeys.size == 0) { + focusInput?.(); + } + return selectedKeys; + }); + onChange?.(); + } + ); + + const onReset = useEvent(() => { + setSelectedKeys(new Set()); + setInputValue(''); + }); + + return { + onRemove, + onSelectionChange, + onInputChange, + selectedItems, + items: filteredItems, + hiddenInputValues, + inputValue, + onReset + }; +} + +export function useRemoteList({ + load, + defaultItems, + defaultSelectedKey, + onChange, + debounce, + allowsCustomValue +}: { + load: Loader; + defaultItems?: Item[]; + defaultSelectedKey?: Key | null; + onChange?: (item: Item | null) => void; + debounce?: number; + allowsCustomValue?: boolean; +}) { + const [defaultSelectedItem, setSelectedItem] = useState(() => { + if (defaultItems) { + return ( + defaultItems.find((item) => item.value == defaultSelectedKey) ?? null + ); + } + return null; + }); + const [inputValue, setInputValue] = useState( + defaultSelectedItem?.label ?? '' + ); + const selectedItem = useMemo(() => { + if (defaultSelectedItem) { + return defaultSelectedItem; + } + if (allowsCustomValue && inputValue != '') { + return { label: inputValue, value: inputValue }; + } + return null; + }, [defaultSelectedItem, inputValue, allowsCustomValue]); + const list = useAsyncList({ getKey, load }); + const setFilterText = useEvent((filterText: string) => { + list.setFilterText(filterText); + }); + const debouncedSetFilterText = useDebounceCallback( + setFilterText, + debounce ?? 300 + ); + + const onSelectionChange = useEvent< + NonNullable + >((key) => { + const item = + typeof key != 'string' + ? null + : selectedItem?.value == key + ? selectedItem + : list.getItem(key); + setSelectedItem(item); + if (item) { + setInputValue(item.label); + } else if (!allowsCustomValue) { + setInputValue(''); + } + onChange?.(item); + }); + + const onInputChange = useEvent>( + (value) => { + debouncedSetFilterText(value); + setInputValue(value); + if (value == '') { + onSelectionChange(null); + } else if (allowsCustomValue && selectedItem?.label != value) { + onChange?.(selectedItem); + } + } + ); + + const onReset = useEvent(() => { + setSelectedItem(null); + setInputValue(''); + }); + + // add to items list current selected item if it's not in the list + const items = + selectedItem && !list.getItem(selectedItem.value) + ? [selectedItem, ...list.items] + : list.items; + + return { + selectedItem, + selectedKey: selectedItem?.value ?? null, + onSelectionChange, + inputValue, + onInputChange, + items, + onReset + }; +} + +function getKey(item: Item) { + return item.value; +} + +const AnnuaireEducationPayload = s.type({ + records: s.array( + s.type({ + fields: s.type({ + identifiant_de_l_etablissement: s.string(), + nom_etablissement: s.string(), + nom_commune: s.string() + }) + }) + ) +}); + +const Coerce = { + Default: s.array(Item), + AnnuaireEducation: s.coerce( + s.array(Item), + AnnuaireEducationPayload, + ({ records }) => + records.map((record) => ({ + label: `${record.fields.nom_etablissement}, ${record.fields.nom_commune} (${record.fields.identifiant_de_l_etablissement})`, + value: record.fields.identifiant_de_l_etablissement, + data: record + })) + ) +}; + +export const createLoader: ( + source: string, + options?: { + minimumInputLength?: number; + limit?: number; + param?: string; + coerce?: keyof typeof Coerce; + } +) => Loader = + (source, options) => + async ({ signal, filterText }) => { + const url = new URL(source, location.href); + const minimumInputLength = options?.minimumInputLength ?? 2; + const param = options?.param ?? 'q'; + const limit = options?.limit ?? 10; + + if (!filterText || filterText.length < minimumInputLength) { + return { items: [] }; + } + url.searchParams.set(param, filterText); + try { + const response = await fetch(url.toString(), { + headers: { accept: 'application/json' }, + signal + }); + if (response.ok) { + const json = await response.json(); + const struct = Coerce[options?.coerce ?? 'Default']; + const [err, items] = s.validate(json, struct, { coerce: true }); + if (!err) { + if (items.length > limit) { + const filteredItems = matchSorter(items, filterText, { + keys: ['label'] + }); + return { items: filteredItems.slice(0, limit) }; + } + return { items }; + } + } + return { items: [] }; + } catch { + return { items: [] }; + } + }; + +export function useLabelledBy(id?: string, ariaLabelledby?: string) { + return useMemo( + () => (ariaLabelledby ? ariaLabelledby : findLabelledbyId(id)), + [id, ariaLabelledby] + ); +} + +function findLabelledbyId(id?: string) { + if (!id) { + return; + } + const label = document.querySelector(`[for="${id}"]`); + if (!label?.id) { + return; + } + return label.id; +} + +export function useOnFormReset(onReset?: () => void) { + const ref = useRef(null); + const onResetListener = useEvent((event) => { + if (event.target == ref.current?.form) { + onReset?.(); + } + }); + useEffect(() => { + if (onReset) { + addEventListener('reset', onResetListener); + return () => { + removeEventListener('reset', onResetListener); + }; + } + }, [onReset, onResetListener]); + + return ref; +} + +function distinctBy(array: T[], key: keyof T): T[] { + const keys = array.map((item) => item[key]); + return array.filter((item, index) => keys.indexOf(item[key]) == index); +} diff --git a/app/javascript/components/react-aria/props.ts b/app/javascript/components/react-aria/props.ts new file mode 100644 index 000000000..0b4347de5 --- /dev/null +++ b/app/javascript/components/react-aria/props.ts @@ -0,0 +1,84 @@ +import type { ReactNode } from 'react'; +import * as s from 'superstruct'; + +import type { Loader } from './hooks'; + +export const Item = s.object({ + label: s.string(), + value: s.string(), + data: s.any() +}); +export type Item = s.Infer; + +const ArrayOfTuples = s.coerce( + s.array(Item), + s.array(s.tuple([s.string(), s.union([s.string(), s.number()])])), + (items) => + items.map(([label, value]) => ({ + label, + value: String(value) + })) +); + +const ArrayOfStrings = s.coerce(s.array(Item), s.array(s.string()), (items) => + items.map((label) => ({ label, value: label })) +); + +const ComboBoxPropsSchema = s.partial( + s.object({ + id: s.string(), + className: s.string(), + name: s.string(), + label: s.string(), + description: s.string(), + isRequired: s.boolean(), + 'aria-label': s.string(), + 'aria-labelledby': s.string(), + 'aria-describedby': s.string(), + items: s.union([s.array(Item), ArrayOfStrings, ArrayOfTuples]), + formValue: s.enums(['text', 'key']), + form: s.string(), + data: s.record(s.string(), s.string()) + }) +); +export const SingleComboBoxProps = s.assign( + ComboBoxPropsSchema, + s.partial( + s.object({ + selectedKey: s.nullable(s.string()), + emptyFilterKey: s.nullable(s.string()) + }) + ) +); +export const MultiComboBoxProps = s.assign( + ComboBoxPropsSchema, + s.partial( + s.object({ + selectedKeys: s.array(s.string()), + allowsCustomValue: s.boolean(), + valueSeparator: s.union([s.string(), s.literal(false)]) + }) + ) +); +export const RemoteComboBoxProps = s.assign( + ComboBoxPropsSchema, + s.partial( + s.object({ + selectedKey: s.nullable(s.string()), + minimumInputLength: s.number(), + limit: s.number(), + allowsCustomValue: s.boolean(), + debounce: s.number(), + coerce: s.enums(['Default', 'AnnuaireEducation']) + }) + ) +); +export type SingleComboBoxProps = s.Infer & { + children?: ReactNode; +}; +export type MultiComboBoxProps = s.Infer; +export type RemoteComboBoxProps = s.Infer & { + children?: ReactNode; + loader: Loader | string; + onChange?: (item: Item | null) => void; +}; diff --git a/app/javascript/components/shared/FlashMessage.tsx b/app/javascript/components/shared/FlashMessage.tsx index 4b358df3c..964a58d56 100644 --- a/app/javascript/components/shared/FlashMessage.tsx +++ b/app/javascript/components/shared/FlashMessage.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { createPortal } from 'react-dom'; import invariant from 'tiny-invariant'; diff --git a/app/javascript/components/shared/hooks.ts b/app/javascript/components/shared/hooks.ts deleted file mode 100644 index 3574455d4..000000000 --- a/app/javascript/components/shared/hooks.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { useRef, useCallback, useMemo, useState } from 'react'; -import { fire } from '@utils'; - -export function useDeferredSubmit(input?: HTMLInputElement): { - (callback: () => void): void; - done: () => void; -} { - const calledRef = useRef(false); - const awaitFormSubmit = useCallback( - (callback: () => void) => { - const form = input?.form; - if (!form) { - return; - } - const interceptFormSubmit = (event: Event) => { - event.preventDefault(); - runCallback(); - - if ( - !Array.from(form.elements).some( - (e) => - e.hasAttribute('data-direct-upload-url') && - 'value' in e && - e.value != '' - ) - ) { - form.submit(); - } - // else: form will be submitted by diret upload once file have been uploaded - }; - calledRef.current = false; - form.addEventListener('submit', interceptFormSubmit); - const runCallback = () => { - form.removeEventListener('submit', interceptFormSubmit); - clearTimeout(timer); - if (!calledRef.current) { - callback(); - } - }; - const timer = setTimeout(runCallback, 400); - }, - [input] - ); - const done = () => { - calledRef.current = true; - }; - return Object.assign(awaitFormSubmit, { done }); -} - -export function groupId(id: string) { - return `#${id.replace(/-input$/, '')}`; -} - -export function useHiddenField( - group?: string, - name = 'value' -): [ - value: string | undefined, - setValue: (value: string) => void, - input: HTMLInputElement | undefined -] { - const hiddenField = useMemo( - () => selectInputInGroup(group, name), - [group, name] - ); - const [value, setValue] = useState(() => hiddenField?.value); - - return [ - value, - (value) => { - if (hiddenField) { - hiddenField.setAttribute('value', value); - setValue(value); - fire(hiddenField, 'change'); - } - }, - hiddenField ?? undefined - ]; -} - -function selectInputInGroup( - group: string | undefined, - name: string -): HTMLInputElement | undefined | null { - if (group) { - return document.querySelector( - `${group} input[type="hidden"][name$="[${name}]"], ${group} input[type="hidden"][name="${name}"]` - ); - } -} diff --git a/app/javascript/components/shared/maplibre/MapLibre.tsx b/app/javascript/components/shared/maplibre/MapLibre.tsx index b2045b6d0..4a66da89f 100644 --- a/app/javascript/components/shared/maplibre/MapLibre.tsx +++ b/app/javascript/components/shared/maplibre/MapLibre.tsx @@ -1,4 +1,4 @@ -import React, { +import { useState, useContext, useRef, @@ -8,13 +8,15 @@ import React, { createContext, useCallback } from 'react'; -import maplibre, { Map, NavigationControl } from 'maplibre-gl'; -import type { Style } from 'maplibre-gl'; +import { createPortal } from 'react-dom'; +import { Map, NavigationControl } from 'maplibre-gl'; +import type { StyleSpecification, IControl } from 'maplibre-gl'; +import 'maplibre-gl/dist/maplibre-gl.css'; import invariant from 'tiny-invariant'; import { useStyle, useElementVisible } from './hooks'; -import { StyleControl } from './StyleControl'; +import { StyleSwitch } from './StyleControl'; const Context = createContext<{ map?: Map | null }>({}); @@ -30,16 +32,15 @@ export function useMapLibre() { } export function MapLibre({ children, layers }: MapLibreProps) { - const isSupported = useMemo( - () => maplibre.supported({ failIfMajorPerformanceCaveat: true }) && !isIE(), - [] - ); + const isSupported = useMemo(() => isWebglSupported(), []); const containerRef = useRef(null); const visible = useElementVisible(containerRef); const [map, setMap] = useState(); + const [styleControlElement, setStyleControlElement] = + useState(null); const onStyleChange = useCallback( - (style: Style) => { + (style: StyleSpecification) => { if (map) { map.setStyle(style); } @@ -56,8 +57,11 @@ export function MapLibre({ children, layers }: MapLibreProps) { style }); map.addControl(new NavigationControl({}), 'top-right'); + const styleControl = new ReactControl(); + map.addControl(styleControl, 'bottom-left'); map.on('load', () => { setMap(map); + setStyleControlElement(styleControl.container); }); } }, [map, style, visible, isSupported]); @@ -91,16 +95,54 @@ export function MapLibre({ children, layers }: MapLibreProps) { return (
    - + {styleControlElement != null + ? createPortal( + , + styleControlElement + ) + : null} {map ? children : null}
    ); } -function isIE() { - const ua = window.navigator.userAgent; - const msie = ua.indexOf('MSIE '); - const trident = ua.indexOf('Trident/'); - return msie > 0 || trident > 0; +function isWebglSupported() { + if (window.WebGLRenderingContext) { + const canvas = document.createElement('canvas'); + try { + // Note that { failIfMajorPerformanceCaveat: true } can be passed as a second argument + // to canvas.getContext(), causing the check to fail if hardware rendering is not available. See + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/getContext + // for more details. + const context = canvas.getContext('webgl2') || canvas.getContext('webgl'); + if (context && typeof context.getParameter == 'function') { + return true; + } + } catch (e) { + // WebGL is supported, but disabled + } + return false; + } + // WebGL not supported + return false; +} + +export class ReactControl implements IControl { + #container: HTMLElement | null = null; + + get container(): HTMLElement | null { + return this.#container; + } + + onAdd(): HTMLElement { + this.#container = document.createElement('div'); + this.#container.className = 'maplibregl-ctrl maplibregl-ctrl-group ds-ctrl'; + return this.#container; + } + + onRemove(): void { + this.#container?.remove(); + this.#container = null; + } } diff --git a/app/javascript/components/shared/maplibre/StyleControl.tsx b/app/javascript/components/shared/maplibre/StyleControl.tsx index ce83b75c1..c844531ed 100644 --- a/app/javascript/components/shared/maplibre/StyleControl.tsx +++ b/app/javascript/components/shared/maplibre/StyleControl.tsx @@ -1,6 +1,5 @@ -import React, { useState, useId } from 'react'; -import { Popover, RadioGroup } from '@headlessui/react'; -import { usePopper } from 'react-popper'; +import { useId, useRef, useEffect } from 'react'; +import { Button, Dialog, DialogTrigger, Popover } from 'react-aria-components'; import { MapIcon } from '@heroicons/react/outline'; import { Slider } from '@reach/slider'; import '@reach/slider/styles.css'; @@ -13,7 +12,7 @@ const STYLES = { ign: 'Carte IGN' }; -export function StyleControl({ +export function StyleSwitch({ styleId, layers, setStyle, @@ -26,107 +25,97 @@ export function StyleControl({ setLayerEnabled: (layer: string, enabled: boolean) => void; setLayerOpacity: (layer: string, opacity: number) => void; }) { - const [buttonElement, setButtonElement] = - useState(); - const [panelElement, setPanelElement] = useState(); - const { styles, attributes } = usePopper(buttonElement, panelElement, { - placement: 'bottom-end' - }); const configurableLayers = Object.entries(layers).filter( ([, { configurable }]) => configurable ); const mapId = useId(); + const buttonRef = useRef(null); + + useEffect(() => { + if (buttonRef.current) { + buttonRef.current.title = 'Sélectionner les couches cartographiques'; + } + }, []); return ( -
    - - - - - - + + + +
    event.preventDefault()} > - {Object.entries(STYLES).map(([style, title]) => ( - - {({ checked }) => ( - <> +
    + {Object.entries(STYLES).map(([style, title]) => ( +
    +
    - - {title.replace(/\s/g, ' ')} - - - )} - - ))} - - {configurableLayers.length ? ( -
      - {configurableLayers.map(([layer, { enabled, opacity, name }]) => ( -
    • -
      - { - setLayerEnabled(layer, event.target.checked); + setStyle(event.target.value); }} /> -
      - { - setLayerOpacity(layer, value); - }} - className="mb-1" - title={`Réglage de l’opacité de la couche «${NBS}${name}${NBS}»`} - getAriaLabel={() => - `Réglage de l’opacité de la couche «${NBS}${name}${NBS}»` - } - getAriaValueText={(value) => - `L’opacité de la couche «${NBS}${name}${NBS}» est à ${value}${NBS}%` - } - /> -
    • +
    ))} - - ) : null} - +
    + {configurableLayers.length ? ( +
    + {configurableLayers.map( + ([layer, { enabled, opacity, name }]) => ( +
    +
    + { + setLayerEnabled(layer, event.target.checked); + }} + /> + +
    + { + setLayerOpacity(layer, value); + }} + className="fr-range fr-range--sm mt-1" + title={`Réglage de l’opacité de la couche «${NBS}${name}${NBS}»`} + getAriaLabel={() => + `Réglage de l’opacité de la couche «${NBS}${name}${NBS}»` + } + getAriaValueText={(value) => + `L’opacité de la couche «${NBS}${name}${NBS}» est à ${value}${NBS}%` + } + /> +
    + ) + )} +
    + ) : null} + +
    -
    + ); } diff --git a/app/javascript/components/shared/maplibre/hooks.ts b/app/javascript/components/shared/maplibre/hooks.ts index 9b5acb875..348360146 100644 --- a/app/javascript/components/shared/maplibre/hooks.ts +++ b/app/javascript/components/shared/maplibre/hooks.ts @@ -9,7 +9,8 @@ import type { LngLatBoundsLike, LngLat, MapLayerEventType, - Style + StyleSpecification, + LngLatLike } from 'maplibre-gl'; import type { Feature, Geometry } from 'geojson'; @@ -39,17 +40,20 @@ export function useFitBoundsNoFly() { export function useFlyTo() { const map = useMapLibre(); return useCallback( - (zoom: number, center: [number, number]) => { + (zoom: number, center: LngLatLike) => { map.flyTo({ zoom, center }); }, [map] ); } -export function useEvent(eventName: string, callback: EventListener) { +export function useEvent( + eventName: string, + callback: (event: CustomEvent) => void +) { return useEffect(() => { - addEventListener(eventName, callback); - return () => removeEventListener(eventName, callback); + addEventListener(eventName, callback as EventListener); + return () => removeEventListener(eventName, callback as EventListener); }, [eventName, callback]); } @@ -100,7 +104,7 @@ function optionalLayersMap(optionalLayers: string[]): LayersMap { export function useStyle( optionalLayers: string[], - onStyleChange: (style: Style) => void + onStyleChange: (style: StyleSpecification) => void ) { const [styleId, setStyle] = useState('ortho'); const [layers, setLayers] = useState(() => optionalLayersMap(optionalLayers)); diff --git a/app/javascript/components/shared/maplibre/styles/base.ts b/app/javascript/components/shared/maplibre/styles/base.ts index 3400a6c09..8ed1eef44 100644 --- a/app/javascript/components/shared/maplibre/styles/base.ts +++ b/app/javascript/components/shared/maplibre/styles/base.ts @@ -1,7 +1,12 @@ -import type { AnyLayer, Style, RasterLayer, RasterSource } from 'maplibre-gl'; +import type { + LayerSpecification, + RasterLayerSpecification, + RasterSourceSpecification, + StyleSpecification +} from 'maplibre-gl'; import invariant from 'tiny-invariant'; -import cadastreLayers from './layers/cadastre'; +import cadastreLayers from './layers/cadastre.json'; function ignServiceURL(layer: string, style: string, format = 'image/png') { const url = `https://data.geopf.fr/wmts`; @@ -163,7 +168,10 @@ function buildSources() { ); } -function rasterSource(tiles: string[], attribution: string): RasterSource { +function rasterSource( + tiles: string[], + attribution: string +): RasterSourceSpecification { return { type: 'raster', tiles, @@ -174,7 +182,10 @@ function rasterSource(tiles: string[], attribution: string): RasterSource { }; } -function rasterLayer(source: string, opacity: number): RasterLayer { +function rasterLayer( + source: string, + opacity: number +): RasterLayerSpecification { return { id: source, source, @@ -186,14 +197,14 @@ function rasterLayer(source: string, opacity: number): RasterLayer { export function buildOptionalLayers( ids: string[], opacity: Record -): AnyLayer[] { +): LayerSpecification[] { return OPTIONAL_LAYERS.filter(({ id }) => ids.includes(id)) .flatMap(({ layers, id }) => layers.map(([, code]) => [code, opacity[id] / 100] as const) ) .flatMap(([code, opacity]) => code === 'CADASTRE' - ? cadastreLayers + ? (cadastreLayers as LayerSpecification[]) : [rasterLayer(getLayerCode(code), opacity)] ); } @@ -210,9 +221,9 @@ function getLayerCode(code: string) { return code.toLowerCase().replace(/\./g, '-'); } -export default { +export const style: StyleSpecification = { version: 8, - metadat: { + metadata: { 'mapbox:autocomposite': false, 'mapbox:groups': { 1444849242106.713: { collapsed: false, name: 'Places' }, @@ -257,5 +268,6 @@ export default { ...buildSources() }, sprite: 'https://openmaptiles.github.io/osm-bright-gl-style/sprite', - glyphs: 'https://openmaptiles.geo.data.gouv.fr/fonts/{fontstack}/{range}.pbf' -} as Style; + glyphs: 'https://openmaptiles.geo.data.gouv.fr/fonts/{fontstack}/{range}.pbf', + layers: [] +}; diff --git a/app/javascript/components/shared/maplibre/styles/index.ts b/app/javascript/components/shared/maplibre/styles/index.ts index b7d865e29..1b9088861 100644 --- a/app/javascript/components/shared/maplibre/styles/index.ts +++ b/app/javascript/components/shared/maplibre/styles/index.ts @@ -1,9 +1,14 @@ -import type { Style } from 'maplibre-gl'; +import type { LayerSpecification, StyleSpecification } from 'maplibre-gl'; -import baseStyle, { buildOptionalLayers, getLayerName, NBS } from './base'; -import orthoStyle from './layers/ortho'; -import vectorStyle from './layers/vector'; -import ignLayers from './layers/ign'; +import { + style as baseStyle, + buildOptionalLayers, + getLayerName, + NBS +} from './base'; +import ignLayers from './layers/ign.json'; +import orthoLayers from './layers/ortho.json'; +import vectorLayers from './layers/vector.json'; export { getLayerName, NBS }; @@ -21,20 +26,20 @@ export function getMapStyle( id: string, layers: string[], opacity: Record -): Style & { id: string } { +): StyleSpecification & { id: string } { const style = { ...baseStyle, id }; switch (id) { case 'ortho': - style.layers = orthoStyle; + style.layers = orthoLayers as LayerSpecification[]; style.name = 'Photographies aériennes'; break; case 'vector': - style.layers = vectorStyle; + style.layers = vectorLayers as LayerSpecification[]; style.name = 'Carte OSM'; break; case 'ign': - style.layers = ignLayers; + style.layers = ignLayers as LayerSpecification[]; style.name = 'Carte IGN'; break; } diff --git a/app/javascript/components/shared/maplibre/styles/layers/cadastre.json b/app/javascript/components/shared/maplibre/styles/layers/cadastre.json new file mode 100644 index 000000000..e72c332a2 --- /dev/null +++ b/app/javascript/components/shared/maplibre/styles/layers/cadastre.json @@ -0,0 +1,106 @@ +[ + { + "id": "batiments-line", + "type": "line", + "source": "cadastre", + "source-layer": "batiments", + "minzoom": 16, + "maxzoom": 22, + "layout": { "visibility": "visible" }, + "paint": { + "line-opacity": 1, + "line-color": "rgba(0, 0, 0, 1)", + "line-width": 1 + } + }, + { + "id": "batiments-fill", + "type": "fill", + "source": "cadastre", + "source-layer": "batiments", + "layout": { "visibility": "visible" }, + "paint": { + "fill-color": "rgba(150, 150, 150, 1)", + "fill-opacity": { + "stops": [ + [16, 0], + [17, 0.6] + ] + }, + "fill-antialias": true + } + }, + { + "id": "parcelles", + "type": "line", + "source": "cadastre", + "source-layer": "parcelles", + "minzoom": 15.5, + "maxzoom": 24, + "layout": { + "visibility": "visible", + "line-cap": "butt", + "line-join": "miter", + "line-miter-limit": 2 + }, + "paint": { + "line-color": "rgba(255, 255, 255, 1)", + "line-opacity": 0.8, + "line-width": { + "stops": [ + [16, 1.5], + [17, 2] + ] + }, + "line-offset": 0, + "line-blur": 0, + "line-translate": [0, 1], + "line-dasharray": [1], + "line-gap-width": 0 + } + }, + { + "id": "parcelles-fill", + "type": "fill", + "source": "cadastre", + "source-layer": "parcelles", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "rgba(129, 123, 0, 1)", + "fill-opacity": [ + "case", + ["boolean", ["feature-state", "hover"], false], + 0.7, + 0.1 + ] + } + }, + { + "id": "parcelle-highlighted", + "type": "fill", + "source": "cadastre", + "source-layer": "parcelles", + "filter": ["in", "id", ""], + "paint": { + "fill-color": "rgba(1, 129, 0, 1)", + "fill-opacity": 0.7 + } + }, + { + "id": "sections", + "type": "line", + "source": "cadastre", + "source-layer": "sections", + "minzoom": 12, + "layout": { "visibility": "visible" }, + "paint": { + "line-color": "rgba(0, 0, 0, 1)", + "line-opacity": 0.7, + "line-width": 2, + "line-dasharray": [3, 3], + "line-translate": [0, 0] + } + } +] diff --git a/app/javascript/components/shared/maplibre/styles/layers/cadastre.ts b/app/javascript/components/shared/maplibre/styles/layers/cadastre.ts deleted file mode 100644 index 4ef68ed6b..000000000 --- a/app/javascript/components/shared/maplibre/styles/layers/cadastre.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { AnyLayer } from 'maplibre-gl'; - -const layers: AnyLayer[] = [ - { - id: 'batiments-line', - type: 'line', - source: 'cadastre', - 'source-layer': 'batiments', - minzoom: 16, - maxzoom: 22, - layout: { visibility: 'visible' }, - paint: { - 'line-opacity': 1, - 'line-color': 'rgba(0, 0, 0, 1)', - 'line-width': 1 - } - }, - { - id: 'batiments-fill', - type: 'fill', - source: 'cadastre', - 'source-layer': 'batiments', - layout: { visibility: 'visible' }, - paint: { - 'fill-color': 'rgba(150, 150, 150, 1)', - 'fill-opacity': { - stops: [ - [16, 0], - [17, 0.6] - ] - }, - 'fill-antialias': true - } - }, - { - id: 'parcelles', - type: 'line', - source: 'cadastre', - 'source-layer': 'parcelles', - minzoom: 15.5, - maxzoom: 24, - layout: { - visibility: 'visible', - 'line-cap': 'butt', - 'line-join': 'miter', - 'line-miter-limit': 2 - }, - paint: { - 'line-color': 'rgba(255, 255, 255, 1)', - 'line-opacity': 0.8, - 'line-width': { - stops: [ - [16, 1.5], - [17, 2] - ] - }, - 'line-offset': 0, - 'line-blur': 0, - 'line-translate': [0, 1], - 'line-dasharray': [1], - 'line-gap-width': 0 - } - }, - { - id: 'parcelles-fill', - type: 'fill', - source: 'cadastre', - 'source-layer': 'parcelles', - layout: { - visibility: 'visible' - }, - paint: { - 'fill-color': 'rgba(129, 123, 0, 1)', - 'fill-opacity': [ - 'case', - ['boolean', ['feature-state', 'hover'], false], - 0.7, - 0.1 - ] - } - }, - { - id: 'parcelle-highlighted', - type: 'fill', - source: 'cadastre', - 'source-layer': 'parcelles', - filter: ['in', 'id', ''], - paint: { - 'fill-color': 'rgba(1, 129, 0, 1)', - 'fill-opacity': 0.7 - } - }, - { - id: 'sections', - type: 'line', - source: 'cadastre', - 'source-layer': 'sections', - minzoom: 12, - layout: { visibility: 'visible' }, - paint: { - 'line-color': 'rgba(0, 0, 0, 1)', - 'line-opacity': 0.7, - 'line-width': 2, - 'line-dasharray': [3, 3], - 'line-translate': [0, 0] - } - } -]; - -export default layers; diff --git a/app/javascript/components/shared/maplibre/styles/layers/ign.json b/app/javascript/components/shared/maplibre/styles/layers/ign.json new file mode 100644 index 000000000..eb582b2af --- /dev/null +++ b/app/javascript/components/shared/maplibre/styles/layers/ign.json @@ -0,0 +1,8 @@ +[ + { + "id": "ign", + "source": "plan-ign", + "type": "raster", + "paint": { "raster-resampling": "linear" } + } +] diff --git a/app/javascript/components/shared/maplibre/styles/layers/ign.ts b/app/javascript/components/shared/maplibre/styles/layers/ign.ts deleted file mode 100644 index 545d41911..000000000 --- a/app/javascript/components/shared/maplibre/styles/layers/ign.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { RasterLayer } from 'maplibre-gl'; - -const layers: RasterLayer[] = [ - { - id: 'ign', - source: 'plan-ign', - type: 'raster', - paint: { 'raster-resampling': 'linear' } - } -]; - -export default layers; diff --git a/app/javascript/components/shared/maplibre/styles/layers/ortho.json b/app/javascript/components/shared/maplibre/styles/layers/ortho.json new file mode 100644 index 000000000..c8c3f0334 --- /dev/null +++ b/app/javascript/components/shared/maplibre/styles/layers/ortho.json @@ -0,0 +1,2647 @@ +[ + { + "id": "photographies-aeriennes", + "type": "raster", + "source": "photographies-aeriennes", + "paint": { "raster-resampling": "linear" } + }, + { + "id": "communes", + "type": "line", + "source": "decoupage-administratif", + "source-layer": "communes", + "minzoom": 10, + "maxzoom": 24, + "filter": ["all"], + "layout": { "visibility": "visible" }, + "paint": { + "line-color": "rgba(0, 0, 0, 1)", + "line-width": 1.5, + "line-opacity": 1, + "line-blur": 0 + } + }, + { + "id": "departements", + "type": "line", + "source": "decoupage-administratif", + "source-layer": "departements", + "minzoom": 0, + "maxzoom": 24, + "layout": { "visibility": "visible" }, + "paint": { + "line-color": "rgba(0, 0, 0, 1)", + "line-width": 1, + "line-opacity": 1 + } + }, + { + "id": "regions", + "type": "line", + "source": "decoupage-administratif", + "source-layer": "regions", + "minzoom": 0, + "maxzoom": 24, + "layout": { "visibility": "visible" }, + "paint": { + "line-color": "rgba(0, 0, 0, 1)", + "line-width": 1, + "line-opacity": 1 + } + }, + { + "id": "waterway_tunnel", + "type": "line", + "source": "openmaptiles", + "source-layer": "waterway", + "minzoom": 14, + "filter": [ + "all", + ["in", "class", "river", "stream", "canal"], + ["==", "brunnel", "tunnel"] + ], + "layout": { "line-cap": "round", "visibility": "none" }, + "paint": { + "line-color": "#a0c8f0", + "line-dasharray": [2, 4], + "line-width": { + "base": 1.3, + "stops": [ + [13, 0.5], + [20, 6] + ] + } + } + }, + { + "id": "waterway-other", + "type": "line", + "metadata": { "mapbox:group": "1444849382550.77" }, + "source": "openmaptiles", + "source-layer": "waterway", + "filter": [ + "all", + ["!in", "class", "canal", "river", "stream"], + ["==", "intermittent", 0] + ], + "layout": { "line-cap": "round", "visibility": "none" }, + "paint": { + "line-color": "#a0c8f0", + "line-width": { + "base": 1.3, + "stops": [ + [13, 0.5], + [20, 2] + ] + } + } + }, + { + "id": "waterway-other-intermittent", + "type": "line", + "metadata": { "mapbox:group": "1444849382550.77" }, + "source": "openmaptiles", + "source-layer": "waterway", + "filter": [ + "all", + ["!in", "class", "canal", "river", "stream"], + ["==", "intermittent", 1] + ], + "layout": { "line-cap": "round", "visibility": "none" }, + "paint": { + "line-color": "#a0c8f0", + "line-width": { + "base": 1.3, + "stops": [ + [13, 0.5], + [20, 2] + ] + }, + "line-dasharray": [4, 3] + } + }, + { + "id": "waterway-stream-canal", + "type": "line", + "metadata": { "mapbox:group": "1444849382550.77" }, + "source": "openmaptiles", + "source-layer": "waterway", + "filter": [ + "all", + ["in", "class", "canal", "stream"], + ["!=", "brunnel", "tunnel"], + ["==", "intermittent", 0] + ], + "layout": { "line-cap": "round", "visibility": "none" }, + "paint": { + "line-color": "#a0c8f0", + "line-width": { + "base": 1.3, + "stops": [ + [13, 0.5], + [20, 6] + ] + } + } + }, + { + "id": "waterway-stream-canal-intermittent", + "type": "line", + "metadata": { "mapbox:group": "1444849382550.77" }, + "source": "openmaptiles", + "source-layer": "waterway", + "filter": [ + "all", + ["in", "class", "canal", "stream"], + ["!=", "brunnel", "tunnel"], + ["==", "intermittent", 1] + ], + "layout": { "line-cap": "round", "visibility": "none" }, + "paint": { + "line-color": "#a0c8f0", + "line-width": { + "base": 1.3, + "stops": [ + [13, 0.5], + [20, 6] + ] + }, + "line-dasharray": [4, 3] + } + }, + { + "id": "waterway-river", + "type": "line", + "metadata": { "mapbox:group": "1444849382550.77" }, + "source": "openmaptiles", + "source-layer": "waterway", + "filter": [ + "all", + ["==", "class", "river"], + ["!=", "brunnel", "tunnel"], + ["==", "intermittent", 0] + ], + "layout": { "line-cap": "round", "visibility": "none" }, + "paint": { + "line-color": "#a0c8f0", + "line-width": { + "base": 1.2, + "stops": [ + [10, 0.8], + [20, 6] + ] + } + } + }, + { + "id": "waterway-river-intermittent", + "type": "line", + "metadata": { "mapbox:group": "1444849382550.77" }, + "source": "openmaptiles", + "source-layer": "waterway", + "filter": [ + "all", + ["==", "class", "river"], + ["!=", "brunnel", "tunnel"], + ["==", "intermittent", 1] + ], + "layout": { "line-cap": "round", "visibility": "none" }, + "paint": { + "line-color": "#a0c8f0", + "line-width": { + "base": 1.2, + "stops": [ + [10, 0.8], + [20, 6] + ] + }, + "line-dasharray": [3, 2.5] + } + }, + { + "id": "tunnel-service-track-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "service", "track"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#cfcdca", + "line-dasharray": [0.5, 0.25], + "line-width": { + "base": 1.2, + "stops": [ + [15, 1], + [16, 4], + [20, 11] + ] + } + } + }, + { + "id": "tunnel-minor-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "tunnel"], ["==", "class", "minor"]], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#cfcdca", + "line-opacity": { + "stops": [ + [12, 0], + [12.5, 1] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [12, 0.5], + [13, 1], + [14, 4], + [20, 15] + ] + } + } + }, + { + "id": "tunnel-secondary-tertiary-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "secondary", "tertiary"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [8, 1.5], + [20, 17] + ] + } + } + }, + { + "id": "tunnel-trunk-primary-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "primary", "trunk"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#e9ac77", + "line-width": { + "base": 1.2, + "stops": [ + [5, 0.4], + [6, 0.6], + [7, 1.5], + [20, 22] + ] + } + } + }, + { + "id": "tunnel-motorway-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "tunnel"], ["==", "class", "motorway"]], + "layout": { "line-join": "round", "visibility": "visible" }, + "paint": { + "line-color": "#e9ac77", + "line-dasharray": [0.5, 0.25], + "line-width": { + "base": 1.2, + "stops": [ + [5, 0.4], + [6, 0.6], + [7, 1.5], + [20, 22] + ] + } + } + }, + { + "id": "tunnel-path", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["all", ["==", "brunnel", "tunnel"], ["==", "class", "path"]] + ], + "paint": { + "line-color": "#cba", + "line-dasharray": [1.5, 0.75], + "line-width": { + "base": 1.2, + "stops": [ + [15, 1.2], + [20, 4] + ] + } + } + }, + { + "id": "tunnel-service-track", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "service", "track"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#fff", + "line-width": { + "base": 1.2, + "stops": [ + [15.5, 0], + [16, 2], + [20, 7.5] + ] + } + } + }, + { + "id": "tunnel-minor", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["==", "class", "minor_road"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [13.5, 0], + [14, 2.5], + [20, 11.5] + ] + } + } + }, + { + "id": "tunnel-secondary-tertiary", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "secondary", "tertiary"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#fff4c6", + "line-width": { + "base": 1.2, + "stops": [ + [6.5, 0], + [7, 0.5], + [20, 10] + ] + } + } + }, + { + "id": "tunnel-trunk-primary", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "primary", "trunk"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#fff4c6", + "line-width": { + "base": 1.2, + "stops": [ + [6.5, 0], + [7, 0.5], + [20, 18] + ] + } + } + }, + { + "id": "tunnel-motorway", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "tunnel"], ["==", "class", "motorway"]], + "layout": { "line-join": "round", "visibility": "visible" }, + "paint": { + "line-color": "#ffdaa6", + "line-width": { + "base": 1.2, + "stops": [ + [6.5, 0], + [7, 0.5], + [20, 18] + ] + } + } + }, + { + "id": "tunnel-railway", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "tunnel"], ["==", "class", "rail"]], + "paint": { + "line-color": "#bbb", + "line-dasharray": [2, 2], + "line-width": { + "base": 1.4, + "stops": [ + [14, 0.4], + [15, 0.75], + [20, 2] + ] + } + } + }, + { + "id": "ferry", + "type": "line", + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["in", "class", "ferry"]], + "layout": { "line-join": "round", "visibility": "visible" }, + "paint": { + "line-color": "rgba(108, 159, 182, 1)", + "line-dasharray": [2, 2], + "line-width": 1.1 + } + }, + { + "id": "aeroway-taxiway-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "aeroway", + "minzoom": 12, + "filter": ["all", ["in", "class", "taxiway"]], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(153, 153, 153, 1)", + "line-opacity": 1, + "line-width": { + "base": 1.5, + "stops": [ + [11, 2], + [17, 12] + ] + } + } + }, + { + "id": "aeroway-runway-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "aeroway", + "minzoom": 12, + "filter": ["all", ["in", "class", "runway"]], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(153, 153, 153, 1)", + "line-opacity": 0.2, + "line-width": { + "base": 1.5, + "stops": [ + [11, 5], + [17, 55] + ] + } + } + }, + { + "id": "aeroway-taxiway", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "aeroway", + "minzoom": 4, + "filter": [ + "all", + ["in", "class", "taxiway"], + ["==", "$type", "LineString"] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(255, 255, 255, 1)", + "line-opacity": { + "base": 1, + "stops": [ + [11, 0], + [12, 1] + ] + }, + "line-width": { + "base": 1.5, + "stops": [ + [11, 1], + [17, 10] + ] + } + } + }, + { + "id": "aeroway-runway", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "aeroway", + "minzoom": 4, + "filter": ["all", ["in", "class", "runway"], ["==", "$type", "LineString"]], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(255, 255, 255, 1)", + "line-opacity": { + "base": 1, + "stops": [ + [11, 0], + [12, 0.2] + ] + }, + "line-width": { + "base": 1.5, + "stops": [ + [11, 4], + [17, 50] + ] + } + } + }, + { + "id": "road_area_pier", + "type": "fill", + "metadata": {}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "$type", "Polygon"], ["==", "class", "pier"]], + "layout": { "visibility": "visible" }, + "paint": { "fill-antialias": true, "fill-color": "#f8f4f0" } + }, + { + "id": "road_pier", + "type": "line", + "metadata": {}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "$type", "LineString"], ["in", "class", "pier"]], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#f8f4f0", + "line-width": { + "base": 1.2, + "stops": [ + [15, 1], + [17, 4] + ] + } + } + }, + { + "id": "highway-area", + "type": "fill", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "$type", "Polygon"], ["!in", "class", "pier"]], + "layout": { "visibility": "visible" }, + "paint": { + "fill-antialias": false, + "fill-color": "hsla(0, 0%, 89%, 0.56)", + "fill-opacity": { + "stops": [ + [15, 0], + [16, 0.9] + ] + }, + "fill-outline-color": "#cfcdca" + } + }, + { + "id": "highway-motorway-link-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 12, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "motorway_link"] + ], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [12, 1], + [13, 3], + [14, 4], + [20, 15] + ] + } + } + }, + { + "id": "highway-link-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + [ + "in", + "class", + "primary_link", + "secondary_link", + "tertiary_link", + "trunk_link" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [12, 1], + [13, 3], + [14, 4], + [20, 15] + ] + } + } + }, + { + "id": "highway-minor-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + [ + "all", + ["!=", "brunnel", "tunnel"], + ["in", "class", "minor", "service", "track"] + ] + ], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-color": "#cfcdca", + "line-opacity": { + "stops": [ + [14, 0], + [15, 0.5] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [15, 0.5], + [16, 5] + ] + } + } + }, + { + "id": "highway-secondary-tertiary-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "secondary", "tertiary"] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": { + "stops": [ + [15, 0], + [16, 0.3] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [8, 1.5], + [20, 17] + ] + } + } + }, + { + "id": "highway-primary-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 5, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "primary"] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": { + "stops": [ + [16, 0], + [17, 0.3] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [7, 0], + [8, 0.6], + [9, 1.5], + [20, 22] + ] + } + } + }, + { + "id": "highway-trunk-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 5, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "trunk"] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": { + "stops": [ + [16, 0], + [17, 0.3] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [5, 0], + [6, 0.6], + [7, 1.5], + [20, 22] + ] + } + } + }, + { + "id": "highway-motorway-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 4, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "motorway"] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": { + "stops": [ + [8, 0], + [9, 0.2] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [4, 0], + [5, 0.4], + [6, 0.6], + [7, 1.5], + [20, 22] + ] + } + } + }, + { + "id": "highway-path", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["all", ["!in", "brunnel", "bridge", "tunnel"], ["==", "class", "path"]] + ], + "layout": { "visibility": "none" }, + "paint": { + "line-color": "#cba", + "line-dasharray": [1.5, 0.75], + "line-width": { + "base": 1.2, + "stops": [ + [15, 1.2], + [20, 4] + ] + } + } + }, + { + "id": "highway-motorway-link", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 12, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "motorway_link"] + ], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-color": "#fc8", + "line-width": { + "base": 1.2, + "stops": [ + [12.5, 0], + [13, 1.5], + [14, 2.5], + [20, 11.5] + ] + } + } + }, + { + "id": "highway-link", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + [ + "in", + "class", + "primary_link", + "secondary_link", + "tertiary_link", + "trunk_link" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [12.5, 0], + [13, 1.5], + [14, 2.5], + [20, 11.5] + ] + } + } + }, + { + "id": "highway-minor", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + [ + "all", + ["!=", "brunnel", "tunnel"], + ["in", "class", "minor", "service", "track"] + ] + ], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-color": "#fff", + "line-opacity": { + "stops": [ + [16, 0], + [17, 0.4] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [16, 0], + [17, 2.5] + ] + } + } + }, + { + "id": "highway-secondary-tertiary", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "secondary", "tertiary"] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [6.5, 0], + [8, 0.5], + [20, 13] + ] + }, + "line-opacity": { + "stops": [ + [11, 0], + [13, 0.3] + ] + } + } + }, + { + "id": "highway-primary", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "primary"] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [8.5, 0], + [9, 0.5], + [20, 18] + ] + }, + "line-opacity": { + "stops": [ + [8, 0], + [9, 0.3] + ] + } + } + }, + { + "id": "highway-trunk", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["all", ["!in", "brunnel", "bridge", "tunnel"], ["in", "class", "trunk"]] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [6.5, 0], + [7, 0.5], + [20, 18] + ] + }, + "line-opacity": { + "stops": [ + [16, 0], + [17, 0.3] + ] + } + } + }, + { + "id": "highway-motorway", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 5, + "filter": [ + "all", + ["==", "$type", "LineString"], + [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "motorway"] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fc8", + "line-width": { + "base": 1.2, + "stops": [ + [6.5, 0], + [7, 0.5], + [20, 18] + ] + }, + "line-opacity": { + "stops": [ + [8, 0], + [9, 0.2] + ] + } + } + }, + { + "id": "railway-transit", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["all", ["==", "class", "transit"], ["!in", "brunnel", "tunnel"]] + ], + "layout": { "visibility": "visible" }, + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.77)", + "line-width": { + "base": 1.4, + "stops": [ + [14, 0.4], + [20, 1] + ] + } + } + }, + { + "id": "railway-transit-hatching", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["all", ["==", "class", "transit"], ["!in", "brunnel", "tunnel"]] + ], + "layout": { "visibility": "visible" }, + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.68)", + "line-dasharray": [0.2, 8], + "line-width": { + "base": 1.4, + "stops": [ + [14.5, 0], + [15, 2], + [20, 6] + ] + } + } + }, + { + "id": "railway-service", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["all", ["==", "class", "rail"], ["has", "service"]] + ], + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.77)", + "line-width": { + "base": 1.4, + "stops": [ + [14, 0.4], + [20, 1] + ] + } + } + }, + { + "id": "railway-service-hatching", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["all", ["==", "class", "rail"], ["has", "service"]] + ], + "layout": { "visibility": "visible" }, + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.68)", + "line-dasharray": [0.2, 8], + "line-width": { + "base": 1.4, + "stops": [ + [14.5, 0], + [15, 2], + [20, 6] + ] + } + } + }, + { + "id": "railway", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + [ + "all", + ["!has", "service"], + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "rail"] + ] + ], + "layout": { "visibility": "visible" }, + "paint": { + "line-color": "#bbb", + "line-width": { + "base": 1.4, + "stops": [ + [14, 0.4], + [15, 0.75], + [20, 2] + ] + }, + "line-opacity": { + "stops": [ + [11, 0], + [13, 1] + ] + } + } + }, + { + "id": "railway-hatching", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + [ + "all", + ["!has", "service"], + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "rail"] + ] + ], + "paint": { + "line-color": "#bbb", + "line-dasharray": [0.2, 8], + "line-width": { + "base": 1.4, + "stops": [ + [14.5, 0], + [15, 3], + [20, 8] + ] + } + } + }, + { + "id": "bridge-motorway-link-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["==", "class", "motorway_link"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [12, 1], + [13, 3], + [14, 4], + [20, 15] + ] + } + } + }, + { + "id": "bridge-link-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + [ + "in", + "class", + "primary_link", + "secondary_link", + "tertiary_link", + "trunk_link" + ] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [12, 1], + [13, 3], + [14, 4], + [20, 15] + ] + } + } + }, + { + "id": "bridge-secondary-tertiary-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["in", "class", "secondary", "tertiary"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 0.3, + "line-width": { + "base": 1.2, + "stops": [ + [8, 1.5], + [20, 28] + ] + } + } + }, + { + "id": "bridge-trunk-primary-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["in", "class", "primary", "trunk"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "hsl(28, 76%, 67%)", + "line-width": { + "base": 1.2, + "stops": [ + [5, 0.4], + [6, 0.6], + [7, 1.5], + [20, 26] + ] + }, + "line-opacity": 0.3 + } + }, + { + "id": "bridge-motorway-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "bridge"], ["==", "class", "motorway"]], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#e9ac77", + "line-width": { + "base": 1.2, + "stops": [ + [5, 0.4], + [6, 0.6], + [7, 1.5], + [20, 22] + ] + }, + "line-opacity": { + "stops": [ + [16, 0], + [17, 0.3] + ] + } + } + }, + { + "id": "bridge-path-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["all", ["==", "brunnel", "bridge"], ["==", "class", "path"]] + ], + "paint": { + "line-color": "#f8f4f0", + "line-width": { + "base": 1.2, + "stops": [ + [15, 1.2], + [20, 18] + ] + } + } + }, + { + "id": "bridge-path", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["all", ["==", "brunnel", "bridge"], ["==", "class", "path"]] + ], + "paint": { + "line-color": "#cba", + "line-dasharray": [1.5, 0.75], + "line-width": { + "base": 1.2, + "stops": [ + [15, 1.2], + [20, 4] + ] + } + } + }, + { + "id": "bridge-motorway-link", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["==", "class", "motorway_link"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#fc8", + "line-width": { + "base": 1.2, + "stops": [ + [12.5, 0], + [13, 1.5], + [14, 2.5], + [20, 11.5] + ] + } + } + }, + { + "id": "bridge-link", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + [ + "in", + "class", + "primary_link", + "secondary_link", + "tertiary_link", + "trunk_link" + ] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [12.5, 0], + [13, 1.5], + [14, 2.5], + [20, 11.5] + ] + } + } + }, + { + "id": "bridge-secondary-tertiary", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["in", "class", "secondary", "tertiary"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [6.5, 0], + [7, 0.5], + [20, 20] + ] + }, + "line-opacity": { + "stops": [ + [16, 0], + [17, 0.3] + ] + } + } + }, + { + "id": "bridge-trunk-primary", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["in", "class", "primary", "trunk"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [6.5, 0], + [7, 0.5], + [20, 18] + ] + }, + "line-opacity": 0.3 + } + }, + { + "id": "bridge-motorway", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "bridge"], ["==", "class", "motorway"]], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#fc8", + "line-width": { + "base": 1.2, + "stops": [ + [6.5, 0], + [7, 0.5], + [20, 18] + ] + }, + "line-opacity": 0.3 + } + }, + { + "id": "bridge-railway", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "bridge"], ["==", "class", "rail"]], + "paint": { + "line-color": "#bbb", + "line-width": { + "base": 1.4, + "stops": [ + [14, 0.4], + [15, 0.75], + [20, 2] + ] + } + } + }, + { + "id": "bridge-railway-hatching", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "bridge"], ["==", "class", "rail"]], + "paint": { + "line-color": "#bbb", + "line-dasharray": [0.2, 8], + "line-width": { + "base": 1.4, + "stops": [ + [14.5, 0], + [15, 3], + [20, 8] + ] + } + } + }, + { + "id": "cablecar", + "type": "line", + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 13, + "filter": ["==", "class", "cable_car"], + "layout": { "line-cap": "round", "visibility": "visible" }, + "paint": { + "line-color": "hsl(0, 0%, 70%)", + "line-width": { + "base": 1, + "stops": [ + [11, 1], + [19, 2.5] + ] + } + } + }, + { + "id": "cablecar-dash", + "type": "line", + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 13, + "filter": ["==", "class", "cable_car"], + "layout": { "line-cap": "round", "visibility": "visible" }, + "paint": { + "line-color": "hsl(0, 0%, 70%)", + "line-dasharray": [2, 3], + "line-width": { + "base": 1, + "stops": [ + [11, 3], + [19, 5.5] + ] + } + } + }, + { + "id": "boundary-land-level-4", + "type": "line", + "source": "openmaptiles", + "source-layer": "boundary", + "filter": [ + "all", + [">=", "admin_level", 4], + ["<=", "admin_level", 8], + ["!=", "maritime", 1] + ], + "layout": { "line-join": "round", "visibility": "none" }, + "paint": { + "line-color": "#9e9cab", + "line-dasharray": [3, 1, 1, 1], + "line-width": { + "base": 1.4, + "stops": [ + [4, 0.4], + [5, 1], + [12, 3] + ] + } + } + }, + { + "id": "boundary-land-level-2", + "type": "line", + "source": "openmaptiles", + "source-layer": "boundary", + "filter": [ + "all", + ["==", "admin_level", 2], + ["!=", "maritime", 1], + ["!=", "disputed", 1] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "none" + }, + "paint": { + "line-color": "hsl(248, 7%, 66%)", + "line-width": { + "base": 1, + "stops": [ + [0, 0.6], + [4, 1.4], + [5, 2], + [12, 8] + ] + } + } + }, + { + "id": "boundary-land-disputed", + "type": "line", + "source": "openmaptiles", + "source-layer": "boundary", + "filter": ["all", ["!=", "maritime", 1], ["==", "disputed", 1]], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "none" + }, + "paint": { + "line-color": "hsl(248, 7%, 70%)", + "line-dasharray": [1, 3], + "line-width": { + "base": 1, + "stops": [ + [0, 0.6], + [4, 1.4], + [5, 2], + [12, 8] + ] + } + } + }, + { + "id": "boundary-water", + "type": "line", + "source": "openmaptiles", + "source-layer": "boundary", + "filter": ["all", ["in", "admin_level", 2, 4], ["==", "maritime", 1]], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(154, 189, 214, 1)", + "line-opacity": { + "stops": [ + [6, 0.6], + [10, 1] + ] + }, + "line-width": { + "base": 1, + "stops": [ + [0, 0.6], + [4, 1.4], + [5, 2], + [12, 8] + ] + } + } + }, + { + "id": "water-name-lakeline", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "water_name", + "filter": ["==", "$type", "LineString"], + "layout": { + "symbol-placement": "line", + "symbol-spacing": 350, + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Italic"], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": 14 + }, + "paint": { + "text-color": "#74aee9", + "text-halo-color": "rgba(255,255,255,0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "water-name-ocean", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "water_name", + "filter": ["all", ["==", "$type", "Point"], ["==", "class", "ocean"]], + "layout": { + "symbol-placement": "point", + "symbol-spacing": 350, + "text-field": "{name:latin}", + "text-font": ["Noto Sans Italic"], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": 14 + }, + "paint": { + "text-color": "#74aee9", + "text-halo-color": "rgba(255,255,255,0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "water-name-other", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "water_name", + "filter": ["all", ["==", "$type", "Point"], ["!in", "class", "ocean"]], + "layout": { + "symbol-placement": "point", + "symbol-spacing": 350, + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Italic"], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": { + "stops": [ + [0, 10], + [6, 14] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "rgba(0, 51, 178, 1)", + "text-halo-color": "rgba(255, 255, 255, 1)", + "text-halo-width": 1.5 + } + }, + { + "id": "road_oneway", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 15, + "filter": [ + "all", + ["==", "oneway", 1], + [ + "in", + "class", + "motorway", + "trunk", + "primary", + "secondary", + "tertiary", + "minor", + "service" + ] + ], + "layout": { + "icon-image": "oneway", + "icon-padding": 2, + "icon-rotate": 90, + "icon-rotation-alignment": "map", + "icon-size": { + "stops": [ + [15, 0.5], + [19, 1] + ] + }, + "symbol-placement": "line", + "symbol-spacing": 75, + "visibility": "visible" + }, + "paint": { "icon-opacity": 0.5 } + }, + { + "id": "road_oneway_opposite", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 15, + "filter": [ + "all", + ["==", "oneway", -1], + [ + "in", + "class", + "motorway", + "trunk", + "primary", + "secondary", + "tertiary", + "minor", + "service" + ] + ], + "layout": { + "icon-image": "oneway", + "icon-padding": 2, + "icon-rotate": -90, + "icon-rotation-alignment": "map", + "icon-size": { + "stops": [ + [15, 0.5], + [19, 1] + ] + }, + "symbol-placement": "line", + "symbol-spacing": 75, + "visibility": "visible" + }, + "paint": { "icon-opacity": 0.5 } + }, + { + "id": "highway-name-path", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation_name", + "minzoom": 15.5, + "filter": ["==", "class", "path"], + "layout": { + "symbol-placement": "line", + "text-field": "{name:latin} {name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "map", + "text-size": { + "base": 1, + "stops": [ + [13, 12], + [14, 13] + ] + } + }, + "paint": { + "text-color": "rgba(171, 86, 0, 1)", + "text-halo-color": "#f8f4f0", + "text-halo-width": 2 + } + }, + { + "id": "highway-name-minor", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation_name", + "minzoom": 15, + "filter": [ + "all", + ["==", "$type", "LineString"], + ["in", "class", "minor", "service", "track"] + ], + "layout": { + "symbol-placement": "line", + "text-field": "{name:latin} {name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "map", + "text-size": { + "base": 1, + "stops": [ + [13, 12], + [14, 13] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "rgba(143, 69, 0, 1)", + "text-halo-blur": 0.5, + "text-halo-width": 2, + "text-halo-color": "rgba(255, 255, 255, 1)" + } + }, + { + "id": "highway-name-major", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation_name", + "minzoom": 12.2, + "filter": ["in", "class", "primary", "secondary", "tertiary", "trunk"], + "layout": { + "symbol-placement": "line", + "text-field": "{name:latin} {name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "map", + "text-size": { + "base": 1, + "stops": [ + [13, 12], + [14, 13] + ] + }, + "visibility": "visible", + "symbol-z-order": "source" + }, + "paint": { + "text-color": "rgba(0, 0, 0, 1)", + "text-halo-blur": 0.5, + "text-halo-width": 1, + "text-halo-color": "rgba(255, 255, 255, 1)" + } + }, + { + "id": "highway-shield-us-interstate", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation_name", + "minzoom": 7, + "filter": [ + "all", + ["<=", "ref_length", 6], + ["==", "$type", "LineString"], + ["in", "network", "us-interstate"] + ], + "layout": { + "icon-image": "{network}_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-placement": { + "base": 1, + "stops": [ + [7, "point"], + [7, "line"], + [8, "line"] + ] + }, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "viewport", + "text-size": 10 + }, + "paint": { "text-color": "rgba(0, 0, 0, 1)" } + }, + { + "id": "highway-shield-us-other", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation_name", + "minzoom": 9, + "filter": [ + "all", + ["<=", "ref_length", 6], + ["==", "$type", "LineString"], + ["in", "network", "us-highway", "us-state"] + ], + "layout": { + "icon-image": "{network}_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-placement": { + "base": 1, + "stops": [ + [10, "point"], + [11, "line"] + ] + }, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "viewport", + "text-size": 10 + }, + "paint": { "text-color": "rgba(0, 0, 0, 1)" } + }, + { + "id": "highway-shield", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation_name", + "minzoom": 8, + "filter": [ + "all", + ["<=", "ref_length", 6], + ["==", "$type", "LineString"], + ["!in", "network", "us-interstate", "us-highway", "us-state"] + ], + "layout": { + "icon-image": "road_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-placement": { + "base": 1, + "stops": [ + [10, "point"], + [11, "line"] + ] + }, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "viewport", + "text-size": 10, + "visibility": "visible" + }, + "paint": { + "icon-opacity": { + "stops": [ + [8, 0], + [9, 1] + ] + }, + "text-opacity": { + "stops": [ + [8, 0], + [9, 1] + ] + } + } + }, + { + "id": "waterway-name", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "waterway", + "minzoom": 13, + "filter": ["all", ["==", "$type", "LineString"], ["has", "name"]], + "layout": { + "symbol-placement": "line", + "symbol-spacing": 350, + "text-field": "{name:latin} {name:nonlatin}", + "text-font": ["Noto Sans Italic"], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": 14, + "visibility": "visible" + }, + "paint": { + "text-color": "rgba(2, 72, 255, 1)", + "text-halo-color": "rgba(255,255,255,1)", + "text-halo-width": 1.5, + "text-halo-blur": 0 + } + }, + { + "id": "airport-label-major", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "aerodrome_label", + "minzoom": 10, + "filter": ["all", ["has", "iata"]], + "layout": { + "icon-image": "airport_11", + "icon-size": 1, + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 9, + "text-offset": [0, 0.6], + "text-optional": true, + "text-padding": 2, + "text-size": 12, + "visibility": "visible" + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "poi-level-3", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "poi", + "minzoom": 16, + "filter": [ + "all", + ["==", "$type", "Point"], + [">=", "rank", 25], + ["any", ["!has", "level"], ["==", "level", 0]] + ], + "layout": { + "icon-image": "{class}_11", + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 9, + "text-offset": [0, 0.6], + "text-padding": 20, + "text-size": 12, + "visibility": "visible", + "symbol-spacing": 250, + "symbol-avoid-edges": false, + "text-letter-spacing": 0, + "icon-padding": 2, + "symbol-placement": "point", + "symbol-z-order": "auto", + "text-line-height": 1.2, + "text-allow-overlap": false, + "text-ignore-placement": false, + "icon-allow-overlap": false, + "icon-ignore-placement": false, + "icon-optional": false + }, + "paint": { + "text-color": "rgba(2, 2, 3, 1)", + "text-halo-blur": 0.5, + "text-halo-color": "rgba(232, 227, 227, 1)", + "text-halo-width": 2, + "text-translate-anchor": "map", + "text-opacity": 1 + } + }, + { + "id": "poi-level-2", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "poi", + "minzoom": 15, + "filter": [ + "all", + ["==", "$type", "Point"], + ["<=", "rank", 24], + [">=", "rank", 15], + ["any", ["!has", "level"], ["==", "level", 0]] + ], + "layout": { + "icon-image": "{class}_11", + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 9, + "text-offset": [0, 0.6], + "text-padding": 2, + "text-size": 12, + "visibility": "visible" + }, + "paint": { + "text-color": "rgba(2, 2, 3, 1)", + "text-halo-blur": 0.5, + "text-halo-color": "rgba(232, 227, 227, 1)", + "text-halo-width": 1 + } + }, + { + "id": "poi-level-1", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "poi", + "minzoom": 14, + "filter": [ + "all", + ["==", "$type", "Point"], + ["<=", "rank", 14], + ["has", "name"], + ["any", ["!has", "level"], ["==", "level", 0]] + ], + "layout": { + "icon-image": "{class}_11", + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 9, + "text-offset": [0, 0.6], + "text-padding": 2, + "text-size": 12, + "visibility": "visible" + }, + "paint": { + "text-color": "rgba(2, 2, 3, 1)", + "text-halo-blur": 0.5, + "text-halo-color": "rgba(232, 227, 227, 1)", + "text-halo-width": 2 + } + }, + { + "id": "poi-railway", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "poi", + "minzoom": 13, + "filter": [ + "all", + ["==", "$type", "Point"], + ["has", "name"], + ["==", "class", "railway"], + ["==", "subclass", "station"] + ], + "layout": { + "icon-allow-overlap": false, + "icon-ignore-placement": false, + "icon-image": "{class}_11", + "icon-optional": false, + "text-allow-overlap": false, + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-ignore-placement": false, + "text-max-width": 9, + "text-offset": [0, 0.6], + "text-optional": true, + "text-padding": 2, + "text-size": 12 + }, + "paint": { + "text-color": "rgba(0, 0, 0, 1)", + "text-halo-blur": 0.5, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1 + } + }, + { + "id": "place-village", + "type": "symbol", + "metadata": { "mapbox:group": "1444849242106.713" }, + "source": "openmaptiles", + "source-layer": "place", + "filter": ["==", "class", "village"], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 8, + "text-size": { + "base": 1.2, + "stops": [ + [10, 12], + [15, 22] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "rgba(0, 0, 0, 1)", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-town", + "type": "symbol", + "metadata": { "mapbox:group": "1444849242106.713" }, + "source": "openmaptiles", + "source-layer": "place", + "filter": ["==", "class", "town"], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 8, + "text-size": { + "base": 1.2, + "stops": [ + [10, 14], + [15, 24] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "rgba(0, 0, 0, 1)", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-city", + "type": "symbol", + "metadata": { "mapbox:group": "1444849242106.713" }, + "source": "openmaptiles", + "source-layer": "place", + "filter": ["all", ["!=", "capital", 2], ["==", "class", "city"]], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 8, + "text-size": { + "base": 1.2, + "stops": [ + [7, 14], + [11, 24] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "rgba(0, 0, 0, 1)", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-city-capital", + "type": "symbol", + "metadata": { "mapbox:group": "1444849242106.713" }, + "source": "openmaptiles", + "source-layer": "place", + "filter": ["all", ["==", "capital", 2], ["==", "class", "city"]], + "layout": { + "icon-image": "star_11", + "icon-size": 0.8, + "text-anchor": "left", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 8, + "text-offset": [0.4, 0], + "text-size": { + "base": 1.2, + "stops": [ + [7, 14], + [11, 24] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#333", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-country-other", + "type": "symbol", + "metadata": { "mapbox:group": "1444849242106.713" }, + "source": "openmaptiles", + "source-layer": "place", + "filter": [ + "all", + ["==", "class", "country"], + [">=", "rank", 3], + ["!has", "iso_a2"] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": ["Noto Sans Italic"], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [3, 11], + [7, 17] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-3", + "type": "symbol", + "metadata": { "mapbox:group": "1444849242106.713" }, + "source": "openmaptiles", + "source-layer": "place", + "filter": [ + "all", + ["==", "class", "country"], + [">=", "rank", 3], + ["has", "iso_a2"] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": ["Noto Sans Bold"], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [3, 11], + [7, 17] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-2", + "type": "symbol", + "metadata": { "mapbox:group": "1444849242106.713" }, + "source": "openmaptiles", + "source-layer": "place", + "filter": [ + "all", + ["==", "class", "country"], + ["==", "rank", 2], + ["has", "iso_a2"] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": ["Noto Sans Bold"], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [2, 11], + [5, 17] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-1", + "type": "symbol", + "metadata": { "mapbox:group": "1444849242106.713" }, + "source": "openmaptiles", + "source-layer": "place", + "filter": [ + "all", + ["==", "class", "country"], + ["==", "rank", 1], + ["has", "iso_a2"] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": ["Noto Sans Bold"], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [1, 11], + [4, 17] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-continent", + "type": "symbol", + "metadata": { "mapbox:group": "1444849242106.713" }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 1, + "filter": ["==", "class", "continent"], + "layout": { + "text-field": "{name:latin}", + "text-font": ["Noto Sans Bold"], + "text-max-width": 6.25, + "text-size": 14, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + } +] diff --git a/app/javascript/components/shared/maplibre/styles/layers/ortho.ts b/app/javascript/components/shared/maplibre/styles/layers/ortho.ts deleted file mode 100644 index 41d70cbcd..000000000 --- a/app/javascript/components/shared/maplibre/styles/layers/ortho.ts +++ /dev/null @@ -1,2644 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import type { AnyLayer } from 'maplibre-gl'; - -const layers: AnyLayer[] = [ - { - id: 'photographies-aeriennes', - type: 'raster', - source: 'photographies-aeriennes', - paint: { 'raster-resampling': 'linear' } - }, - { - id: 'communes', - type: 'line', - source: 'decoupage-administratif', - 'source-layer': 'communes', - minzoom: 10, - maxzoom: 24, - filter: ['all'], - layout: { visibility: 'visible' }, - paint: { - 'line-color': 'rgba(0, 0, 0, 1)', - 'line-width': 1.5, - 'line-opacity': 1, - 'line-blur': 0 - } - }, - { - id: 'departements', - type: 'line', - source: 'decoupage-administratif', - 'source-layer': 'departements', - minzoom: 0, - maxzoom: 24, - layout: { visibility: 'visible' }, - paint: { - 'line-color': 'rgba(0, 0, 0, 1)', - 'line-width': 1, - 'line-opacity': 1 - } - }, - { - id: 'regions', - type: 'line', - source: 'decoupage-administratif', - 'source-layer': 'regions', - minzoom: 0, - maxzoom: 24, - layout: { visibility: 'visible' }, - paint: { - 'line-color': 'rgba(0, 0, 0, 1)', - 'line-width': 1, - 'line-opacity': 1 - } - }, - { - id: 'waterway_tunnel', - type: 'line', - source: 'openmaptiles', - 'source-layer': 'waterway', - minzoom: 14, - filter: [ - 'all', - ['in', 'class', 'river', 'stream', 'canal'], - ['==', 'brunnel', 'tunnel'] - ], - layout: { 'line-cap': 'round', visibility: 'none' }, - paint: { - 'line-color': '#a0c8f0', - 'line-dasharray': [2, 4], - 'line-width': { - base: 1.3, - stops: [ - [13, 0.5], - [20, 6] - ] - } - } - }, - { - id: 'waterway-other', - type: 'line', - metadata: { 'mapbox:group': '1444849382550.77' }, - source: 'openmaptiles', - 'source-layer': 'waterway', - filter: [ - 'all', - ['!in', 'class', 'canal', 'river', 'stream'], - ['==', 'intermittent', 0] - ], - layout: { 'line-cap': 'round', visibility: 'none' }, - paint: { - 'line-color': '#a0c8f0', - 'line-width': { - base: 1.3, - stops: [ - [13, 0.5], - [20, 2] - ] - } - } - }, - { - id: 'waterway-other-intermittent', - type: 'line', - metadata: { 'mapbox:group': '1444849382550.77' }, - source: 'openmaptiles', - 'source-layer': 'waterway', - filter: [ - 'all', - ['!in', 'class', 'canal', 'river', 'stream'], - ['==', 'intermittent', 1] - ], - layout: { 'line-cap': 'round', visibility: 'none' }, - paint: { - 'line-color': '#a0c8f0', - 'line-width': { - base: 1.3, - stops: [ - [13, 0.5], - [20, 2] - ] - }, - 'line-dasharray': [4, 3] - } - }, - { - id: 'waterway-stream-canal', - type: 'line', - metadata: { 'mapbox:group': '1444849382550.77' }, - source: 'openmaptiles', - 'source-layer': 'waterway', - filter: [ - 'all', - ['in', 'class', 'canal', 'stream'], - ['!=', 'brunnel', 'tunnel'], - ['==', 'intermittent', 0] - ], - layout: { 'line-cap': 'round', visibility: 'none' }, - paint: { - 'line-color': '#a0c8f0', - 'line-width': { - base: 1.3, - stops: [ - [13, 0.5], - [20, 6] - ] - } - } - }, - { - id: 'waterway-stream-canal-intermittent', - type: 'line', - metadata: { 'mapbox:group': '1444849382550.77' }, - source: 'openmaptiles', - 'source-layer': 'waterway', - filter: [ - 'all', - ['in', 'class', 'canal', 'stream'], - ['!=', 'brunnel', 'tunnel'], - ['==', 'intermittent', 1] - ], - layout: { 'line-cap': 'round', visibility: 'none' }, - paint: { - 'line-color': '#a0c8f0', - 'line-width': { - base: 1.3, - stops: [ - [13, 0.5], - [20, 6] - ] - }, - 'line-dasharray': [4, 3] - } - }, - { - id: 'waterway-river', - type: 'line', - metadata: { 'mapbox:group': '1444849382550.77' }, - source: 'openmaptiles', - 'source-layer': 'waterway', - filter: [ - 'all', - ['==', 'class', 'river'], - ['!=', 'brunnel', 'tunnel'], - ['==', 'intermittent', 0] - ], - layout: { 'line-cap': 'round', visibility: 'none' }, - paint: { - 'line-color': '#a0c8f0', - 'line-width': { - base: 1.2, - stops: [ - [10, 0.8], - [20, 6] - ] - } - } - }, - { - id: 'waterway-river-intermittent', - type: 'line', - metadata: { 'mapbox:group': '1444849382550.77' }, - source: 'openmaptiles', - 'source-layer': 'waterway', - filter: [ - 'all', - ['==', 'class', 'river'], - ['!=', 'brunnel', 'tunnel'], - ['==', 'intermittent', 1] - ], - layout: { 'line-cap': 'round', visibility: 'none' }, - paint: { - 'line-color': '#a0c8f0', - 'line-width': { - base: 1.2, - stops: [ - [10, 0.8], - [20, 6] - ] - }, - 'line-dasharray': [3, 2.5] - } - }, - { - id: 'tunnel-service-track-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'tunnel'], - ['in', 'class', 'service', 'track'] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#cfcdca', - 'line-dasharray': [0.5, 0.25], - 'line-width': { - base: 1.2, - stops: [ - [15, 1], - [16, 4], - [20, 11] - ] - } - } - }, - { - id: 'tunnel-minor-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['==', 'brunnel', 'tunnel'], ['==', 'class', 'minor']], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#cfcdca', - 'line-opacity': { - stops: [ - [12, 0], - [12.5, 1] - ] - }, - 'line-width': { - base: 1.2, - stops: [ - [12, 0.5], - [13, 1], - [14, 4], - [20, 15] - ] - } - } - }, - { - id: 'tunnel-secondary-tertiary-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'tunnel'], - ['in', 'class', 'secondary', 'tertiary'] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#e9ac77', - 'line-opacity': 1, - 'line-width': { - base: 1.2, - stops: [ - [8, 1.5], - [20, 17] - ] - } - } - }, - { - id: 'tunnel-trunk-primary-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'tunnel'], - ['in', 'class', 'primary', 'trunk'] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#e9ac77', - 'line-width': { - base: 1.2, - stops: [ - [5, 0.4], - [6, 0.6], - [7, 1.5], - [20, 22] - ] - } - } - }, - { - id: 'tunnel-motorway-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['==', 'brunnel', 'tunnel'], ['==', 'class', 'motorway']], - layout: { 'line-join': 'round', visibility: 'visible' }, - paint: { - 'line-color': '#e9ac77', - 'line-dasharray': [0.5, 0.25], - 'line-width': { - base: 1.2, - stops: [ - [5, 0.4], - [6, 0.6], - [7, 1.5], - [20, 22] - ] - } - } - }, - { - id: 'tunnel-path', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - ['all', ['==', 'brunnel', 'tunnel'], ['==', 'class', 'path']] - ], - paint: { - 'line-color': '#cba', - 'line-dasharray': [1.5, 0.75], - 'line-width': { - base: 1.2, - stops: [ - [15, 1.2], - [20, 4] - ] - } - } - }, - { - id: 'tunnel-service-track', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'tunnel'], - ['in', 'class', 'service', 'track'] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#fff', - 'line-width': { - base: 1.2, - stops: [ - [15.5, 0], - [16, 2], - [20, 7.5] - ] - } - } - }, - { - id: 'tunnel-minor', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['==', 'brunnel', 'tunnel'], ['==', 'class', 'minor_road']], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#fff', - 'line-opacity': 1, - 'line-width': { - base: 1.2, - stops: [ - [13.5, 0], - [14, 2.5], - [20, 11.5] - ] - } - } - }, - { - id: 'tunnel-secondary-tertiary', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'tunnel'], - ['in', 'class', 'secondary', 'tertiary'] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#fff4c6', - 'line-width': { - base: 1.2, - stops: [ - [6.5, 0], - [7, 0.5], - [20, 10] - ] - } - } - }, - { - id: 'tunnel-trunk-primary', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'tunnel'], - ['in', 'class', 'primary', 'trunk'] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#fff4c6', - 'line-width': { - base: 1.2, - stops: [ - [6.5, 0], - [7, 0.5], - [20, 18] - ] - } - } - }, - { - id: 'tunnel-motorway', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['==', 'brunnel', 'tunnel'], ['==', 'class', 'motorway']], - layout: { 'line-join': 'round', visibility: 'visible' }, - paint: { - 'line-color': '#ffdaa6', - 'line-width': { - base: 1.2, - stops: [ - [6.5, 0], - [7, 0.5], - [20, 18] - ] - } - } - }, - { - id: 'tunnel-railway', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['==', 'brunnel', 'tunnel'], ['==', 'class', 'rail']], - paint: { - 'line-color': '#bbb', - 'line-dasharray': [2, 2], - 'line-width': { - base: 1.4, - stops: [ - [14, 0.4], - [15, 0.75], - [20, 2] - ] - } - } - }, - { - id: 'ferry', - type: 'line', - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['in', 'class', 'ferry']], - layout: { 'line-join': 'round', visibility: 'visible' }, - paint: { - 'line-color': 'rgba(108, 159, 182, 1)', - 'line-dasharray': [2, 2], - 'line-width': 1.1 - } - }, - { - id: 'aeroway-taxiway-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'aeroway', - minzoom: 12, - filter: ['all', ['in', 'class', 'taxiway']], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': 'rgba(153, 153, 153, 1)', - 'line-opacity': 1, - 'line-width': { - base: 1.5, - stops: [ - [11, 2], - [17, 12] - ] - } - } - }, - { - id: 'aeroway-runway-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'aeroway', - minzoom: 12, - filter: ['all', ['in', 'class', 'runway']], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': 'rgba(153, 153, 153, 1)', - 'line-opacity': 0.2, - 'line-width': { - base: 1.5, - stops: [ - [11, 5], - [17, 55] - ] - } - } - }, - { - id: 'aeroway-taxiway', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'aeroway', - minzoom: 4, - filter: ['all', ['in', 'class', 'taxiway'], ['==', '$type', 'LineString']], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': 'rgba(255, 255, 255, 1)', - 'line-opacity': { - base: 1, - stops: [ - [11, 0], - [12, 1] - ] - }, - 'line-width': { - base: 1.5, - stops: [ - [11, 1], - [17, 10] - ] - } - } - }, - { - id: 'aeroway-runway', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'aeroway', - minzoom: 4, - filter: ['all', ['in', 'class', 'runway'], ['==', '$type', 'LineString']], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': 'rgba(255, 255, 255, 1)', - 'line-opacity': { - base: 1, - stops: [ - [11, 0], - [12, 0.2] - ] - }, - 'line-width': { - base: 1.5, - stops: [ - [11, 4], - [17, 50] - ] - } - } - }, - { - id: 'road_area_pier', - type: 'fill', - metadata: {}, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['==', '$type', 'Polygon'], ['==', 'class', 'pier']], - layout: { visibility: 'visible' }, - paint: { 'fill-antialias': true, 'fill-color': '#f8f4f0' } - }, - { - id: 'road_pier', - type: 'line', - metadata: {}, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['==', '$type', 'LineString'], ['in', 'class', 'pier']], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': '#f8f4f0', - 'line-width': { - base: 1.2, - stops: [ - [15, 1], - [17, 4] - ] - } - } - }, - { - id: 'highway-area', - type: 'fill', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['==', '$type', 'Polygon'], ['!in', 'class', 'pier']], - layout: { visibility: 'visible' }, - paint: { - 'fill-antialias': false, - 'fill-color': 'hsla(0, 0%, 89%, 0.56)', - 'fill-opacity': { - stops: [ - [15, 0], - [16, 0.9] - ] - }, - 'fill-outline-color': '#cfcdca' - } - }, - { - id: 'highway-motorway-link-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 12, - filter: [ - 'all', - ['!in', 'brunnel', 'bridge', 'tunnel'], - ['==', 'class', 'motorway_link'] - ], - layout: { 'line-cap': 'round', 'line-join': 'round' }, - paint: { - 'line-color': '#e9ac77', - 'line-opacity': 1, - 'line-width': { - base: 1.2, - stops: [ - [12, 1], - [13, 3], - [14, 4], - [20, 15] - ] - } - } - }, - { - id: 'highway-link-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 13, - filter: [ - 'all', - ['!in', 'brunnel', 'bridge', 'tunnel'], - [ - 'in', - 'class', - 'primary_link', - 'secondary_link', - 'tertiary_link', - 'trunk_link' - ] - ], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': '#e9ac77', - 'line-opacity': 1, - 'line-width': { - base: 1.2, - stops: [ - [12, 1], - [13, 3], - [14, 4], - [20, 15] - ] - } - } - }, - { - id: 'highway-minor-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - [ - 'all', - ['!=', 'brunnel', 'tunnel'], - ['in', 'class', 'minor', 'service', 'track'] - ] - ], - layout: { 'line-cap': 'round', 'line-join': 'round' }, - paint: { - 'line-color': '#cfcdca', - 'line-opacity': { - stops: [ - [14, 0], - [15, 0.5] - ] - }, - 'line-width': { - base: 1.2, - stops: [ - [15, 0.5], - [16, 5] - ] - } - } - }, - { - id: 'highway-secondary-tertiary-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['!in', 'brunnel', 'bridge', 'tunnel'], - ['in', 'class', 'secondary', 'tertiary'] - ], - layout: { - 'line-cap': 'butt', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': '#e9ac77', - 'line-opacity': { - stops: [ - [15, 0], - [16, 0.3] - ] - }, - 'line-width': { - base: 1.2, - stops: [ - [8, 1.5], - [20, 17] - ] - } - } - }, - { - id: 'highway-primary-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 5, - filter: [ - 'all', - ['!in', 'brunnel', 'bridge', 'tunnel'], - ['in', 'class', 'primary'] - ], - layout: { - 'line-cap': 'butt', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': '#e9ac77', - 'line-opacity': { - stops: [ - [16, 0], - [17, 0.3] - ] - }, - 'line-width': { - base: 1.2, - stops: [ - [7, 0], - [8, 0.6], - [9, 1.5], - [20, 22] - ] - } - } - }, - { - id: 'highway-trunk-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 5, - filter: [ - 'all', - ['!in', 'brunnel', 'bridge', 'tunnel'], - ['in', 'class', 'trunk'] - ], - layout: { - 'line-cap': 'butt', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': '#e9ac77', - 'line-opacity': { - stops: [ - [16, 0], - [17, 0.3] - ] - }, - 'line-width': { - base: 1.2, - stops: [ - [5, 0], - [6, 0.6], - [7, 1.5], - [20, 22] - ] - } - } - }, - { - id: 'highway-motorway-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 4, - filter: [ - 'all', - ['!in', 'brunnel', 'bridge', 'tunnel'], - ['==', 'class', 'motorway'] - ], - layout: { - 'line-cap': 'butt', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': '#e9ac77', - 'line-opacity': { - stops: [ - [8, 0], - [9, 0.2] - ] - }, - 'line-width': { - base: 1.2, - stops: [ - [4, 0], - [5, 0.4], - [6, 0.6], - [7, 1.5], - [20, 22] - ] - } - } - }, - { - id: 'highway-path', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - ['all', ['!in', 'brunnel', 'bridge', 'tunnel'], ['==', 'class', 'path']] - ], - layout: { visibility: 'none' }, - paint: { - 'line-color': '#cba', - 'line-dasharray': [1.5, 0.75], - 'line-width': { - base: 1.2, - stops: [ - [15, 1.2], - [20, 4] - ] - } - } - }, - { - id: 'highway-motorway-link', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 12, - filter: [ - 'all', - ['!in', 'brunnel', 'bridge', 'tunnel'], - ['==', 'class', 'motorway_link'] - ], - layout: { 'line-cap': 'round', 'line-join': 'round' }, - paint: { - 'line-color': '#fc8', - 'line-width': { - base: 1.2, - stops: [ - [12.5, 0], - [13, 1.5], - [14, 2.5], - [20, 11.5] - ] - } - } - }, - { - id: 'highway-link', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 13, - filter: [ - 'all', - ['!in', 'brunnel', 'bridge', 'tunnel'], - [ - 'in', - 'class', - 'primary_link', - 'secondary_link', - 'tertiary_link', - 'trunk_link' - ] - ], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': '#fea', - 'line-width': { - base: 1.2, - stops: [ - [12.5, 0], - [13, 1.5], - [14, 2.5], - [20, 11.5] - ] - } - } - }, - { - id: 'highway-minor', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - [ - 'all', - ['!=', 'brunnel', 'tunnel'], - ['in', 'class', 'minor', 'service', 'track'] - ] - ], - layout: { 'line-cap': 'round', 'line-join': 'round' }, - paint: { - 'line-color': '#fff', - 'line-opacity': { - stops: [ - [16, 0], - [17, 0.4] - ] - }, - 'line-width': { - base: 1.2, - stops: [ - [16, 0], - [17, 2.5] - ] - } - } - }, - { - id: 'highway-secondary-tertiary', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['!in', 'brunnel', 'bridge', 'tunnel'], - ['in', 'class', 'secondary', 'tertiary'] - ], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': '#fea', - 'line-width': { - base: 1.2, - stops: [ - [6.5, 0], - [8, 0.5], - [20, 13] - ] - }, - 'line-opacity': { - stops: [ - [11, 0], - [13, 0.3] - ] - } - } - }, - { - id: 'highway-primary', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - [ - 'all', - ['!in', 'brunnel', 'bridge', 'tunnel'], - ['in', 'class', 'primary'] - ] - ], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': '#fea', - 'line-width': { - base: 1.2, - stops: [ - [8.5, 0], - [9, 0.5], - [20, 18] - ] - }, - 'line-opacity': { - stops: [ - [8, 0], - [9, 0.3] - ] - } - } - }, - { - id: 'highway-trunk', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - ['all', ['!in', 'brunnel', 'bridge', 'tunnel'], ['in', 'class', 'trunk']] - ], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': '#fea', - 'line-width': { - base: 1.2, - stops: [ - [6.5, 0], - [7, 0.5], - [20, 18] - ] - }, - 'line-opacity': { - stops: [ - [16, 0], - [17, 0.3] - ] - } - } - }, - { - id: 'highway-motorway', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 5, - filter: [ - 'all', - ['==', '$type', 'LineString'], - [ - 'all', - ['!in', 'brunnel', 'bridge', 'tunnel'], - ['==', 'class', 'motorway'] - ] - ], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': '#fc8', - 'line-width': { - base: 1.2, - stops: [ - [6.5, 0], - [7, 0.5], - [20, 18] - ] - }, - 'line-opacity': { - stops: [ - [8, 0], - [9, 0.2] - ] - } - } - }, - { - id: 'railway-transit', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - ['all', ['==', 'class', 'transit'], ['!in', 'brunnel', 'tunnel']] - ], - layout: { visibility: 'visible' }, - paint: { - 'line-color': 'hsla(0, 0%, 73%, 0.77)', - 'line-width': { - base: 1.4, - stops: [ - [14, 0.4], - [20, 1] - ] - } - } - }, - { - id: 'railway-transit-hatching', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - ['all', ['==', 'class', 'transit'], ['!in', 'brunnel', 'tunnel']] - ], - layout: { visibility: 'visible' }, - paint: { - 'line-color': 'hsla(0, 0%, 73%, 0.68)', - 'line-dasharray': [0.2, 8], - 'line-width': { - base: 1.4, - stops: [ - [14.5, 0], - [15, 2], - [20, 6] - ] - } - } - }, - { - id: 'railway-service', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - ['all', ['==', 'class', 'rail'], ['has', 'service']] - ], - paint: { - 'line-color': 'hsla(0, 0%, 73%, 0.77)', - 'line-width': { - base: 1.4, - stops: [ - [14, 0.4], - [20, 1] - ] - } - } - }, - { - id: 'railway-service-hatching', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - ['all', ['==', 'class', 'rail'], ['has', 'service']] - ], - layout: { visibility: 'visible' }, - paint: { - 'line-color': 'hsla(0, 0%, 73%, 0.68)', - 'line-dasharray': [0.2, 8], - 'line-width': { - base: 1.4, - stops: [ - [14.5, 0], - [15, 2], - [20, 6] - ] - } - } - }, - { - id: 'railway', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - [ - 'all', - ['!has', 'service'], - ['!in', 'brunnel', 'bridge', 'tunnel'], - ['==', 'class', 'rail'] - ] - ], - layout: { visibility: 'visible' }, - paint: { - 'line-color': '#bbb', - 'line-width': { - base: 1.4, - stops: [ - [14, 0.4], - [15, 0.75], - [20, 2] - ] - }, - 'line-opacity': { - stops: [ - [11, 0], - [13, 1] - ] - } - } - }, - { - id: 'railway-hatching', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - [ - 'all', - ['!has', 'service'], - ['!in', 'brunnel', 'bridge', 'tunnel'], - ['==', 'class', 'rail'] - ] - ], - paint: { - 'line-color': '#bbb', - 'line-dasharray': [0.2, 8], - 'line-width': { - base: 1.4, - stops: [ - [14.5, 0], - [15, 3], - [20, 8] - ] - } - } - }, - { - id: 'bridge-motorway-link-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'bridge'], - ['==', 'class', 'motorway_link'] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#e9ac77', - 'line-opacity': 1, - 'line-width': { - base: 1.2, - stops: [ - [12, 1], - [13, 3], - [14, 4], - [20, 15] - ] - } - } - }, - { - id: 'bridge-link-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'bridge'], - [ - 'in', - 'class', - 'primary_link', - 'secondary_link', - 'tertiary_link', - 'trunk_link' - ] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#e9ac77', - 'line-opacity': 1, - 'line-width': { - base: 1.2, - stops: [ - [12, 1], - [13, 3], - [14, 4], - [20, 15] - ] - } - } - }, - { - id: 'bridge-secondary-tertiary-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'bridge'], - ['in', 'class', 'secondary', 'tertiary'] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#e9ac77', - 'line-opacity': 0.3, - 'line-width': { - base: 1.2, - stops: [ - [8, 1.5], - [20, 28] - ] - } - } - }, - { - id: 'bridge-trunk-primary-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'bridge'], - ['in', 'class', 'primary', 'trunk'] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': 'hsl(28, 76%, 67%)', - 'line-width': { - base: 1.2, - stops: [ - [5, 0.4], - [6, 0.6], - [7, 1.5], - [20, 26] - ] - }, - 'line-opacity': 0.3 - } - }, - { - id: 'bridge-motorway-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['==', 'brunnel', 'bridge'], ['==', 'class', 'motorway']], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#e9ac77', - 'line-width': { - base: 1.2, - stops: [ - [5, 0.4], - [6, 0.6], - [7, 1.5], - [20, 22] - ] - }, - 'line-opacity': { - stops: [ - [16, 0], - [17, 0.3] - ] - } - } - }, - { - id: 'bridge-path-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - ['all', ['==', 'brunnel', 'bridge'], ['==', 'class', 'path']] - ], - paint: { - 'line-color': '#f8f4f0', - 'line-width': { - base: 1.2, - stops: [ - [15, 1.2], - [20, 18] - ] - } - } - }, - { - id: 'bridge-path', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - ['all', ['==', 'brunnel', 'bridge'], ['==', 'class', 'path']] - ], - paint: { - 'line-color': '#cba', - 'line-dasharray': [1.5, 0.75], - 'line-width': { - base: 1.2, - stops: [ - [15, 1.2], - [20, 4] - ] - } - } - }, - { - id: 'bridge-motorway-link', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'bridge'], - ['==', 'class', 'motorway_link'] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#fc8', - 'line-width': { - base: 1.2, - stops: [ - [12.5, 0], - [13, 1.5], - [14, 2.5], - [20, 11.5] - ] - } - } - }, - { - id: 'bridge-link', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'bridge'], - [ - 'in', - 'class', - 'primary_link', - 'secondary_link', - 'tertiary_link', - 'trunk_link' - ] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#fea', - 'line-width': { - base: 1.2, - stops: [ - [12.5, 0], - [13, 1.5], - [14, 2.5], - [20, 11.5] - ] - } - } - }, - { - id: 'bridge-secondary-tertiary', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'bridge'], - ['in', 'class', 'secondary', 'tertiary'] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#fea', - 'line-width': { - base: 1.2, - stops: [ - [6.5, 0], - [7, 0.5], - [20, 20] - ] - }, - 'line-opacity': { - stops: [ - [16, 0], - [17, 0.3] - ] - } - } - }, - { - id: 'bridge-trunk-primary', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'bridge'], - ['in', 'class', 'primary', 'trunk'] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#fea', - 'line-width': { - base: 1.2, - stops: [ - [6.5, 0], - [7, 0.5], - [20, 18] - ] - }, - 'line-opacity': 0.3 - } - }, - { - id: 'bridge-motorway', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['==', 'brunnel', 'bridge'], ['==', 'class', 'motorway']], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#fc8', - 'line-width': { - base: 1.2, - stops: [ - [6.5, 0], - [7, 0.5], - [20, 18] - ] - }, - 'line-opacity': 0.3 - } - }, - { - id: 'bridge-railway', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['==', 'brunnel', 'bridge'], ['==', 'class', 'rail']], - paint: { - 'line-color': '#bbb', - 'line-width': { - base: 1.4, - stops: [ - [14, 0.4], - [15, 0.75], - [20, 2] - ] - } - } - }, - { - id: 'bridge-railway-hatching', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['==', 'brunnel', 'bridge'], ['==', 'class', 'rail']], - paint: { - 'line-color': '#bbb', - 'line-dasharray': [0.2, 8], - 'line-width': { - base: 1.4, - stops: [ - [14.5, 0], - [15, 3], - [20, 8] - ] - } - } - }, - { - id: 'cablecar', - type: 'line', - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 13, - filter: ['==', 'class', 'cable_car'], - layout: { 'line-cap': 'round', visibility: 'visible' }, - paint: { - 'line-color': 'hsl(0, 0%, 70%)', - 'line-width': { - base: 1, - stops: [ - [11, 1], - [19, 2.5] - ] - } - } - }, - { - id: 'cablecar-dash', - type: 'line', - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 13, - filter: ['==', 'class', 'cable_car'], - layout: { 'line-cap': 'round', visibility: 'visible' }, - paint: { - 'line-color': 'hsl(0, 0%, 70%)', - 'line-dasharray': [2, 3], - 'line-width': { - base: 1, - stops: [ - [11, 3], - [19, 5.5] - ] - } - } - }, - { - id: 'boundary-land-level-4', - type: 'line', - source: 'openmaptiles', - 'source-layer': 'boundary', - filter: [ - 'all', - ['>=', 'admin_level', 4], - ['<=', 'admin_level', 8], - ['!=', 'maritime', 1] - ], - layout: { 'line-join': 'round', visibility: 'none' }, - paint: { - 'line-color': '#9e9cab', - 'line-dasharray': [3, 1, 1, 1], - 'line-width': { - base: 1.4, - stops: [ - [4, 0.4], - [5, 1], - [12, 3] - ] - } - } - }, - { - id: 'boundary-land-level-2', - type: 'line', - source: 'openmaptiles', - 'source-layer': 'boundary', - filter: [ - 'all', - ['==', 'admin_level', 2], - ['!=', 'maritime', 1], - ['!=', 'disputed', 1] - ], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'none' - }, - paint: { - 'line-color': 'hsl(248, 7%, 66%)', - 'line-width': { - base: 1, - stops: [ - [0, 0.6], - [4, 1.4], - [5, 2], - [12, 8] - ] - } - } - }, - { - id: 'boundary-land-disputed', - type: 'line', - source: 'openmaptiles', - 'source-layer': 'boundary', - filter: ['all', ['!=', 'maritime', 1], ['==', 'disputed', 1]], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'none' - }, - paint: { - 'line-color': 'hsl(248, 7%, 70%)', - 'line-dasharray': [1, 3], - 'line-width': { - base: 1, - stops: [ - [0, 0.6], - [4, 1.4], - [5, 2], - [12, 8] - ] - } - } - }, - { - id: 'boundary-water', - type: 'line', - source: 'openmaptiles', - 'source-layer': 'boundary', - filter: ['all', ['in', 'admin_level', 2, 4], ['==', 'maritime', 1]], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': 'rgba(154, 189, 214, 1)', - 'line-opacity': { - stops: [ - [6, 0.6], - [10, 1] - ] - }, - 'line-width': { - base: 1, - stops: [ - [0, 0.6], - [4, 1.4], - [5, 2], - [12, 8] - ] - } - } - }, - { - id: 'water-name-lakeline', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'water_name', - filter: ['==', '$type', 'LineString'], - layout: { - 'symbol-placement': 'line', - 'symbol-spacing': 350, - 'text-field': '{name:latin}\n{name:nonlatin}', - 'text-font': ['Noto Sans Italic'], - 'text-letter-spacing': 0.2, - 'text-max-width': 5, - 'text-rotation-alignment': 'map', - 'text-size': 14 - }, - paint: { - 'text-color': '#74aee9', - 'text-halo-color': 'rgba(255,255,255,0.7)', - 'text-halo-width': 1.5 - } - }, - { - id: 'water-name-ocean', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'water_name', - filter: ['all', ['==', '$type', 'Point'], ['==', 'class', 'ocean']], - layout: { - 'symbol-placement': 'point', - 'symbol-spacing': 350, - 'text-field': '{name:latin}', - 'text-font': ['Noto Sans Italic'], - 'text-letter-spacing': 0.2, - 'text-max-width': 5, - 'text-rotation-alignment': 'map', - 'text-size': 14 - }, - paint: { - 'text-color': '#74aee9', - 'text-halo-color': 'rgba(255,255,255,0.7)', - 'text-halo-width': 1.5 - } - }, - { - id: 'water-name-other', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'water_name', - filter: ['all', ['==', '$type', 'Point'], ['!in', 'class', 'ocean']], - layout: { - 'symbol-placement': 'point', - 'symbol-spacing': 350, - 'text-field': '{name:latin}\n{name:nonlatin}', - 'text-font': ['Noto Sans Italic'], - 'text-letter-spacing': 0.2, - 'text-max-width': 5, - 'text-rotation-alignment': 'map', - 'text-size': { - stops: [ - [0, 10], - [6, 14] - ] - }, - visibility: 'visible' - }, - paint: { - 'text-color': 'rgba(0, 51, 178, 1)', - 'text-halo-color': 'rgba(255, 255, 255, 1)', - 'text-halo-width': 1.5 - } - }, - { - id: 'road_oneway', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 15, - filter: [ - 'all', - ['==', 'oneway', 1], - [ - 'in', - 'class', - 'motorway', - 'trunk', - 'primary', - 'secondary', - 'tertiary', - 'minor', - 'service' - ] - ], - layout: { - 'icon-image': 'oneway', - 'icon-padding': 2, - 'icon-rotate': 90, - 'icon-rotation-alignment': 'map', - 'icon-size': { - stops: [ - [15, 0.5], - [19, 1] - ] - }, - 'symbol-placement': 'line', - 'symbol-spacing': 75, - visibility: 'visible' - }, - paint: { 'icon-opacity': 0.5 } - }, - { - id: 'road_oneway_opposite', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 15, - filter: [ - 'all', - ['==', 'oneway', -1], - [ - 'in', - 'class', - 'motorway', - 'trunk', - 'primary', - 'secondary', - 'tertiary', - 'minor', - 'service' - ] - ], - layout: { - 'icon-image': 'oneway', - 'icon-padding': 2, - 'icon-rotate': -90, - 'icon-rotation-alignment': 'map', - 'icon-size': { - stops: [ - [15, 0.5], - [19, 1] - ] - }, - 'symbol-placement': 'line', - 'symbol-spacing': 75, - visibility: 'visible' - }, - paint: { 'icon-opacity': 0.5 } - }, - { - id: 'highway-name-path', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'transportation_name', - minzoom: 15.5, - filter: ['==', 'class', 'path'], - layout: { - 'symbol-placement': 'line', - 'text-field': '{name:latin} {name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-rotation-alignment': 'map', - 'text-size': { - base: 1, - stops: [ - [13, 12], - [14, 13] - ] - } - }, - paint: { - 'text-color': 'rgba(171, 86, 0, 1)', - 'text-halo-color': '#f8f4f0', - 'text-halo-width': 2 - } - }, - { - id: 'highway-name-minor', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'transportation_name', - minzoom: 15, - filter: [ - 'all', - ['==', '$type', 'LineString'], - ['in', 'class', 'minor', 'service', 'track'] - ], - layout: { - 'symbol-placement': 'line', - 'text-field': '{name:latin} {name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-rotation-alignment': 'map', - 'text-size': { - base: 1, - stops: [ - [13, 12], - [14, 13] - ] - }, - visibility: 'visible' - }, - paint: { - 'text-color': 'rgba(143, 69, 0, 1)', - 'text-halo-blur': 0.5, - 'text-halo-width': 2, - 'text-halo-color': 'rgba(255, 255, 255, 1)' - } - }, - { - id: 'highway-name-major', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'transportation_name', - minzoom: 12.2, - filter: ['in', 'class', 'primary', 'secondary', 'tertiary', 'trunk'], - layout: { - 'symbol-placement': 'line', - 'text-field': '{name:latin} {name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-rotation-alignment': 'map', - 'text-size': { - base: 1, - stops: [ - [13, 12], - [14, 13] - ] - }, - visibility: 'visible', - 'symbol-z-order': 'source' - }, - paint: { - 'text-color': 'rgba(0, 0, 0, 1)', - 'text-halo-blur': 0.5, - 'text-halo-width': 1, - 'text-halo-color': 'rgba(255, 255, 255, 1)' - } - }, - { - id: 'highway-shield-us-interstate', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'transportation_name', - minzoom: 7, - filter: [ - 'all', - ['<=', 'ref_length', 6], - ['==', '$type', 'LineString'], - ['in', 'network', 'us-interstate'] - ], - layout: { - 'icon-image': '{network}_{ref_length}', - 'icon-rotation-alignment': 'viewport', - 'icon-size': 1, - 'symbol-placement': { - base: 1, - stops: [ - [7, 'point'], - [7, 'line'], - [8, 'line'] - ] - }, - 'symbol-spacing': 200, - 'text-field': '{ref}', - 'text-font': ['Noto Sans Regular'], - 'text-rotation-alignment': 'viewport', - 'text-size': 10 - }, - paint: { 'text-color': 'rgba(0, 0, 0, 1)' } - }, - { - id: 'highway-shield-us-other', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'transportation_name', - minzoom: 9, - filter: [ - 'all', - ['<=', 'ref_length', 6], - ['==', '$type', 'LineString'], - ['in', 'network', 'us-highway', 'us-state'] - ], - layout: { - 'icon-image': '{network}_{ref_length}', - 'icon-rotation-alignment': 'viewport', - 'icon-size': 1, - 'symbol-placement': { - base: 1, - stops: [ - [10, 'point'], - [11, 'line'] - ] - } as any, - 'symbol-spacing': 200, - 'text-field': '{ref}', - 'text-font': ['Noto Sans Regular'], - 'text-rotation-alignment': 'viewport', - 'text-size': 10 - }, - paint: { 'text-color': 'rgba(0, 0, 0, 1)' } - }, - { - id: 'highway-shield', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'transportation_name', - minzoom: 8, - filter: [ - 'all', - ['<=', 'ref_length', 6], - ['==', '$type', 'LineString'], - ['!in', 'network', 'us-interstate', 'us-highway', 'us-state'] - ], - layout: { - 'icon-image': 'road_{ref_length}', - 'icon-rotation-alignment': 'viewport', - 'icon-size': 1, - 'symbol-placement': { - base: 1, - stops: [ - [10, 'point'], - [11, 'line'] - ] - } as any, - 'symbol-spacing': 200, - 'text-field': '{ref}', - 'text-font': ['Noto Sans Regular'], - 'text-rotation-alignment': 'viewport', - 'text-size': 10, - visibility: 'visible' - }, - paint: { - 'icon-opacity': { - stops: [ - [8, 0], - [9, 1] - ] - }, - 'text-opacity': { - stops: [ - [8, 0], - [9, 1] - ] - } - } - }, - { - id: 'waterway-name', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'waterway', - minzoom: 13, - filter: ['all', ['==', '$type', 'LineString'], ['has', 'name']], - layout: { - 'symbol-placement': 'line', - 'symbol-spacing': 350, - 'text-field': '{name:latin} {name:nonlatin}', - 'text-font': ['Noto Sans Italic'], - 'text-letter-spacing': 0.2, - 'text-max-width': 5, - 'text-rotation-alignment': 'map', - 'text-size': 14, - visibility: 'visible' - }, - paint: { - 'text-color': 'rgba(2, 72, 255, 1)', - 'text-halo-color': 'rgba(255,255,255,1)', - 'text-halo-width': 1.5, - 'text-halo-blur': 0 - } - }, - { - id: 'airport-label-major', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'aerodrome_label', - minzoom: 10, - filter: ['all', ['has', 'iata']], - layout: { - 'icon-image': 'airport_11', - 'icon-size': 1, - 'text-anchor': 'top', - 'text-field': '{name:latin}\n{name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-max-width': 9, - 'text-offset': [0, 0.6], - 'text-optional': true, - 'text-padding': 2, - 'text-size': 12, - visibility: 'visible' - }, - paint: { - 'text-color': '#666', - 'text-halo-blur': 0.5, - 'text-halo-color': '#ffffff', - 'text-halo-width': 1 - } - }, - { - id: 'poi-level-3', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'poi', - minzoom: 16, - filter: [ - 'all', - ['==', '$type', 'Point'], - ['>=', 'rank', 25], - ['any', ['!has', 'level'], ['==', 'level', 0]] - ], - layout: { - 'icon-image': '{class}_11', - 'text-anchor': 'top', - 'text-field': '{name:latin}\n{name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-max-width': 9, - 'text-offset': [0, 0.6], - 'text-padding': 20, - 'text-size': 12, - visibility: 'visible', - 'symbol-spacing': 250, - 'symbol-avoid-edges': false, - 'text-letter-spacing': 0, - 'icon-padding': 2, - 'symbol-placement': 'point', - 'symbol-z-order': 'auto' as any, - 'text-line-height': 1.2, - 'text-allow-overlap': false, - 'text-ignore-placement': false, - 'icon-allow-overlap': false, - 'icon-ignore-placement': false, - 'icon-optional': false - }, - paint: { - 'text-color': 'rgba(2, 2, 3, 1)', - 'text-halo-blur': 0.5, - 'text-halo-color': 'rgba(232, 227, 227, 1)', - 'text-halo-width': 2, - 'text-translate-anchor': 'map', - 'text-opacity': 1 - } - }, - { - id: 'poi-level-2', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'poi', - minzoom: 15, - filter: [ - 'all', - ['==', '$type', 'Point'], - ['<=', 'rank', 24], - ['>=', 'rank', 15], - ['any', ['!has', 'level'], ['==', 'level', 0]] - ], - layout: { - 'icon-image': '{class}_11', - 'text-anchor': 'top', - 'text-field': '{name:latin}\n{name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-max-width': 9, - 'text-offset': [0, 0.6], - 'text-padding': 2, - 'text-size': 12, - visibility: 'visible' - }, - paint: { - 'text-color': 'rgba(2, 2, 3, 1)', - 'text-halo-blur': 0.5, - 'text-halo-color': 'rgba(232, 227, 227, 1)', - 'text-halo-width': 1 - } - }, - { - id: 'poi-level-1', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'poi', - minzoom: 14, - filter: [ - 'all', - ['==', '$type', 'Point'], - ['<=', 'rank', 14], - ['has', 'name'], - ['any', ['!has', 'level'], ['==', 'level', 0]] - ], - layout: { - 'icon-image': '{class}_11', - 'text-anchor': 'top', - 'text-field': '{name:latin}\n{name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-max-width': 9, - 'text-offset': [0, 0.6], - 'text-padding': 2, - 'text-size': 12, - visibility: 'visible' - }, - paint: { - 'text-color': 'rgba(2, 2, 3, 1)', - 'text-halo-blur': 0.5, - 'text-halo-color': 'rgba(232, 227, 227, 1)', - 'text-halo-width': 2 - } - }, - { - id: 'poi-railway', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'poi', - minzoom: 13, - filter: [ - 'all', - ['==', '$type', 'Point'], - ['has', 'name'], - ['==', 'class', 'railway'], - ['==', 'subclass', 'station'] - ], - layout: { - 'icon-allow-overlap': false, - 'icon-ignore-placement': false, - 'icon-image': '{class}_11', - 'icon-optional': false, - 'text-allow-overlap': false, - 'text-anchor': 'top', - 'text-field': '{name:latin}\n{name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-ignore-placement': false, - 'text-max-width': 9, - 'text-offset': [0, 0.6], - 'text-optional': true, - 'text-padding': 2, - 'text-size': 12 - }, - paint: { - 'text-color': 'rgba(0, 0, 0, 1)', - 'text-halo-blur': 0.5, - 'text-halo-color': 'rgba(255,255,255,0.8)', - 'text-halo-width': 1 - } - }, - { - id: 'place-village', - type: 'symbol', - metadata: { 'mapbox:group': '1444849242106.713' }, - source: 'openmaptiles', - 'source-layer': 'place', - filter: ['==', 'class', 'village'], - layout: { - 'text-field': '{name:latin}\n{name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-max-width': 8, - 'text-size': { - base: 1.2, - stops: [ - [10, 12], - [15, 22] - ] - }, - visibility: 'visible' - }, - paint: { - 'text-color': 'rgba(0, 0, 0, 1)', - 'text-halo-color': 'rgba(255,255,255,0.8)', - 'text-halo-width': 1.2 - } - }, - { - id: 'place-town', - type: 'symbol', - metadata: { 'mapbox:group': '1444849242106.713' }, - source: 'openmaptiles', - 'source-layer': 'place', - filter: ['==', 'class', 'town'], - layout: { - 'text-field': '{name:latin}\n{name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-max-width': 8, - 'text-size': { - base: 1.2, - stops: [ - [10, 14], - [15, 24] - ] - }, - visibility: 'visible' - }, - paint: { - 'text-color': 'rgba(0, 0, 0, 1)', - 'text-halo-color': 'rgba(255,255,255,0.8)', - 'text-halo-width': 1.2 - } - }, - { - id: 'place-city', - type: 'symbol', - metadata: { 'mapbox:group': '1444849242106.713' }, - source: 'openmaptiles', - 'source-layer': 'place', - filter: ['all', ['!=', 'capital', 2], ['==', 'class', 'city']], - layout: { - 'text-field': '{name:latin}\n{name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-max-width': 8, - 'text-size': { - base: 1.2, - stops: [ - [7, 14], - [11, 24] - ] - }, - visibility: 'visible' - }, - paint: { - 'text-color': 'rgba(0, 0, 0, 1)', - 'text-halo-color': 'rgba(255,255,255,0.8)', - 'text-halo-width': 1.2 - } - }, - { - id: 'place-city-capital', - type: 'symbol', - metadata: { 'mapbox:group': '1444849242106.713' }, - source: 'openmaptiles', - 'source-layer': 'place', - filter: ['all', ['==', 'capital', 2], ['==', 'class', 'city']], - layout: { - 'icon-image': 'star_11', - 'icon-size': 0.8, - 'text-anchor': 'left', - 'text-field': '{name:latin}\n{name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-max-width': 8, - 'text-offset': [0.4, 0], - 'text-size': { - base: 1.2, - stops: [ - [7, 14], - [11, 24] - ] - }, - visibility: 'visible' - }, - paint: { - 'text-color': '#333', - 'text-halo-color': 'rgba(255,255,255,0.8)', - 'text-halo-width': 1.2 - } - }, - { - id: 'place-country-other', - type: 'symbol', - metadata: { 'mapbox:group': '1444849242106.713' }, - source: 'openmaptiles', - 'source-layer': 'place', - filter: [ - 'all', - ['==', 'class', 'country'], - ['>=', 'rank', 3], - ['!has', 'iso_a2'] - ], - layout: { - 'text-field': '{name:latin}', - 'text-font': ['Noto Sans Italic'], - 'text-max-width': 6.25, - 'text-size': { - stops: [ - [3, 11], - [7, 17] - ] - }, - 'text-transform': 'uppercase', - visibility: 'visible' - }, - paint: { - 'text-color': '#334', - 'text-halo-blur': 1, - 'text-halo-color': 'rgba(255,255,255,0.8)', - 'text-halo-width': 2 - } - }, - { - id: 'place-country-3', - type: 'symbol', - metadata: { 'mapbox:group': '1444849242106.713' }, - source: 'openmaptiles', - 'source-layer': 'place', - filter: [ - 'all', - ['==', 'class', 'country'], - ['>=', 'rank', 3], - ['has', 'iso_a2'] - ], - layout: { - 'text-field': '{name:latin}', - 'text-font': ['Noto Sans Bold'], - 'text-max-width': 6.25, - 'text-size': { - stops: [ - [3, 11], - [7, 17] - ] - }, - 'text-transform': 'uppercase', - visibility: 'visible' - }, - paint: { - 'text-color': '#334', - 'text-halo-blur': 1, - 'text-halo-color': 'rgba(255,255,255,0.8)', - 'text-halo-width': 2 - } - }, - { - id: 'place-country-2', - type: 'symbol', - metadata: { 'mapbox:group': '1444849242106.713' }, - source: 'openmaptiles', - 'source-layer': 'place', - filter: [ - 'all', - ['==', 'class', 'country'], - ['==', 'rank', 2], - ['has', 'iso_a2'] - ], - layout: { - 'text-field': '{name:latin}', - 'text-font': ['Noto Sans Bold'], - 'text-max-width': 6.25, - 'text-size': { - stops: [ - [2, 11], - [5, 17] - ] - }, - 'text-transform': 'uppercase', - visibility: 'visible' - }, - paint: { - 'text-color': '#334', - 'text-halo-blur': 1, - 'text-halo-color': 'rgba(255,255,255,0.8)', - 'text-halo-width': 2 - } - }, - { - id: 'place-country-1', - type: 'symbol', - metadata: { 'mapbox:group': '1444849242106.713' }, - source: 'openmaptiles', - 'source-layer': 'place', - filter: [ - 'all', - ['==', 'class', 'country'], - ['==', 'rank', 1], - ['has', 'iso_a2'] - ], - layout: { - 'text-field': '{name:latin}', - 'text-font': ['Noto Sans Bold'], - 'text-max-width': 6.25, - 'text-size': { - stops: [ - [1, 11], - [4, 17] - ] - }, - 'text-transform': 'uppercase', - visibility: 'visible' - }, - paint: { - 'text-color': '#334', - 'text-halo-blur': 1, - 'text-halo-color': 'rgba(255,255,255,0.8)', - 'text-halo-width': 2 - } - }, - { - id: 'place-continent', - type: 'symbol', - metadata: { 'mapbox:group': '1444849242106.713' }, - source: 'openmaptiles', - 'source-layer': 'place', - maxzoom: 1, - filter: ['==', 'class', 'continent'], - layout: { - 'text-field': '{name:latin}', - 'text-font': ['Noto Sans Bold'], - 'text-max-width': 6.25, - 'text-size': 14, - 'text-transform': 'uppercase', - visibility: 'visible' - }, - paint: { - 'text-color': '#334', - 'text-halo-blur': 1, - 'text-halo-color': 'rgba(255,255,255,0.8)', - 'text-halo-width': 2 - } - } -]; - -export default layers; diff --git a/app/javascript/components/shared/maplibre/styles/layers/vector.json b/app/javascript/components/shared/maplibre/styles/layers/vector.json new file mode 100644 index 000000000..a177876b6 --- /dev/null +++ b/app/javascript/components/shared/maplibre/styles/layers/vector.json @@ -0,0 +1,2866 @@ +[ + { + "id": "background", + "type": "background", + "minzoom": 0, + "maxzoom": 24, + "layout": { "visibility": "visible" }, + "paint": { "background-color": "rgba(255, 246, 241, 1)" } + }, + { + "id": "landcover-glacier", + "type": "fill", + "metadata": { "mapbox:group": "1444849388993.3071" }, + "source": "openmaptiles", + "source-layer": "landcover", + "filter": ["==", "subclass", "glacier"], + "layout": { "visibility": "visible" }, + "paint": { + "fill-color": "#fff", + "fill-opacity": { + "base": 1, + "stops": [ + [0, 0.9], + [10, 0.3] + ] + } + } + }, + { + "id": "landuse-residential", + "type": "fill", + "metadata": { "mapbox:group": "1444849388993.3071" }, + "source": "openmaptiles", + "source-layer": "landuse", + "filter": [ + "all", + ["in", "class", "residential", "suburb", "neighbourhood"] + ], + "layout": { "visibility": "visible" }, + "paint": { + "fill-color": { + "base": 1, + "stops": [ + [12, "hsla(30, 19%, 90%, 0.4)"], + [16, "hsla(30, 19%, 90%, 0.2)"] + ] + } + } + }, + { + "id": "landuse-commercial", + "type": "fill", + "metadata": { "mapbox:group": "1444849388993.3071" }, + "source": "openmaptiles", + "source-layer": "landuse", + "filter": [ + "all", + ["==", "$type", "Polygon"], + ["==", "class", "commercial"] + ], + "layout": { "visibility": "visible" }, + "paint": { "fill-color": "hsla(0, 60%, 87%, 0.23)" } + }, + { + "id": "landuse-industrial", + "type": "fill", + "metadata": { "mapbox:group": "1444849388993.3071" }, + "source": "openmaptiles", + "source-layer": "landuse", + "filter": [ + "all", + ["==", "$type", "Polygon"], + ["==", "class", "industrial"] + ], + "paint": { "fill-color": "hsla(49, 100%, 88%, 0.34)" } + }, + { + "id": "landuse-cemetery", + "type": "fill", + "metadata": { "mapbox:group": "1444849388993.3071" }, + "source": "openmaptiles", + "source-layer": "landuse", + "filter": ["==", "class", "cemetery"], + "paint": { "fill-color": "#e0e4dd" } + }, + { + "id": "landuse-hospital", + "type": "fill", + "metadata": { "mapbox:group": "1444849388993.3071" }, + "source": "openmaptiles", + "source-layer": "landuse", + "filter": ["==", "class", "hospital"], + "paint": { "fill-color": "#fde" } + }, + { + "id": "landuse-school", + "type": "fill", + "metadata": { "mapbox:group": "1444849388993.3071" }, + "source": "openmaptiles", + "source-layer": "landuse", + "filter": ["==", "class", "school"], + "paint": { "fill-color": "#f0e8f8" } + }, + { + "id": "landuse-railway", + "type": "fill", + "metadata": { "mapbox:group": "1444849388993.3071" }, + "source": "openmaptiles", + "source-layer": "landuse", + "filter": ["==", "class", "railway"], + "paint": { "fill-color": "hsla(30, 19%, 90%, 0.4)" } + }, + { + "id": "landcover-wood", + "type": "fill", + "metadata": { "mapbox:group": "1444849388993.3071" }, + "source": "openmaptiles", + "source-layer": "landcover", + "filter": ["==", "class", "wood"], + "paint": { + "fill-antialias": { + "base": 1, + "stops": [ + [0, false], + [9, true] + ] + }, + "fill-color": "#6a4", + "fill-opacity": 0.1, + "fill-outline-color": "hsla(0, 0%, 0%, 0.03)" + } + }, + { + "id": "landcover-grass", + "type": "fill", + "metadata": { "mapbox:group": "1444849388993.3071" }, + "source": "openmaptiles", + "source-layer": "landcover", + "filter": ["==", "class", "grass"], + "paint": { "fill-color": "#d8e8c8", "fill-opacity": 1 } + }, + { + "id": "landcover-grass-park", + "type": "fill", + "metadata": { "mapbox:group": "1444849388993.3071" }, + "source": "openmaptiles", + "source-layer": "park", + "filter": ["==", "class", "public_park"], + "paint": { "fill-color": "#d8e8c8", "fill-opacity": 0.8 } + }, + { + "id": "waterway_tunnel", + "type": "line", + "source": "openmaptiles", + "source-layer": "waterway", + "minzoom": 14, + "filter": [ + "all", + ["in", "class", "river", "stream", "canal"], + ["==", "brunnel", "tunnel"] + ], + "layout": { "line-cap": "round", "visibility": "visible" }, + "paint": { + "line-color": "#a0c8f0", + "line-dasharray": [2, 4], + "line-width": { + "base": 1.3, + "stops": [ + [13, 0.5], + [20, 6] + ] + } + } + }, + { + "id": "waterway-other", + "type": "line", + "metadata": { "mapbox:group": "1444849382550.77" }, + "source": "openmaptiles", + "source-layer": "waterway", + "filter": [ + "all", + ["!in", "class", "canal", "river", "stream"], + ["==", "intermittent", 0] + ], + "layout": { "line-cap": "round", "visibility": "visible" }, + "paint": { + "line-color": "#a0c8f0", + "line-width": { + "base": 1.3, + "stops": [ + [13, 0.5], + [20, 2] + ] + } + } + }, + { + "id": "waterway-other-intermittent", + "type": "line", + "metadata": { "mapbox:group": "1444849382550.77" }, + "source": "openmaptiles", + "source-layer": "waterway", + "filter": [ + "all", + ["!in", "class", "canal", "river", "stream"], + ["==", "intermittent", 1] + ], + "layout": { "line-cap": "round", "visibility": "visible" }, + "paint": { + "line-color": "#a0c8f0", + "line-width": { + "base": 1.3, + "stops": [ + [13, 0.5], + [20, 2] + ] + }, + "line-dasharray": [4, 3] + } + }, + { + "id": "waterway-stream-canal", + "type": "line", + "metadata": { "mapbox:group": "1444849382550.77" }, + "source": "openmaptiles", + "source-layer": "waterway", + "filter": [ + "all", + ["in", "class", "canal", "stream"], + ["!=", "brunnel", "tunnel"], + ["==", "intermittent", 0] + ], + "layout": { "line-cap": "round", "visibility": "visible" }, + "paint": { + "line-color": "#a0c8f0", + "line-width": { + "base": 1.3, + "stops": [ + [13, 0.5], + [20, 6] + ] + } + } + }, + { + "id": "waterway-stream-canal-intermittent", + "type": "line", + "metadata": { "mapbox:group": "1444849382550.77" }, + "source": "openmaptiles", + "source-layer": "waterway", + "filter": [ + "all", + ["in", "class", "canal", "stream"], + ["!=", "brunnel", "tunnel"], + ["==", "intermittent", 1] + ], + "layout": { "line-cap": "round", "visibility": "visible" }, + "paint": { + "line-color": "#a0c8f0", + "line-width": { + "base": 1.3, + "stops": [ + [13, 0.5], + [20, 6] + ] + }, + "line-dasharray": [4, 3] + } + }, + { + "id": "waterway-river", + "type": "line", + "metadata": { "mapbox:group": "1444849382550.77" }, + "source": "openmaptiles", + "source-layer": "waterway", + "filter": [ + "all", + ["==", "class", "river"], + ["!=", "brunnel", "tunnel"], + ["==", "intermittent", 0] + ], + "layout": { "line-cap": "round", "visibility": "visible" }, + "paint": { + "line-color": "#a0c8f0", + "line-width": { + "base": 1.2, + "stops": [ + [10, 0.8], + [20, 6] + ] + } + } + }, + { + "id": "waterway-river-intermittent", + "type": "line", + "metadata": { "mapbox:group": "1444849382550.77" }, + "source": "openmaptiles", + "source-layer": "waterway", + "filter": [ + "all", + ["==", "class", "river"], + ["!=", "brunnel", "tunnel"], + ["==", "intermittent", 1] + ], + "layout": { "line-cap": "round", "visibility": "visible" }, + "paint": { + "line-color": "#a0c8f0", + "line-width": { + "base": 1.2, + "stops": [ + [10, 0.8], + [20, 6] + ] + }, + "line-dasharray": [3, 2.5] + } + }, + { + "id": "water-offset", + "type": "fill", + "metadata": { "mapbox:group": "1444849382550.77" }, + "source": "openmaptiles", + "source-layer": "water", + "maxzoom": 8, + "filter": ["==", "$type", "Polygon"], + "layout": { "visibility": "visible" }, + "paint": { + "fill-color": "#a0c8f0", + "fill-opacity": 1, + "fill-translate": { + "base": 1, + "stops": [ + [6, [2, 0]], + [8, [0, 0]] + ] + } + } + }, + { + "id": "water", + "type": "fill", + "metadata": { "mapbox:group": "1444849382550.77" }, + "source": "openmaptiles", + "source-layer": "water", + "filter": ["all", ["!=", "intermittent", 1]], + "layout": { "visibility": "visible" }, + "paint": { "fill-color": "hsl(210, 67%, 85%)" } + }, + { + "id": "water-intermittent", + "type": "fill", + "metadata": { "mapbox:group": "1444849382550.77" }, + "source": "openmaptiles", + "source-layer": "water", + "filter": ["all", ["==", "intermittent", 1]], + "layout": { "visibility": "visible" }, + "paint": { "fill-color": "hsl(210, 67%, 85%)", "fill-opacity": 0.7 } + }, + { + "id": "water-pattern", + "type": "fill", + "metadata": { "mapbox:group": "1444849382550.77" }, + "source": "openmaptiles", + "source-layer": "water", + "filter": ["all"], + "layout": { "visibility": "visible" }, + "paint": { "fill-pattern": "wave", "fill-translate": [0, 2.5] } + }, + { + "id": "landcover-ice-shelf", + "type": "fill", + "metadata": { "mapbox:group": "1444849382550.77" }, + "source": "openmaptiles", + "source-layer": "landcover", + "filter": ["==", "subclass", "ice_shelf"], + "layout": { "visibility": "visible" }, + "paint": { + "fill-color": "#fff", + "fill-opacity": { + "base": 1, + "stops": [ + [0, 0.9], + [10, 0.3] + ] + } + } + }, + { + "id": "landcover-sand", + "type": "fill", + "metadata": { "mapbox:group": "1444849382550.77" }, + "source": "openmaptiles", + "source-layer": "landcover", + "filter": ["all", ["==", "class", "sand"]], + "layout": { "visibility": "visible" }, + "paint": { "fill-color": "rgba(245, 238, 188, 1)", "fill-opacity": 1 } + }, + { + "id": "building", + "type": "fill", + "metadata": { "mapbox:group": "1444849364238.8171" }, + "source": "openmaptiles", + "source-layer": "building", + "layout": { "visibility": "none" }, + "paint": { + "fill-antialias": true, + "fill-color": { + "base": 1, + "stops": [ + [15.5, "#f2eae2"], + [16, "#dfdbd7"] + ] + } + } + }, + { + "id": "building-top", + "type": "fill", + "metadata": { "mapbox:group": "1444849364238.8171" }, + "source": "openmaptiles", + "source-layer": "building", + "layout": { "visibility": "none" }, + "paint": { + "fill-color": "#f2eae2", + "fill-opacity": { + "base": 1, + "stops": [ + [13, 0], + [16, 1] + ] + }, + "fill-outline-color": "#dfdbd7", + "fill-translate": { + "base": 1, + "stops": [ + [14, [0, 0]], + [16, [-2, -2]] + ] + } + } + }, + { + "id": "tunnel-service-track-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "service", "track"] + ], + "layout": { "line-join": "round", "visibility": "visible" }, + "paint": { + "line-color": "#cfcdca", + "line-dasharray": [0.5, 0.25], + "line-width": { + "base": 1.2, + "stops": [ + [15, 1], + [16, 4], + [20, 11] + ] + } + } + }, + { + "id": "tunnel-minor-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "tunnel"], ["==", "class", "minor"]], + "layout": { "line-join": "round", "visibility": "visible" }, + "paint": { + "line-color": "#cfcdca", + "line-opacity": { + "stops": [ + [12, 0], + [12.5, 1] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [12, 0.5], + [13, 1], + [14, 4], + [20, 15] + ] + } + } + }, + { + "id": "tunnel-secondary-tertiary-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "secondary", "tertiary"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [8, 1.5], + [20, 17] + ] + } + } + }, + { + "id": "tunnel-trunk-primary-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "primary", "trunk"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#e9ac77", + "line-width": { + "base": 1.2, + "stops": [ + [5, 0.4], + [6, 0.6], + [7, 1.5], + [20, 22] + ] + } + } + }, + { + "id": "tunnel-motorway-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "tunnel"], ["==", "class", "motorway"]], + "layout": { "line-join": "round", "visibility": "visible" }, + "paint": { + "line-color": "#e9ac77", + "line-dasharray": [0.5, 0.25], + "line-width": { + "base": 1.2, + "stops": [ + [5, 0.4], + [6, 0.6], + [7, 1.5], + [20, 22] + ] + } + } + }, + { + "id": "tunnel-path", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["all", ["==", "brunnel", "tunnel"], ["==", "class", "path"]] + ], + "layout": { "visibility": "visible" }, + "paint": { + "line-color": "#cba", + "line-dasharray": [1.5, 0.75], + "line-width": { + "base": 1.2, + "stops": [ + [15, 1.2], + [20, 4] + ] + } + } + }, + { + "id": "tunnel-service-track", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "service", "track"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#fff", + "line-width": { + "base": 1.2, + "stops": [ + [15.5, 0], + [16, 2], + [20, 7.5] + ] + } + } + }, + { + "id": "tunnel-minor", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["==", "class", "minor_road"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [13.5, 0], + [14, 2.5], + [20, 11.5] + ] + } + } + }, + { + "id": "tunnel-secondary-tertiary", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "secondary", "tertiary"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#fff4c6", + "line-width": { + "base": 1.2, + "stops": [ + [6.5, 0], + [7, 0.5], + [20, 10] + ] + } + } + }, + { + "id": "tunnel-trunk-primary", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "primary", "trunk"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#fff4c6", + "line-width": { + "base": 1.2, + "stops": [ + [6.5, 0], + [7, 0.5], + [20, 18] + ] + } + } + }, + { + "id": "tunnel-motorway", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "tunnel"], ["==", "class", "motorway"]], + "layout": { "line-join": "round", "visibility": "visible" }, + "paint": { + "line-color": "#ffdaa6", + "line-width": { + "base": 1.2, + "stops": [ + [6.5, 0], + [7, 0.5], + [20, 18] + ] + } + } + }, + { + "id": "tunnel-railway", + "type": "line", + "metadata": { "mapbox:group": "1444849354174.1904" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "tunnel"], ["==", "class", "rail"]], + "paint": { + "line-color": "#bbb", + "line-dasharray": [2, 2], + "line-width": { + "base": 1.4, + "stops": [ + [14, 0.4], + [15, 0.75], + [20, 2] + ] + } + } + }, + { + "id": "ferry", + "type": "line", + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["in", "class", "ferry"]], + "layout": { "line-join": "round", "visibility": "none" }, + "paint": { + "line-color": "rgba(108, 159, 182, 1)", + "line-dasharray": [2, 2], + "line-width": 1.1 + } + }, + { + "id": "aeroway-taxiway-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "aeroway", + "minzoom": 12, + "filter": ["all", ["in", "class", "taxiway"]], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(153, 153, 153, 1)", + "line-opacity": 1, + "line-width": { + "base": 1.5, + "stops": [ + [11, 2], + [17, 12] + ] + } + } + }, + { + "id": "aeroway-runway-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "aeroway", + "minzoom": 12, + "filter": ["all", ["in", "class", "runway"]], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(153, 153, 153, 1)", + "line-opacity": 1, + "line-width": { + "base": 1.5, + "stops": [ + [11, 5], + [17, 55] + ] + } + } + }, + { + "id": "aeroway-area", + "type": "fill", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "aeroway", + "minzoom": 4, + "filter": [ + "all", + ["==", "$type", "Polygon"], + ["in", "class", "runway", "taxiway"] + ], + "layout": { "visibility": "visible" }, + "paint": { + "fill-color": "rgba(255, 255, 255, 1)", + "fill-opacity": { + "base": 1, + "stops": [ + [13, 0], + [14, 1] + ] + } + } + }, + { + "id": "aeroway-taxiway", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "aeroway", + "minzoom": 4, + "filter": [ + "all", + ["in", "class", "taxiway"], + ["==", "$type", "LineString"] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(255, 255, 255, 1)", + "line-opacity": { + "base": 1, + "stops": [ + [11, 0], + [12, 1] + ] + }, + "line-width": { + "base": 1.5, + "stops": [ + [11, 1], + [17, 10] + ] + } + } + }, + { + "id": "aeroway-runway", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "aeroway", + "minzoom": 4, + "filter": ["all", ["in", "class", "runway"], ["==", "$type", "LineString"]], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(255, 255, 255, 1)", + "line-opacity": { + "base": 1, + "stops": [ + [11, 0], + [12, 1] + ] + }, + "line-width": { + "base": 1.5, + "stops": [ + [11, 4], + [17, 50] + ] + } + } + }, + { + "id": "road_area_pier", + "type": "fill", + "metadata": {}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "$type", "Polygon"], ["==", "class", "pier"]], + "layout": { "visibility": "none" }, + "paint": { "fill-antialias": true, "fill-color": "#f8f4f0" } + }, + { + "id": "road_pier", + "type": "line", + "metadata": {}, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 0, + "filter": ["all", ["==", "$type", "LineString"], ["in", "class", "pier"]], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "none" + }, + "paint": { + "line-color": "#f8f4f0", + "line-width": { + "base": 1.2, + "stops": [ + [15, 1], + [17, 4] + ] + } + } + }, + { + "id": "highway-area", + "type": "fill", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "$type", "Polygon"], ["!in", "class", "pier"]], + "layout": { "visibility": "visible" }, + "paint": { + "fill-antialias": false, + "fill-color": "hsla(0, 0%, 89%, 0.56)", + "fill-opacity": 0.9, + "fill-outline-color": "#cfcdca" + } + }, + { + "id": "highway-motorway-link-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 12, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "motorway_link"] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [12, 1], + [13, 3], + [14, 4], + [20, 15] + ] + } + } + }, + { + "id": "highway-link-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + [ + "in", + "class", + "primary_link", + "secondary_link", + "tertiary_link", + "trunk_link" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [12, 1], + [13, 3], + [14, 4], + [20, 15] + ] + } + } + }, + { + "id": "highway-minor-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + [ + "all", + ["!=", "brunnel", "tunnel"], + ["in", "class", "minor", "service", "track"] + ] + ], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-color": "#cfcdca", + "line-opacity": { + "stops": [ + [12, 0], + [12.5, 1] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [12, 0.5], + [13, 1], + [14, 4], + [20, 15] + ] + } + } + }, + { + "id": "highway-secondary-tertiary-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "secondary", "tertiary"] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [8, 1.5], + [20, 17] + ] + } + } + }, + { + "id": "highway-primary-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 5, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "primary"] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": { + "stops": [ + [7, 0], + [8, 1] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [7, 0], + [8, 0.6], + [9, 1.5], + [20, 22] + ] + } + } + }, + { + "id": "highway-trunk-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 5, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "trunk"] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": { + "stops": [ + [5, 0], + [6, 1] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [5, 0], + [6, 0.6], + [7, 1.5], + [20, 22] + ] + } + } + }, + { + "id": "highway-motorway-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 4, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "motorway"] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": { + "stops": [ + [4, 0], + [5, 1] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [4, 0], + [5, 0.4], + [6, 0.6], + [7, 1.5], + [20, 22] + ] + } + } + }, + { + "id": "highway-path", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["all", ["!in", "brunnel", "bridge", "tunnel"], ["==", "class", "path"]] + ], + "paint": { + "line-color": "#cba", + "line-dasharray": [1.5, 0.75], + "line-width": { + "base": 1.2, + "stops": [ + [15, 1.2], + [20, 4] + ] + } + } + }, + { + "id": "highway-motorway-link", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 12, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "motorway_link"] + ], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-color": "#fc8", + "line-width": { + "base": 1.2, + "stops": [ + [12.5, 0], + [13, 1.5], + [14, 2.5], + [20, 11.5] + ] + } + } + }, + { + "id": "highway-link", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + [ + "in", + "class", + "primary_link", + "secondary_link", + "tertiary_link", + "trunk_link" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [12.5, 0], + [13, 1.5], + [14, 2.5], + [20, 11.5] + ] + } + } + }, + { + "id": "highway-minor", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + [ + "all", + ["!=", "brunnel", "tunnel"], + ["in", "class", "minor", "service", "track"] + ] + ], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [13.5, 0], + [14, 2.5], + [20, 11.5] + ] + } + } + }, + { + "id": "highway-secondary-tertiary", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "secondary", "tertiary"] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [6.5, 0], + [8, 0.5], + [20, 13] + ] + } + } + }, + { + "id": "highway-primary", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "primary"] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [8.5, 0], + [9, 0.5], + [20, 18] + ] + } + } + }, + { + "id": "highway-trunk", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["all", ["!in", "brunnel", "bridge", "tunnel"], ["in", "class", "trunk"]] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [6.5, 0], + [7, 0.5], + [20, 18] + ] + } + } + }, + { + "id": "highway-motorway", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 5, + "filter": [ + "all", + ["==", "$type", "LineString"], + [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "motorway"] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fc8", + "line-width": { + "base": 1.2, + "stops": [ + [6.5, 0], + [7, 0.5], + [20, 18] + ] + } + } + }, + { + "id": "railway-transit", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["all", ["==", "class", "transit"], ["!in", "brunnel", "tunnel"]] + ], + "layout": { "visibility": "visible" }, + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.77)", + "line-width": { + "base": 1.4, + "stops": [ + [14, 0.4], + [20, 1] + ] + } + } + }, + { + "id": "railway-transit-hatching", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["all", ["==", "class", "transit"], ["!in", "brunnel", "tunnel"]] + ], + "layout": { "visibility": "visible" }, + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.68)", + "line-dasharray": [0.2, 8], + "line-width": { + "base": 1.4, + "stops": [ + [14.5, 0], + [15, 2], + [20, 6] + ] + } + } + }, + { + "id": "railway-service", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["all", ["==", "class", "rail"], ["has", "service"]] + ], + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.77)", + "line-width": { + "base": 1.4, + "stops": [ + [14, 0.4], + [20, 1] + ] + } + } + }, + { + "id": "railway-service-hatching", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["all", ["==", "class", "rail"], ["has", "service"]] + ], + "layout": { "visibility": "visible" }, + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.68)", + "line-dasharray": [0.2, 8], + "line-width": { + "base": 1.4, + "stops": [ + [14.5, 0], + [15, 2], + [20, 6] + ] + } + } + }, + { + "id": "railway", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + [ + "all", + ["!has", "service"], + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "rail"] + ] + ], + "paint": { + "line-color": "#bbb", + "line-width": { + "base": 1.4, + "stops": [ + [14, 0.4], + [15, 0.75], + [20, 2] + ] + } + } + }, + { + "id": "railway-hatching", + "type": "line", + "metadata": { "mapbox:group": "1444849345966.4436" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + [ + "all", + ["!has", "service"], + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "rail"] + ] + ], + "paint": { + "line-color": "#bbb", + "line-dasharray": [0.2, 8], + "line-width": { + "base": 1.4, + "stops": [ + [14.5, 0], + [15, 3], + [20, 8] + ] + } + } + }, + { + "id": "bridge-motorway-link-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["==", "class", "motorway_link"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [12, 1], + [13, 3], + [14, 4], + [20, 15] + ] + } + } + }, + { + "id": "bridge-link-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + [ + "in", + "class", + "primary_link", + "secondary_link", + "tertiary_link", + "trunk_link" + ] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [12, 1], + [13, 3], + [14, 4], + [20, 15] + ] + } + } + }, + { + "id": "bridge-secondary-tertiary-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["in", "class", "secondary", "tertiary"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [8, 1.5], + [20, 28] + ] + } + } + }, + { + "id": "bridge-trunk-primary-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["in", "class", "primary", "trunk"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "hsl(28, 76%, 67%)", + "line-width": { + "base": 1.2, + "stops": [ + [5, 0.4], + [6, 0.6], + [7, 1.5], + [20, 26] + ] + } + } + }, + { + "id": "bridge-motorway-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "bridge"], ["==", "class", "motorway"]], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#e9ac77", + "line-width": { + "base": 1.2, + "stops": [ + [5, 0.4], + [6, 0.6], + [7, 1.5], + [20, 22] + ] + } + } + }, + { + "id": "bridge-path-casing", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["all", ["==", "brunnel", "bridge"], ["==", "class", "path"]] + ], + "paint": { + "line-color": "#f8f4f0", + "line-width": { + "base": 1.2, + "stops": [ + [15, 1.2], + [20, 18] + ] + } + } + }, + { + "id": "bridge-path", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["all", ["==", "brunnel", "bridge"], ["==", "class", "path"]] + ], + "paint": { + "line-color": "#cba", + "line-dasharray": [1.5, 0.75], + "line-width": { + "base": 1.2, + "stops": [ + [15, 1.2], + [20, 4] + ] + } + } + }, + { + "id": "bridge-motorway-link", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["==", "class", "motorway_link"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#fc8", + "line-width": { + "base": 1.2, + "stops": [ + [12.5, 0], + [13, 1.5], + [14, 2.5], + [20, 11.5] + ] + } + } + }, + { + "id": "bridge-link", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + [ + "in", + "class", + "primary_link", + "secondary_link", + "tertiary_link", + "trunk_link" + ] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [12.5, 0], + [13, 1.5], + [14, 2.5], + [20, 11.5] + ] + } + } + }, + { + "id": "bridge-secondary-tertiary", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["in", "class", "secondary", "tertiary"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [6.5, 0], + [7, 0.5], + [20, 20] + ] + } + } + }, + { + "id": "bridge-trunk-primary", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["in", "class", "primary", "trunk"] + ], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [6.5, 0], + [7, 0.5], + [20, 18] + ] + } + } + }, + { + "id": "bridge-motorway", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "bridge"], ["==", "class", "motorway"]], + "layout": { "line-join": "round" }, + "paint": { + "line-color": "#fc8", + "line-width": { + "base": 1.2, + "stops": [ + [6.5, 0], + [7, 0.5], + [20, 18] + ] + } + } + }, + { + "id": "bridge-railway", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "bridge"], ["==", "class", "rail"]], + "paint": { + "line-color": "#bbb", + "line-width": { + "base": 1.4, + "stops": [ + [14, 0.4], + [15, 0.75], + [20, 2] + ] + } + } + }, + { + "id": "bridge-railway-hatching", + "type": "line", + "metadata": { "mapbox:group": "1444849334699.1902" }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "bridge"], ["==", "class", "rail"]], + "paint": { + "line-color": "#bbb", + "line-dasharray": [0.2, 8], + "line-width": { + "base": 1.4, + "stops": [ + [14.5, 0], + [15, 3], + [20, 8] + ] + } + } + }, + { + "id": "cablecar", + "type": "line", + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 13, + "filter": ["==", "class", "cable_car"], + "layout": { "line-cap": "round", "visibility": "visible" }, + "paint": { + "line-color": "hsl(0, 0%, 70%)", + "line-width": { + "base": 1, + "stops": [ + [11, 1], + [19, 2.5] + ] + } + } + }, + { + "id": "cablecar-dash", + "type": "line", + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 13, + "filter": ["==", "class", "cable_car"], + "layout": { "line-cap": "round", "visibility": "visible" }, + "paint": { + "line-color": "hsl(0, 0%, 70%)", + "line-dasharray": [2, 3], + "line-width": { + "base": 1, + "stops": [ + [11, 3], + [19, 5.5] + ] + } + } + }, + { + "id": "boundary-land-level-4", + "type": "line", + "source": "openmaptiles", + "source-layer": "boundary", + "filter": [ + "all", + [">=", "admin_level", 4], + ["<=", "admin_level", 8], + ["!=", "maritime", 1] + ], + "layout": { "line-join": "round", "visibility": "visible" }, + "paint": { + "line-color": "#9e9cab", + "line-dasharray": [3, 1, 1, 1], + "line-width": { + "base": 1.4, + "stops": [ + [4, 0.4], + [5, 1], + [12, 3] + ] + } + } + }, + { + "id": "boundary-land-level-2", + "type": "line", + "source": "openmaptiles", + "source-layer": "boundary", + "filter": [ + "all", + ["==", "admin_level", 2], + ["!=", "maritime", 1], + ["!=", "disputed", 1] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(248, 7%, 66%)", + "line-width": { + "base": 1, + "stops": [ + [0, 0.6], + [4, 1.4], + [5, 2], + [12, 8] + ] + } + } + }, + { + "id": "boundary-land-disputed", + "type": "line", + "source": "openmaptiles", + "source-layer": "boundary", + "filter": ["all", ["!=", "maritime", 1], ["==", "disputed", 1]], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(248, 7%, 70%)", + "line-dasharray": [1, 3], + "line-width": { + "base": 1, + "stops": [ + [0, 0.6], + [4, 1.4], + [5, 2], + [12, 8] + ] + } + } + }, + { + "id": "boundary-water", + "type": "line", + "source": "openmaptiles", + "source-layer": "boundary", + "filter": ["all", ["in", "admin_level", 2, 4], ["==", "maritime", 1]], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(154, 189, 214, 1)", + "line-opacity": { + "stops": [ + [6, 0.6], + [10, 1] + ] + }, + "line-width": { + "base": 1, + "stops": [ + [0, 0.6], + [4, 1.4], + [5, 2], + [12, 8] + ] + } + } + }, + { + "id": "waterway-name", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "waterway", + "minzoom": 13, + "filter": ["all", ["==", "$type", "LineString"], ["has", "name"]], + "layout": { + "symbol-placement": "line", + "symbol-spacing": 350, + "text-field": "{name:latin} {name:nonlatin}", + "text-font": ["Noto Sans Italic"], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": 14, + "visibility": "visible" + }, + "paint": { + "text-color": "#74aee9", + "text-halo-color": "rgba(255,255,255,0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "communes", + "type": "line", + "source": "decoupage-administratif", + "source-layer": "communes", + "minzoom": 10, + "maxzoom": 24, + "layout": { "visibility": "visible" } + }, + { + "id": "departements", + "type": "line", + "source": "decoupage-administratif", + "source-layer": "departements", + "layout": { "visibility": "visible" } + }, + { + "id": "regions", + "type": "line", + "source": "decoupage-administratif", + "source-layer": "regions", + "layout": { "visibility": "visible" } + }, + { + "id": "water-name-lakeline", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "water_name", + "filter": ["==", "$type", "LineString"], + "layout": { + "symbol-placement": "line", + "symbol-spacing": 350, + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Italic"], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": 14 + }, + "paint": { + "text-color": "#74aee9", + "text-halo-color": "rgba(255,255,255,0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "water-name-ocean", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "water_name", + "filter": ["all", ["==", "$type", "Point"], ["==", "class", "ocean"]], + "layout": { + "symbol-placement": "point", + "symbol-spacing": 350, + "text-field": "{name:latin}", + "text-font": ["Noto Sans Italic"], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": 14 + }, + "paint": { + "text-color": "#74aee9", + "text-halo-color": "rgba(255,255,255,0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "water-name-other", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "water_name", + "filter": ["all", ["==", "$type", "Point"], ["!in", "class", "ocean"]], + "layout": { + "symbol-placement": "point", + "symbol-spacing": 350, + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Italic"], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": { + "stops": [ + [0, 10], + [6, 14] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#74aee9", + "text-halo-color": "rgba(255,255,255,0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "road_oneway", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 15, + "filter": [ + "all", + ["==", "oneway", 1], + [ + "in", + "class", + "motorway", + "trunk", + "primary", + "secondary", + "tertiary", + "minor", + "service" + ] + ], + "layout": { + "icon-image": "oneway", + "icon-padding": 2, + "icon-rotate": 90, + "icon-rotation-alignment": "map", + "icon-size": { + "stops": [ + [15, 0.5], + [19, 1] + ] + }, + "symbol-placement": "line", + "symbol-spacing": 75, + "visibility": "visible" + }, + "paint": { "icon-opacity": 0.5 } + }, + { + "id": "road_oneway_opposite", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 15, + "filter": [ + "all", + ["==", "oneway", -1], + [ + "in", + "class", + "motorway", + "trunk", + "primary", + "secondary", + "tertiary", + "minor", + "service" + ] + ], + "layout": { + "icon-image": "oneway", + "icon-padding": 2, + "icon-rotate": -90, + "icon-rotation-alignment": "map", + "icon-size": { + "stops": [ + [15, 0.5], + [19, 1] + ] + }, + "symbol-placement": "line", + "symbol-spacing": 75, + "visibility": "visible" + }, + "paint": { "icon-opacity": 0.5 } + }, + { + "id": "highway-name-path", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation_name", + "minzoom": 15.5, + "filter": ["==", "class", "path"], + "layout": { + "symbol-placement": "line", + "text-field": "{name:latin} {name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "map", + "text-size": { + "base": 1, + "stops": [ + [13, 12], + [14, 13] + ] + } + }, + "paint": { + "text-color": "hsl(30, 23%, 62%)", + "text-halo-color": "#f8f4f0", + "text-halo-width": 0.5 + } + }, + { + "id": "highway-name-minor", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation_name", + "minzoom": 15, + "filter": [ + "all", + ["==", "$type", "LineString"], + ["in", "class", "minor", "service", "track"] + ], + "layout": { + "symbol-placement": "line", + "text-field": "{name:latin} {name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "map", + "text-size": { + "base": 1, + "stops": [ + [13, 12], + [14, 13] + ] + } + }, + "paint": { + "text-color": "#765", + "text-halo-blur": 0.5, + "text-halo-width": 1 + } + }, + { + "id": "highway-name-major", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation_name", + "minzoom": 12.2, + "filter": ["in", "class", "primary", "secondary", "tertiary", "trunk"], + "layout": { + "symbol-placement": "line", + "text-field": "{name:latin} {name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "map", + "text-size": { + "base": 1, + "stops": [ + [13, 12], + [14, 13] + ] + } + }, + "paint": { + "text-color": "#765", + "text-halo-blur": 0.5, + "text-halo-width": 1 + } + }, + { + "id": "highway-shield", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation_name", + "minzoom": 8, + "filter": [ + "all", + ["<=", "ref_length", 6], + ["==", "$type", "LineString"], + ["!in", "network", "us-interstate", "us-highway", "us-state"] + ], + "layout": { + "icon-image": "road_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-placement": { + "base": 1, + "stops": [ + [10, "point"], + [11, "line"] + ] + }, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "viewport", + "text-size": 10 + }, + "paint": {} + }, + { + "id": "highway-shield-us-interstate", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation_name", + "minzoom": 7, + "filter": [ + "all", + ["<=", "ref_length", 6], + ["==", "$type", "LineString"], + ["in", "network", "us-interstate"] + ], + "layout": { + "icon-image": "{network}_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-placement": { + "base": 1, + "stops": [ + [7, "point"], + [7, "line"], + [8, "line"] + ] + }, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "viewport", + "text-size": 10 + }, + "paint": { "text-color": "rgba(0, 0, 0, 1)" } + }, + { + "id": "highway-shield-us-other", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation_name", + "minzoom": 9, + "filter": [ + "all", + ["<=", "ref_length", 6], + ["==", "$type", "LineString"], + ["in", "network", "us-highway", "us-state"] + ], + "layout": { + "icon-image": "{network}_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-placement": { + "base": 1, + "stops": [ + [10, "point"], + [11, "line"] + ] + }, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "viewport", + "text-size": 10 + }, + "paint": { "text-color": "rgba(0, 0, 0, 1)" } + }, + { + "id": "airport-label-major", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "aerodrome_label", + "minzoom": 10, + "filter": ["all", ["has", "iata"]], + "layout": { + "icon-image": "airport_11", + "icon-size": 1, + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 9, + "text-offset": [0, 0.6], + "text-optional": true, + "text-padding": 2, + "text-size": 12, + "visibility": "visible" + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "poi-level-3", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "poi", + "minzoom": 16, + "filter": [ + "all", + ["==", "$type", "Point"], + [">=", "rank", 25], + ["any", ["!has", "level"], ["==", "level", 0]] + ], + "layout": { + "icon-image": "{class}_11", + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 9, + "text-offset": [0, 0.6], + "text-padding": 2, + "text-size": 12, + "visibility": "visible" + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "poi-level-2", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "poi", + "minzoom": 15, + "filter": [ + "all", + ["==", "$type", "Point"], + ["<=", "rank", 24], + [">=", "rank", 15], + ["any", ["!has", "level"], ["==", "level", 0]] + ], + "layout": { + "icon-image": "{class}_11", + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 9, + "text-offset": [0, 0.6], + "text-padding": 2, + "text-size": 12, + "visibility": "visible" + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "poi-level-1", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "poi", + "minzoom": 14, + "filter": [ + "all", + ["==", "$type", "Point"], + ["<=", "rank", 14], + ["has", "name"], + ["any", ["!has", "level"], ["==", "level", 0]] + ], + "layout": { + "icon-image": "{class}_11", + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 9, + "text-offset": [0, 0.6], + "text-padding": 2, + "text-size": 12, + "visibility": "visible" + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "poi-railway", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "poi", + "minzoom": 13, + "filter": [ + "all", + ["==", "$type", "Point"], + ["has", "name"], + ["==", "class", "railway"], + ["==", "subclass", "station"] + ], + "layout": { + "icon-allow-overlap": false, + "icon-ignore-placement": false, + "icon-image": "{class}_11", + "icon-optional": false, + "text-allow-overlap": false, + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-ignore-placement": false, + "text-max-width": 9, + "text-offset": [0, 0.6], + "text-optional": true, + "text-padding": 2, + "text-size": 12 + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "place-other", + "type": "symbol", + "metadata": { "mapbox:group": "1444849242106.713" }, + "source": "openmaptiles", + "source-layer": "place", + "filter": [ + "!in", + "class", + "city", + "town", + "village", + "country", + "continent" + ], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Bold"], + "text-letter-spacing": 0.1, + "text-max-width": 9, + "text-size": { + "base": 1.2, + "stops": [ + [12, 10], + [15, 14] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#633", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-village", + "type": "symbol", + "metadata": { "mapbox:group": "1444849242106.713" }, + "source": "openmaptiles", + "source-layer": "place", + "filter": ["==", "class", "village"], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 8, + "text-size": { + "base": 1.2, + "stops": [ + [10, 12], + [15, 22] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#333", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-town", + "type": "symbol", + "metadata": { "mapbox:group": "1444849242106.713" }, + "source": "openmaptiles", + "source-layer": "place", + "filter": ["==", "class", "town"], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 8, + "text-size": { + "base": 1.2, + "stops": [ + [10, 14], + [15, 24] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#333", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-city", + "type": "symbol", + "metadata": { "mapbox:group": "1444849242106.713" }, + "source": "openmaptiles", + "source-layer": "place", + "filter": ["all", ["!=", "capital", 2], ["==", "class", "city"]], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 8, + "text-size": { + "base": 1.2, + "stops": [ + [7, 14], + [11, 24] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#333", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-city-capital", + "type": "symbol", + "metadata": { "mapbox:group": "1444849242106.713" }, + "source": "openmaptiles", + "source-layer": "place", + "filter": ["all", ["==", "capital", 2], ["==", "class", "city"]], + "layout": { + "icon-image": "star_11", + "icon-size": 0.8, + "text-anchor": "left", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 8, + "text-offset": [0.4, 0], + "text-size": { + "base": 1.2, + "stops": [ + [7, 14], + [11, 24] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#333", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-country-other", + "type": "symbol", + "metadata": { "mapbox:group": "1444849242106.713" }, + "source": "openmaptiles", + "source-layer": "place", + "filter": [ + "all", + ["==", "class", "country"], + [">=", "rank", 3], + ["!has", "iso_a2"] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": ["Noto Sans Italic"], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [3, 11], + [7, 17] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-3", + "type": "symbol", + "metadata": { "mapbox:group": "1444849242106.713" }, + "source": "openmaptiles", + "source-layer": "place", + "filter": [ + "all", + ["==", "class", "country"], + [">=", "rank", 3], + ["has", "iso_a2"] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": ["Noto Sans Bold"], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [3, 11], + [7, 17] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-2", + "type": "symbol", + "metadata": { "mapbox:group": "1444849242106.713" }, + "source": "openmaptiles", + "source-layer": "place", + "filter": [ + "all", + ["==", "class", "country"], + ["==", "rank", 2], + ["has", "iso_a2"] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": ["Noto Sans Bold"], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [2, 11], + [5, 17] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-1", + "type": "symbol", + "metadata": { "mapbox:group": "1444849242106.713" }, + "source": "openmaptiles", + "source-layer": "place", + "filter": [ + "all", + ["==", "class", "country"], + ["==", "rank", 1], + ["has", "iso_a2"] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": ["Noto Sans Bold"], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [1, 11], + [4, 17] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-continent", + "type": "symbol", + "metadata": { "mapbox:group": "1444849242106.713" }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 1, + "filter": ["==", "class", "continent"], + "layout": { + "text-field": "{name:latin}", + "text-font": ["Noto Sans Bold"], + "text-max-width": 6.25, + "text-size": 14, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + } +] diff --git a/app/javascript/components/shared/maplibre/styles/layers/vector.ts b/app/javascript/components/shared/maplibre/styles/layers/vector.ts deleted file mode 100644 index b96800c2c..000000000 --- a/app/javascript/components/shared/maplibre/styles/layers/vector.ts +++ /dev/null @@ -1,2844 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import type { AnyLayer } from 'maplibre-gl'; - -const layers: AnyLayer[] = [ - { - id: 'background', - type: 'background', - minzoom: 0, - maxzoom: 24, - layout: { visibility: 'visible' }, - paint: { 'background-color': 'rgba(255, 246, 241, 1)' } - }, - { - id: 'landcover-glacier', - type: 'fill', - metadata: { 'mapbox:group': '1444849388993.3071' }, - source: 'openmaptiles', - 'source-layer': 'landcover', - filter: ['==', 'subclass', 'glacier'], - layout: { visibility: 'visible' }, - paint: { - 'fill-color': '#fff', - 'fill-opacity': { - base: 1, - stops: [ - [0, 0.9], - [10, 0.3] - ] - } - } - }, - { - id: 'landuse-residential', - type: 'fill', - metadata: { 'mapbox:group': '1444849388993.3071' }, - source: 'openmaptiles', - 'source-layer': 'landuse', - filter: ['all', ['in', 'class', 'residential', 'suburb', 'neighbourhood']], - layout: { visibility: 'visible' }, - paint: { - 'fill-color': { - base: 1, - stops: [ - [12, 'hsla(30, 19%, 90%, 0.4)'], - [16, 'hsla(30, 19%, 90%, 0.2)'] - ] - } - } - }, - { - id: 'landuse-commercial', - type: 'fill', - metadata: { 'mapbox:group': '1444849388993.3071' }, - source: 'openmaptiles', - 'source-layer': 'landuse', - filter: ['all', ['==', '$type', 'Polygon'], ['==', 'class', 'commercial']], - layout: { visibility: 'visible' }, - paint: { 'fill-color': 'hsla(0, 60%, 87%, 0.23)' } - }, - { - id: 'landuse-industrial', - type: 'fill', - metadata: { 'mapbox:group': '1444849388993.3071' }, - source: 'openmaptiles', - 'source-layer': 'landuse', - filter: ['all', ['==', '$type', 'Polygon'], ['==', 'class', 'industrial']], - paint: { 'fill-color': 'hsla(49, 100%, 88%, 0.34)' } - }, - { - id: 'landuse-cemetery', - type: 'fill', - metadata: { 'mapbox:group': '1444849388993.3071' }, - source: 'openmaptiles', - 'source-layer': 'landuse', - filter: ['==', 'class', 'cemetery'], - paint: { 'fill-color': '#e0e4dd' } - }, - { - id: 'landuse-hospital', - type: 'fill', - metadata: { 'mapbox:group': '1444849388993.3071' }, - source: 'openmaptiles', - 'source-layer': 'landuse', - filter: ['==', 'class', 'hospital'], - paint: { 'fill-color': '#fde' } - }, - { - id: 'landuse-school', - type: 'fill', - metadata: { 'mapbox:group': '1444849388993.3071' }, - source: 'openmaptiles', - 'source-layer': 'landuse', - filter: ['==', 'class', 'school'], - paint: { 'fill-color': '#f0e8f8' } - }, - { - id: 'landuse-railway', - type: 'fill', - metadata: { 'mapbox:group': '1444849388993.3071' }, - source: 'openmaptiles', - 'source-layer': 'landuse', - filter: ['==', 'class', 'railway'], - paint: { 'fill-color': 'hsla(30, 19%, 90%, 0.4)' } - }, - { - id: 'landcover-wood', - type: 'fill', - metadata: { 'mapbox:group': '1444849388993.3071' }, - source: 'openmaptiles', - 'source-layer': 'landcover', - filter: ['==', 'class', 'wood'], - paint: { - 'fill-antialias': { - base: 1, - stops: [ - [0, false], - [9, true] - ] - } as any, - 'fill-color': '#6a4', - 'fill-opacity': 0.1, - 'fill-outline-color': 'hsla(0, 0%, 0%, 0.03)' - } - }, - { - id: 'landcover-grass', - type: 'fill', - metadata: { 'mapbox:group': '1444849388993.3071' }, - source: 'openmaptiles', - 'source-layer': 'landcover', - filter: ['==', 'class', 'grass'], - paint: { 'fill-color': '#d8e8c8', 'fill-opacity': 1 } - }, - { - id: 'landcover-grass-park', - type: 'fill', - metadata: { 'mapbox:group': '1444849388993.3071' }, - source: 'openmaptiles', - 'source-layer': 'park', - filter: ['==', 'class', 'public_park'], - paint: { 'fill-color': '#d8e8c8', 'fill-opacity': 0.8 } - }, - { - id: 'waterway_tunnel', - type: 'line', - source: 'openmaptiles', - 'source-layer': 'waterway', - minzoom: 14, - filter: [ - 'all', - ['in', 'class', 'river', 'stream', 'canal'], - ['==', 'brunnel', 'tunnel'] - ], - layout: { 'line-cap': 'round', visibility: 'visible' }, - paint: { - 'line-color': '#a0c8f0', - 'line-dasharray': [2, 4], - 'line-width': { - base: 1.3, - stops: [ - [13, 0.5], - [20, 6] - ] - } - } - }, - { - id: 'waterway-other', - type: 'line', - metadata: { 'mapbox:group': '1444849382550.77' }, - source: 'openmaptiles', - 'source-layer': 'waterway', - filter: [ - 'all', - ['!in', 'class', 'canal', 'river', 'stream'], - ['==', 'intermittent', 0] - ], - layout: { 'line-cap': 'round', visibility: 'visible' }, - paint: { - 'line-color': '#a0c8f0', - 'line-width': { - base: 1.3, - stops: [ - [13, 0.5], - [20, 2] - ] - } - } - }, - { - id: 'waterway-other-intermittent', - type: 'line', - metadata: { 'mapbox:group': '1444849382550.77' }, - source: 'openmaptiles', - 'source-layer': 'waterway', - filter: [ - 'all', - ['!in', 'class', 'canal', 'river', 'stream'], - ['==', 'intermittent', 1] - ], - layout: { 'line-cap': 'round', visibility: 'visible' }, - paint: { - 'line-color': '#a0c8f0', - 'line-width': { - base: 1.3, - stops: [ - [13, 0.5], - [20, 2] - ] - }, - 'line-dasharray': [4, 3] - } - }, - { - id: 'waterway-stream-canal', - type: 'line', - metadata: { 'mapbox:group': '1444849382550.77' }, - source: 'openmaptiles', - 'source-layer': 'waterway', - filter: [ - 'all', - ['in', 'class', 'canal', 'stream'], - ['!=', 'brunnel', 'tunnel'], - ['==', 'intermittent', 0] - ], - layout: { 'line-cap': 'round', visibility: 'visible' }, - paint: { - 'line-color': '#a0c8f0', - 'line-width': { - base: 1.3, - stops: [ - [13, 0.5], - [20, 6] - ] - } - } - }, - { - id: 'waterway-stream-canal-intermittent', - type: 'line', - metadata: { 'mapbox:group': '1444849382550.77' }, - source: 'openmaptiles', - 'source-layer': 'waterway', - filter: [ - 'all', - ['in', 'class', 'canal', 'stream'], - ['!=', 'brunnel', 'tunnel'], - ['==', 'intermittent', 1] - ], - layout: { 'line-cap': 'round', visibility: 'visible' }, - paint: { - 'line-color': '#a0c8f0', - 'line-width': { - base: 1.3, - stops: [ - [13, 0.5], - [20, 6] - ] - }, - 'line-dasharray': [4, 3] - } - }, - { - id: 'waterway-river', - type: 'line', - metadata: { 'mapbox:group': '1444849382550.77' }, - source: 'openmaptiles', - 'source-layer': 'waterway', - filter: [ - 'all', - ['==', 'class', 'river'], - ['!=', 'brunnel', 'tunnel'], - ['==', 'intermittent', 0] - ], - layout: { 'line-cap': 'round', visibility: 'visible' }, - paint: { - 'line-color': '#a0c8f0', - 'line-width': { - base: 1.2, - stops: [ - [10, 0.8], - [20, 6] - ] - } - } - }, - { - id: 'waterway-river-intermittent', - type: 'line', - metadata: { 'mapbox:group': '1444849382550.77' }, - source: 'openmaptiles', - 'source-layer': 'waterway', - filter: [ - 'all', - ['==', 'class', 'river'], - ['!=', 'brunnel', 'tunnel'], - ['==', 'intermittent', 1] - ], - layout: { 'line-cap': 'round', visibility: 'visible' }, - paint: { - 'line-color': '#a0c8f0', - 'line-width': { - base: 1.2, - stops: [ - [10, 0.8], - [20, 6] - ] - }, - 'line-dasharray': [3, 2.5] - } - }, - { - id: 'water-offset', - type: 'fill', - metadata: { 'mapbox:group': '1444849382550.77' }, - source: 'openmaptiles', - 'source-layer': 'water', - maxzoom: 8, - filter: ['==', '$type', 'Polygon'], - layout: { visibility: 'visible' }, - paint: { - 'fill-color': '#a0c8f0', - 'fill-opacity': 1, - 'fill-translate': { - base: 1, - stops: [ - [6, [2, 0]], - [8, [0, 0]] - ] - } as any - } - }, - { - id: 'water', - type: 'fill', - metadata: { 'mapbox:group': '1444849382550.77' }, - source: 'openmaptiles', - 'source-layer': 'water', - filter: ['all', ['!=', 'intermittent', 1]], - layout: { visibility: 'visible' }, - paint: { 'fill-color': 'hsl(210, 67%, 85%)' } - }, - { - id: 'water-intermittent', - type: 'fill', - metadata: { 'mapbox:group': '1444849382550.77' }, - source: 'openmaptiles', - 'source-layer': 'water', - filter: ['all', ['==', 'intermittent', 1]], - layout: { visibility: 'visible' }, - paint: { 'fill-color': 'hsl(210, 67%, 85%)', 'fill-opacity': 0.7 } - }, - { - id: 'water-pattern', - type: 'fill', - metadata: { 'mapbox:group': '1444849382550.77' }, - source: 'openmaptiles', - 'source-layer': 'water', - filter: ['all'], - layout: { visibility: 'visible' }, - paint: { 'fill-pattern': 'wave', 'fill-translate': [0, 2.5] } - }, - { - id: 'landcover-ice-shelf', - type: 'fill', - metadata: { 'mapbox:group': '1444849382550.77' }, - source: 'openmaptiles', - 'source-layer': 'landcover', - filter: ['==', 'subclass', 'ice_shelf'], - layout: { visibility: 'visible' }, - paint: { - 'fill-color': '#fff', - 'fill-opacity': { - base: 1, - stops: [ - [0, 0.9], - [10, 0.3] - ] - } - } - }, - { - id: 'landcover-sand', - type: 'fill', - metadata: { 'mapbox:group': '1444849382550.77' }, - source: 'openmaptiles', - 'source-layer': 'landcover', - filter: ['all', ['==', 'class', 'sand']], - layout: { visibility: 'visible' }, - paint: { 'fill-color': 'rgba(245, 238, 188, 1)', 'fill-opacity': 1 } - }, - { - id: 'building', - type: 'fill', - metadata: { 'mapbox:group': '1444849364238.8171' }, - source: 'openmaptiles', - 'source-layer': 'building', - layout: { visibility: 'none' }, - paint: { - 'fill-antialias': true, - 'fill-color': { - base: 1, - stops: [ - [15.5, '#f2eae2'], - [16, '#dfdbd7'] - ] - } - } - }, - { - id: 'building-top', - type: 'fill', - metadata: { 'mapbox:group': '1444849364238.8171' }, - source: 'openmaptiles', - 'source-layer': 'building', - layout: { visibility: 'none' }, - paint: { - 'fill-color': '#f2eae2', - 'fill-opacity': { - base: 1, - stops: [ - [13, 0], - [16, 1] - ] - }, - 'fill-outline-color': '#dfdbd7', - 'fill-translate': { - base: 1, - stops: [ - [14, [0, 0]], - [16, [-2, -2]] - ] - } as any - } - }, - { - id: 'tunnel-service-track-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'tunnel'], - ['in', 'class', 'service', 'track'] - ], - layout: { 'line-join': 'round', visibility: 'visible' }, - paint: { - 'line-color': '#cfcdca', - 'line-dasharray': [0.5, 0.25], - 'line-width': { - base: 1.2, - stops: [ - [15, 1], - [16, 4], - [20, 11] - ] - } - } - }, - { - id: 'tunnel-minor-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['==', 'brunnel', 'tunnel'], ['==', 'class', 'minor']], - layout: { 'line-join': 'round', visibility: 'visible' }, - paint: { - 'line-color': '#cfcdca', - 'line-opacity': { - stops: [ - [12, 0], - [12.5, 1] - ] - }, - 'line-width': { - base: 1.2, - stops: [ - [12, 0.5], - [13, 1], - [14, 4], - [20, 15] - ] - } - } - }, - { - id: 'tunnel-secondary-tertiary-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'tunnel'], - ['in', 'class', 'secondary', 'tertiary'] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#e9ac77', - 'line-opacity': 1, - 'line-width': { - base: 1.2, - stops: [ - [8, 1.5], - [20, 17] - ] - } - } - }, - { - id: 'tunnel-trunk-primary-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'tunnel'], - ['in', 'class', 'primary', 'trunk'] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#e9ac77', - 'line-width': { - base: 1.2, - stops: [ - [5, 0.4], - [6, 0.6], - [7, 1.5], - [20, 22] - ] - } - } - }, - { - id: 'tunnel-motorway-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['==', 'brunnel', 'tunnel'], ['==', 'class', 'motorway']], - layout: { 'line-join': 'round', visibility: 'visible' }, - paint: { - 'line-color': '#e9ac77', - 'line-dasharray': [0.5, 0.25], - 'line-width': { - base: 1.2, - stops: [ - [5, 0.4], - [6, 0.6], - [7, 1.5], - [20, 22] - ] - } - } - }, - { - id: 'tunnel-path', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - ['all', ['==', 'brunnel', 'tunnel'], ['==', 'class', 'path']] - ], - layout: { visibility: 'visible' }, - paint: { - 'line-color': '#cba', - 'line-dasharray': [1.5, 0.75], - 'line-width': { - base: 1.2, - stops: [ - [15, 1.2], - [20, 4] - ] - } - } - }, - { - id: 'tunnel-service-track', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'tunnel'], - ['in', 'class', 'service', 'track'] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#fff', - 'line-width': { - base: 1.2, - stops: [ - [15.5, 0], - [16, 2], - [20, 7.5] - ] - } - } - }, - { - id: 'tunnel-minor', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['==', 'brunnel', 'tunnel'], ['==', 'class', 'minor_road']], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#fff', - 'line-opacity': 1, - 'line-width': { - base: 1.2, - stops: [ - [13.5, 0], - [14, 2.5], - [20, 11.5] - ] - } - } - }, - { - id: 'tunnel-secondary-tertiary', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'tunnel'], - ['in', 'class', 'secondary', 'tertiary'] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#fff4c6', - 'line-width': { - base: 1.2, - stops: [ - [6.5, 0], - [7, 0.5], - [20, 10] - ] - } - } - }, - { - id: 'tunnel-trunk-primary', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'tunnel'], - ['in', 'class', 'primary', 'trunk'] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#fff4c6', - 'line-width': { - base: 1.2, - stops: [ - [6.5, 0], - [7, 0.5], - [20, 18] - ] - } - } - }, - { - id: 'tunnel-motorway', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['==', 'brunnel', 'tunnel'], ['==', 'class', 'motorway']], - layout: { 'line-join': 'round', visibility: 'visible' }, - paint: { - 'line-color': '#ffdaa6', - 'line-width': { - base: 1.2, - stops: [ - [6.5, 0], - [7, 0.5], - [20, 18] - ] - } - } - }, - { - id: 'tunnel-railway', - type: 'line', - metadata: { 'mapbox:group': '1444849354174.1904' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['==', 'brunnel', 'tunnel'], ['==', 'class', 'rail']], - paint: { - 'line-color': '#bbb', - 'line-dasharray': [2, 2], - 'line-width': { - base: 1.4, - stops: [ - [14, 0.4], - [15, 0.75], - [20, 2] - ] - } - } - }, - { - id: 'ferry', - type: 'line', - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['in', 'class', 'ferry']], - layout: { 'line-join': 'round', visibility: 'none' }, - paint: { - 'line-color': 'rgba(108, 159, 182, 1)', - 'line-dasharray': [2, 2], - 'line-width': 1.1 - } - }, - { - id: 'aeroway-taxiway-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'aeroway', - minzoom: 12, - filter: ['all', ['in', 'class', 'taxiway']], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': 'rgba(153, 153, 153, 1)', - 'line-opacity': 1, - 'line-width': { - base: 1.5, - stops: [ - [11, 2], - [17, 12] - ] - } - } - }, - { - id: 'aeroway-runway-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'aeroway', - minzoom: 12, - filter: ['all', ['in', 'class', 'runway']], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': 'rgba(153, 153, 153, 1)', - 'line-opacity': 1, - 'line-width': { - base: 1.5, - stops: [ - [11, 5], - [17, 55] - ] - } - } - }, - { - id: 'aeroway-area', - type: 'fill', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'aeroway', - minzoom: 4, - filter: [ - 'all', - ['==', '$type', 'Polygon'], - ['in', 'class', 'runway', 'taxiway'] - ], - layout: { visibility: 'visible' }, - paint: { - 'fill-color': 'rgba(255, 255, 255, 1)', - 'fill-opacity': { - base: 1, - stops: [ - [13, 0], - [14, 1] - ] - } - } - }, - { - id: 'aeroway-taxiway', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'aeroway', - minzoom: 4, - filter: ['all', ['in', 'class', 'taxiway'], ['==', '$type', 'LineString']], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': 'rgba(255, 255, 255, 1)', - 'line-opacity': { - base: 1, - stops: [ - [11, 0], - [12, 1] - ] - }, - 'line-width': { - base: 1.5, - stops: [ - [11, 1], - [17, 10] - ] - } - } - }, - { - id: 'aeroway-runway', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'aeroway', - minzoom: 4, - filter: ['all', ['in', 'class', 'runway'], ['==', '$type', 'LineString']], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': 'rgba(255, 255, 255, 1)', - 'line-opacity': { - base: 1, - stops: [ - [11, 0], - [12, 1] - ] - }, - 'line-width': { - base: 1.5, - stops: [ - [11, 4], - [17, 50] - ] - } - } - }, - { - id: 'road_area_pier', - type: 'fill', - metadata: {}, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['==', '$type', 'Polygon'], ['==', 'class', 'pier']], - layout: { visibility: 'none' }, - paint: { 'fill-antialias': true, 'fill-color': '#f8f4f0' } - }, - { - id: 'road_pier', - type: 'line', - metadata: {}, - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 0, - filter: ['all', ['==', '$type', 'LineString'], ['in', 'class', 'pier']], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'none' - }, - paint: { - 'line-color': '#f8f4f0', - 'line-width': { - base: 1.2, - stops: [ - [15, 1], - [17, 4] - ] - } - } - }, - { - id: 'highway-area', - type: 'fill', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['==', '$type', 'Polygon'], ['!in', 'class', 'pier']], - layout: { visibility: 'visible' }, - paint: { - 'fill-antialias': false, - 'fill-color': 'hsla(0, 0%, 89%, 0.56)', - 'fill-opacity': 0.9, - 'fill-outline-color': '#cfcdca' - } - }, - { - id: 'highway-motorway-link-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 12, - filter: [ - 'all', - ['!in', 'brunnel', 'bridge', 'tunnel'], - ['==', 'class', 'motorway_link'] - ], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': '#e9ac77', - 'line-opacity': 1, - 'line-width': { - base: 1.2, - stops: [ - [12, 1], - [13, 3], - [14, 4], - [20, 15] - ] - } - } - }, - { - id: 'highway-link-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 13, - filter: [ - 'all', - ['!in', 'brunnel', 'bridge', 'tunnel'], - [ - 'in', - 'class', - 'primary_link', - 'secondary_link', - 'tertiary_link', - 'trunk_link' - ] - ], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': '#e9ac77', - 'line-opacity': 1, - 'line-width': { - base: 1.2, - stops: [ - [12, 1], - [13, 3], - [14, 4], - [20, 15] - ] - } - } - }, - { - id: 'highway-minor-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - [ - 'all', - ['!=', 'brunnel', 'tunnel'], - ['in', 'class', 'minor', 'service', 'track'] - ] - ], - layout: { 'line-cap': 'round', 'line-join': 'round' }, - paint: { - 'line-color': '#cfcdca', - 'line-opacity': { - stops: [ - [12, 0], - [12.5, 1] - ] - }, - 'line-width': { - base: 1.2, - stops: [ - [12, 0.5], - [13, 1], - [14, 4], - [20, 15] - ] - } - } - }, - { - id: 'highway-secondary-tertiary-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['!in', 'brunnel', 'bridge', 'tunnel'], - ['in', 'class', 'secondary', 'tertiary'] - ], - layout: { - 'line-cap': 'butt', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': '#e9ac77', - 'line-opacity': 1, - 'line-width': { - base: 1.2, - stops: [ - [8, 1.5], - [20, 17] - ] - } - } - }, - { - id: 'highway-primary-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 5, - filter: [ - 'all', - ['!in', 'brunnel', 'bridge', 'tunnel'], - ['in', 'class', 'primary'] - ], - layout: { - 'line-cap': 'butt', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': '#e9ac77', - 'line-opacity': { - stops: [ - [7, 0], - [8, 1] - ] - }, - 'line-width': { - base: 1.2, - stops: [ - [7, 0], - [8, 0.6], - [9, 1.5], - [20, 22] - ] - } - } - }, - { - id: 'highway-trunk-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 5, - filter: [ - 'all', - ['!in', 'brunnel', 'bridge', 'tunnel'], - ['in', 'class', 'trunk'] - ], - layout: { - 'line-cap': 'butt', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': '#e9ac77', - 'line-opacity': { - stops: [ - [5, 0], - [6, 1] - ] - }, - 'line-width': { - base: 1.2, - stops: [ - [5, 0], - [6, 0.6], - [7, 1.5], - [20, 22] - ] - } - } - }, - { - id: 'highway-motorway-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 4, - filter: [ - 'all', - ['!in', 'brunnel', 'bridge', 'tunnel'], - ['==', 'class', 'motorway'] - ], - layout: { - 'line-cap': 'butt', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': '#e9ac77', - 'line-opacity': { - stops: [ - [4, 0], - [5, 1] - ] - }, - 'line-width': { - base: 1.2, - stops: [ - [4, 0], - [5, 0.4], - [6, 0.6], - [7, 1.5], - [20, 22] - ] - } - } - }, - { - id: 'highway-path', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - ['all', ['!in', 'brunnel', 'bridge', 'tunnel'], ['==', 'class', 'path']] - ], - paint: { - 'line-color': '#cba', - 'line-dasharray': [1.5, 0.75], - 'line-width': { - base: 1.2, - stops: [ - [15, 1.2], - [20, 4] - ] - } - } - }, - { - id: 'highway-motorway-link', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 12, - filter: [ - 'all', - ['!in', 'brunnel', 'bridge', 'tunnel'], - ['==', 'class', 'motorway_link'] - ], - layout: { 'line-cap': 'round', 'line-join': 'round' }, - paint: { - 'line-color': '#fc8', - 'line-width': { - base: 1.2, - stops: [ - [12.5, 0], - [13, 1.5], - [14, 2.5], - [20, 11.5] - ] - } - } - }, - { - id: 'highway-link', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 13, - filter: [ - 'all', - ['!in', 'brunnel', 'bridge', 'tunnel'], - [ - 'in', - 'class', - 'primary_link', - 'secondary_link', - 'tertiary_link', - 'trunk_link' - ] - ], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': '#fea', - 'line-width': { - base: 1.2, - stops: [ - [12.5, 0], - [13, 1.5], - [14, 2.5], - [20, 11.5] - ] - } - } - }, - { - id: 'highway-minor', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - [ - 'all', - ['!=', 'brunnel', 'tunnel'], - ['in', 'class', 'minor', 'service', 'track'] - ] - ], - layout: { 'line-cap': 'round', 'line-join': 'round' }, - paint: { - 'line-color': '#fff', - 'line-opacity': 1, - 'line-width': { - base: 1.2, - stops: [ - [13.5, 0], - [14, 2.5], - [20, 11.5] - ] - } - } - }, - { - id: 'highway-secondary-tertiary', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['!in', 'brunnel', 'bridge', 'tunnel'], - ['in', 'class', 'secondary', 'tertiary'] - ], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': '#fea', - 'line-width': { - base: 1.2, - stops: [ - [6.5, 0], - [8, 0.5], - [20, 13] - ] - } - } - }, - { - id: 'highway-primary', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - [ - 'all', - ['!in', 'brunnel', 'bridge', 'tunnel'], - ['in', 'class', 'primary'] - ] - ], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': '#fea', - 'line-width': { - base: 1.2, - stops: [ - [8.5, 0], - [9, 0.5], - [20, 18] - ] - } - } - }, - { - id: 'highway-trunk', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - ['all', ['!in', 'brunnel', 'bridge', 'tunnel'], ['in', 'class', 'trunk']] - ], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': '#fea', - 'line-width': { - base: 1.2, - stops: [ - [6.5, 0], - [7, 0.5], - [20, 18] - ] - } - } - }, - { - id: 'highway-motorway', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 5, - filter: [ - 'all', - ['==', '$type', 'LineString'], - [ - 'all', - ['!in', 'brunnel', 'bridge', 'tunnel'], - ['==', 'class', 'motorway'] - ] - ], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': '#fc8', - 'line-width': { - base: 1.2, - stops: [ - [6.5, 0], - [7, 0.5], - [20, 18] - ] - } - } - }, - { - id: 'railway-transit', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - ['all', ['==', 'class', 'transit'], ['!in', 'brunnel', 'tunnel']] - ], - layout: { visibility: 'visible' }, - paint: { - 'line-color': 'hsla(0, 0%, 73%, 0.77)', - 'line-width': { - base: 1.4, - stops: [ - [14, 0.4], - [20, 1] - ] - } - } - }, - { - id: 'railway-transit-hatching', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - ['all', ['==', 'class', 'transit'], ['!in', 'brunnel', 'tunnel']] - ], - layout: { visibility: 'visible' }, - paint: { - 'line-color': 'hsla(0, 0%, 73%, 0.68)', - 'line-dasharray': [0.2, 8], - 'line-width': { - base: 1.4, - stops: [ - [14.5, 0], - [15, 2], - [20, 6] - ] - } - } - }, - { - id: 'railway-service', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - ['all', ['==', 'class', 'rail'], ['has', 'service']] - ], - paint: { - 'line-color': 'hsla(0, 0%, 73%, 0.77)', - 'line-width': { - base: 1.4, - stops: [ - [14, 0.4], - [20, 1] - ] - } - } - }, - { - id: 'railway-service-hatching', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - ['all', ['==', 'class', 'rail'], ['has', 'service']] - ], - layout: { visibility: 'visible' }, - paint: { - 'line-color': 'hsla(0, 0%, 73%, 0.68)', - 'line-dasharray': [0.2, 8], - 'line-width': { - base: 1.4, - stops: [ - [14.5, 0], - [15, 2], - [20, 6] - ] - } - } - }, - { - id: 'railway', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - [ - 'all', - ['!has', 'service'], - ['!in', 'brunnel', 'bridge', 'tunnel'], - ['==', 'class', 'rail'] - ] - ], - paint: { - 'line-color': '#bbb', - 'line-width': { - base: 1.4, - stops: [ - [14, 0.4], - [15, 0.75], - [20, 2] - ] - } - } - }, - { - id: 'railway-hatching', - type: 'line', - metadata: { 'mapbox:group': '1444849345966.4436' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - [ - 'all', - ['!has', 'service'], - ['!in', 'brunnel', 'bridge', 'tunnel'], - ['==', 'class', 'rail'] - ] - ], - paint: { - 'line-color': '#bbb', - 'line-dasharray': [0.2, 8], - 'line-width': { - base: 1.4, - stops: [ - [14.5, 0], - [15, 3], - [20, 8] - ] - } - } - }, - { - id: 'bridge-motorway-link-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'bridge'], - ['==', 'class', 'motorway_link'] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#e9ac77', - 'line-opacity': 1, - 'line-width': { - base: 1.2, - stops: [ - [12, 1], - [13, 3], - [14, 4], - [20, 15] - ] - } - } - }, - { - id: 'bridge-link-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'bridge'], - [ - 'in', - 'class', - 'primary_link', - 'secondary_link', - 'tertiary_link', - 'trunk_link' - ] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#e9ac77', - 'line-opacity': 1, - 'line-width': { - base: 1.2, - stops: [ - [12, 1], - [13, 3], - [14, 4], - [20, 15] - ] - } - } - }, - { - id: 'bridge-secondary-tertiary-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'bridge'], - ['in', 'class', 'secondary', 'tertiary'] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#e9ac77', - 'line-opacity': 1, - 'line-width': { - base: 1.2, - stops: [ - [8, 1.5], - [20, 28] - ] - } - } - }, - { - id: 'bridge-trunk-primary-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'bridge'], - ['in', 'class', 'primary', 'trunk'] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': 'hsl(28, 76%, 67%)', - 'line-width': { - base: 1.2, - stops: [ - [5, 0.4], - [6, 0.6], - [7, 1.5], - [20, 26] - ] - } - } - }, - { - id: 'bridge-motorway-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['==', 'brunnel', 'bridge'], ['==', 'class', 'motorway']], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#e9ac77', - 'line-width': { - base: 1.2, - stops: [ - [5, 0.4], - [6, 0.6], - [7, 1.5], - [20, 22] - ] - } - } - }, - { - id: 'bridge-path-casing', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - ['all', ['==', 'brunnel', 'bridge'], ['==', 'class', 'path']] - ], - paint: { - 'line-color': '#f8f4f0', - 'line-width': { - base: 1.2, - stops: [ - [15, 1.2], - [20, 18] - ] - } - } - }, - { - id: 'bridge-path', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', '$type', 'LineString'], - ['all', ['==', 'brunnel', 'bridge'], ['==', 'class', 'path']] - ], - paint: { - 'line-color': '#cba', - 'line-dasharray': [1.5, 0.75], - 'line-width': { - base: 1.2, - stops: [ - [15, 1.2], - [20, 4] - ] - } - } - }, - { - id: 'bridge-motorway-link', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'bridge'], - ['==', 'class', 'motorway_link'] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#fc8', - 'line-width': { - base: 1.2, - stops: [ - [12.5, 0], - [13, 1.5], - [14, 2.5], - [20, 11.5] - ] - } - } - }, - { - id: 'bridge-link', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'bridge'], - [ - 'in', - 'class', - 'primary_link', - 'secondary_link', - 'tertiary_link', - 'trunk_link' - ] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#fea', - 'line-width': { - base: 1.2, - stops: [ - [12.5, 0], - [13, 1.5], - [14, 2.5], - [20, 11.5] - ] - } - } - }, - { - id: 'bridge-secondary-tertiary', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'bridge'], - ['in', 'class', 'secondary', 'tertiary'] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#fea', - 'line-width': { - base: 1.2, - stops: [ - [6.5, 0], - [7, 0.5], - [20, 20] - ] - } - } - }, - { - id: 'bridge-trunk-primary', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: [ - 'all', - ['==', 'brunnel', 'bridge'], - ['in', 'class', 'primary', 'trunk'] - ], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#fea', - 'line-width': { - base: 1.2, - stops: [ - [6.5, 0], - [7, 0.5], - [20, 18] - ] - } - } - }, - { - id: 'bridge-motorway', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['==', 'brunnel', 'bridge'], ['==', 'class', 'motorway']], - layout: { 'line-join': 'round' }, - paint: { - 'line-color': '#fc8', - 'line-width': { - base: 1.2, - stops: [ - [6.5, 0], - [7, 0.5], - [20, 18] - ] - } - } - }, - { - id: 'bridge-railway', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['==', 'brunnel', 'bridge'], ['==', 'class', 'rail']], - paint: { - 'line-color': '#bbb', - 'line-width': { - base: 1.4, - stops: [ - [14, 0.4], - [15, 0.75], - [20, 2] - ] - } - } - }, - { - id: 'bridge-railway-hatching', - type: 'line', - metadata: { 'mapbox:group': '1444849334699.1902' }, - source: 'openmaptiles', - 'source-layer': 'transportation', - filter: ['all', ['==', 'brunnel', 'bridge'], ['==', 'class', 'rail']], - paint: { - 'line-color': '#bbb', - 'line-dasharray': [0.2, 8], - 'line-width': { - base: 1.4, - stops: [ - [14.5, 0], - [15, 3], - [20, 8] - ] - } - } - }, - { - id: 'cablecar', - type: 'line', - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 13, - filter: ['==', 'class', 'cable_car'], - layout: { 'line-cap': 'round', visibility: 'visible' }, - paint: { - 'line-color': 'hsl(0, 0%, 70%)', - 'line-width': { - base: 1, - stops: [ - [11, 1], - [19, 2.5] - ] - } - } - }, - { - id: 'cablecar-dash', - type: 'line', - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 13, - filter: ['==', 'class', 'cable_car'], - layout: { 'line-cap': 'round', visibility: 'visible' }, - paint: { - 'line-color': 'hsl(0, 0%, 70%)', - 'line-dasharray': [2, 3], - 'line-width': { - base: 1, - stops: [ - [11, 3], - [19, 5.5] - ] - } - } - }, - { - id: 'boundary-land-level-4', - type: 'line', - source: 'openmaptiles', - 'source-layer': 'boundary', - filter: [ - 'all', - ['>=', 'admin_level', 4], - ['<=', 'admin_level', 8], - ['!=', 'maritime', 1] - ], - layout: { 'line-join': 'round', visibility: 'visible' }, - paint: { - 'line-color': '#9e9cab', - 'line-dasharray': [3, 1, 1, 1], - 'line-width': { - base: 1.4, - stops: [ - [4, 0.4], - [5, 1], - [12, 3] - ] - } - } - }, - { - id: 'boundary-land-level-2', - type: 'line', - source: 'openmaptiles', - 'source-layer': 'boundary', - filter: [ - 'all', - ['==', 'admin_level', 2], - ['!=', 'maritime', 1], - ['!=', 'disputed', 1] - ], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': 'hsl(248, 7%, 66%)', - 'line-width': { - base: 1, - stops: [ - [0, 0.6], - [4, 1.4], - [5, 2], - [12, 8] - ] - } - } - }, - { - id: 'boundary-land-disputed', - type: 'line', - source: 'openmaptiles', - 'source-layer': 'boundary', - filter: ['all', ['!=', 'maritime', 1], ['==', 'disputed', 1]], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': 'hsl(248, 7%, 70%)', - 'line-dasharray': [1, 3], - 'line-width': { - base: 1, - stops: [ - [0, 0.6], - [4, 1.4], - [5, 2], - [12, 8] - ] - } - } - }, - { - id: 'boundary-water', - type: 'line', - source: 'openmaptiles', - 'source-layer': 'boundary', - filter: ['all', ['in', 'admin_level', 2, 4], ['==', 'maritime', 1]], - layout: { - 'line-cap': 'round', - 'line-join': 'round', - visibility: 'visible' - }, - paint: { - 'line-color': 'rgba(154, 189, 214, 1)', - 'line-opacity': { - stops: [ - [6, 0.6], - [10, 1] - ] - }, - 'line-width': { - base: 1, - stops: [ - [0, 0.6], - [4, 1.4], - [5, 2], - [12, 8] - ] - } - } - }, - { - id: 'waterway-name', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'waterway', - minzoom: 13, - filter: ['all', ['==', '$type', 'LineString'], ['has', 'name']], - layout: { - 'symbol-placement': 'line', - 'symbol-spacing': 350, - 'text-field': '{name:latin} {name:nonlatin}', - 'text-font': ['Noto Sans Italic'], - 'text-letter-spacing': 0.2, - 'text-max-width': 5, - 'text-rotation-alignment': 'map', - 'text-size': 14, - visibility: 'visible' - }, - paint: { - 'text-color': '#74aee9', - 'text-halo-color': 'rgba(255,255,255,0.7)', - 'text-halo-width': 1.5 - } - }, - { - id: 'communes', - type: 'line', - source: 'decoupage-administratif', - 'source-layer': 'communes', - minzoom: 10, - maxzoom: 24, - layout: { visibility: 'visible' } - }, - { - id: 'departements', - type: 'line', - source: 'decoupage-administratif', - 'source-layer': 'departements', - layout: { visibility: 'visible' } - }, - { - id: 'regions', - type: 'line', - source: 'decoupage-administratif', - 'source-layer': 'regions', - layout: { visibility: 'visible' } - }, - { - id: 'water-name-lakeline', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'water_name', - filter: ['==', '$type', 'LineString'], - layout: { - 'symbol-placement': 'line', - 'symbol-spacing': 350, - 'text-field': '{name:latin}\n{name:nonlatin}', - 'text-font': ['Noto Sans Italic'], - 'text-letter-spacing': 0.2, - 'text-max-width': 5, - 'text-rotation-alignment': 'map', - 'text-size': 14 - }, - paint: { - 'text-color': '#74aee9', - 'text-halo-color': 'rgba(255,255,255,0.7)', - 'text-halo-width': 1.5 - } - }, - { - id: 'water-name-ocean', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'water_name', - filter: ['all', ['==', '$type', 'Point'], ['==', 'class', 'ocean']], - layout: { - 'symbol-placement': 'point', - 'symbol-spacing': 350, - 'text-field': '{name:latin}', - 'text-font': ['Noto Sans Italic'], - 'text-letter-spacing': 0.2, - 'text-max-width': 5, - 'text-rotation-alignment': 'map', - 'text-size': 14 - }, - paint: { - 'text-color': '#74aee9', - 'text-halo-color': 'rgba(255,255,255,0.7)', - 'text-halo-width': 1.5 - } - }, - { - id: 'water-name-other', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'water_name', - filter: ['all', ['==', '$type', 'Point'], ['!in', 'class', 'ocean']], - layout: { - 'symbol-placement': 'point', - 'symbol-spacing': 350, - 'text-field': '{name:latin}\n{name:nonlatin}', - 'text-font': ['Noto Sans Italic'], - 'text-letter-spacing': 0.2, - 'text-max-width': 5, - 'text-rotation-alignment': 'map', - 'text-size': { - stops: [ - [0, 10], - [6, 14] - ] - }, - visibility: 'visible' - }, - paint: { - 'text-color': '#74aee9', - 'text-halo-color': 'rgba(255,255,255,0.7)', - 'text-halo-width': 1.5 - } - }, - { - id: 'road_oneway', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 15, - filter: [ - 'all', - ['==', 'oneway', 1], - [ - 'in', - 'class', - 'motorway', - 'trunk', - 'primary', - 'secondary', - 'tertiary', - 'minor', - 'service' - ] - ], - layout: { - 'icon-image': 'oneway', - 'icon-padding': 2, - 'icon-rotate': 90, - 'icon-rotation-alignment': 'map', - 'icon-size': { - stops: [ - [15, 0.5], - [19, 1] - ] - }, - 'symbol-placement': 'line', - 'symbol-spacing': 75, - visibility: 'visible' - }, - paint: { 'icon-opacity': 0.5 } - }, - { - id: 'road_oneway_opposite', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'transportation', - minzoom: 15, - filter: [ - 'all', - ['==', 'oneway', -1], - [ - 'in', - 'class', - 'motorway', - 'trunk', - 'primary', - 'secondary', - 'tertiary', - 'minor', - 'service' - ] - ], - layout: { - 'icon-image': 'oneway', - 'icon-padding': 2, - 'icon-rotate': -90, - 'icon-rotation-alignment': 'map', - 'icon-size': { - stops: [ - [15, 0.5], - [19, 1] - ] - }, - 'symbol-placement': 'line', - 'symbol-spacing': 75, - visibility: 'visible' - }, - paint: { 'icon-opacity': 0.5 } - }, - { - id: 'highway-name-path', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'transportation_name', - minzoom: 15.5, - filter: ['==', 'class', 'path'], - layout: { - 'symbol-placement': 'line', - 'text-field': '{name:latin} {name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-rotation-alignment': 'map', - 'text-size': { - base: 1, - stops: [ - [13, 12], - [14, 13] - ] - } - }, - paint: { - 'text-color': 'hsl(30, 23%, 62%)', - 'text-halo-color': '#f8f4f0', - 'text-halo-width': 0.5 - } - }, - { - id: 'highway-name-minor', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'transportation_name', - minzoom: 15, - filter: [ - 'all', - ['==', '$type', 'LineString'], - ['in', 'class', 'minor', 'service', 'track'] - ], - layout: { - 'symbol-placement': 'line', - 'text-field': '{name:latin} {name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-rotation-alignment': 'map', - 'text-size': { - base: 1, - stops: [ - [13, 12], - [14, 13] - ] - } - }, - paint: { - 'text-color': '#765', - 'text-halo-blur': 0.5, - 'text-halo-width': 1 - } - }, - { - id: 'highway-name-major', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'transportation_name', - minzoom: 12.2, - filter: ['in', 'class', 'primary', 'secondary', 'tertiary', 'trunk'], - layout: { - 'symbol-placement': 'line', - 'text-field': '{name:latin} {name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-rotation-alignment': 'map', - 'text-size': { - base: 1, - stops: [ - [13, 12], - [14, 13] - ] - } - }, - paint: { - 'text-color': '#765', - 'text-halo-blur': 0.5, - 'text-halo-width': 1 - } - }, - { - id: 'highway-shield', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'transportation_name', - minzoom: 8, - filter: [ - 'all', - ['<=', 'ref_length', 6], - ['==', '$type', 'LineString'], - ['!in', 'network', 'us-interstate', 'us-highway', 'us-state'] - ], - layout: { - 'icon-image': 'road_{ref_length}', - 'icon-rotation-alignment': 'viewport', - 'icon-size': 1, - 'symbol-placement': { - base: 1, - stops: [ - [10, 'point'], - [11, 'line'] - ] - } as any, - 'symbol-spacing': 200, - 'text-field': '{ref}', - 'text-font': ['Noto Sans Regular'], - 'text-rotation-alignment': 'viewport', - 'text-size': 10 - }, - paint: {} - }, - { - id: 'highway-shield-us-interstate', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'transportation_name', - minzoom: 7, - filter: [ - 'all', - ['<=', 'ref_length', 6], - ['==', '$type', 'LineString'], - ['in', 'network', 'us-interstate'] - ], - layout: { - 'icon-image': '{network}_{ref_length}', - 'icon-rotation-alignment': 'viewport', - 'icon-size': 1, - 'symbol-placement': { - base: 1, - stops: [ - [7, 'point'], - [7, 'line'], - [8, 'line'] - ] - } as any, - 'symbol-spacing': 200, - 'text-field': '{ref}', - 'text-font': ['Noto Sans Regular'], - 'text-rotation-alignment': 'viewport', - 'text-size': 10 - }, - paint: { 'text-color': 'rgba(0, 0, 0, 1)' } - }, - { - id: 'highway-shield-us-other', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'transportation_name', - minzoom: 9, - filter: [ - 'all', - ['<=', 'ref_length', 6], - ['==', '$type', 'LineString'], - ['in', 'network', 'us-highway', 'us-state'] - ], - layout: { - 'icon-image': '{network}_{ref_length}', - 'icon-rotation-alignment': 'viewport', - 'icon-size': 1, - 'symbol-placement': { - base: 1, - stops: [ - [10, 'point'], - [11, 'line'] - ] - } as any, - 'symbol-spacing': 200, - 'text-field': '{ref}', - 'text-font': ['Noto Sans Regular'], - 'text-rotation-alignment': 'viewport', - 'text-size': 10 - }, - paint: { 'text-color': 'rgba(0, 0, 0, 1)' } - }, - { - id: 'airport-label-major', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'aerodrome_label', - minzoom: 10, - filter: ['all', ['has', 'iata']], - layout: { - 'icon-image': 'airport_11', - 'icon-size': 1, - 'text-anchor': 'top', - 'text-field': '{name:latin}\n{name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-max-width': 9, - 'text-offset': [0, 0.6], - 'text-optional': true, - 'text-padding': 2, - 'text-size': 12, - visibility: 'visible' - }, - paint: { - 'text-color': '#666', - 'text-halo-blur': 0.5, - 'text-halo-color': '#ffffff', - 'text-halo-width': 1 - } - }, - { - id: 'poi-level-3', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'poi', - minzoom: 16, - filter: [ - 'all', - ['==', '$type', 'Point'], - ['>=', 'rank', 25], - ['any', ['!has', 'level'], ['==', 'level', 0]] - ], - layout: { - 'icon-image': '{class}_11', - 'text-anchor': 'top', - 'text-field': '{name:latin}\n{name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-max-width': 9, - 'text-offset': [0, 0.6], - 'text-padding': 2, - 'text-size': 12, - visibility: 'visible' - }, - paint: { - 'text-color': '#666', - 'text-halo-blur': 0.5, - 'text-halo-color': '#ffffff', - 'text-halo-width': 1 - } - }, - { - id: 'poi-level-2', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'poi', - minzoom: 15, - filter: [ - 'all', - ['==', '$type', 'Point'], - ['<=', 'rank', 24], - ['>=', 'rank', 15], - ['any', ['!has', 'level'], ['==', 'level', 0]] - ], - layout: { - 'icon-image': '{class}_11', - 'text-anchor': 'top', - 'text-field': '{name:latin}\n{name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-max-width': 9, - 'text-offset': [0, 0.6], - 'text-padding': 2, - 'text-size': 12, - visibility: 'visible' - }, - paint: { - 'text-color': '#666', - 'text-halo-blur': 0.5, - 'text-halo-color': '#ffffff', - 'text-halo-width': 1 - } - }, - { - id: 'poi-level-1', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'poi', - minzoom: 14, - filter: [ - 'all', - ['==', '$type', 'Point'], - ['<=', 'rank', 14], - ['has', 'name'], - ['any', ['!has', 'level'], ['==', 'level', 0]] - ], - layout: { - 'icon-image': '{class}_11', - 'text-anchor': 'top', - 'text-field': '{name:latin}\n{name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-max-width': 9, - 'text-offset': [0, 0.6], - 'text-padding': 2, - 'text-size': 12, - visibility: 'visible' - }, - paint: { - 'text-color': '#666', - 'text-halo-blur': 0.5, - 'text-halo-color': '#ffffff', - 'text-halo-width': 1 - } - }, - { - id: 'poi-railway', - type: 'symbol', - source: 'openmaptiles', - 'source-layer': 'poi', - minzoom: 13, - filter: [ - 'all', - ['==', '$type', 'Point'], - ['has', 'name'], - ['==', 'class', 'railway'], - ['==', 'subclass', 'station'] - ], - layout: { - 'icon-allow-overlap': false, - 'icon-ignore-placement': false, - 'icon-image': '{class}_11', - 'icon-optional': false, - 'text-allow-overlap': false, - 'text-anchor': 'top', - 'text-field': '{name:latin}\n{name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-ignore-placement': false, - 'text-max-width': 9, - 'text-offset': [0, 0.6], - 'text-optional': true, - 'text-padding': 2, - 'text-size': 12 - }, - paint: { - 'text-color': '#666', - 'text-halo-blur': 0.5, - 'text-halo-color': '#ffffff', - 'text-halo-width': 1 - } - }, - { - id: 'place-other', - type: 'symbol', - metadata: { 'mapbox:group': '1444849242106.713' }, - source: 'openmaptiles', - 'source-layer': 'place', - filter: ['!in', 'class', 'city', 'town', 'village', 'country', 'continent'], - layout: { - 'text-field': '{name:latin}\n{name:nonlatin}', - 'text-font': ['Noto Sans Bold'], - 'text-letter-spacing': 0.1, - 'text-max-width': 9, - 'text-size': { - base: 1.2, - stops: [ - [12, 10], - [15, 14] - ] - }, - 'text-transform': 'uppercase', - visibility: 'visible' - }, - paint: { - 'text-color': '#633', - 'text-halo-color': 'rgba(255,255,255,0.8)', - 'text-halo-width': 1.2 - } - }, - { - id: 'place-village', - type: 'symbol', - metadata: { 'mapbox:group': '1444849242106.713' }, - source: 'openmaptiles', - 'source-layer': 'place', - filter: ['==', 'class', 'village'], - layout: { - 'text-field': '{name:latin}\n{name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-max-width': 8, - 'text-size': { - base: 1.2, - stops: [ - [10, 12], - [15, 22] - ] - }, - visibility: 'visible' - }, - paint: { - 'text-color': '#333', - 'text-halo-color': 'rgba(255,255,255,0.8)', - 'text-halo-width': 1.2 - } - }, - { - id: 'place-town', - type: 'symbol', - metadata: { 'mapbox:group': '1444849242106.713' }, - source: 'openmaptiles', - 'source-layer': 'place', - filter: ['==', 'class', 'town'], - layout: { - 'text-field': '{name:latin}\n{name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-max-width': 8, - 'text-size': { - base: 1.2, - stops: [ - [10, 14], - [15, 24] - ] - }, - visibility: 'visible' - }, - paint: { - 'text-color': '#333', - 'text-halo-color': 'rgba(255,255,255,0.8)', - 'text-halo-width': 1.2 - } - }, - { - id: 'place-city', - type: 'symbol', - metadata: { 'mapbox:group': '1444849242106.713' }, - source: 'openmaptiles', - 'source-layer': 'place', - filter: ['all', ['!=', 'capital', 2], ['==', 'class', 'city']], - layout: { - 'text-field': '{name:latin}\n{name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-max-width': 8, - 'text-size': { - base: 1.2, - stops: [ - [7, 14], - [11, 24] - ] - }, - visibility: 'visible' - }, - paint: { - 'text-color': '#333', - 'text-halo-color': 'rgba(255,255,255,0.8)', - 'text-halo-width': 1.2 - } - }, - { - id: 'place-city-capital', - type: 'symbol', - metadata: { 'mapbox:group': '1444849242106.713' }, - source: 'openmaptiles', - 'source-layer': 'place', - filter: ['all', ['==', 'capital', 2], ['==', 'class', 'city']], - layout: { - 'icon-image': 'star_11', - 'icon-size': 0.8, - 'text-anchor': 'left', - 'text-field': '{name:latin}\n{name:nonlatin}', - 'text-font': ['Noto Sans Regular'], - 'text-max-width': 8, - 'text-offset': [0.4, 0], - 'text-size': { - base: 1.2, - stops: [ - [7, 14], - [11, 24] - ] - }, - visibility: 'visible' - }, - paint: { - 'text-color': '#333', - 'text-halo-color': 'rgba(255,255,255,0.8)', - 'text-halo-width': 1.2 - } - }, - { - id: 'place-country-other', - type: 'symbol', - metadata: { 'mapbox:group': '1444849242106.713' }, - source: 'openmaptiles', - 'source-layer': 'place', - filter: [ - 'all', - ['==', 'class', 'country'], - ['>=', 'rank', 3], - ['!has', 'iso_a2'] - ], - layout: { - 'text-field': '{name:latin}', - 'text-font': ['Noto Sans Italic'], - 'text-max-width': 6.25, - 'text-size': { - stops: [ - [3, 11], - [7, 17] - ] - }, - 'text-transform': 'uppercase', - visibility: 'visible' - }, - paint: { - 'text-color': '#334', - 'text-halo-blur': 1, - 'text-halo-color': 'rgba(255,255,255,0.8)', - 'text-halo-width': 2 - } - }, - { - id: 'place-country-3', - type: 'symbol', - metadata: { 'mapbox:group': '1444849242106.713' }, - source: 'openmaptiles', - 'source-layer': 'place', - filter: [ - 'all', - ['==', 'class', 'country'], - ['>=', 'rank', 3], - ['has', 'iso_a2'] - ], - layout: { - 'text-field': '{name:latin}', - 'text-font': ['Noto Sans Bold'], - 'text-max-width': 6.25, - 'text-size': { - stops: [ - [3, 11], - [7, 17] - ] - }, - 'text-transform': 'uppercase', - visibility: 'visible' - }, - paint: { - 'text-color': '#334', - 'text-halo-blur': 1, - 'text-halo-color': 'rgba(255,255,255,0.8)', - 'text-halo-width': 2 - } - }, - { - id: 'place-country-2', - type: 'symbol', - metadata: { 'mapbox:group': '1444849242106.713' }, - source: 'openmaptiles', - 'source-layer': 'place', - filter: [ - 'all', - ['==', 'class', 'country'], - ['==', 'rank', 2], - ['has', 'iso_a2'] - ], - layout: { - 'text-field': '{name:latin}', - 'text-font': ['Noto Sans Bold'], - 'text-max-width': 6.25, - 'text-size': { - stops: [ - [2, 11], - [5, 17] - ] - }, - 'text-transform': 'uppercase', - visibility: 'visible' - }, - paint: { - 'text-color': '#334', - 'text-halo-blur': 1, - 'text-halo-color': 'rgba(255,255,255,0.8)', - 'text-halo-width': 2 - } - }, - { - id: 'place-country-1', - type: 'symbol', - metadata: { 'mapbox:group': '1444849242106.713' }, - source: 'openmaptiles', - 'source-layer': 'place', - filter: [ - 'all', - ['==', 'class', 'country'], - ['==', 'rank', 1], - ['has', 'iso_a2'] - ], - layout: { - 'text-field': '{name:latin}', - 'text-font': ['Noto Sans Bold'], - 'text-max-width': 6.25, - 'text-size': { - stops: [ - [1, 11], - [4, 17] - ] - }, - 'text-transform': 'uppercase', - visibility: 'visible' - }, - paint: { - 'text-color': '#334', - 'text-halo-blur': 1, - 'text-halo-color': 'rgba(255,255,255,0.8)', - 'text-halo-width': 2 - } - }, - { - id: 'place-continent', - type: 'symbol', - metadata: { 'mapbox:group': '1444849242106.713' }, - source: 'openmaptiles', - 'source-layer': 'place', - maxzoom: 1, - filter: ['==', 'class', 'continent'], - layout: { - 'text-field': '{name:latin}', - 'text-font': ['Noto Sans Bold'], - 'text-max-width': 6.25, - 'text-size': 14, - 'text-transform': 'uppercase', - visibility: 'visible' - }, - paint: { - 'text-color': '#334', - 'text-halo-blur': 1, - 'text-halo-color': 'rgba(255,255,255,0.8)', - 'text-halo-width': 2 - } - } -]; - -export default layers; diff --git a/app/javascript/components/shared/queryClient.ts b/app/javascript/components/shared/queryClient.ts deleted file mode 100644 index 700dc595d..000000000 --- a/app/javascript/components/shared/queryClient.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { QueryClient, QueryFunction } from 'react-query'; -import { httpRequest, getConfig } from '@utils'; - -const API_EDUCATION_QUERY_LIMIT = 5; -const API_ADRESSE_QUERY_LIMIT = 5; - -const { - autocomplete: { api_adresse_url, api_education_url } -} = getConfig(); - -type QueryKey = readonly [ - scope: string, - term: string, - extra: string | undefined -]; - -function buildURL(scope: string, term: string) { - term = term.replace(/\(|\)/g, ''); - const params = new URLSearchParams(); - let path = ''; - - if (scope == 'adresse') { - path = `${api_adresse_url}/search`; - params.set('q', term); - params.set('limit', `${API_ADRESSE_QUERY_LIMIT}`); - } else if (scope == 'annuaire-education') { - path = `${api_education_url}/search`; - params.set('q', term); - params.set('rows', `${API_EDUCATION_QUERY_LIMIT}`); - params.set('dataset', 'fr-en-annuaire-education'); - } - - return `${path}?${params}`; -} - -const defaultQueryFn: QueryFunction = async ({ - queryKey: [scope, term], - signal -}) => { - // BAN will error with queries less then 3 chars long - if (scope == 'adresse' && term.length < 3) { - return { - type: 'FeatureCollection', - version: 'draft', - features: [], - query: term - }; - } - - const url = buildURL(scope, term); - return httpRequest(url, { csrf: false, signal }).json(); -}; - -export const queryClient = new QueryClient({ - defaultOptions: { - queries: { - // we don't really care about global queryFn type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - queryFn: defaultQueryFn as any - } - } -}); diff --git a/app/javascript/controllers/autofocus_controller.ts b/app/javascript/controllers/autofocus_controller.ts index 289185948..72b5198ad 100644 --- a/app/javascript/controllers/autofocus_controller.ts +++ b/app/javascript/controllers/autofocus_controller.ts @@ -2,8 +2,10 @@ import { Controller } from '@hotwired/stimulus'; export class AutofocusController extends Controller { connect() { - const element = this.element as HTMLInputElement; + const element = this.element as HTMLInputElement | HTMLElement; element.focus(); - element.setSelectionRange(0, element.value.length); + if ('value' in element) { + element.setSelectionRange(0, element.value.length); + } } } diff --git a/app/javascript/controllers/autoresize_controller.ts b/app/javascript/controllers/autoresize_controller.ts index 217f5156c..484ddfba2 100644 --- a/app/javascript/controllers/autoresize_controller.ts +++ b/app/javascript/controllers/autoresize_controller.ts @@ -3,16 +3,31 @@ import { attach } from '@frsource/autoresize-textarea'; import { isTextAreaElement } from '@coldwired/utils'; export class AutoresizeController extends ApplicationController { + declare observer: IntersectionObserver; + #detach?: () => void; connect(): void { if (isTextAreaElement(this.element)) { - this.#detach = attach(this.element)?.detach; this.element.classList.add('resize-none'); + this.observer = new IntersectionObserver(this.onIntersect.bind(this), { + threshold: [0] + }); + this.observer.observe(this.element); + } + } + + onIntersect(entries: IntersectionObserverEntry[]): void { + const visible = entries[0].isIntersecting == true; + + if (visible) { + this.#detach = attach(this.element as HTMLTextAreaElement)?.detach; + this.observer.unobserve(this.element); } } disconnect(): void { this.#detach?.(); + this.observer.unobserve(this.element); this.element.classList.remove('resize-none'); } } diff --git a/app/javascript/controllers/autosave_controller.ts b/app/javascript/controllers/autosave_controller.ts index 863794195..96aca094d 100644 --- a/app/javascript/controllers/autosave_controller.ts +++ b/app/javascript/controllers/autosave_controller.ts @@ -1,13 +1,13 @@ -import { httpRequest, ResponseError, getConfig } from '@utils'; -import { matchInputElement, isButtonElement } from '@coldwired/utils'; +import { isButtonElement, matchInputElement } from '@coldwired/utils'; +import { getConfig, httpRequest, ResponseError } from '@utils'; -import { ApplicationController } from './application_controller'; import { AutoUpload } from '../shared/activestorage/auto-upload'; import { - FileUploadError, + ERROR_CODE_READ, FAILURE_CLIENT, - ERROR_CODE_READ + FileUploadError } from '../shared/activestorage/file-upload-error'; +import { ApplicationController } from './application_controller'; const { autosave: { debounce_delay } @@ -182,7 +182,7 @@ export class AutosaveController extends ApplicationController { .catch((e) => { const error = e as FileUploadError; - this.globalDispatch('autosave:error'); + this.globalDispatch('autosave:error', { error }); // Report unexpected client errors to Sentry. // (But ignore usual client errors, or errors we can monitor better on the server side.) @@ -252,7 +252,10 @@ export class AutosaveController extends ApplicationController { return httpRequest(form.action, { method: 'post', body: formData, - headers: { 'x-http-method-override': 'PATCH' }, + headers: { + 'x-http-method-override': + form.dataset.turboMethod?.toUpperCase() || 'PATCH' + }, signal: this.#abortController.signal, timeout: AUTOSAVE_TIMEOUT_DELAY }).turbo(); diff --git a/app/javascript/controllers/checkbox_select_all_controller.ts b/app/javascript/controllers/checkbox_select_all_controller.ts new file mode 100644 index 000000000..86fbe44d4 --- /dev/null +++ b/app/javascript/controllers/checkbox_select_all_controller.ts @@ -0,0 +1,71 @@ +import { ApplicationController } from './application_controller'; + +export class CheckboxSelectAll extends ApplicationController { + declare readonly hasCheckboxAllTarget: boolean; + declare readonly checkboxTargets: HTMLInputElement[]; + declare readonly checkboxAllTarget: HTMLInputElement; + + static targets: string[] = ['checkboxAll', 'checkbox']; + + initialize() { + this.toggle = this.toggle.bind(this); + this.refresh = this.refresh.bind(this); + } + + checkboxAllTargetConnected(checkbox: HTMLInputElement): void { + checkbox.addEventListener('change', this.toggle); + + this.refresh(); + } + + checkboxTargetConnected(checkbox: HTMLInputElement): void { + checkbox.addEventListener('change', this.refresh); + + this.refresh(); + } + + checkboxAllTargetDisconnected(checkbox: HTMLInputElement): void { + checkbox.removeEventListener('change', this.toggle); + + this.refresh(); + } + + checkboxTargetDisconnected(checkbox: HTMLInputElement): void { + checkbox.removeEventListener('change', this.refresh); + + this.refresh(); + } + + toggle(e: Event): void { + e.preventDefault(); + + this.checkboxTargets.forEach((checkbox) => { + // @ts-expect-error faut savoir hein + checkbox.checked = e.target.checked; + this.triggerInputEvent(checkbox); + }); + } + + refresh(): void { + const checkboxesCount = this.checkboxTargets.length; + const checkboxesCheckedCount = this.checked.length; + + this.checkboxAllTarget.checked = checkboxesCheckedCount > 0; + this.checkboxAllTarget.indeterminate = + checkboxesCheckedCount > 0 && checkboxesCheckedCount < checkboxesCount; + } + + triggerInputEvent(checkbox: HTMLInputElement): void { + const event = new Event('input', { bubbles: false, cancelable: true }); + + checkbox.dispatchEvent(event); + } + + get checked(): HTMLInputElement[] { + return this.checkboxTargets.filter((checkbox) => checkbox.checked); + } + + get unchecked(): HTMLInputElement[] { + return this.checkboxTargets.filter((checkbox) => !checkbox.checked); + } +} diff --git a/app/javascript/controllers/clipboard_controller.ts b/app/javascript/controllers/clipboard_controller.ts index d2b4ae1cf..cecdc5d93 100644 --- a/app/javascript/controllers/clipboard_controller.ts +++ b/app/javascript/controllers/clipboard_controller.ts @@ -17,7 +17,11 @@ export class ClipboardController extends Controller { connect(): void { // some extensions or browsers block clipboard if (!navigator.clipboard) { - this.element.classList.add('hidden'); + if (this.hasToHideTarget) { + this.toHideTarget.classList.add('hidden'); + } else { + this.element.classList.add('hidden'); + } } } diff --git a/app/javascript/controllers/combobox_controller.ts b/app/javascript/controllers/combobox_controller.ts deleted file mode 100644 index 36eddca2c..000000000 --- a/app/javascript/controllers/combobox_controller.ts +++ /dev/null @@ -1,99 +0,0 @@ -import invariant from 'tiny-invariant'; -import { isInputElement, isElement } from '@coldwired/utils'; - -import { Hint } from '../shared/combobox'; -import { ComboboxUI } from '../shared/combobox-ui'; -import { ApplicationController } from './application_controller'; - -export class ComboboxController extends ApplicationController { - #combobox?: ComboboxUI; - - connect() { - const { input, selectedValueInput, valueSlots, list, item, hint } = - this.getElements(); - const hints = JSON.parse(list.dataset.hints ?? '{}') as Record< - string, - string - >; - this.#combobox = new ComboboxUI({ - input, - selectedValueInput, - valueSlots, - list, - item, - hint, - allowsCustomValue: this.element.hasAttribute('data-allows-custom-value'), - limit: this.element.hasAttribute('data-limit') - ? Number(this.element.getAttribute('data-limit')) - : undefined, - getHintText: (hint) => getHintText(hints, hint) - }); - this.#combobox.init(); - } - - disconnect() { - this.#combobox?.destroy(); - } - - private getElements() { - const input = - this.element.querySelector('input[type="text"]'); - const selectedValueInput = this.element.querySelector( - 'input[type="hidden"]' - ); - const valueSlots = this.element.querySelectorAll( - 'input[type="hidden"][data-value-slot]' - ); - const list = this.element.querySelector('[role=listbox]'); - const item = this.element.querySelector('template'); - const hint = - this.element.querySelector('[aria-live]') ?? undefined; - - invariant( - isInputElement(input), - 'ComboboxController requires a input element' - ); - invariant( - isInputElement(selectedValueInput), - 'ComboboxController requires a hidden input element' - ); - invariant( - isElement(list), - 'ComboboxController requires a [role=listbox] element' - ); - invariant( - isElement(item), - 'ComboboxController requires a template element' - ); - - return { input, selectedValueInput, valueSlots, list, item, hint }; - } -} - -function getHintText(hints: Record, hint: Hint): string { - const slot = hints[getSlotName(hint)]; - switch (hint.type) { - case 'empty': - return slot; - case 'selected': - return slot.replace('{label}', hint.label ?? ''); - default: - return slot - .replace('{count}', String(hint.count)) - .replace('{label}', hint.label ?? ''); - } -} - -function getSlotName(hint: Hint): string { - switch (hint.type) { - case 'empty': - return 'empty'; - case 'selected': - return 'selected'; - default: - if (hint.count == 1) { - return hint.label ? 'oneWithLabel' : 'one'; - } - return hint.label ? 'manyWithLabel' : 'many'; - } -} diff --git a/app/javascript/controllers/support_controller.ts b/app/javascript/controllers/contact_controller.ts similarity index 95% rename from app/javascript/controllers/support_controller.ts rename to app/javascript/controllers/contact_controller.ts index 22d947f0e..436adc913 100644 --- a/app/javascript/controllers/support_controller.ts +++ b/app/javascript/controllers/contact_controller.ts @@ -1,7 +1,7 @@ import { ApplicationController } from './application_controller'; import { hide, show } from '@utils'; -export class SupportController extends ApplicationController { +export class ContactController extends ApplicationController { static targets = ['inputRadio', 'content']; declare readonly inputRadioTargets: HTMLInputElement[]; diff --git a/app/javascript/controllers/date_input_hint_controller.ts b/app/javascript/controllers/date_input_hint_controller.ts index 8eacd6d53..d7cad2ebf 100644 --- a/app/javascript/controllers/date_input_hint_controller.ts +++ b/app/javascript/controllers/date_input_hint_controller.ts @@ -36,7 +36,7 @@ export class DateInputHintController extends ApplicationController { private translatePlaceholder() { const locale = document.documentElement.lang as 'fr' | 'en'; - const parts = PARTS[locale]; + const parts = PARTS[locale] ?? PARTS.fr; const example = new Date(2022, 9, 15).toLocaleDateString(); return [ Object.entries(parts).reduce( diff --git a/app/javascript/controllers/datetime_controller.ts b/app/javascript/controllers/datetime_controller.ts index 0567167d0..58dcb1e98 100644 --- a/app/javascript/controllers/datetime_controller.ts +++ b/app/javascript/controllers/datetime_controller.ts @@ -1,4 +1,4 @@ -import format from 'date-fns/format'; +import { format } from 'date-fns/format'; import { ApplicationController } from './application_controller'; diff --git a/app/javascript/controllers/email_france_connect_controller.ts b/app/javascript/controllers/email_france_connect_controller.ts new file mode 100644 index 000000000..d9be703af --- /dev/null +++ b/app/javascript/controllers/email_france_connect_controller.ts @@ -0,0 +1,33 @@ +import { ApplicationController } from './application_controller'; + +export class EmailFranceConnectController extends ApplicationController { + static targets = ['useFranceConnectEmail', 'emailField']; + + emailFieldTarget!: HTMLElement; + useFranceConnectEmailTargets!: HTMLInputElement[]; + + connect() { + this.triggerEmailField(); + } + + triggerEmailField() { + const checkedTarget = this.useFranceConnectEmailTargets.find( + (target) => target.checked + ); + + const inputElement = this.emailFieldTarget.querySelector( + 'input[type="email"]' + ) as HTMLInputElement; + + if (checkedTarget && checkedTarget.value === 'false') { + this.emailFieldTarget.classList.remove('hidden'); + this.emailFieldTarget.setAttribute('aria-hidden', 'false'); + inputElement.setAttribute('required', ''); + } else { + this.emailFieldTarget.classList.add('hidden'); + this.emailFieldTarget.setAttribute('aria-hidden', 'true'); + inputElement.removeAttribute('required'); + inputElement.value = ''; + } + } +} diff --git a/app/javascript/controllers/email_input_controller.ts b/app/javascript/controllers/email_input_controller.ts index 8eed97fa9..6a51cc2a2 100644 --- a/app/javascript/controllers/email_input_controller.ts +++ b/app/javascript/controllers/email_input_controller.ts @@ -1,33 +1,72 @@ -import { suggest } from 'email-butler'; -import { show, hide } from '@utils'; +import { hide, httpRequest, show } from '@utils'; import { ApplicationController } from './application_controller'; +type CheckEmailResponse = + | { + success: true; + suggestions?: string[]; + } + | { success: false }; + export class EmailInputController extends ApplicationController { static targets = ['ariaRegion', 'suggestion', 'input']; + static values = { + url: String + }; + + declare readonly urlValue: string; + declare readonly ariaRegionTarget: HTMLElement; declare readonly suggestionTarget: HTMLElement; declare readonly inputTarget: HTMLInputElement; - checkEmail() { - const suggestion = suggest(this.inputTarget.value); - if (suggestion && suggestion.full) { - this.suggestionTarget.innerHTML = suggestion.full; - show(this.ariaRegionTarget); - this.ariaRegionTarget.setAttribute('aria-live', 'assertive'); + async checkEmail() { + if ( + !this.inputTarget.value || + this.inputTarget.value.length < 5 || + !this.inputTarget.value.includes('@') + ) { + return; + } + + const url = new URL(this.urlValue, document.baseURI); + url.searchParams.append('email', this.inputTarget.value); + + const data = await httpRequest(url.toString()) + .json() + .catch(() => null); + + if (data?.success) { + const suggestion = data.suggestions?.at(0); + if (suggestion) { + this.suggestionTarget.innerHTML = suggestion; + show(this.ariaRegionTarget); + this.ariaRegionTarget.focus(); + } } } accept() { - this.ariaRegionTarget.setAttribute('aria-live', 'off'); hide(this.ariaRegionTarget); this.inputTarget.value = this.suggestionTarget.innerHTML; this.suggestionTarget.innerHTML = ''; + const nextTarget = document.querySelector( + '[data-email-input-target="next"]' + ); + if (nextTarget) { + nextTarget.focus(); + } } discard() { - this.ariaRegionTarget.setAttribute('aria-live', 'off'); hide(this.ariaRegionTarget); this.suggestionTarget.innerHTML = ''; + const nextTarget = document.querySelector( + '[data-email-input-target="next"]' + ); + if (nextTarget) { + nextTarget.focus(); + } } } diff --git a/app/javascript/controllers/enable_submit_if_uploaded_controller.tsx b/app/javascript/controllers/enable_submit_if_uploaded_controller.tsx new file mode 100644 index 000000000..7135f1da0 --- /dev/null +++ b/app/javascript/controllers/enable_submit_if_uploaded_controller.tsx @@ -0,0 +1,21 @@ +import { Controller } from '@hotwired/stimulus'; +import { enable, disable } from '@utils'; + +export class EnableSubmitIfUploadedController extends Controller { + connect() { + const fileInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + const submitButton = document.getElementById( + 'submit-button' + ) as HTMLButtonElement; + + fileInput.addEventListener('change', function () { + if (fileInput.files && fileInput.files.length > 0) { + enable(submitButton); + } else { + disable(submitButton); + } + }); + } +} diff --git a/app/javascript/controllers/file_input_reset_controller.ts b/app/javascript/controllers/file_input_reset_controller.ts index cbce6ab6b..5f4070917 100644 --- a/app/javascript/controllers/file_input_reset_controller.ts +++ b/app/javascript/controllers/file_input_reset_controller.ts @@ -1,37 +1,79 @@ import { ApplicationController } from './application_controller'; -import { hide, show } from '@utils'; - export class FileInputResetController extends ApplicationController { - static targets = ['reset']; - - declare readonly resetTarget: HTMLElement; + static targets = ['fileList']; + declare fileListTarget: HTMLElement; connect() { - this.on('change', (event) => { - if (event.target == this.fileInput) { - this.showResetButton(); + super.connect(); + this.updateFileList(); + this.element.addEventListener('change', (event) => { + if ( + event.target instanceof HTMLInputElement && + event.target.type === 'file' + ) { + this.updateFileList(); } }); } - reset(event: Event) { - event.preventDefault(); - this.fileInput.value = ''; - hide(this.resetTarget); + updateFileList() { + const files = this.fileInput?.files ?? []; + this.fileListTarget.innerHTML = ''; + + const deleteLabel = + this.element.getAttribute('data-delete-label') || 'Delete'; + + Array.from(files).forEach((file, index) => { + const container = document.createElement('li'); + container.classList.add('flex', 'flex-gap-2', 'fr-mb-1w'); + + const deleteButton = this.createDeleteButton(deleteLabel, index); + container.appendChild(deleteButton); + + const listItem = document.createElement('div'); + listItem.textContent = file.name; + + container.appendChild(listItem); + this.fileListTarget.appendChild(container); + }); } - showResetButton() { - show(this.resetTarget); + createDeleteButton(deleteLabel: string, index: number) { + const button = document.createElement('button'); + button.textContent = deleteLabel; + button.classList.add( + 'fr-btn', + 'fr-btn--tertiary', + 'fr-btn--sm', + 'fr-icon-delete-line' + ); + + button.addEventListener('click', (event) => { + event.preventDefault(); + this.removeFile(index); + }); + + return button; } - private get fileInput() { - const inputs = - this.element.querySelectorAll('input[type="file"]'); - if (inputs.length == 0) { - throw new Error('No file input found'); - } else if (inputs.length > 1) { - throw new Error('Multiple file inputs found'); - } - return inputs[0]; + removeFile(index: number) { + const files = this.fileInput?.files; + if (!files) return; + + const dataTransfer = new DataTransfer(); + Array.from(files).forEach((file, i) => { + if (index !== i) { + dataTransfer.items.add(file); + } + }); + + if (this.fileInput) this.fileInput.files = dataTransfer.files; + this.updateFileList(); + } + + private get fileInput(): HTMLInputElement | null { + return this.element.querySelector( + 'input[type="file"]' + ) as HTMLInputElement | null; } } diff --git a/app/javascript/controllers/for_tiers_controller.ts b/app/javascript/controllers/for_tiers_controller.ts index 7aa955da0..6ec3a821c 100644 --- a/app/javascript/controllers/for_tiers_controller.ts +++ b/app/javascript/controllers/for_tiers_controller.ts @@ -1,87 +1,32 @@ +import { toggle } from '@utils'; import { ApplicationController } from './application_controller'; +function onVisibleEnableInputs(element: HTMLInputElement) { + element.disabled = false; + element.required = true; +} + +function onHiddenDisableInputs(element: HTMLInputElement) { + element.disabled = true; + element.required = false; +} + export class ForTiersController extends ApplicationController { - static targets = [ - 'mandataireFirstName', - 'mandataireLastName', - 'forTiers', - 'mandataireBlock', - 'beneficiaireNotificationBlock', - 'email', - 'notificationMethod', - 'mandataireTitle', - 'beneficiaireTitle', - 'emailInput' - ]; + static targets = ['emailContainer', 'emailInput', 'notificationMethod']; - declare mandataireFirstNameTarget: HTMLInputElement; - declare mandataireLastNameTarget: HTMLInputElement; - declare forTiersTargets: NodeListOf; - declare mandataireBlockTarget: HTMLElement; - declare beneficiaireNotificationBlockTarget: HTMLElement; declare notificationMethodTargets: NodeListOf; - declare emailTarget: HTMLInputElement; - declare mandataireTitleTarget: HTMLElement; - declare beneficiaireTitleTarget: HTMLElement; - declare emailInput: HTMLInputElement; + declare emailContainerTarget: HTMLElement; + declare emailInputTarget: HTMLInputElement; - connect() { - const emailInputElement = this.emailTarget.querySelector('input'); - if (emailInputElement) { - this.emailInput = emailInputElement; - } - this.toggleFieldRequirements(); - this.addAllEventListeners(); - } - - addAllEventListeners() { - this.forTiersTargets.forEach((radio) => { - radio.addEventListener('change', () => this.toggleFieldRequirements()); - }); - this.notificationMethodTargets.forEach((radio) => { - radio.addEventListener('change', () => this.toggleEmailInput()); - }); - } - - toggleFieldRequirements() { - const forTiersSelected = this.isForTiersSelected(); - this.toggleDisplay(this.mandataireBlockTarget, forTiersSelected); - this.toggleDisplay( - this.beneficiaireNotificationBlockTarget, - forTiersSelected - ); - this.mandataireFirstNameTarget.required = forTiersSelected; - this.mandataireLastNameTarget.required = forTiersSelected; - this.mandataireTitleTarget.classList.toggle('hidden', forTiersSelected); - this.beneficiaireTitleTarget.classList.toggle('hidden', !forTiersSelected); - this.notificationMethodTargets.forEach((radio) => { - radio.required = forTiersSelected; - }); - - this.toggleEmailInput(); - } - - isForTiersSelected() { - return Array.from(this.forTiersTargets).some( - (radio) => radio.checked && radio.value === 'true' - ); - } - - toggleDisplay(element: HTMLElement, shouldDisplay: boolean) { - element.classList.toggle('hidden', !shouldDisplay); - } toggleEmailInput() { const isEmailSelected = this.isEmailSelected(); - const forTiersSelected = this.isForTiersSelected(); - if (this.emailInput) { - this.emailInput.required = forTiersSelected && isEmailSelected; + toggle(this.emailContainerTarget, isEmailSelected); - if (!isEmailSelected) { - this.emailInput.value = ''; - } - - this.toggleDisplay(this.emailTarget, forTiersSelected && isEmailSelected); + if (isEmailSelected) { + onVisibleEnableInputs(this.emailInputTarget); + } else { + onHiddenDisableInputs(this.emailInputTarget); } } diff --git a/app/javascript/controllers/format_controller.ts b/app/javascript/controllers/format_controller.ts index d5712d46e..1e76d4219 100644 --- a/app/javascript/controllers/format_controller.ts +++ b/app/javascript/controllers/format_controller.ts @@ -4,6 +4,13 @@ export class FormatController extends ApplicationController { connect() { const format = this.element.getAttribute('data-format'); switch (format) { + case 'deleteSpace': + this.on('change', (event) => { + const target = event.target as HTMLInputElement; + const value = this.deleteSpace(target.value); + replaceValue(target, value); + }); + break; case 'list': this.on('change', (event) => { const target = event.target as HTMLInputElement; @@ -11,6 +18,13 @@ export class FormatController extends ApplicationController { replaceValue(target, value); }); break; + case 'siret': + this.on('input', (event) => { + const target = event.target as HTMLInputElement; + const value = this.formatSIRET(target.value); + replaceValue(target, value); + }); + break; case 'iban': this.on('input', (event) => { const target = event.target as HTMLInputElement; @@ -34,11 +48,21 @@ export class FormatController extends ApplicationController { break; } } + private deleteSpace(value: string) { + return value.replace(/\s*/g, ''); + } private formatList(value: string) { return value.replace(/;/g, ','); } + private formatSIRET(value: string) { + return value + .replace(/[^\d]/gi, '') + .replace(/^\s*(\d{3})\s*(\d{3})\s*(\d{3})\s*(\d{5})\s*$/gi, '$1 $2 $3 $4') + .trim(); + } + private formatIBAN(value: string) { return value .replace(/[^\dA-Z]/gi, '') diff --git a/app/javascript/controllers/hide_target_controller.ts b/app/javascript/controllers/hide_target_controller.ts new file mode 100644 index 000000000..607543aac --- /dev/null +++ b/app/javascript/controllers/hide_target_controller.ts @@ -0,0 +1,19 @@ +import { Controller } from '@hotwired/stimulus'; + +export class HideTargetController extends Controller { + static targets = ['source', 'toHide']; + declare readonly toHideTargets: HTMLDivElement[]; + declare readonly sourceTargets: HTMLInputElement[]; + + connect() { + this.sourceTargets.forEach((source) => { + source.addEventListener('click', this.handleInput.bind(this)); + }); + } + + handleInput() { + this.toHideTargets.forEach((toHide) => { + toHide.classList.toggle('fr-hidden'); + }); + } +} diff --git a/app/javascript/controllers/ineligibilite_rules_match_controller.ts b/app/javascript/controllers/ineligibilite_rules_match_controller.ts new file mode 100644 index 000000000..5b47d79b5 --- /dev/null +++ b/app/javascript/controllers/ineligibilite_rules_match_controller.ts @@ -0,0 +1,19 @@ +import { ApplicationController } from './application_controller'; +declare interface modal { + disclose: () => void; +} +declare interface dsfr { + modal: modal; +} +declare const window: Window & + typeof globalThis & { dsfr: (elem: HTMLElement) => dsfr }; + +export class InvalidIneligibiliteRulesController extends ApplicationController { + static targets = ['dialog']; + + declare dialogTarget: HTMLElement; + + connect() { + setTimeout(() => window.dsfr(this.dialogTarget).modal.disclose(), 100); + } +} diff --git a/app/javascript/controllers/lazy/chartkick_controller.ts b/app/javascript/controllers/lazy/chartkick_controller.ts index 90758e10f..a5e84394c 100644 --- a/app/javascript/controllers/lazy/chartkick_controller.ts +++ b/app/javascript/controllers/lazy/chartkick_controller.ts @@ -1,44 +1,43 @@ import { Controller } from '@hotwired/stimulus'; -import { toggle, delegate } from '@utils'; -import Highcharts from 'highcharts'; import Chartkick from 'chartkick'; - -export class ChartkickController extends Controller { - async connect() { - delegate('click', '[data-toggle-chart]', (event) => - toggleChart(event as MouseEvent) - ); - } -} +import Highcharts from 'highcharts'; +import invariant from 'tiny-invariant'; Chartkick.use(Highcharts); -function reflow(nextChartId?: string) { - nextChartId && Chartkick.charts[nextChartId]?.getChartObject()?.reflow(); -} - -function toggleChart(event: MouseEvent) { - const nextSelectorItem = event.target as HTMLButtonElement, - chartClass = nextSelectorItem.dataset.toggleChart, - nextChart = chartClass - ? document.querySelector(chartClass) - : undefined, - nextChartId = nextChart?.children[0]?.id, - currentSelectorItem = nextSelectorItem.parentElement?.querySelector( - '.segmented-control-item-active' - ), - currentChart = - nextSelectorItem.parentElement?.parentElement?.querySelector( - '.chart:not(.hidden)' - ); - - // Change the current selector and the next selector states - currentSelectorItem?.classList.toggle('segmented-control-item-active'); - nextSelectorItem.classList.toggle('segmented-control-item-active'); - - // Hide the currently shown chart and show the new one - currentChart && toggle(currentChart); - nextChart && toggle(nextChart); - - // Reflow needed, see https://github.com/highcharts/highcharts/issues/1979 - reflow(nextChartId); + +export default class ChartkickController extends Controller { + static targets = ['chart']; + + declare readonly chartTargets: HTMLElement[]; + + toggleChart(event: Event) { + const target = event.currentTarget as HTMLInputElement; + const chartClass = target.dataset.toggleChart; + + invariant(chartClass, 'Missing data-toggle-chart attribute'); + + const nextChart = document.querySelector(chartClass); + const currentChart = this.chartTargets.find( + (chart) => !chart.classList.contains('hidden') + ); + + if (currentChart) { + currentChart.classList.add('hidden'); + } + + if (nextChart) { + nextChart.classList.remove('hidden'); + const nextChartId = nextChart.children[0]?.id; + this.reflow(nextChartId); + } + } + + reflow(chartId: string) { + if (chartId) { + const chart = Chartkick.charts[chartId]; + if (chart) { + chart.getChartObject()?.reflow(); + } + } + } } diff --git a/app/javascript/controllers/lazy/lightbox_controller.ts b/app/javascript/controllers/lazy/lightbox_controller.ts new file mode 100644 index 000000000..2820c8a35 --- /dev/null +++ b/app/javascript/controllers/lazy/lightbox_controller.ts @@ -0,0 +1,48 @@ +import { ApplicationController } from '../application_controller'; +import lightGallery from 'lightgallery'; +import { LightGallery } from 'lightgallery/lightgallery'; +import lgThumbnail from 'lightgallery/plugins/thumbnail'; +import lgZoom from 'lightgallery/plugins/zoom'; +import lgRotate from 'lightgallery/plugins/rotate'; +import lgHash from 'lightgallery/plugins/hash'; +import 'lightgallery/css/lightgallery-bundle.css'; + +export default class extends ApplicationController { + lightGallery?: LightGallery; + + connect(): void { + const options = { + plugins: [lgZoom, lgThumbnail, lgRotate, lgHash], + flipVertical: false, + flipHorizontal: false, + animateThumb: false, + zoomFromOrigin: false, + allowMediaOverlap: true, + toggleThumb: true, + selector: '.gallery-link', + // license key is not mandatory for open source projects but we purchased + // an organization license to show our support (see https://www.lightgalleryjs.com/license/) + licenseKey: import.meta.env.VITE_LIGHTGALLERY_LICENSE_KEY + }; + + const gallery = document.querySelector('.gallery'); + + if (gallery != null) { + gallery.addEventListener('lgBeforeOpen', () => { + window.history.pushState({}, 'Gallery opened'); + }); + } + + this.lightGallery = lightGallery(this.element as HTMLElement, options); + + const downloadIcon = document.querySelector('.lg-download'); + + if (downloadIcon != null) { + downloadIcon.removeAttribute('target'); + } + } + + disconnect(): void { + this.lightGallery?.destroy(); + } +} diff --git a/app/javascript/controllers/tiptap_controller.ts b/app/javascript/controllers/lazy/tiptap_controller.ts similarity index 63% rename from app/javascript/controllers/tiptap_controller.ts rename to app/javascript/controllers/lazy/tiptap_controller.ts index caf7c4a66..eeb2a58bb 100644 --- a/app/javascript/controllers/tiptap_controller.ts +++ b/app/javascript/controllers/lazy/tiptap_controller.ts @@ -1,16 +1,17 @@ -import { Editor, type JSONContent } from '@tiptap/core'; +import { Editor } from '@tiptap/core'; import { isButtonElement, isHTMLElement } from '@coldwired/utils'; -import { z } from 'zod'; +import * as s from 'superstruct'; -import { ApplicationController } from './application_controller'; -import { getAction } from '../shared/tiptap/actions'; -import { tagSchema, type TagSchema } from '../shared/tiptap/tags'; -import { createEditor } from '../shared/tiptap/editor'; +import { ApplicationController } from '../application_controller'; +import { getAction } from '../../shared/tiptap/actions'; +import { tagSchema, type TagSchema } from '../../shared/tiptap/tags'; +import { createEditor } from '../../shared/tiptap/editor'; export class TiptapController extends ApplicationController { static targets = ['editor', 'input', 'button', 'tag']; static values = { - insertAfterTag: { type: String, default: '' } + insertAfterTag: { type: String, default: '' }, + attributes: { type: Object, default: {} } }; declare editorTarget: Element; @@ -18,6 +19,7 @@ export class TiptapController extends ApplicationController { declare buttonTargets: HTMLButtonElement[]; declare tagTargets: HTMLElement[]; declare insertAfterTagValue: string; + declare attributesValue: Record; #initializing = true; #editor?: Editor; @@ -28,6 +30,7 @@ export class TiptapController extends ApplicationController { content: this.content, tags: this.tags, buttons: this.menuButtons, + attributes: { class: 'fr-input', ...this.attributesValue }, onChange: ({ editor }) => { for (const button of this.buttonTargets) { const action = getAction(editor, button); @@ -61,7 +64,7 @@ export class TiptapController extends ApplicationController { insertTag(event: MouseEvent) { if (this.#editor && isHTMLElement(event.target)) { - const tag = tagSchema.parse(event.target.dataset); + const tag = s.create(event.target.dataset, tagSchema); const editor = this.#editor .chain() .focus() @@ -77,12 +80,12 @@ export class TiptapController extends ApplicationController { private get content() { const value = this.inputTarget.value; if (value) { - return jsonContentSchema.parse(JSON.parse(value)); + return s.create(JSON.parse(value), jsonContentSchema); } } private get tags(): TagSchema[] { - return this.tagTargets.map((tag) => tagSchema.parse(tag.dataset)); + return this.tagTargets.map((tag) => s.create(tag.dataset, tagSchema)); } private get menuButtons() { @@ -92,13 +95,24 @@ export class TiptapController extends ApplicationController { } } -const jsonContentSchema: z.ZodType = z.object({ - type: z.string().optional(), - text: z.string().optional(), - attrs: z.record(z.any()).optional(), - marks: z - .object({ type: z.string(), attrs: z.record(z.any()).optional() }) - .array() - .optional(), - content: z.lazy(() => z.array(jsonContentSchema).optional()) +const Attrs = s.record(s.string(), s.any()); +const Marks = s.array( + s.type({ + type: s.string(), + attrs: s.optional(Attrs) + }) +); +type JSONContent = { + type?: string; + text?: string; + attrs?: s.Infer; + marks?: s.Infer; + content?: JSONContent[]; +}; +const jsonContentSchema: s.Describe = s.type({ + type: s.optional(s.string()), + text: s.optional(s.string()), + attrs: s.optional(Attrs), + marks: s.optional(Marks), + content: s.lazy(() => s.optional(s.array(jsonContentSchema))) }); diff --git a/app/javascript/controllers/menu_button_controller.ts b/app/javascript/controllers/menu_button_controller.ts index c0b407242..b89b9025c 100644 --- a/app/javascript/controllers/menu_button_controller.ts +++ b/app/javascript/controllers/menu_button_controller.ts @@ -61,6 +61,12 @@ export class MenuButtonController extends ApplicationController { }); } + close() { + this.buttonTarget.setAttribute('aria-expanded', 'false'); + this.menuTarget.parentElement?.classList.remove('open'); + this.setFocusToMenuitem(null); + } + private open(focusMenuItem: 'first' | 'last' = 'first') { this.buttonTarget.setAttribute('aria-expanded', 'true'); this.menuTarget.parentElement?.classList.add('open'); @@ -75,17 +81,12 @@ export class MenuButtonController extends ApplicationController { }); } - private close() { - this.buttonTarget.setAttribute('aria-expanded', 'false'); - this.menuTarget.parentElement?.classList.remove('open'); - this.setFocusToMenuitem(null); - } - private isClickOutside(target: HTMLElement) { return ( target.isConnected && !this.element.contains(target) && !target.closest('reach-portal') && + !target.closest('#rac-portal') && this.isOpen ); } diff --git a/app/javascript/controllers/react_controller.tsx b/app/javascript/controllers/react_controller.tsx deleted file mode 100644 index cdaff89de..000000000 --- a/app/javascript/controllers/react_controller.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Controller } from '@hotwired/stimulus'; -import React, { lazy, Suspense, FunctionComponent } from 'react'; -import { render, unmountComponentAtNode } from 'react-dom'; -import invariant from 'tiny-invariant'; - -type Props = Record; -type Loader = () => Promise<{ default: FunctionComponent }>; -const componentsRegistry = new Map>(); -const components = import.meta.glob('../components/*.tsx'); - -for (const [path, loader] of Object.entries(components)) { - const [filename] = path.split('/').reverse(); - const componentClassName = filename.replace(/\.(ts|tsx)$/, ''); - console.debug( - `Registered lazy default export for "${componentClassName}" component` - ); - componentsRegistry.set( - componentClassName, - LoadableComponent(loader as Loader) - ); -} - -// Initialize React components when their markup appears into the DOM. -// -// Example: -//
    -// -export class ReactController extends Controller { - static values = { - component: String, - props: Object - }; - - declare readonly componentValue: string; - declare readonly propsValue: Props; - - connect(): void { - this.mountComponent(this.element as HTMLElement); - } - - disconnect(): void { - unmountComponentAtNode(this.element as HTMLElement); - } - - private mountComponent(node: HTMLElement): void { - const componentName = this.componentValue; - const props = this.propsValue; - const Component = this.getComponent(componentName); - - invariant( - Component, - `Cannot find a React component with class "${componentName}"` - ); - render(, node); - } - - private getComponent(componentName: string): FunctionComponent | null { - return componentsRegistry.get(componentName) ?? null; - } -} - -const Spinner = () =>
    ; - -function LoadableComponent(loader: Loader): FunctionComponent { - const LazyComponent = lazy(loader); - const Component: FunctionComponent = (props: Props) => ( - }> - - - ); - return Component; -} diff --git a/app/javascript/controllers/sticky_top_controller.ts b/app/javascript/controllers/sticky_top_controller.ts new file mode 100644 index 000000000..c8490cea0 --- /dev/null +++ b/app/javascript/controllers/sticky_top_controller.ts @@ -0,0 +1,43 @@ +import { ApplicationController } from './application_controller'; + +export class StickyTopController extends ApplicationController { + // Ajusts top of sticky top components when there is a sticky header. + + connect(): void { + const header = document.getElementById('sticky-header'); + + if (!header) { + return; + } + + this.adjustTop(header); + + window.addEventListener('resize', () => this.adjustTop(header)); + + this.listenHeaderMutations(header); + } + + private listenHeaderMutations(header: HTMLElement) { + const config = { childList: true, subtree: true }; + + const callback: MutationCallback = (mutationsList) => { + for (const mutation of mutationsList) { + if (mutation.type === 'childList') { + this.adjustTop(header); + break; + } + } + }; + + const observer = new MutationObserver(callback); + observer.observe(header, config); + } + + private adjustTop(header: HTMLElement) { + const headerHeight = header.clientHeight; + + if (headerHeight > 0) { + (this.element as HTMLElement).style.top = `${headerHeight + 8}px`; + } + } +} diff --git a/app/javascript/controllers/tiptap_to_template_controller.ts b/app/javascript/controllers/tiptap_to_template_controller.ts new file mode 100644 index 000000000..90f58294e --- /dev/null +++ b/app/javascript/controllers/tiptap_to_template_controller.ts @@ -0,0 +1,20 @@ +import { Controller } from '@hotwired/stimulus'; + +export class TiptapToTemplateController extends Controller { + static targets = ['output', 'trigger']; + + declare readonly outputTarget: HTMLElement; + declare readonly triggerTarget: HTMLButtonElement; + + connect() { + this.triggerTarget.addEventListener('click', this.handleClick.bind(this)); + } + + handleClick() { + const template = this.element.querySelector('.tiptap.ProseMirror p'); + + if (template) { + this.outputTarget.innerHTML = template.innerHTML; + } + } +} diff --git a/app/javascript/controllers/turbo_controller.ts b/app/javascript/controllers/turbo_controller.ts index 6c8374bc5..58e298294 100644 --- a/app/javascript/controllers/turbo_controller.ts +++ b/app/javascript/controllers/turbo_controller.ts @@ -1,7 +1,9 @@ import { Actions } from '@coldwired/actions'; import { parseTurboStream } from '@coldwired/turbo-stream'; +import { createRoot, createReactPlugin, type Root } from '@coldwired/react'; import invariant from 'tiny-invariant'; import { session as TurboSession, type StreamElement } from '@hotwired/turbo'; +import type { ComponentType } from 'react'; import { ApplicationController } from './application_controller'; @@ -20,6 +22,7 @@ export class TurboController extends ApplicationController { #submitting = false; #actions?: Actions; + #root?: Root; // `actions` instrface exposes all available actions as methods and also `applyActions` method // wich allows to apply a batch of actions. On top of regular `turbo-stream` actions we also @@ -32,6 +35,17 @@ export class TurboController extends ApplicationController { } connect() { + this.#root = createRoot({ + layoutComponentName: 'Layout/Layout', + loader, + schema: { + fragmentTagName: 'react-fragment', + componentTagName: 'react-component', + slotTagName: 'react-slot', + loadingClassName: 'loading' + } + }); + const plugin = createReactPlugin(this.#root); this.#actions = new Actions({ element: document.body, schema: { @@ -40,6 +54,7 @@ export class TurboController extends ApplicationController { focusDirectionAttribute: 'data-turbo-focus-direction', hiddenClassName: 'hidden' }, + plugins: [plugin], debug: false }); @@ -47,6 +62,10 @@ export class TurboController extends ApplicationController { // They allow us to preserve certain HTML changes across mutations. this.#actions.observe(); + this.#actions.ready().then(() => { + document.body.classList.add('dom-ready'); + }); + // setup spinner events this.onGlobal('turbo:submit-start', () => this.startSpinner()); this.onGlobal('turbo:submit-end', () => this.stopSpinner()); @@ -73,6 +92,11 @@ export class TurboController extends ApplicationController { }); } + disconnect(): void { + this.#actions?.disconnect(); + this.#root?.destroy(); + } + private startSpinner() { this.#submitting = true; this.actions.show({ targets: this.spinnerTargets }); @@ -89,3 +113,24 @@ export class TurboController extends ApplicationController { } } } + +type Loader = (exportName: string) => Promise>; +const componentsRegistry: Record = {}; +const components = import.meta.glob('../components/*.tsx'); + +const loader: Loader = (name) => { + const [moduleName, exportName] = name.split('/'); + const loader = componentsRegistry[moduleName]; + invariant(loader, `Cannot find a React component with name "${name}"`); + return loader(exportName ?? 'default'); +}; + +for (const [path, loader] of Object.entries(components)) { + const [filename] = path.split('/').reverse(); + const componentClassName = filename.replace(/\.(ts|tsx)$/, ''); + console.debug(`Registered lazy export for "${componentClassName}" component`); + componentsRegistry[componentClassName] = (exportName) => + loader().then( + (m) => (m as Record>)[exportName] + ); +} diff --git a/app/javascript/entrypoints/application.js b/app/javascript/entrypoints/application.js index 0ceb3b061..709340d42 100644 --- a/app/javascript/entrypoints/application.js +++ b/app/javascript/entrypoints/application.js @@ -3,11 +3,12 @@ import Rails from '@rails/ujs'; import * as ActiveStorage from '@rails/activestorage'; import * as Turbo from '@hotwired/turbo'; import { Application } from '@hotwired/stimulus'; -import '@gouvfr/dsfr/dist/dsfr.module.js'; +import '../shared/dsfr'; import '../shared/activestorage/ujs'; import '../shared/safari-11-empty-file-workaround'; import '../shared/toggle-target'; +import '../shared/intl-listformat'; import { registerControllers } from '../shared/stimulus-loader'; @@ -48,12 +49,5 @@ Turbo.session.drive = false; // Expose globals window.DS = window.DS || DS; -// enable legacy mode of DSFR when vite is not detectde as modern browser -window.addEventListener('load', function () { - if (!window.__vite_is_modern_browser) { - window.dsfr.internals.legacy.setLegacy(); - } -}); - import('../shared/track/matomo'); import('../shared/track/sentry'); diff --git a/app/javascript/entrypoints/axe-core.ts b/app/javascript/entrypoints/axe-core.ts deleted file mode 100644 index 4ca470d6e..000000000 --- a/app/javascript/entrypoints/axe-core.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { AxeResults, NodeResult, RelatedNode } from 'axe-core'; -import axe from 'axe-core'; - -domReady().then(() => { - axe.run(document.body, { reporter: 'v2' }).then((results) => { - logToConsole(results); - }); -}); - -// contrasted against Chrome default color of #ffffff -const lightTheme = { - serious: '#d93251', - minor: '#d24700', - text: 'black' -}; - -// contrasted against Safari dark mode color of #535353 -const darkTheme = { - serious: '#ffb3b3', - minor: '#ffd500', - text: 'white' -}; - -const theme = - window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches - ? darkTheme - : lightTheme; - -const boldCourier = 'font-weight:bold;font-family:Courier;'; -const critical = `color:${theme.serious};font-weight:bold;`; -const serious = `color:${theme.serious};font-weight:normal;`; -const moderate = `color:${theme.minor};font-weight:bold;`; -const minor = `color:${theme.minor};font-weight:normal;`; -const defaultReset = `font-color:${theme.text};font-weight:normal;`; - -function logToConsole(results: AxeResults): void { - console.group('%cNew axe issues', serious); - results.violations.forEach((result) => { - let fmt: string; - switch (result.impact) { - case 'critical': - fmt = critical; - break; - case 'serious': - fmt = serious; - break; - case 'moderate': - fmt = moderate; - break; - case 'minor': - fmt = minor; - break; - default: - fmt = minor; - break; - } - console.groupCollapsed( - '%c%s: %c%s %s', - fmt, - result.impact, - defaultReset, - result.help, - result.helpUrl - ); - result.nodes.forEach((node) => { - failureSummary(node, 'any'); - failureSummary(node, 'none'); - }); - console.groupEnd(); - }); - console.groupEnd(); -} - -function failureSummary(node: NodeResult, key: AxeCoreNodeResultKey): void { - if (node[key].length > 0) { - logElement(node, console.groupCollapsed); - logHtml(node); - logFailureMessage(node, key); - - let relatedNodes: RelatedNode[] = []; - node[key].forEach((check) => { - relatedNodes = relatedNodes.concat(check.relatedNodes ?? []); - }); - - if (relatedNodes.length > 0) { - console.groupCollapsed('Related nodes'); - relatedNodes.forEach((relatedNode) => { - logElement(relatedNode, console.log); - logHtml(relatedNode); - }); - console.groupEnd(); - } - - console.groupEnd(); - } -} - -function logFailureMessage(node: NodeResult, key: AxeCoreNodeResultKey): void { - // this exists on axe but we don't export it as part of the typescript - // namespace, so just let me use it as I need - const message: string = ( - axe as unknown as AxeWithAudit - )._audit.data.failureSummaries[key].failureMessage( - node[key].map((check) => check.message || '') - ); - - console.error(message); -} - -function logElement( - node: NodeResult | RelatedNode, - logFn: (...args: unknown[]) => void -): void { - const el = document.querySelector(node.target.toString()); - if (!el) { - logFn('Selector: %c%s', boldCourier, node.target.toString()); - } else { - logFn('Element: %o', el); - } -} - -function logHtml(node: NodeResult | RelatedNode): void { - console.log('HTML: %c%s', boldCourier, node.html); -} - -type AxeCoreNodeResultKey = 'any' | 'all' | 'none'; - -interface AxeWithAudit { - _audit: { - data: { - failureSummaries: { - any: { - failureMessage: (args: string[]) => string; - }; - all: { - failureMessage: (args: string[]) => string; - }; - none: { - failureMessage: (args: string[]) => string; - }; - }; - }; - }; -} - -function domReady() { - return new Promise((resolve) => { - if (document.readyState == 'loading') { - document.addEventListener('DOMContentLoaded', () => resolve(), { - once: true - }); - } else { - resolve(); - } - }); -} diff --git a/app/javascript/entrypoints/main.css b/app/javascript/entrypoints/main.css index aa7dec85b..aa49669eb 100644 --- a/app/javascript/entrypoints/main.css +++ b/app/javascript/entrypoints/main.css @@ -24,6 +24,7 @@ @import '@gouvfr/dsfr/dist/component/modal/modal.css'; @import '@gouvfr/dsfr/dist/component/navigation/navigation.css'; @import '@gouvfr/dsfr/dist/component/notice/notice.css'; +@import '@gouvfr/dsfr/dist/component/segmented/segmented.css'; @import '@gouvfr/dsfr/dist/component/table/table.css'; @import '@gouvfr/dsfr/dist/component/tile/tile.css'; @import '@gouvfr/dsfr/dist/component/tag/tag.css'; @@ -39,3 +40,4 @@ @import '@gouvfr/dsfr/dist/component/accordion/accordion.css'; @import '@gouvfr/dsfr/dist/component/tab/tab.css'; @import '@gouvfr/dsfr/dist/component/tooltip/tooltip.css'; +@import '@gouvfr/dsfr/dist/component/range/range.css'; diff --git a/app/javascript/entrypoints/playground.ts b/app/javascript/entrypoints/playground.ts index 476c9989b..07d7a135c 100644 --- a/app/javascript/entrypoints/playground.ts +++ b/app/javascript/entrypoints/playground.ts @@ -24,8 +24,7 @@ function GraphiQLWithExplorer() { plugins: [explorer], query: query, variables: defaultVariables, - onEditQuery: setQuery, - isHeadersEditorEnabled: false + onEditQuery: setQuery }); } diff --git a/app/javascript/shared/combobox-ui.ts b/app/javascript/shared/combobox-ui.ts deleted file mode 100644 index c55b7359d..000000000 --- a/app/javascript/shared/combobox-ui.ts +++ /dev/null @@ -1,470 +0,0 @@ -import invariant from 'tiny-invariant'; -import { isElement, dispatch, isInputElement } from '@coldwired/utils'; -import { dispatchAction } from '@coldwired/actions'; -import { createPopper, Instance as Popper } from '@popperjs/core'; - -import { - Combobox, - Action, - type State, - type Option, - type Hint, - type Fetcher -} from './combobox'; - -const ctrlBindings = !!navigator.userAgent.match(/Macintosh/); - -export type ComboboxUIOptions = { - input: HTMLInputElement; - selectedValueInput: HTMLInputElement; - list: HTMLUListElement; - item: HTMLTemplateElement; - valueSlots?: HTMLInputElement[] | NodeListOf; - allowsCustomValue?: boolean; - limit?: number; - hint?: HTMLElement; - getHintText?: (hint: Hint) => string; -}; - -export class ComboboxUI implements EventListenerObject { - #combobox?: Combobox; - #popper?: Popper; - #interactingWithList = false; - #mouseOverList = false; - #isComposing = false; - - #input: HTMLInputElement; - #selectedValueInput: HTMLInputElement; - #valueSlots: HTMLInputElement[]; - #list: HTMLUListElement; - #item: HTMLTemplateElement; - #hint?: HTMLElement; - - #getHintText = defaultGetHintText; - #allowsCustomValue: boolean; - #limit?: number; - - #selectedData: Option['data'] = null; - - constructor({ - input, - selectedValueInput, - valueSlots, - list, - item, - hint, - getHintText, - allowsCustomValue, - limit - }: ComboboxUIOptions) { - this.#input = input; - this.#selectedValueInput = selectedValueInput; - this.#valueSlots = valueSlots ? Array.from(valueSlots) : []; - this.#list = list; - this.#item = item; - this.#hint = hint; - this.#getHintText = getHintText ?? defaultGetHintText; - this.#allowsCustomValue = allowsCustomValue ?? false; - this.#limit = limit; - } - - init() { - if (this.#list.dataset.url) { - const fetcher = createFetcher(this.#list.dataset.url); - - this.#list.removeAttribute('data-url'); - - const selected: Option | null = this.#input.value - ? { label: this.#input.value, value: this.#selectedValueInput.value } - : null; - this.#combobox = new Combobox({ - options: fetcher, - selected, - allowsCustomValue: this.#allowsCustomValue, - limit: this.#limit, - render: (state) => this.render(state) - }); - } else { - const selectedValue = this.#selectedValueInput.value; - const options = JSON.parse( - this.#list.dataset.options ?? '[]' - ) as Option[]; - const selected = - options.find(({ value }) => value == selectedValue) ?? null; - - this.#list.removeAttribute('data-options'); - this.#list.removeAttribute('data-selected'); - - this.#combobox = new Combobox({ - options, - selected, - allowsCustomValue: this.#allowsCustomValue, - limit: this.#limit, - render: (state) => this.render(state) - }); - } - - this.#combobox.init(); - - this.#input.addEventListener('blur', this); - this.#input.addEventListener('focus', this); - this.#input.addEventListener('click', this); - this.#input.addEventListener('input', this); - this.#input.addEventListener('keydown', this); - - this.#list.addEventListener('mousedown', this); - this.#list.addEventListener('mouseenter', this); - this.#list.addEventListener('mouseleave', this); - - document.body.addEventListener('mouseup', this); - } - - destroy() { - this.#combobox?.destroy(); - this.#popper?.destroy(); - - this.#input.removeEventListener('blur', this); - this.#input.removeEventListener('focus', this); - this.#input.removeEventListener('click', this); - this.#input.removeEventListener('input', this); - this.#input.removeEventListener('keydown', this); - - this.#list.removeEventListener('mousedown', this); - this.#list.removeEventListener('mouseenter', this); - this.#list.removeEventListener('mouseleave', this); - - document.body.removeEventListener('mouseup', this); - } - - handleEvent(event: Event) { - switch (event.type) { - case 'input': - this.onInputChange(event as InputEvent); - break; - case 'blur': - this.onInputBlur(); - break; - case 'focus': - this.onInputFocus(); - break; - case 'click': - if (event.target == this.#input) { - this.onInputClick(event as MouseEvent); - } else { - this.onListClick(event as MouseEvent); - } - break; - case 'keydown': - this.onKeydown(event as KeyboardEvent); - break; - case 'mousedown': - this.onListMouseDown(); - break; - case 'mouseenter': - this.onListMouseEnter(); - break; - case 'mouseleave': - this.onListMouseLeave(); - break; - case 'mouseup': - this.onBodyMouseUp(event); - break; - case 'compositionstart': - case 'compositionend': - this.#isComposing = event.type == 'compositionstart'; - break; - } - } - - private get combobox() { - invariant(this.#combobox, 'ComboboxUI requires a Combobox instance'); - return this.#combobox; - } - - private render(state: State) { - console.debug('combobox render', state); - switch (state.action) { - case Action.Select: - case Action.Clear: - this.renderSelect(state); - break; - } - this.renderList(state); - this.renderOptionList(state); - this.renderValue(state); - this.renderHintForScreenReader(state.hint); - } - - private renderList(state: State): void { - if (state.open) { - if (!this.#list.hidden) return; - this.#list.hidden = false; - this.#list.classList.remove('hidden'); - this.#list.addEventListener('click', this); - - this.#input.setAttribute('aria-expanded', 'true'); - - this.#input.addEventListener('compositionstart', this); - this.#input.addEventListener('compositionend', this); - - this.#popper = createPopper(this.#input, this.#list, { - placement: 'bottom-start' - }); - } else { - if (this.#list.hidden) return; - this.#list.hidden = true; - this.#list.classList.add('hidden'); - this.#list.removeEventListener('click', this); - - this.#input.setAttribute('aria-expanded', 'false'); - this.#input.removeEventListener('compositionstart', this); - this.#input.removeEventListener('compositionend', this); - - this.#popper?.destroy(); - this.#interactingWithList = false; - } - } - - private renderValue(state: State): void { - if (this.#input.value != state.inputValue) { - this.#input.value = state.inputValue; - } - this.dispatchChange(() => { - if (this.#selectedValueInput.value != state.inputValue) { - if (state.allowsCustomValue || !state.inputValue) { - this.#selectedValueInput.value = state.inputValue; - } - } - return state.selection?.data; - }); - } - - private renderSelect(state: State): void { - this.dispatchChange(() => { - this.#selectedValueInput.value = state.selection?.value ?? ''; - this.#input.value = state.selection?.label ?? ''; - return state.selection?.data; - }); - } - - private renderOptionList(state: State): void { - const html = state.options - .map(({ label, value }) => { - const fragment = this.#item.content.cloneNode(true) as DocumentFragment; - const item = fragment.querySelector('li'); - if (item) { - item.id = optionId(value); - item.setAttribute('data-turbo-force', 'server'); - if (state.focused?.value == value) { - item.setAttribute('aria-selected', 'true'); - } else { - item.removeAttribute('aria-selected'); - } - item.setAttribute('data-value', value); - item.querySelector('slot[name="label"]')?.replaceWith(label); - return item.outerHTML; - } - return ''; - }) - .join(''); - - dispatchAction({ targets: this.#list, action: 'update', fragment: html }); - - if (state.focused) { - const id = optionId(state.focused.value); - const item = this.#list.querySelector(`#${id}`); - this.#input.setAttribute('aria-activedescendant', id); - if (item) { - scrollTo(this.#list, item); - } - } else { - this.#input.removeAttribute('aria-activedescendant'); - } - } - - private renderHintForScreenReader(hint: Hint | null): void { - if (this.#hint) { - if (hint) { - this.#hint.textContent = this.#getHintText(hint); - } else { - this.#hint.textContent = ''; - } - } - } - - private dispatchChange(cb: () => Option['data']): void { - const value = this.#selectedValueInput.value; - const data = cb(); - if (value != this.#selectedValueInput.value || data != this.#selectedData) { - this.#selectedData = data; - for (const input of this.#valueSlots) { - switch (input.dataset.valueSlot) { - case 'value': - input.value = this.#selectedValueInput.value; - break; - case 'label': - input.value = this.#input.value; - break; - case 'data:string': - input.value = data ? String(data) : ''; - break; - case 'data': - input.value = data ? JSON.stringify(data) : ''; - break; - } - } - console.debug('combobox change', this.#selectedValueInput.value); - dispatch('change', { - target: this.#selectedValueInput, - detail: data ? { data } : undefined - }); - } - } - - private onKeydown(event: KeyboardEvent): void { - if (event.shiftKey || event.metaKey || event.altKey) return; - if (!ctrlBindings && event.ctrlKey) return; - if (this.#isComposing) return; - - if (this.combobox.keyboard(event.key)) { - event.preventDefault(); - event.stopPropagation(); - } - } - - private onInputClick(event: MouseEvent): void { - const rect = this.#input.getBoundingClientRect(); - const clickOnArrow = - event.clientX >= rect.right - 40 && - event.clientX <= rect.right && - event.clientY >= rect.top && - event.clientY <= rect.bottom; - - if (clickOnArrow) { - this.combobox.toggle(); - } - } - - private onListClick(event: MouseEvent): void { - if (isElement(event.target)) { - const element = event.target.closest('[role="option"]'); - if (element) { - const value = element.getAttribute('data-value')?.trim(); - if (value) { - this.combobox.select(value); - } - } - } - } - - private onInputFocus(): void { - this.combobox.focus(); - } - - private onInputBlur(): void { - if (!this.#interactingWithList) { - this.combobox.close(); - } - } - - private onInputChange(event: InputEvent): void { - if (isInputElement(event.target)) { - this.combobox.input(event.target.value); - } - } - - private onListMouseDown(): void { - this.#interactingWithList = true; - } - - private onBodyMouseUp(event: Event): void { - if ( - this.#interactingWithList && - !this.#mouseOverList && - isElement(event.target) && - event.target != this.#list && - !this.#list.contains(event.target) - ) { - this.combobox.close(); - } - } - - private onListMouseEnter(): void { - this.#mouseOverList = true; - } - - private onListMouseLeave(): void { - this.#mouseOverList = false; - } -} - -function scrollTo(container: HTMLElement, target: HTMLElement): void { - if (!inViewport(container, target)) { - container.scrollTop = target.offsetTop; - } -} - -function inViewport(container: HTMLElement, element: HTMLElement): boolean { - const scrollTop = container.scrollTop; - const containerBottom = scrollTop + container.clientHeight; - const top = element.offsetTop; - const bottom = top + element.clientHeight; - return top >= scrollTop && bottom <= containerBottom; -} - -function optionId(value: string) { - return `option-${value - .toLowerCase() - // Replace spaces and special characters with underscores - .replace(/[^a-z0-9]/g, '_') - // Remove non-alphanumeric characters at start and end - .replace(/^[^a-z]+|[^\w]$/g, '')}`; -} - -function defaultGetHintText(hint: Hint): string { - switch (hint.type) { - case 'results': - if (hint.label) { - return `${hint.count} results. ${hint.label} is the top result: press Enter to activate.`; - } - return `${hint.count} results.`; - case 'empty': - return 'No results.'; - case 'selected': - return `${hint.label} selected.`; - } -} - -function createFetcher(source: string, param = 'q'): Fetcher { - const url = new URL(source, location.href); - - const fetcher: Fetcher = (term: string, options) => { - url.searchParams.set(param, term); - return fetch(url.toString(), { - headers: { accept: 'application/json' }, - signal: options?.signal - }).then((response) => { - if (response.ok) { - return response.json(); - } - return []; - }); - }; - - return async (term: string, options) => { - await wait(500, options?.signal); - return fetcher(term, options); - }; -} - -function wait(ms: number, signal?: AbortSignal) { - return new Promise((resolve, reject) => { - const abort = () => reject(new DOMException('Aborted', 'AbortError')); - if (signal?.aborted) { - abort(); - } else { - signal?.addEventListener('abort', abort); - setTimeout(resolve, ms); - } - }); -} diff --git a/app/javascript/shared/combobox.test.ts b/app/javascript/shared/combobox.test.ts deleted file mode 100644 index 45633b997..000000000 --- a/app/javascript/shared/combobox.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { suite, test, beforeEach, expect } from 'vitest'; -import { matchSorter } from 'match-sorter'; - -import { Combobox, Option, State } from './combobox'; - -suite('Combobox', () => { - const options: Option[] = - 'Fraises,Myrtilles,Framboises,Mûres,Canneberges,Groseilles,Baies de sureau,Mûres blanches,Baies de genièvre,Baies d’açaï' - .split(',') - .map((label) => ({ label, value: label })); - - let combobox: Combobox; - let currentState: State; - - suite('single select without custom value', () => { - suite('with default selection', () => { - beforeEach(() => { - combobox = new Combobox({ - options, - selected: options.at(0) ?? null, - render: (state) => { - currentState = state; - } - }); - combobox.init(); - }); - - test('open select box and select option with click', () => { - expect(currentState.open).toBeFalsy(); - expect(currentState.loading).toBe(null); - expect(currentState.selection?.label).toBe('Fraises'); - - combobox.open(); - expect(currentState.open).toBeTruthy(); - - combobox.select('Mûres'); - expect(currentState.selection?.label).toBe('Mûres'); - expect(currentState.open).toBeFalsy(); - }); - - test('open select box and select option with enter', () => { - expect(currentState.open).toBeFalsy(); - expect(currentState.selection?.label).toBe('Fraises'); - - combobox.keyboard('ArrowDown'); - expect(currentState.open).toBeTruthy(); - expect(currentState.selection?.label).toBe('Fraises'); - expect(currentState.focused?.label).toBe('Fraises'); - - combobox.keyboard('ArrowDown'); - expect(currentState.selection?.label).toBe('Fraises'); - expect(currentState.focused?.label).toBe('Myrtilles'); - - combobox.keyboard('Enter'); - expect(currentState.selection?.label).toBe('Myrtilles'); - expect(currentState.open).toBeFalsy(); - - combobox.keyboard('Enter'); - expect(currentState.selection?.label).toBe('Myrtilles'); - expect(currentState.open).toBeFalsy(); - }); - - test('open select box and select option with tab', () => { - combobox.keyboard('ArrowDown'); - combobox.keyboard('ArrowDown'); - - combobox.keyboard('Tab'); - expect(currentState.selection?.label).toBe('Myrtilles'); - expect(currentState.open).toBeFalsy(); - expect(currentState.hint).toEqual({ - type: 'selected', - label: 'Myrtilles' - }); - }); - - test('do not open select box on focus', () => { - combobox.focus(); - expect(currentState.open).toBeFalsy(); - }); - }); - - suite('empty', () => { - beforeEach(() => { - combobox = new Combobox({ - options, - selected: null, - render: (state) => { - currentState = state; - } - }); - combobox.init(); - }); - - test('open select box on focus', () => { - combobox.focus(); - expect(currentState.open).toBeTruthy(); - }); - - suite('open', () => { - beforeEach(() => { - combobox.open(); - }); - - test('if tab on empty input nothing is selected', () => { - expect(currentState.open).toBeTruthy(); - expect(currentState.selection).toBeNull(); - combobox.keyboard('Tab'); - - expect(currentState.open).toBeFalsy(); - expect(currentState.selection).toBeNull(); - }); - - test('if enter on empty input nothing is selected', () => { - expect(currentState.open).toBeTruthy(); - expect(currentState.selection).toBeNull(); - - combobox.keyboard('Enter'); - expect(currentState.open).toBeFalsy(); - expect(currentState.selection).toBeNull(); - }); - }); - - suite('closed', () => { - test('if tab on empty input nothing is selected', () => { - expect(currentState.open).toBeFalsy(); - expect(currentState.selection).toBeNull(); - - combobox.keyboard('Tab'); - expect(currentState.open).toBeFalsy(); - expect(currentState.selection).toBeNull(); - }); - - test('if enter on empty input nothing is selected', () => { - expect(currentState.open).toBeFalsy(); - expect(currentState.selection).toBeNull(); - - combobox.keyboard('Enter'); - expect(currentState.open).toBeFalsy(); - expect(currentState.selection).toBeNull(); - }); - }); - - test('type exact match and press enter', () => { - combobox.input('Baies'); - expect(currentState.open).toBeTruthy(); - expect(currentState.selection).toBeNull(); - expect(currentState.options.length).toEqual(3); - - combobox.keyboard('Enter'); - expect(currentState.open).toBeFalsy(); - expect(currentState.selection?.label).toBe('Baies d’açaï'); - }); - - test('type exact match and press tab', () => { - combobox.input('Baies'); - expect(currentState.open).toBeTruthy(); - expect(currentState.selection).toBeNull(); - - combobox.keyboard('Tab'); - expect(currentState.open).toBeFalsy(); - expect(currentState.selection?.label).toBe('Baies d’açaï'); - expect(currentState.inputValue).toEqual('Baies d’açaï'); - }); - - test('type non matching input and press enter', () => { - combobox.input('toto'); - expect(currentState.open).toBeFalsy(); - expect(currentState.selection).toBeNull(); - - combobox.keyboard('Enter'); - expect(currentState.open).toBeFalsy(); - expect(currentState.selection).toBeNull(); - expect(currentState.inputValue).toEqual(''); - }); - - test('type non matching input and press tab', () => { - combobox.input('toto'); - expect(currentState.open).toBeFalsy(); - expect(currentState.selection).toBeNull(); - - combobox.keyboard('Tab'); - expect(currentState.open).toBeFalsy(); - expect(currentState.selection).toBeNull(); - expect(currentState.inputValue).toEqual(''); - }); - - test('type non matching input and close', () => { - combobox.input('toto'); - expect(currentState.open).toBeFalsy(); - expect(currentState.selection).toBeNull(); - - combobox.close(); - expect(currentState.open).toBeFalsy(); - expect(currentState.selection).toBeNull(); - expect(currentState.inputValue).toEqual(''); - }); - - test('focus should circle', () => { - combobox.input('Baie'); - expect(currentState.open).toBeTruthy(); - expect(currentState.options.map(({ label }) => label)).toEqual([ - 'Baies d’açaï', - 'Baies de genièvre', - 'Baies de sureau' - ]); - expect(currentState.focused).toBeNull(); - combobox.keyboard('ArrowDown'); - expect(currentState.focused?.label).toBe('Baies d’açaï'); - combobox.keyboard('ArrowDown'); - expect(currentState.focused?.label).toBe('Baies de genièvre'); - combobox.keyboard('ArrowDown'); - expect(currentState.focused?.label).toBe('Baies de sureau'); - combobox.keyboard('ArrowDown'); - expect(currentState.focused?.label).toBe('Baies d’açaï'); - combobox.keyboard('ArrowUp'); - expect(currentState.focused?.label).toBe('Baies de sureau'); - }); - }); - }); - - suite('single select with custom value', () => { - beforeEach(() => { - combobox = new Combobox({ - options, - selected: null, - allowsCustomValue: true, - render: (state) => { - currentState = state; - } - }); - combobox.init(); - }); - - test('type non matching input and press enter', () => { - combobox.input('toto'); - expect(currentState.open).toBeFalsy(); - expect(currentState.selection).toBeNull(); - - combobox.keyboard('Enter'); - expect(currentState.open).toBeFalsy(); - expect(currentState.selection).toBeNull(); - expect(currentState.inputValue).toEqual('toto'); - }); - - test('type non matching input and press tab', () => { - combobox.input('toto'); - expect(currentState.open).toBeFalsy(); - expect(currentState.selection).toBeNull(); - - combobox.keyboard('Tab'); - expect(currentState.open).toBeFalsy(); - expect(currentState.selection).toBeNull(); - expect(currentState.inputValue).toEqual('toto'); - }); - - test('type non matching input and close', () => { - combobox.input('toto'); - expect(currentState.open).toBeFalsy(); - expect(currentState.selection).toBeNull(); - - combobox.close(); - expect(currentState.open).toBeFalsy(); - expect(currentState.selection).toBeNull(); - expect(currentState.inputValue).toEqual('toto'); - }); - }); - - suite('single select with fetcher', () => { - beforeEach(() => { - combobox = new Combobox({ - options: (term: string) => - Promise.resolve(matchSorter(options, term, { keys: ['value'] })), - selected: null, - render: (state) => { - currentState = state; - } - }); - combobox.init(); - }); - - test('type and get options from fetcher', async () => { - expect(currentState.open).toBeFalsy(); - expect(currentState.loading).toBe(false); - - const result = combobox.input('Baies'); - - expect(currentState.loading).toBe(true); - await result; - expect(currentState.loading).toBe(false); - expect(currentState.open).toBeTruthy(); - expect(currentState.selection).toBeNull(); - expect(currentState.options.length).toEqual(3); - }); - }); -}); diff --git a/app/javascript/shared/combobox.ts b/app/javascript/shared/combobox.ts deleted file mode 100644 index b5c82c524..000000000 --- a/app/javascript/shared/combobox.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { matchSorter } from 'match-sorter'; - -export enum Action { - Init = 'init', - Open = 'open', - Close = 'close', - Navigate = 'navigate', - Select = 'select', - Clear = 'clear', - Update = 'update' -} -export type Option = { value: string; label: string; data?: unknown }; -export type Hint = - | { - type: 'results'; - label: string | null; - count: number; - } - | { type: 'empty' } - | { type: 'selected'; label: string }; -export type State = { - action: Action; - open: boolean; - inputValue: string; - focused: Option | null; - selection: Option | null; - options: Option[]; - allowsCustomValue: boolean; - hint: Hint | null; - loading: boolean | null; -}; - -export type Fetcher = ( - term: string, - options?: { signal: AbortSignal } -) => Promise; - -export class Combobox { - #allowsCustomValue = false; - #limit?: number; - #open = false; - #inputValue = ''; - #selectedOption: Option | null = null; - #focusedOption: Option | null = null; - #options: Option[] = []; - #visibleOptions: Option[] = []; - #render: (state: State) => void; - #fetcher: Fetcher | null; - #abortController?: AbortController | null; - - constructor({ - options, - selected, - allowsCustomValue, - limit, - render - }: { - options: Option[] | Fetcher; - selected: Option | null; - allowsCustomValue?: boolean; - limit?: number; - render: (state: State) => void; - }) { - this.#allowsCustomValue = allowsCustomValue ?? false; - this.#limit = limit; - this.#options = Array.isArray(options) ? options : []; - this.#fetcher = Array.isArray(options) ? null : options; - this.#selectedOption = selected; - if (this.#selectedOption) { - this.#inputValue = this.#selectedOption.label; - } - this.#render = render; - } - - init(): void { - this.#visibleOptions = this._filterOptions(); - this._render(Action.Init); - } - - destroy(): void { - this.#render = () => null; - } - - navigate(indexDiff: -1 | 1 = 1): void { - const focusIndex = this._focusedOptionIndex; - const lastIndex = this.#visibleOptions.length - 1; - - let indexOfItem = indexDiff == 1 ? 0 : lastIndex; - if (focusIndex == lastIndex && indexDiff == 1) { - indexOfItem = 0; - } else if (focusIndex == 0 && indexDiff == -1) { - indexOfItem = lastIndex; - } else if (focusIndex == -1) { - indexOfItem = 0; - } else { - indexOfItem = focusIndex + indexDiff; - } - - this.#focusedOption = this.#visibleOptions.at(indexOfItem) ?? null; - - this._render(Action.Navigate); - } - - select(value?: string): boolean { - const maybeValue = this._nextSelectValue(value); - if (!maybeValue) { - this.close(); - return false; - } - - const option = this.#visibleOptions.find( - (option) => option.value.trim() == maybeValue.trim() - ); - if (!option) return false; - - this.#selectedOption = option; - this.#focusedOption = null; - this.#inputValue = option.label; - this.#open = false; - this.#visibleOptions = this._filterOptions(); - - this._render(Action.Select); - return true; - } - - async input(value: string) { - if (this.#inputValue == value) return; - - this.#inputValue = value; - - if (this.#fetcher) { - this.#abortController?.abort(); - this.#abortController = new AbortController(); - this._render(Action.Update); - this.#options = await this.#fetcher(value, { - signal: this.#abortController.signal - }).catch(() => []); - this.#abortController = null; - this._render(Action.Update); - - this.#selectedOption = null; - } else { - this.#selectedOption = null; - } - - this.#visibleOptions = this._filterOptions(); - - if (this.#visibleOptions.length > 0) { - if (!this.#open) { - this.open(); - } else { - this._render(Action.Update); - } - } else if (this.#allowsCustomValue) { - this.#open = false; - this.#focusedOption = null; - this._render(Action.Close); - } else { - this._render(Action.Update); - } - } - - keyboard(key: string) { - switch (key) { - case 'Enter': - case 'Tab': - return this.select(); - case 'Escape': - this.close(); - return true; - case 'ArrowDown': - if (this.#open) { - this.navigate(1); - } else { - this.open(); - } - return true; - case 'ArrowUp': - if (this.#open) { - this.navigate(-1); - } else { - this.open(); - } - return true; - } - } - - clear() { - if (!this.#inputValue && !this.#selectedOption) return; - this.#inputValue = ''; - this.#selectedOption = this.#focusedOption = null; - this.#visibleOptions = this.#options; - this.#visibleOptions = this._filterOptions(); - this._render(Action.Clear); - } - - open() { - if (this.#open || this.#visibleOptions.length == 0) return; - this.#open = true; - this.#focusedOption = this.#selectedOption; - this._render(Action.Open); - } - - close() { - this.#open = false; - this.#focusedOption = null; - if (!this.#allowsCustomValue && !this.#selectedOption) { - this.#inputValue = ''; - } - this.#visibleOptions = this._filterOptions(); - this._render(Action.Close); - } - - focus() { - if (this.#open) return; - if (this.#selectedOption) return; - - this.open(); - } - - toggle() { - this.#open ? this.close() : this.open(); - } - - private _nextSelectValue(value?: string): string | false { - if (value) { - return value; - } - if (this.#focusedOption && this._focusedOptionIndex != -1) { - return this.#focusedOption.value; - } - if (this.#allowsCustomValue) { - return false; - } - if (this.#inputValue.length > 0 && !this.#selectedOption) { - return this.#visibleOptions.at(0)?.value ?? false; - } - return false; - } - - private _filterOptions(): Option[] { - const emptyOrSelected = - !this.#inputValue || this.#inputValue == this.#selectedOption?.value; - const options = emptyOrSelected - ? this.#options - : matchSorter(this.#options, this.#inputValue, { - keys: ['label'] - }); - - if (this.#limit) { - return options.slice(0, this.#limit); - } - return options; - } - - private get _focusedOptionIndex(): number { - if (this.#focusedOption) { - return this.#visibleOptions.indexOf(this.#focusedOption); - } - return -1; - } - - private _render(action: Action): void { - this.#render(this._getState(action)); - } - - private _getState(action: Action): State { - const state = { - action, - open: this.#open, - options: this.#visibleOptions, - inputValue: this.#inputValue, - focused: this.#focusedOption, - selection: this.#selectedOption, - allowsCustomValue: this.#allowsCustomValue, - hint: null, - loading: this.#abortController ? true : this.#fetcher ? false : null - }; - - return { ...state, hint: this._getFeedback(state) }; - } - - private _getFeedback(state: State): Hint | null { - const count = state.options.length; - if (state.action == Action.Open || state.action == Action.Update) { - if (!state.selection) { - const defaultOption = state.options.at(0); - if (defaultOption) { - return { type: 'results', label: defaultOption.label, count }; - } else if (count > 0) { - return { type: 'results', label: null, count }; - } - return { type: 'empty' }; - } - } else if (state.action == Action.Select && state.selection) { - return { type: 'selected', label: state.selection.label }; - } - return null; - } -} diff --git a/app/javascript/shared/dsfr.ts b/app/javascript/shared/dsfr.ts new file mode 100644 index 000000000..4a342376d --- /dev/null +++ b/app/javascript/shared/dsfr.ts @@ -0,0 +1,18 @@ +import '@gouvfr/dsfr/dist/core/core.module'; +import '@gouvfr/dsfr/dist/scheme/scheme.module'; + +import '@gouvfr/dsfr/dist/component/display/display.module'; +import '@gouvfr/dsfr/dist/component/toggle/toggle.module'; +import '@gouvfr/dsfr/dist/component/breadcrumb/breadcrumb.module'; +import '@gouvfr/dsfr/dist/component/modal/modal.module'; +import '@gouvfr/dsfr/dist/component/navigation/navigation.module'; +import '@gouvfr/dsfr/dist/component/segmented/segmented.module'; +import '@gouvfr/dsfr/dist/component/table/table.module'; +import '@gouvfr/dsfr/dist/component/tile/tile.module'; +import '@gouvfr/dsfr/dist/component/card/card.module'; +import '@gouvfr/dsfr/dist/component/header/header.module'; +import '@gouvfr/dsfr/dist/component/sidemenu/sidemenu.module'; +import '@gouvfr/dsfr/dist/component/password/password.module'; +import '@gouvfr/dsfr/dist/component/accordion/accordion.module'; +import '@gouvfr/dsfr/dist/component/tab/tab.module'; +import '@gouvfr/dsfr/dist/component/tooltip/tooltip.module'; diff --git a/app/javascript/shared/intl-listformat.ts b/app/javascript/shared/intl-listformat.ts new file mode 100644 index 000000000..5beb754e0 --- /dev/null +++ b/app/javascript/shared/intl-listformat.ts @@ -0,0 +1,3 @@ +import '@formatjs/intl-listformat/polyfill'; +import '@formatjs/intl-listformat/locale-data/en'; +import '@formatjs/intl-listformat/locale-data/fr'; diff --git a/app/javascript/shared/polyfills/dataset.js b/app/javascript/shared/polyfills/dataset.js deleted file mode 100644 index 653787247..000000000 --- a/app/javascript/shared/polyfills/dataset.js +++ /dev/null @@ -1,75 +0,0 @@ -/* - @preserve dataset polyfill for IE < 11. See https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset and http://caniuse.com/#search=dataset - - @author ShirtlessKirk copyright 2015 - @license WTFPL (http://www.wtfpl.net/txt/copying) -*/ - -const dash = /-([a-z])/gi; -const dataRegEx = /^data-(.+)/; -const hasEventListener = !!document.addEventListener; -const test = document.createElement('_'); -const DOMAttrModified = 'DOMAttrModified'; - -let mutationSupport = false; - -function clearDataset(event) { - delete event.target._datasetCache; -} - -function toCamelCase(string) { - return string.replace(dash, function (_, letter) { - return letter.toUpperCase(); - }); -} - -function getDataset() { - const dataset = {}; - - for (let attribute of this.attributes) { - let match = attribute.name.match(dataRegEx); - if (match) { - dataset[toCamelCase(match[1])] = attribute.value; - } - } - - return dataset; -} - -function mutation() { - if (hasEventListener) { - test.removeEventListener(DOMAttrModified, mutation, false); - } else { - test.detachEvent(`on${DOMAttrModified}`, mutation); - } - - mutationSupport = true; -} - -if (!test.dataset) { - if (hasEventListener) { - test.addEventListener(DOMAttrModified, mutation, false); - } else { - test.attachEvent(`on${DOMAttrModified}`, mutation); - } - - // trigger event (if supported) - test.setAttribute('foo', 'bar'); - - Object.defineProperty(Element.prototype, 'dataset', { - get: mutationSupport - ? function get() { - if (!this._datasetCache) { - this._datasetCache = getDataset.call(this); - } - - return this._datasetCache; - } - : getDataset - }); - - if (mutationSupport && hasEventListener) { - // < IE9 supports neither - document.addEventListener(DOMAttrModified, clearDataset, false); - } -} diff --git a/app/javascript/shared/tiptap/actions.ts b/app/javascript/shared/tiptap/actions.ts index 727331112..6a1d6fcae 100644 --- a/app/javascript/shared/tiptap/actions.ts +++ b/app/javascript/shared/tiptap/actions.ts @@ -1,5 +1,5 @@ import { Editor } from '@tiptap/core'; -import { z } from 'zod'; +import * as s from 'superstruct'; type EditorAction = { run(): void; @@ -11,7 +11,7 @@ export function getAction( editor: Editor, button: HTMLButtonElement ): EditorAction { - return tiptapActionSchema.parse(button.dataset)(editor); + return s.create(button.dataset, tiptapActionSchema)(editor); } const EDITOR_ACTIONS: Record EditorAction> = { @@ -109,8 +109,15 @@ const EDITOR_ACTIONS: Record EditorAction> = { }) }; -const tiptapActionSchema = z - .object({ - tiptapAction: z.enum(Object.keys(EDITOR_ACTIONS) as [string, ...string[]]) - }) - .transform(({ tiptapAction }) => EDITOR_ACTIONS[tiptapAction]); +const EditorActionFn = s.define<(editor: Editor) => EditorAction>( + 'EditorActionFn', + (fn) => typeof fn == 'function' +); + +const tiptapActionSchema = s.coerce( + EditorActionFn, + s.type({ + tiptapAction: s.enums(Object.keys(EDITOR_ACTIONS) as [string, ...string[]]) + }), + ({ tiptapAction }) => EDITOR_ACTIONS[tiptapAction] +); diff --git a/app/javascript/shared/tiptap/editor.ts b/app/javascript/shared/tiptap/editor.ts index 3c12fc792..590aa0701 100644 --- a/app/javascript/shared/tiptap/editor.ts +++ b/app/javascript/shared/tiptap/editor.ts @@ -33,6 +33,7 @@ export function createEditor({ content, tags, buttons, + attributes, onChange }: { editorElement: Element; @@ -40,8 +41,15 @@ export function createEditor({ tags: TagSchema[]; buttons: string[]; onChange(change: { editor: Editor }): void; + attributes?: Record; }): Editor { - const options = getEditorOptions(editorElement, tags, buttons, content); + const options = getEditorOptions( + editorElement, + tags, + buttons, + content, + attributes + ); const editor = new Editor(options); editor.on('transaction', onChange); return editor; @@ -51,7 +59,8 @@ function getEditorOptions( element: Element, tags: TagSchema[], actions: string[], - content?: JSONContent + content?: JSONContent, + attributes?: Record ): Partial { const extensions: Extensions = []; for (const action of actions) { @@ -123,7 +132,7 @@ function getEditorOptions( return { element, content, - editorProps: { attributes: { class: 'fr-input' } }, + editorProps: { attributes }, extensions: [ actions.includes('title') ? DocumentWithHeader : Document, Hystory, diff --git a/app/javascript/shared/tiptap/tags.ts b/app/javascript/shared/tiptap/tags.ts index 3a76ca1cd..fc79ffeb5 100644 --- a/app/javascript/shared/tiptap/tags.ts +++ b/app/javascript/shared/tiptap/tags.ts @@ -1,12 +1,17 @@ import type { SuggestionOptions, SuggestionProps } from '@tiptap/suggestion'; -import { z } from 'zod'; +import * as s from 'superstruct'; import tippy, { type Instance as TippyInstance } from 'tippy.js'; import { matchSorter } from 'match-sorter'; -export const tagSchema = z - .object({ tagLabel: z.string(), tagId: z.string() }) - .transform(({ tagId, tagLabel }) => ({ label: tagLabel, id: tagId })); -export type TagSchema = z.infer; +export const tagSchema = s.coerce( + s.object({ label: s.string(), id: s.string() }), + s.type({ + tagLabel: s.string(), + tagId: s.string() + }), + ({ tagId, tagLabel }) => ({ label: tagLabel, id: tagId }) +); +export type TagSchema = s.Infer; class SuggestionMenu { #selectedIndex = 0; diff --git a/app/javascript/shared/track/sentry.ts b/app/javascript/shared/track/sentry.ts index 1cbb3053b..14d880625 100644 --- a/app/javascript/shared/track/sentry.ts +++ b/app/javascript/shared/track/sentry.ts @@ -23,10 +23,9 @@ if (enabled && key) { ] }); - Sentry.configureScope((scope) => { - scope.setUser(user); - scope.setExtra('browser', browser.modern ? 'modern' : 'legacy'); - }); + const scope = Sentry.getCurrentScope(); + scope.setUser(user); + scope.setExtra('browser', browser.modern ? 'modern' : 'legacy'); // Register a way to explicitely capture messages from a different bundle. addEventListener('sentry:capture-exception', (event) => { diff --git a/app/javascript/shared/utils.ts b/app/javascript/shared/utils.ts index 5a756a850..8602ff8e8 100644 --- a/app/javascript/shared/utils.ts +++ b/app/javascript/shared/utils.ts @@ -1,72 +1,87 @@ import { session } from '@hotwired/turbo'; -import { z } from 'zod'; +import * as s from 'superstruct'; -const Gon = z - .object({ - autosave: z - .object({ - debounce_delay: z.number().default(0), - status_visible_duration: z.number().default(0) - }) - .default({}), - autocomplete: z - .object({ - api_geo_url: z.string().optional(), - api_adresse_url: z.string().optional(), - api_education_url: z.string().optional() - }) - .default({}), - locale: z.string().default('fr'), - matomo: z - .object({ - cookieDomain: z.string().optional(), - domain: z.string().optional(), - enabled: z.boolean().default(false), - host: z.string().optional(), - key: z.string().or(z.number()).nullish() - }) - .default({}), - sentry: z - .object({ - key: z.string().nullish(), - enabled: z.boolean().default(false), - environment: z.string().optional(), - user: z.object({ id: z.string() }).default({ id: '' }), - browser: z.object({ modern: z.boolean() }).default({ modern: false }), - release: z.string().nullish() - }) - .default({}), - crisp: z - .object({ - key: z.string().nullish(), - enabled: z.boolean().default(false), - administrateur: z - .object({ - email: z.string(), - DS_SIGN_IN_COUNT: z.number(), - DS_NB_DEMARCHES_BROUILLONS: z.number(), - DS_NB_DEMARCHES_ACTIVES: z.number(), - DS_NB_DEMARCHES_ARCHIVES: z.number(), - DS_ID: z.number() - }) - .default({ +function nullish(struct: s.Struct) { + return s.optional(s.union([s.literal(null), struct])); +} + +const Gon = s.defaulted( + s.type({ + autosave: s.defaulted( + s.type({ + debounce_delay: s.defaulted(s.number(), 0), + status_visible_duration: s.defaulted(s.number(), 0) + }), + {} + ), + autocomplete: s.defaulted( + s.partial( + s.type({ + api_geo_url: s.string(), + api_adresse_url: s.string(), + api_education_url: s.string() + }) + ), + {} + ), + locale: s.defaulted(s.string(), 'fr'), + matomo: s.defaulted( + s.type({ + cookieDomain: s.optional(s.string()), + domain: s.optional(s.string()), + enabled: s.defaulted(s.boolean(), false), + host: s.optional(s.string()), + key: nullish(s.union([s.string(), s.number()])) + }), + {} + ), + sentry: s.defaulted( + s.type({ + key: nullish(s.string()), + enabled: s.defaulted(s.boolean(), false), + environment: s.optional(s.string()), + user: s.defaulted(s.type({ id: s.string() }), { id: '' }), + browser: s.defaulted(s.type({ modern: s.boolean() }), { + modern: false + }), + release: nullish(s.string()) + }), + {} + ), + crisp: s.defaulted( + s.type({ + key: nullish(s.string()), + enabled: s.defaulted(s.boolean(), false), + administrateur: s.defaulted( + s.type({ + email: s.string(), + DS_SIGN_IN_COUNT: s.number(), + DS_NB_DEMARCHES_BROUILLONS: s.number(), + DS_NB_DEMARCHES_ACTIVES: s.number(), + DS_NB_DEMARCHES_ARCHIVES: s.number(), + DS_ID: s.number() + }), + { email: '', DS_SIGN_IN_COUNT: 0, DS_NB_DEMARCHES_BROUILLONS: 0, DS_NB_DEMARCHES_ACTIVES: 0, DS_NB_DEMARCHES_ARCHIVES: 0, DS_ID: 0 - }) - }) - .default({}), - defaultQuery: z.string().optional(), - defaultVariables: z.string().optional() - }) - .default({}); + } + ) + }), + {} + ), + defaultQuery: s.optional(s.string()), + defaultVariables: s.optional(s.string()) + }), + {} +); declare const window: Window & typeof globalThis & { gon: unknown }; export function getConfig() { - return Gon.parse(window.gon); + return s.create(window.gon, Gon); } export function show(el: Element | null) { diff --git a/app/jobs/admin_update_default_zones_job.rb b/app/jobs/admin_update_default_zones_job.rb index 421526cb4..f09cee59f 100644 --- a/app/jobs/admin_update_default_zones_job.rb +++ b/app/jobs/admin_update_default_zones_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AdminUpdateDefaultZonesJob < ApplicationJob def perform(admin) tchap_hs = APITchap::HsAdapter.new(admin.email).to_hs diff --git a/app/jobs/api_entreprise/association_job.rb b/app/jobs/api_entreprise/association_job.rb index 45e2cfb73..9e3ad13a4 100644 --- a/app/jobs/api_entreprise/association_job.rb +++ b/app/jobs/api_entreprise/association_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::AssociationJob < APIEntreprise::Job def perform(etablissement_id, procedure_id) find_etablissement(etablissement_id) diff --git a/app/jobs/api_entreprise/attestation_fiscale_job.rb b/app/jobs/api_entreprise/attestation_fiscale_job.rb index 6b01a89f1..fe1db782d 100644 --- a/app/jobs/api_entreprise/attestation_fiscale_job.rb +++ b/app/jobs/api_entreprise/attestation_fiscale_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::AttestationFiscaleJob < APIEntreprise::Job def perform(etablissement_id, procedure_id, user_id) find_etablissement(etablissement_id) diff --git a/app/jobs/api_entreprise/attestation_sociale_job.rb b/app/jobs/api_entreprise/attestation_sociale_job.rb index f385a14d3..373cc3b07 100644 --- a/app/jobs/api_entreprise/attestation_sociale_job.rb +++ b/app/jobs/api_entreprise/attestation_sociale_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::AttestationSocialeJob < APIEntreprise::Job def perform(etablissement_id, procedure_id) find_etablissement(etablissement_id) diff --git a/app/jobs/api_entreprise/bilans_bdf_job.rb b/app/jobs/api_entreprise/bilans_bdf_job.rb index 9dca78f85..2dad9f4c1 100644 --- a/app/jobs/api_entreprise/bilans_bdf_job.rb +++ b/app/jobs/api_entreprise/bilans_bdf_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::BilansBdfJob < APIEntreprise::Job def perform(etablissement_id, procedure_id) find_etablissement(etablissement_id) diff --git a/app/jobs/api_entreprise/effectifs_annuels_job.rb b/app/jobs/api_entreprise/effectifs_annuels_job.rb index e3bcb7908..6154e1ab7 100644 --- a/app/jobs/api_entreprise/effectifs_annuels_job.rb +++ b/app/jobs/api_entreprise/effectifs_annuels_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::EffectifsAnnuelsJob < APIEntreprise::Job def perform(etablissement_id, procedure_id, year = default_year) find_etablissement(etablissement_id) diff --git a/app/jobs/api_entreprise/effectifs_job.rb b/app/jobs/api_entreprise/effectifs_job.rb index 40bdc21a2..63e039260 100644 --- a/app/jobs/api_entreprise/effectifs_job.rb +++ b/app/jobs/api_entreprise/effectifs_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::EffectifsJob < APIEntreprise::Job def perform(etablissement_id, procedure_id) etablissement = Etablissement.find(etablissement_id) diff --git a/app/jobs/api_entreprise/entreprise_job.rb b/app/jobs/api_entreprise/entreprise_job.rb index 35c0af070..1fe7c37fa 100644 --- a/app/jobs/api_entreprise/entreprise_job.rb +++ b/app/jobs/api_entreprise/entreprise_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::EntrepriseJob < APIEntreprise::Job def perform(etablissement_id, procedure_id) find_etablissement(etablissement_id) diff --git a/app/jobs/api_entreprise/etablissement_job.rb b/app/jobs/api_entreprise/etablissement_job.rb new file mode 100644 index 000000000..fac8732f6 --- /dev/null +++ b/app/jobs/api_entreprise/etablissement_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class APIEntreprise::EtablissementJob < APIEntreprise::Job + def perform(etablissement_id, procedure_id) + find_etablissement(etablissement_id) + APIEntrepriseService.update_etablissement_from_degraded_mode(etablissement, procedure_id) + end +end diff --git a/app/jobs/api_entreprise/exercices_job.rb b/app/jobs/api_entreprise/exercices_job.rb index de0759a09..0f0afcbe2 100644 --- a/app/jobs/api_entreprise/exercices_job.rb +++ b/app/jobs/api_entreprise/exercices_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::ExercicesJob < APIEntreprise::Job rescue_from(APIEntreprise::API::Error::BadFormatRequest) do |exception| end diff --git a/app/jobs/api_entreprise/extrait_kbis_job.rb b/app/jobs/api_entreprise/extrait_kbis_job.rb index 45b80a486..7ebc097a0 100644 --- a/app/jobs/api_entreprise/extrait_kbis_job.rb +++ b/app/jobs/api_entreprise/extrait_kbis_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::ExtraitKbisJob < APIEntreprise::Job def perform(etablissement_id, procedure_id) find_etablissement(etablissement_id) diff --git a/app/jobs/api_entreprise/job.rb b/app/jobs/api_entreprise/job.rb index f62a31d69..d228acbe7 100644 --- a/app/jobs/api_entreprise/job.rb +++ b/app/jobs/api_entreprise/job.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + class APIEntreprise::Job < ApplicationJob DEFAULT_MAX_ATTEMPTS_API_ENTREPRISE_JOBS = 5 - queue_as :api_entreprise + queue_as :default # BadGateway could mean # - acoss: réessayer ultérieurement diff --git a/app/jobs/api_entreprise/service_job.rb b/app/jobs/api_entreprise/service_job.rb index 01e5c62c3..f8b8eaa94 100644 --- a/app/jobs/api_entreprise/service_job.rb +++ b/app/jobs/api_entreprise/service_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::ServiceJob < APIEntreprise::Job def perform(service_id) service = Service.find(service_id) diff --git a/app/jobs/api_entreprise/tva_job.rb b/app/jobs/api_entreprise/tva_job.rb index 630409fb0..8573a9d66 100644 --- a/app/jobs/api_entreprise/tva_job.rb +++ b/app/jobs/api_entreprise/tva_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::TvaJob < APIEntreprise::Job def perform(etablissement_id, procedure_id) find_etablissement(etablissement_id) diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb index f9c3a8b34..505ca1586 100644 --- a/app/jobs/application_job.rb +++ b/app/jobs/application_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ApplicationJob < ActiveJob::Base include ActiveJob::RetryOnTransientErrors diff --git a/app/jobs/archive_creation_job.rb b/app/jobs/archive_creation_job.rb index 7a7abbfa2..de4069fcf 100644 --- a/app/jobs/archive_creation_job.rb +++ b/app/jobs/archive_creation_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ArchiveCreationJob < ApplicationJob discard_on ActiveRecord::RecordNotFound diff --git a/app/jobs/auto_archive_procedure_dossiers_job.rb b/app/jobs/auto_archive_procedure_dossiers_job.rb new file mode 100644 index 000000000..44b3387ab --- /dev/null +++ b/app/jobs/auto_archive_procedure_dossiers_job.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class AutoArchiveProcedureDossiersJob < ApplicationJob + def perform(procedure) + procedure + .dossiers + .state_en_construction + .find_each do |d| + begin + d.passer_automatiquement_en_instruction! + rescue StandardError => e + Sentry.capture_exception(e, extra: { procedure_id: procedure.id }) + end + end + end +end diff --git a/app/jobs/batch_operation_enqueue_all_job.rb b/app/jobs/batch_operation_enqueue_all_job.rb index e01810b93..f99d7a79d 100644 --- a/app/jobs/batch_operation_enqueue_all_job.rb +++ b/app/jobs/batch_operation_enqueue_all_job.rb @@ -1,4 +1,8 @@ +# frozen_string_literal: true + class BatchOperationEnqueueAllJob < ApplicationJob + queue_as :critical + def perform(batch_operation) batch_operation.enqueue_all end diff --git a/app/jobs/batch_operation_process_one_job.rb b/app/jobs/batch_operation_process_one_job.rb index 32870ac57..102b27a76 100644 --- a/app/jobs/batch_operation_process_one_job.rb +++ b/app/jobs/batch_operation_process_one_job.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + class BatchOperationProcessOneJob < ApplicationJob + queue_as :critical retry_on StandardError, attempts: 1 # default 5, for now no retryable behavior def perform(batch_operation, dossier) diff --git a/app/jobs/champ_fetch_external_data_job.rb b/app/jobs/champ_fetch_external_data_job.rb index 648150d9d..134d579e0 100644 --- a/app/jobs/champ_fetch_external_data_job.rb +++ b/app/jobs/champ_fetch_external_data_job.rb @@ -1,5 +1,8 @@ +# frozen_string_literal: true + class ChampFetchExternalDataJob < ApplicationJob discard_on ActiveJob::DeserializationError + queue_as :critical # ui feedback, asap include Dry::Monads[:result] diff --git a/app/jobs/concerns/datagouv_cron_schedulable_concern.rb b/app/jobs/concerns/datagouv_cron_schedulable_concern.rb index c1d10114a..0394d788a 100644 --- a/app/jobs/concerns/datagouv_cron_schedulable_concern.rb +++ b/app/jobs/concerns/datagouv_cron_schedulable_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DatagouvCronSchedulableConcern extend ActiveSupport::Concern class_methods do diff --git a/app/jobs/cron/administrateur_activate_before_expiration_job.rb b/app/jobs/cron/administrateur_activate_before_expiration_job.rb index 0c5779e16..d6c962715 100644 --- a/app/jobs/cron/administrateur_activate_before_expiration_job.rb +++ b/app/jobs/cron/administrateur_activate_before_expiration_job.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class Cron::AdministrateurActivateBeforeExpirationJob < Cron::CronJob - self.schedule_expression = "every day at 8 am" + self.schedule_expression = "every day at 08:00" def perform(*args) Administrateur diff --git a/app/jobs/cron/auto_archive_procedure_job.rb b/app/jobs/cron/auto_archive_procedure_job.rb index 9a998cf43..6dd5593fb 100644 --- a/app/jobs/cron/auto_archive_procedure_job.rb +++ b/app/jobs/cron/auto_archive_procedure_job.rb @@ -1,17 +1,16 @@ +# frozen_string_literal: true + class Cron::AutoArchiveProcedureJob < Cron::CronJob self.schedule_expression = "every 1 minute" + queue_as :critical def perform(*args) procedures_to_close.each do |procedure| # A buggy procedure should NEVER prevent the closing of another procedure # we therefore exceptionally add a `begin resue` block. begin - procedure - .dossiers - .state_en_construction - .find_each(&:passer_automatiquement_en_instruction!) - procedure.close! + AutoArchiveProcedureDossiersJob.perform_later(procedure) rescue StandardError => e Sentry.capture_exception(e, extra: { procedure_id: procedure.id }) end diff --git a/app/jobs/cron/backfill_siret_degraded_mode_job.rb b/app/jobs/cron/backfill_siret_degraded_mode_job.rb index 11ac5d0fb..bb80ed637 100644 --- a/app/jobs/cron/backfill_siret_degraded_mode_job.rb +++ b/app/jobs/cron/backfill_siret_degraded_mode_job.rb @@ -1,3 +1,6 @@ +# frozen_string_literal: true + +# TODO: remove this job in a few days once all failed etablissements are queued as separate jobs class Cron::BackfillSiretDegradedModeJob < Cron::CronJob self.schedule_expression = "every 2 hour" diff --git a/app/jobs/cron/cron_job.rb b/app/jobs/cron/cron_job.rb index 10a3f4524..7ca5e04e6 100644 --- a/app/jobs/cron/cron_job.rb +++ b/app/jobs/cron/cron_job.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class Cron::CronJob < ApplicationJob - queue_as :cron + queue_as :default class_attribute :schedule_expression class << self diff --git a/app/jobs/cron/datagouv/account_by_month_job.rb b/app/jobs/cron/datagouv/account_by_month_job.rb index 187da87db..77a6d0045 100644 --- a/app/jobs/cron/datagouv/account_by_month_job.rb +++ b/app/jobs/cron/datagouv/account_by_month_job.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + class Cron::Datagouv::AccountByMonthJob < Cron::CronJob include DatagouvCronSchedulableConcern - self.schedule_expression = "every month at 3:00" + self.schedule_expression = "every month at 4:30" FILE_NAME = "nb_comptes_crees_par_mois" def perform(*args) diff --git a/app/jobs/cron/datagouv/administrateur_by_month_job.rb b/app/jobs/cron/datagouv/administrateur_by_month_job.rb index 1391df690..b4cc46846 100644 --- a/app/jobs/cron/datagouv/administrateur_by_month_job.rb +++ b/app/jobs/cron/datagouv/administrateur_by_month_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Cron::Datagouv::AdministrateurByMonthJob < Cron::CronJob include DatagouvCronSchedulableConcern self.schedule_expression = "every month at 3:00" diff --git a/app/jobs/cron/datagouv/export_and_publish_demarches_publiques_job.rb b/app/jobs/cron/datagouv/export_and_publish_demarches_publiques_job.rb index c11b8eec4..7218329bb 100644 --- a/app/jobs/cron/datagouv/export_and_publish_demarches_publiques_job.rb +++ b/app/jobs/cron/datagouv/export_and_publish_demarches_publiques_job.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + class Cron::Datagouv::ExportAndPublishDemarchesPubliquesJob < Cron::CronJob include DatagouvCronSchedulableConcern - self.schedule_expression = "every month at 4:00" + self.schedule_expression = "every month at 4:10" def perform(*args) gzip_filepath = [ diff --git a/app/jobs/cron/datagouv/file_by_month_job.rb b/app/jobs/cron/datagouv/file_by_month_job.rb index 7cf7b0357..51a52a4c8 100644 --- a/app/jobs/cron/datagouv/file_by_month_job.rb +++ b/app/jobs/cron/datagouv/file_by_month_job.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + class Cron::Datagouv::FileByMonthJob < Cron::CronJob include DatagouvCronSchedulableConcern - self.schedule_expression = "every month at 3:00" + self.schedule_expression = "every month at 3:15" FILE_NAME = "nb_dossiers_crees_par_mois" def perform(*args) diff --git a/app/jobs/cron/datagouv/file_depose_by_month_job.rb b/app/jobs/cron/datagouv/file_depose_by_month_job.rb index 4191356e9..dd3d89922 100644 --- a/app/jobs/cron/datagouv/file_depose_by_month_job.rb +++ b/app/jobs/cron/datagouv/file_depose_by_month_job.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + class Cron::Datagouv::FileDeposeByMonthJob < Cron::CronJob include DatagouvCronSchedulableConcern - self.schedule_expression = "every month at 3:00" + self.schedule_expression = "every month at 5:00" FILE_NAME = "nb_dossiers_deposes_par_mois" def perform(*args) diff --git a/app/jobs/cron/datagouv/instructeur_by_month_job.rb b/app/jobs/cron/datagouv/instructeur_by_month_job.rb index ea98d5dd9..718937a6d 100644 --- a/app/jobs/cron/datagouv/instructeur_by_month_job.rb +++ b/app/jobs/cron/datagouv/instructeur_by_month_job.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + class Cron::Datagouv::InstructeurByMonthJob < Cron::CronJob include DatagouvCronSchedulableConcern - self.schedule_expression = "every month at 3:00" + self.schedule_expression = "every month at 4:00" FILE_NAME = "nb_instructeurs_crees_par_mois" def perform(*args) diff --git a/app/jobs/cron/datagouv/instructeur_connected_by_month_job.rb b/app/jobs/cron/datagouv/instructeur_connected_by_month_job.rb index cbcb20e08..97319ace6 100644 --- a/app/jobs/cron/datagouv/instructeur_connected_by_month_job.rb +++ b/app/jobs/cron/datagouv/instructeur_connected_by_month_job.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + class Cron::Datagouv::InstructeurConnectedByMonthJob < Cron::CronJob include DatagouvCronSchedulableConcern - self.schedule_expression = "every month at 3:00" + self.schedule_expression = "every month at 4:45" FILE_NAME = "nb_instructeurs_connectes_par_mois" def perform(*args) diff --git a/app/jobs/cron/datagouv/procedure_by_month_job.rb b/app/jobs/cron/datagouv/procedure_by_month_job.rb index 2a044cf43..7be1725de 100644 --- a/app/jobs/cron/datagouv/procedure_by_month_job.rb +++ b/app/jobs/cron/datagouv/procedure_by_month_job.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + class Cron::Datagouv::ProcedureByMonthJob < Cron::CronJob include DatagouvCronSchedulableConcern - self.schedule_expression = "every month at 3:00" + self.schedule_expression = "every month at 4:15" FILE_NAME = "nb_procedures_creees_par_mois" def perform(*args) diff --git a/app/jobs/cron/datagouv/procedure_closed_by_month_job.rb b/app/jobs/cron/datagouv/procedure_closed_by_month_job.rb index 315aa7474..b42ebab5a 100644 --- a/app/jobs/cron/datagouv/procedure_closed_by_month_job.rb +++ b/app/jobs/cron/datagouv/procedure_closed_by_month_job.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + class Cron::Datagouv::ProcedureClosedByMonthJob < Cron::CronJob include DatagouvCronSchedulableConcern - self.schedule_expression = "every month at 3:00" + self.schedule_expression = "every month at 4:00" FILE_NAME = "nb_procedures_closes_par_mois" def perform(*args) diff --git a/app/jobs/cron/datagouv/procedure_deleted_by_month_job.rb b/app/jobs/cron/datagouv/procedure_deleted_by_month_job.rb index 17bdccf63..a52ec15bc 100644 --- a/app/jobs/cron/datagouv/procedure_deleted_by_month_job.rb +++ b/app/jobs/cron/datagouv/procedure_deleted_by_month_job.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + class Cron::Datagouv::ProcedureDeletedByMonthJob < Cron::CronJob include DatagouvCronSchedulableConcern - self.schedule_expression = "every month at 3:00" + self.schedule_expression = "every month at 3:30" FILE_NAME = "nb_procedures_supprimees_par_mois" def perform(*args) diff --git a/app/jobs/cron/datagouv/user_connected_with_france_connect_by_month_job.rb b/app/jobs/cron/datagouv/user_connected_with_france_connect_by_month_job.rb index a4aea9be7..2938474ce 100644 --- a/app/jobs/cron/datagouv/user_connected_with_france_connect_by_month_job.rb +++ b/app/jobs/cron/datagouv/user_connected_with_france_connect_by_month_job.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + class Cron::Datagouv::UserConnectedWithFranceConnectByMonthJob < Cron::CronJob include DatagouvCronSchedulableConcern - self.schedule_expression = "every month at 3:00" + self.schedule_expression = "every month at 3:45" FILE_NAME = "nb_utilisateurs_connectes_france_connect_par_mois" def perform(*args) diff --git a/app/jobs/cron/discarded_dossiers_deletion_job.rb b/app/jobs/cron/discarded_dossiers_deletion_job.rb index 9db9623bf..82490e3d4 100644 --- a/app/jobs/cron/discarded_dossiers_deletion_job.rb +++ b/app/jobs/cron/discarded_dossiers_deletion_job.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class Cron::DiscardedDossiersDeletionJob < Cron::CronJob - self.schedule_expression = "every day at 2 am" + self.schedule_expression = "every day at 02:00" def perform Dossier.purge_discarded diff --git a/app/jobs/cron/discarded_procedures_deletion_job.rb b/app/jobs/cron/discarded_procedures_deletion_job.rb index d67cde8a9..be740796e 100644 --- a/app/jobs/cron/discarded_procedures_deletion_job.rb +++ b/app/jobs/cron/discarded_procedures_deletion_job.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class Cron::DiscardedProceduresDeletionJob < Cron::CronJob - self.schedule_expression = "every day at 1 am" + self.schedule_expression = "every day at 00:45" def perform Procedure.purge_discarded diff --git a/app/jobs/cron/dossier_operation_log_move_to_cold_storage_job.rb b/app/jobs/cron/dossier_operation_log_move_to_cold_storage_job.rb index e4159317f..221f9bdeb 100644 --- a/app/jobs/cron/dossier_operation_log_move_to_cold_storage_job.rb +++ b/app/jobs/cron/dossier_operation_log_move_to_cold_storage_job.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class Cron::DossierOperationLogMoveToColdStorageJob < Cron::CronJob - self.schedule_expression = "every day at 1 am" + self.schedule_expression = "every day at 23:00" def perform DossierOperationLog diff --git a/app/jobs/cron/enable_procedure_expires_when_termine_enabled_job.rb b/app/jobs/cron/enable_procedure_expires_when_termine_enabled_job.rb index a61be7d39..1e2d89666 100644 --- a/app/jobs/cron/enable_procedure_expires_when_termine_enabled_job.rb +++ b/app/jobs/cron/enable_procedure_expires_when_termine_enabled_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Cron::EnableProcedureExpiresWhenTermineEnabledJob < Cron::CronJob self.schedule_expression = Expired.schedule_at(self) discard_on StandardError diff --git a/app/jobs/cron/expired_dossiers_brouillon_deletion_job.rb b/app/jobs/cron/expired_dossiers_brouillon_deletion_job.rb index cc8812352..35e957ed6 100644 --- a/app/jobs/cron/expired_dossiers_brouillon_deletion_job.rb +++ b/app/jobs/cron/expired_dossiers_brouillon_deletion_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Cron::ExpiredDossiersBrouillonDeletionJob < Cron::CronJob self.schedule_expression = Expired.schedule_at(self) diff --git a/app/jobs/cron/expired_dossiers_en_construction_deletion_job.rb b/app/jobs/cron/expired_dossiers_en_construction_deletion_job.rb index 36ad0fb96..552592db2 100644 --- a/app/jobs/cron/expired_dossiers_en_construction_deletion_job.rb +++ b/app/jobs/cron/expired_dossiers_en_construction_deletion_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Cron::ExpiredDossiersEnConstructionDeletionJob < Cron::CronJob self.schedule_expression = Expired.schedule_at(self) diff --git a/app/jobs/cron/expired_dossiers_termine_deletion_job.rb b/app/jobs/cron/expired_dossiers_termine_deletion_job.rb index 3e69cb788..ed4f0f8ee 100644 --- a/app/jobs/cron/expired_dossiers_termine_deletion_job.rb +++ b/app/jobs/cron/expired_dossiers_termine_deletion_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Cron::ExpiredDossiersTermineDeletionJob < Cron::CronJob self.schedule_expression = Expired.schedule_at(self) diff --git a/app/jobs/cron/expired_prefilled_dossiers_deletion_job.rb b/app/jobs/cron/expired_prefilled_dossiers_deletion_job.rb index d825ba742..f1b6e8c72 100644 --- a/app/jobs/cron/expired_prefilled_dossiers_deletion_job.rb +++ b/app/jobs/cron/expired_prefilled_dossiers_deletion_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Cron::ExpiredPrefilledDossiersDeletionJob < Cron::CronJob self.schedule_expression = Expired.schedule_at(self) diff --git a/app/jobs/cron/expired_users_deletion_job.rb b/app/jobs/cron/expired_users_deletion_job.rb index 1dd923a81..d43e2f926 100644 --- a/app/jobs/cron/expired_users_deletion_job.rb +++ b/app/jobs/cron/expired_users_deletion_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Cron::ExpiredUsersDeletionJob < Cron::CronJob self.schedule_expression = Expired.schedule_at(self) discard_on StandardError diff --git a/app/jobs/cron/fix_missing_antivirus_analysis_job.rb b/app/jobs/cron/fix_missing_antivirus_analysis_job.rb index 52bb90848..5540c6c9e 100644 --- a/app/jobs/cron/fix_missing_antivirus_analysis_job.rb +++ b/app/jobs/cron/fix_missing_antivirus_analysis_job.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class Cron::FixMissingAntivirusAnalysisJob < Cron::CronJob - self.schedule_expression = "every day at 2 am" + self.schedule_expression = "every day at 01:45" def perform ActiveStorage::Blob.where(virus_scan_result: ActiveStorage::VirusScanner::PENDING).find_each do |blob| diff --git a/app/jobs/cron/instructeur_email_notification_job.rb b/app/jobs/cron/instructeur_email_notification_job.rb index 756a8b95b..6553629e4 100644 --- a/app/jobs/cron/instructeur_email_notification_job.rb +++ b/app/jobs/cron/instructeur_email_notification_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Cron::InstructeurEmailNotificationJob < Cron::CronJob self.schedule_expression = "from monday through friday at 9 am" diff --git a/app/jobs/cron/notify_draft_not_submitted_job.rb b/app/jobs/cron/notify_draft_not_submitted_job.rb index 24100930a..702bf2cc2 100644 --- a/app/jobs/cron/notify_draft_not_submitted_job.rb +++ b/app/jobs/cron/notify_draft_not_submitted_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Cron::NotifyDraftNotSubmittedJob < Cron::CronJob self.schedule_expression = "from monday through friday at 7 am" diff --git a/app/jobs/cron/operations_signature_job.rb b/app/jobs/cron/operations_signature_job.rb index 605cea333..ae08c0db0 100644 --- a/app/jobs/cron/operations_signature_job.rb +++ b/app/jobs/cron/operations_signature_job.rb @@ -1,15 +1,20 @@ +# frozen_string_literal: true + class Cron::OperationsSignatureJob < Cron::CronJob - self.schedule_expression = "every day at 6 am" + self.schedule_expression = "every day at 06:00" def perform(*args) + start_date = DossierOperationLog.where(bill_signature: nil).order(:executed_at).pick(:executed_at).beginning_of_day last_midnight = Time.zone.today.beginning_of_day - operations_by_day = BillSignatureService.grouped_unsigned_operation_until(last_midnight) - operations_by_day.each do |day, operations| - begin - BillSignatureService.sign_operations(operations, day) - rescue - raise # let errors show up on Sentry and delayed_jobs - end + + while start_date < last_midnight + operations = DossierOperationLog + .select(:id, :digest) + .where(executed_at: start_date...start_date.tomorrow, bill_signature: nil) + + BillSignatureService.sign_operations(operations, start_date) if operations.present? + + start_date = start_date.tomorrow end end end diff --git a/app/jobs/cron/procedure_external_url_check_job.rb b/app/jobs/cron/procedure_external_url_check_job.rb index ab18c8c37..d81bece90 100644 --- a/app/jobs/cron/procedure_external_url_check_job.rb +++ b/app/jobs/cron/procedure_external_url_check_job.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class Cron::ProcedureExternalURLCheckJob < Cron::CronJob - self.schedule_expression = "every week on monday at 1 am" + self.schedule_expression = "every week on monday at 01:00" def perform Procedure.with_external_urls.find_each { ::ProcedureExternalURLCheckJob.perform_later(_1) } diff --git a/app/jobs/cron/procedure_process_sva_svr_job.rb b/app/jobs/cron/procedure_process_sva_svr_job.rb index cda3e8468..30f8c84a6 100644 --- a/app/jobs/cron/procedure_process_sva_svr_job.rb +++ b/app/jobs/cron/procedure_process_sva_svr_job.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class Cron::ProcedureProcessSVASVRJob < Cron::CronJob - self.schedule_expression = "every day at 1:00" + self.schedule_expression = "every day at 01:15" def perform Procedure.sva_svr.find_each do |procedure| diff --git a/app/jobs/cron/purge_manager_administrateur_sessions_job.rb b/app/jobs/cron/purge_manager_administrateur_sessions_job.rb index b85714dd5..e204546f1 100644 --- a/app/jobs/cron/purge_manager_administrateur_sessions_job.rb +++ b/app/jobs/cron/purge_manager_administrateur_sessions_job.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class Cron::PurgeManagerAdministrateurSessionsJob < Cron::CronJob - self.schedule_expression = "every day at 3 am" + self.schedule_expression = "every day at 02:45" def perform # TODO: add id column to administrateurs_procedures and use destroy_all diff --git a/app/jobs/cron/purge_old_email_event_job.rb b/app/jobs/cron/purge_old_email_event_job.rb index 5de61c5c0..5b22f9094 100644 --- a/app/jobs/cron/purge_old_email_event_job.rb +++ b/app/jobs/cron/purge_old_email_event_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Cron::PurgeOldEmailEventJob < Cron::CronJob self.schedule_expression = "every week at 3:00" diff --git a/app/jobs/cron/purge_old_sib_mails_job.rb b/app/jobs/cron/purge_old_sib_mails_job.rb index 3ed3685a9..054112cd2 100644 --- a/app/jobs/cron/purge_old_sib_mails_job.rb +++ b/app/jobs/cron/purge_old_sib_mails_job.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class Cron::PurgeOldSibMailsJob < Cron::CronJob - self.schedule_expression = "every day at midnight" + self.schedule_expression = "every day at 00:15" def perform sib = Sendinblue::API.new diff --git a/app/jobs/cron/purge_stale_archives_job.rb b/app/jobs/cron/purge_stale_archives_job.rb index 196002d69..c315dc843 100644 --- a/app/jobs/cron/purge_stale_archives_job.rb +++ b/app/jobs/cron/purge_stale_archives_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Cron::PurgeStaleArchivesJob < Cron::CronJob self.schedule_expression = "every 5 minutes" diff --git a/app/jobs/cron/purge_stale_batch_operation_job.rb b/app/jobs/cron/purge_stale_batch_operation_job.rb index 822553a13..e23baf053 100644 --- a/app/jobs/cron/purge_stale_batch_operation_job.rb +++ b/app/jobs/cron/purge_stale_batch_operation_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Cron::PurgeStaleBatchOperationJob < Cron::CronJob self.schedule_expression = "every 5 minutes" diff --git a/app/jobs/cron/purge_stale_exports_job.rb b/app/jobs/cron/purge_stale_exports_job.rb index 20462ce0e..a5ab2561e 100644 --- a/app/jobs/cron/purge_stale_exports_job.rb +++ b/app/jobs/cron/purge_stale_exports_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Cron::PurgeStaleExportsJob < Cron::CronJob self.schedule_expression = "every 5 minutes" diff --git a/app/jobs/cron/purge_stale_transfers_job.rb b/app/jobs/cron/purge_stale_transfers_job.rb index bf54c1c81..6472f8298 100644 --- a/app/jobs/cron/purge_stale_transfers_job.rb +++ b/app/jobs/cron/purge_stale_transfers_job.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class Cron::PurgeStaleTransfersJob < Cron::CronJob - self.schedule_expression = "every day at midnight" + self.schedule_expression = "every day at 00:00" def perform DossierTransfer.destroy_stale diff --git a/app/jobs/cron/purge_unattached_blobs_job.rb b/app/jobs/cron/purge_unattached_blobs_job.rb index 218b6f5d2..5a324b8b8 100644 --- a/app/jobs/cron/purge_unattached_blobs_job.rb +++ b/app/jobs/cron/purge_unattached_blobs_job.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class Cron::PurgeUnattachedBlobsJob < Cron::CronJob - self.schedule_expression = "every day at midnight" + self.schedule_expression = "every day at 00:30" def perform # .in_batches { _1.each... } is more efficient in this case that in_batches.each_record or find_each diff --git a/app/jobs/cron/purge_unused_admin_job.rb b/app/jobs/cron/purge_unused_admin_job.rb index d58f479d4..c196f6147 100644 --- a/app/jobs/cron/purge_unused_admin_job.rb +++ b/app/jobs/cron/purge_unused_admin_job.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class Cron::PurgeUnusedAdminJob < Cron::CronJob - self.schedule_expression = "every monday at 5 am" + self.schedule_expression = "every monday at 5:15" def perform(*args) Administrateur.unused.destroy_all diff --git a/app/jobs/cron/release_crashed_export_job.rb b/app/jobs/cron/release_crashed_export_job.rb index 6a36237a1..23adf0d2f 100644 --- a/app/jobs/cron/release_crashed_export_job.rb +++ b/app/jobs/cron/release_crashed_export_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Cron::ReleaseCrashedExportJob < Cron::CronJob self.schedule_expression = "every 10 minute" SECSCAN_LIMIT = 20_000 diff --git a/app/jobs/cron/send_api_token_expiration_notice_job.rb b/app/jobs/cron/send_api_token_expiration_notice_job.rb index 48d69c0d8..9c864a8da 100644 --- a/app/jobs/cron/send_api_token_expiration_notice_job.rb +++ b/app/jobs/cron/send_api_token_expiration_notice_job.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class Cron::SendAPITokenExpirationNoticeJob < Cron::CronJob - self.schedule_expression = "every day at midnight" + self.schedule_expression = "every day at 23:45" def perform windows = [ diff --git a/app/jobs/cron/stalled_declarative_procedures_job.rb b/app/jobs/cron/stalled_declarative_procedures_job.rb index 9e4e60882..7616e19c2 100644 --- a/app/jobs/cron/stalled_declarative_procedures_job.rb +++ b/app/jobs/cron/stalled_declarative_procedures_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Cron::StalledDeclarativeProceduresJob < Cron::CronJob self.schedule_expression = "every 10 minutes" diff --git a/app/jobs/cron/update_stats_job.rb b/app/jobs/cron/update_stats_job.rb index a5dcd2c43..aedd5792c 100644 --- a/app/jobs/cron/update_stats_job.rb +++ b/app/jobs/cron/update_stats_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Cron::UpdateStatsJob < Cron::CronJob self.schedule_expression = "every 1 hour" diff --git a/app/jobs/cron/weekly_overview_job.rb b/app/jobs/cron/weekly_overview_job.rb index 45408c58d..0524a6eac 100644 --- a/app/jobs/cron/weekly_overview_job.rb +++ b/app/jobs/cron/weekly_overview_job.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class Cron::WeeklyOverviewJob < Cron::CronJob - self.schedule_expression = "every monday at 4 am" + self.schedule_expression = "every monday at 04:05" def perform # Feature flipped to avoid mails in staging due to unprocessed dossier diff --git a/app/jobs/destroy_record_later_job.rb b/app/jobs/destroy_record_later_job.rb index 6b9def26f..426c967a7 100644 --- a/app/jobs/destroy_record_later_job.rb +++ b/app/jobs/destroy_record_later_job.rb @@ -1,5 +1,8 @@ +# frozen_string_literal: true + class DestroyRecordLaterJob < ApplicationJob discard_on ActiveRecord::RecordNotFound + queue_as :low # destroy later, will be done when possible def perform(record) record.destroy diff --git a/app/jobs/dolist_report_job.rb b/app/jobs/dolist_report_job.rb index 8a7b38efe..60e1be5bf 100644 --- a/app/jobs/dolist_report_job.rb +++ b/app/jobs/dolist_report_job.rb @@ -1,6 +1,9 @@ +# frozen_string_literal: true + class DolistReportJob < ApplicationJob # Consolidate random recent emails dispatched to Dolist with their statuses # and send a report by email. + queue_as :low # reporting will be done asap def perform(report_to, sample_size = 1000) events = EmailEvent.dolist.dispatched.where(processed_at: 2.weeks.ago..).order("RANDOM()").limit(sample_size) diff --git a/app/jobs/dossier_index_search_terms_job.rb b/app/jobs/dossier_index_search_terms_job.rb new file mode 100644 index 000000000..dc0079a5a --- /dev/null +++ b/app/jobs/dossier_index_search_terms_job.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class DossierIndexSearchTermsJob < ApplicationJob + queue_as :low + + discard_on ActiveRecord::RecordNotFound + + def perform(dossier) + dossier.index_search_terms + end +end diff --git a/app/jobs/dossier_operation_log_move_to_cold_storage_batch_job.rb b/app/jobs/dossier_operation_log_move_to_cold_storage_batch_job.rb index 9c6e561bb..d2fb60224 100644 --- a/app/jobs/dossier_operation_log_move_to_cold_storage_batch_job.rb +++ b/app/jobs/dossier_operation_log_move_to_cold_storage_batch_job.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class DossierOperationLogMoveToColdStorageBatchJob < ApplicationJob - queue_as :low_priority + queue_as :low def perform(ids) DossierOperationLog.where(id: ids) diff --git a/app/jobs/dossier_rebase_job.rb b/app/jobs/dossier_rebase_job.rb index 50d1c7d36..2ca0eb8c9 100644 --- a/app/jobs/dossier_rebase_job.rb +++ b/app/jobs/dossier_rebase_job.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class DossierRebaseJob < ApplicationJob - queue_as :low_priority # they are massively enqueued, so don't interfere with others especially antivirus + queue_as :low # they are massively enqueued, so don't interfere with others especially antivirus # If by the time the job runs the Dossier has been deleted, ignore the rebase discard_on ActiveRecord::RecordNotFound diff --git a/app/jobs/dossier_update_search_terms_job.rb b/app/jobs/dossier_update_search_terms_job.rb deleted file mode 100644 index 8b997e3f5..000000000 --- a/app/jobs/dossier_update_search_terms_job.rb +++ /dev/null @@ -1,8 +0,0 @@ -class DossierUpdateSearchTermsJob < ApplicationJob - discard_on ActiveRecord::RecordNotFound - - def perform(dossier) - dossier.update_search_terms - dossier.save!(touch: false) - end -end diff --git a/app/jobs/etablissement_update_job.rb b/app/jobs/etablissement_update_job.rb index e8eb2b12b..f5eaacba0 100644 --- a/app/jobs/etablissement_update_job.rb +++ b/app/jobs/etablissement_update_job.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + class EtablissementUpdateJob < ApplicationJob + queue_as :critical # reporting will be done asap, but no occurence found. maube dead? def perform(dossier, siret) begin etablissement_attributes = APIEntrepriseService.get_etablissement_params_for_siret(siret, dossier.procedure.id) diff --git a/app/jobs/export_job.rb b/app/jobs/export_job.rb index 0ea91c844..40eb25d75 100644 --- a/app/jobs/export_job.rb +++ b/app/jobs/export_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ExportJob < ApplicationJob queue_as :exports @@ -10,6 +12,8 @@ class ExportJob < ApplicationJob def perform(export) return if export.generated? + Sentry.set_tags(procedure: export.procedure.id) + export.compute_with_safe_stale_for_purge do export.compute end diff --git a/app/jobs/helpscout_create_conversation_job.rb b/app/jobs/helpscout_create_conversation_job.rb new file mode 100644 index 000000000..8239898c7 --- /dev/null +++ b/app/jobs/helpscout_create_conversation_job.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +class HelpscoutCreateConversationJob < ApplicationJob + queue_as :critical # user feedback is critical + + def max_attempts = 15 # ~10h + + class FileNotScannedYetError < StandardError + end + + retry_on FileNotScannedYetError, wait: :exponentially_longer, attempts: 10 + + attr_reader :contact_form + attr_reader :api + + def perform(contact_form) + @contact_form = contact_form + + if contact_form.piece_jointe.attached? + raise FileNotScannedYetError if contact_form.piece_jointe.virus_scanner.pending? + end + + @api = Helpscout::API.new + + create_conversation + + contact_form.delete + rescue StandardError + contact_form.delete if executions >= max_attempts + + raise + end + + private + + def create_conversation + response = api.create_conversation( + contact_form.email.presence || contact_form.user.email, + contact_form.subject, + contact_form.text, + safe_blob + ) + + if response.success? + conversation_id = response.headers['Resource-ID'] + + if contact_form.phone.present? + api.add_phone_number(contact_form.email, contact_form.phone) + end + + api.add_tags(conversation_id, contact_form.tags) + else + fail "Error while creating conversation: #{response.response_code} '#{response.body}'" + end + end + + def safe_blob + return if !contact_form.piece_jointe.virus_scanner&.safe? + return if contact_form.piece_jointe.byte_size.zero? # HS don't support empty attachment + + contact_form.piece_jointe + end +end diff --git a/app/jobs/image_processor_job.rb b/app/jobs/image_processor_job.rb new file mode 100644 index 000000000..0a4d3ee85 --- /dev/null +++ b/app/jobs/image_processor_job.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +class ImageProcessorJob < ApplicationJob + queue_as :low # thumbnails and watermarks. Execution depends of virus scanner which is more urgent + + class FileNotScannedYetError < StandardError + end + + # If by the time the job runs the blob has been deleted, ignore the error + discard_on ActiveRecord::RecordNotFound + # If the file is deleted during the scan, ignore the error + discard_on ActiveStorage::FileNotFoundError + discard_on ActiveRecord::InvalidForeignKey + # If the file is not an image, not in format we can process or the image is corrupted, ignore the error + DISCARDABLE_ERRORS = [ + 'improper image header', + 'width or height exceeds limit', + 'attempt to perform an operation not allowed by the security policy', + 'no decode delegate for this image format' + ] + discard_on do |_, error| + DISCARDABLE_ERRORS.any? { error.message.match?(_1) } + end + # If the file is not analyzed or scanned for viruses yet, retry later + # (to avoid modifying the file while it is being scanned). + retry_on FileNotScannedYetError, wait: :exponentially_longer, attempts: 10 + + # Usually invalid image or ImageMagick decoder blocked for this format + retry_on MiniMagick::Invalid, attempts: 3 + retry_on MiniMagick::Error, attempts: 3 + + rescue_from ActiveStorage::PreviewError do + retry_or_discard + end + + def perform(blob) + return if blob.nil? + raise FileNotScannedYetError if blob.virus_scanner.pending? + return if ActiveStorage::Attachment.find_by(blob_id: blob.id)&.record_type == "ActiveStorage::VariantRecord" + + auto_rotate(blob) if ["image/jpeg", "image/jpg"].include?(blob.content_type) + uninterlace(blob) if blob.content_type == "image/png" + create_representations(blob) if blob.representation_required? + add_watermark(blob) if blob.watermark_pending? + end + + private + + def auto_rotate(blob) + blob.open do |file| + Tempfile.create(["rotated", File.extname(file)]) do |output| + processed = AutoRotateService.new.process(file, output) + return if processed.blank? + + blob.upload(processed) # also update checksum & byte_size accordingly + blob.save! + end + end + end + + def uninterlace(blob) + blob.open do |file| + processed = UninterlaceService.new.process(file) + return if processed.blank? + + blob.upload(processed) + blob.save! + end + end + + def create_representations(blob) + blob.attachments.each do |attachment| + next unless attachment&.representable? + attachment.representation(resize_to_limit: [400, 400]).processed + if attachment.blob.content_type.in?(RARE_IMAGE_TYPES) + attachment.variant(resize_to_limit: [2000, 2000]).processed + end + if attachment.record.class == ActionText::RichText + attachment.variant(resize_to_limit: [1024, 768]).processed + end + end + end + + def add_watermark(blob) + return if blob.watermark_done? + + blob.open do |file| + Tempfile.create(["watermarked", File.extname(file)]) do |output| + processed = WatermarkService.new.process(file, output) + return if processed.blank? + + blob.upload(processed) # also update checksum & byte_size accordingly + blob.watermarked_at = Time.current + blob.save! + end + end + end + + def retry_or_discard + if executions < 3 + retry_job wait: 5.minutes + end + end +end diff --git a/app/jobs/migrations/backfill_dossier_repetition_job.rb b/app/jobs/migrations/backfill_dossier_repetition_job.rb index 6c03b5dc5..4b19dca61 100644 --- a/app/jobs/migrations/backfill_dossier_repetition_job.rb +++ b/app/jobs/migrations/backfill_dossier_repetition_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Migrations::BackfillDossierRepetitionJob < ApplicationJob def perform(dossier_ids) Dossier.where(id: dossier_ids) @@ -7,10 +9,10 @@ class Migrations::BackfillDossierRepetitionJob < ApplicationJob .revision .types_de_champ .filter do |type_de_champ| - type_de_champ.type_champ == 'repetition' && dossier.champs.none? { _1.type_de_champ_id == type_de_champ.id } + type_de_champ.type_champ == 'repetition' && dossier.champs.none? { _1.stable_id == type_de_champ.stable_id } end .each do |type_de_champ| - dossier.champs << type_de_champ.champ.build + dossier.champs << type_de_champ.build_champ end end end diff --git a/app/jobs/migrations/backfill_row_id_job.rb b/app/jobs/migrations/backfill_row_id_job.rb index 30abf408c..daedd6ec8 100644 --- a/app/jobs/migrations/backfill_row_id_job.rb +++ b/app/jobs/migrations/backfill_row_id_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Migrations::BackfillRowIdJob < ApplicationJob def perform(batch) batch.each do |(row_id, champ_ids)| diff --git a/app/jobs/migrations/backfill_stable_id_job.rb b/app/jobs/migrations/backfill_stable_id_job.rb index aa23352ff..6cc33fcc9 100644 --- a/app/jobs/migrations/backfill_stable_id_job.rb +++ b/app/jobs/migrations/backfill_stable_id_job.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class Migrations::BackfillStableIdJob < ApplicationJob - queue_as :low_priority + queue_as :low DEFAULT_LIMIT = 50_000 diff --git a/app/jobs/migrations/backfill_virus_scan_blobs_job.rb b/app/jobs/migrations/backfill_virus_scan_blobs_job.rb index f9ba1b002..ca327aff3 100644 --- a/app/jobs/migrations/backfill_virus_scan_blobs_job.rb +++ b/app/jobs/migrations/backfill_virus_scan_blobs_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Migrations::BackfillVirusScanBlobsJob < ApplicationJob def perform(batch) ActiveStorage::Blob.where(id: batch) diff --git a/app/jobs/migrations/batch_update_datetime_values_job.rb b/app/jobs/migrations/batch_update_datetime_values_job.rb index 5ef4207aa..fb9b8a39d 100644 --- a/app/jobs/migrations/batch_update_datetime_values_job.rb +++ b/app/jobs/migrations/batch_update_datetime_values_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Migrations::BatchUpdateDatetimeValuesJob < ApplicationJob def perform(ids) Champs::DatetimeChamp.where(id: ids).find_each do |datetime_champ| diff --git a/app/jobs/migrations/batch_update_pays_values_job.rb b/app/jobs/migrations/batch_update_pays_values_job.rb index 01add5af8..225bb88e5 100644 --- a/app/jobs/migrations/batch_update_pays_values_job.rb +++ b/app/jobs/migrations/batch_update_pays_values_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Migrations::BatchUpdatePaysValuesJob < ApplicationJob UNUSUAL_COUNTRY_NAME_MATCHER = { "ACORES, MADERE" => "Portugal", diff --git a/app/jobs/migrations/normalize_communes_job.rb b/app/jobs/migrations/normalize_communes_job.rb index 62a43771f..2830d5886 100644 --- a/app/jobs/migrations/normalize_communes_job.rb +++ b/app/jobs/migrations/normalize_communes_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Migrations::NormalizeCommunesJob < ApplicationJob def perform(ids) Champs::CommuneChamp.where(id: ids).find_each do |champ| diff --git a/app/jobs/migrations/normalize_departements_with_empty_external_id_job.rb b/app/jobs/migrations/normalize_departements_with_empty_external_id_job.rb index 00a8c9e5c..ddb3b7af9 100644 --- a/app/jobs/migrations/normalize_departements_with_empty_external_id_job.rb +++ b/app/jobs/migrations/normalize_departements_with_empty_external_id_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Migrations::NormalizeDepartementsWithEmptyExternalIdJob < ApplicationJob def perform(ids) Champs::DepartementChamp.where(id: ids).find_each do |champ| diff --git a/app/jobs/migrations/normalize_departements_with_nil_external_id_job.rb b/app/jobs/migrations/normalize_departements_with_nil_external_id_job.rb index 109ca6c9a..d7cc508e9 100644 --- a/app/jobs/migrations/normalize_departements_with_nil_external_id_job.rb +++ b/app/jobs/migrations/normalize_departements_with_nil_external_id_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Migrations::NormalizeDepartementsWithNilExternalIdJob < ApplicationJob def perform(ids) Champs::DepartementChamp.where(id: ids).find_each do |champ| diff --git a/app/jobs/migrations/normalize_departements_with_present_external_id_job.rb b/app/jobs/migrations/normalize_departements_with_present_external_id_job.rb index a024582d2..4547396a6 100644 --- a/app/jobs/migrations/normalize_departements_with_present_external_id_job.rb +++ b/app/jobs/migrations/normalize_departements_with_present_external_id_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Migrations::NormalizeDepartementsWithPresentExternalIdJob < ApplicationJob def perform(ids) Champs::DepartementChamp.where(id: ids).find_each do |champ| diff --git a/app/jobs/priorized_mail_delivery_job.rb b/app/jobs/priorized_mail_delivery_job.rb index 8ebe5549c..bf0ea10b7 100644 --- a/app/jobs/priorized_mail_delivery_job.rb +++ b/app/jobs/priorized_mail_delivery_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PriorizedMailDeliveryJob < ActionMailer::MailDeliveryJob discard_on ActiveJob::DeserializationError @@ -11,6 +13,6 @@ class PriorizedMailDeliveryJob < ActionMailer::MailDeliveryJob end def custom_queue - ENV.fetch('BULK_EMAIL_QUEUE') { Rails.application.config.action_mailer.deliver_later_queue_name } + 'default' end end diff --git a/app/jobs/procedure_external_url_check_job.rb b/app/jobs/procedure_external_url_check_job.rb index c0678e882..78a25582d 100644 --- a/app/jobs/procedure_external_url_check_job.rb +++ b/app/jobs/procedure_external_url_check_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ProcedureExternalURLCheckJob < ApplicationJob def perform(procedure) procedure.validate diff --git a/app/jobs/procedure_sva_svr_process_dossier_job.rb b/app/jobs/procedure_sva_svr_process_dossier_job.rb index 12fb06604..48a8d7634 100644 --- a/app/jobs/procedure_sva_svr_process_dossier_job.rb +++ b/app/jobs/procedure_sva_svr_process_dossier_job.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class ProcedureSVASVRProcessDossierJob < ApplicationJob - queue_as :sva + queue_as :critical def perform(dossier) dossier.process_sva_svr! diff --git a/app/jobs/process_stalled_declarative_dossier_job.rb b/app/jobs/process_stalled_declarative_dossier_job.rb index 1e2481688..aaa49fb70 100644 --- a/app/jobs/process_stalled_declarative_dossier_job.rb +++ b/app/jobs/process_stalled_declarative_dossier_job.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + class ProcessStalledDeclarativeDossierJob < ApplicationJob + queue_as :low def perform(dossier) return if dossier.declarative_triggered_at.present? diff --git a/app/jobs/reset_expiring_dossiers_job.rb b/app/jobs/reset_expiring_dossiers_job.rb index 6ab941bf3..95a87f571 100644 --- a/app/jobs/reset_expiring_dossiers_job.rb +++ b/app/jobs/reset_expiring_dossiers_job.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + class ResetExpiringDossiersJob < ApplicationJob + queue_as :low def perform(procedure) procedure .dossiers diff --git a/app/jobs/send_closing_notification_job.rb b/app/jobs/send_closing_notification_job.rb index 919b6baa6..f4850b870 100644 --- a/app/jobs/send_closing_notification_job.rb +++ b/app/jobs/send_closing_notification_job.rb @@ -1,4 +1,8 @@ +# frozen_string_literal: true + class SendClosingNotificationJob < ApplicationJob + queue_as :low # no rush on this one + def perform(user_ids, content, procedure) User.where(id: user_ids).find_each do |user| Expired::MailRateLimiter.new().send_with_delay(UserMailer.notify_after_closing(user, content, @procedure)) diff --git a/app/jobs/sidekiq_again_job.rb b/app/jobs/sidekiq_again_job.rb deleted file mode 100644 index d819ed936..000000000 --- a/app/jobs/sidekiq_again_job.rb +++ /dev/null @@ -1,12 +0,0 @@ -class SidekiqAgainJob < ApplicationJob - self.queue_adapter = :sidekiq - queue_as :default - - def perform(user, with_exception: false) - if with_exception - raise 'Nop' - end - Sentry.capture_message('this is a message from sidekiq') - UserMailer.new_account_warning(user).deliver_now - end -end diff --git a/app/jobs/titre_identite_watermark_job.rb b/app/jobs/titre_identite_watermark_job.rb deleted file mode 100644 index 1b261e291..000000000 --- a/app/jobs/titre_identite_watermark_job.rb +++ /dev/null @@ -1,28 +0,0 @@ -class TitreIdentiteWatermarkJob < ApplicationJob - class FileNotScannedYetError < StandardError - end - - # If by the time the job runs the blob has been deleted, ignore the error - discard_on ActiveRecord::RecordNotFound - # If the file is deleted during the scan, ignore the error - discard_on ActiveStorage::FileNotFoundError - # If the file is not analyzed or scanned for viruses yet, retry later - # (to avoid modifying the file while it is being scanned). - retry_on FileNotScannedYetError, wait: :exponentially_longer, attempts: 10 - - def perform(blob) - return if blob.watermark_done? - raise FileNotScannedYetError if blob.virus_scanner.pending? - - blob.open do |file| - Tempfile.create(["watermarked", File.extname(file)]) do |output| - processed = WatermarkService.new.process(file, output) - return if processed.blank? - - blob.upload(processed) # also update checksum & byte_size accordingly - blob.watermarked_at = Time.current - blob.save! - end - end - end -end diff --git a/app/jobs/virus_scanner_job.rb b/app/jobs/virus_scanner_job.rb index 3d2ea0944..34d20930c 100644 --- a/app/jobs/virus_scanner_job.rb +++ b/app/jobs/virus_scanner_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class VirusScannerJob < ApplicationJob # If by the time the job runs the blob has been deleted, ignore the error discard_on ActiveRecord::RecordNotFound diff --git a/app/jobs/web_hook_job.rb b/app/jobs/web_hook_job.rb index 2b9f811c5..40b37c837 100644 --- a/app/jobs/web_hook_job.rb +++ b/app/jobs/web_hook_job.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class WebHookJob < ApplicationJob - queue_as :webhooks_v1 + queue_as :default TIMEOUT = 10 diff --git a/app/lib/active_job/application_log_subscriber.rb b/app/lib/active_job/application_log_subscriber.rb index 23d31f072..c4ee3a23f 100644 --- a/app/lib/active_job/application_log_subscriber.rb +++ b/app/lib/active_job/application_log_subscriber.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'active_job/logging' require 'logstash-event' @@ -33,6 +35,7 @@ class ActiveJob::ApplicationLogSubscriber < ::ActiveJob::LogSubscriber def process_event(event, type) data = extract_metadata(event) data.merge!(extract_exception(event)) + data[:request_id] = Current.request_id if Current.request_id.present? case type when 'enqueue_at' diff --git a/app/lib/active_job/retry_on_transient_errors.rb b/app/lib/active_job/retry_on_transient_errors.rb index cf714174a..d75af1e1a 100644 --- a/app/lib/active_job/retry_on_transient_errors.rb +++ b/app/lib/active_job/retry_on_transient_errors.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveJob::RetryOnTransientErrors extend ActiveSupport::Concern diff --git a/app/lib/active_storage/downloadable_file.rb b/app/lib/active_storage/downloadable_file.rb index b89c20997..13db48d70 100644 --- a/app/lib/active_storage/downloadable_file.rb +++ b/app/lib/active_storage/downloadable_file.rb @@ -1,10 +1,17 @@ +# frozen_string_literal: true + require 'fog/openstack' class ActiveStorage::DownloadableFile - def self.create_list_from_dossiers(dossiers:, user_profile:) - pj_service = PiecesJustificativesService.new(user_profile:) + def self.create_list_from_dossiers(dossiers:, user_profile:, export_template: nil) + pj_service = PiecesJustificativesService.new(user_profile:, export_template:) - pj_service.generate_dossiers_export(dossiers) + pj_service.liste_documents(dossiers) + files = [] + DossierPreloader.new(dossiers).in_batches_with_block do |loaded_dossiers| + files += pj_service.generate_dossiers_export(loaded_dossiers) + pj_service.liste_documents(loaded_dossiers) + end + + files end def self.cleanup_list_from_dossier(files) diff --git a/app/lib/active_storage/fake_attachment.rb b/app/lib/active_storage/fake_attachment.rb index f84d8caf2..59358365c 100644 --- a/app/lib/active_storage/fake_attachment.rb +++ b/app/lib/active_storage/fake_attachment.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ActiveStorage::FakeAttachment < Hashie::Dash property :filename property :name diff --git a/app/lib/active_storage/virus_scanner.rb b/app/lib/active_storage/virus_scanner.rb index 6e5d26d01..7d78f122d 100644 --- a/app/lib/active_storage/virus_scanner.rb +++ b/app/lib/active_storage/virus_scanner.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ActiveStorage::VirusScanner def initialize(blob) @blob = blob diff --git a/app/lib/api/client.rb b/app/lib/api/client.rb index dcf8eab2d..d7f7ea783 100644 --- a/app/lib/api/client.rb +++ b/app/lib/api/client.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class API::Client include Dry::Monads[:result] @@ -17,6 +19,12 @@ class API::Client timeout: TIMEOUT) end handle_response(response, schema:) + rescue StandardError => reason + if reason.is_a?(URI::InvalidURIError) + Failure(Error[:uri, 0, false, reason]) + else + Failure(Error[:error, 0, false, reason]) + end end private diff --git a/app/lib/api_datagouv/api.rb b/app/lib/api_datagouv/api.rb index 22fa5cd98..0cf4e11a1 100644 --- a/app/lib/api_datagouv/api.rb +++ b/app/lib/api_datagouv/api.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIDatagouv::API class RequestFailed < StandardError def initialize(url, response) diff --git a/app/lib/api_education/annuaire_education_adapter.rb b/app/lib/api_education/annuaire_education_adapter.rb index 492f274d9..0a3186b07 100644 --- a/app/lib/api_education/annuaire_education_adapter.rb +++ b/app/lib/api_education/annuaire_education_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'json_schemer' class APIEducation::AnnuaireEducationAdapter diff --git a/app/lib/api_education/api.rb b/app/lib/api_education/api.rb index d0ee6bcae..d0bd5c977 100644 --- a/app/lib/api_education/api.rb +++ b/app/lib/api_education/api.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEducation::API class ResourceNotFound < StandardError end diff --git a/app/lib/api_entreprise/adapter.rb b/app/lib/api_entreprise/adapter.rb index a79a7f09b..d634c8c76 100644 --- a/app/lib/api_entreprise/adapter.rb +++ b/app/lib/api_entreprise/adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::Adapter UNAVAILABLE = 'Donnée indisponible' diff --git a/app/lib/api_entreprise/api.rb b/app/lib/api_entreprise/api.rb index ca2d9d7c1..1977b5ee0 100644 --- a/app/lib/api_entreprise/api.rb +++ b/app/lib/api_entreprise/api.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::API ENTREPRISE_RESOURCE_NAME = "v3/insee/sirene/unites_legales/%{id}" ETABLISSEMENT_RESOURCE_NAME = "v3/insee/sirene/etablissements/%{id}" @@ -125,10 +127,10 @@ class APIEntreprise::API raise Error::ResourceNotFound.new(response) elsif response.code == 400 raise Error::BadFormatRequest.new(response) + elsif service_unavailable?(response) + raise Error::ServiceUnavailable.new(response) elsif response.code == 502 raise Error::BadGateway.new(response) - elsif response.code == 503 - raise Error::ServiceUnavailable.new(response) elsif response.timed_out? raise Error::TimedOut.new(response) else @@ -136,6 +138,19 @@ class APIEntreprise::API end end + SERVICE_UNAVAILABLE_ERRORS = ["01000", "01001", "01002", "02002", "03002", "28002", "29002", "31002", "34002"] + def service_unavailable?(response) + if response.code == 502 || response.code == 504 + parse_response_errors(response).any? { _1.is_a?(Hash) && _1[:code]&.in?(SERVICE_UNAVAILABLE_ERRORS) } + end + end + + def parse_response_errors(response) + JSON.parse(response.body, symbolize_names: true).fetch(:errors, []) + rescue JSON::ParserError + [] + end + def make_url(resource_name, siret_or_siren = nil) [API_ENTREPRISE_URL, format(resource_name, id: siret_or_siren)].compact.join("/") end diff --git a/app/lib/api_entreprise/api/error.rb b/app/lib/api_entreprise/api/error.rb index 1178d9b08..c9482720d 100644 --- a/app/lib/api_entreprise/api/error.rb +++ b/app/lib/api_entreprise/api/error.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::API::Error < ::StandardError def initialize(response) # use uri to avoid sending token diff --git a/app/lib/api_entreprise/api/error/bad_format_request.rb b/app/lib/api_entreprise/api/error/bad_format_request.rb index 147718e0e..0ae77236a 100644 --- a/app/lib/api_entreprise/api/error/bad_format_request.rb +++ b/app/lib/api_entreprise/api/error/bad_format_request.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::API::Error::BadFormatRequest < APIEntreprise::API::Error def network_error? false diff --git a/app/lib/api_entreprise/api/error/bad_gateway.rb b/app/lib/api_entreprise/api/error/bad_gateway.rb index 0943c2635..3bc16c842 100644 --- a/app/lib/api_entreprise/api/error/bad_gateway.rb +++ b/app/lib/api_entreprise/api/error/bad_gateway.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class APIEntreprise::API::Error::BadGateway < APIEntreprise::API::Error end diff --git a/app/lib/api_entreprise/api/error/request_failed.rb b/app/lib/api_entreprise/api/error/request_failed.rb index e99ca8b44..632f3f156 100644 --- a/app/lib/api_entreprise/api/error/request_failed.rb +++ b/app/lib/api_entreprise/api/error/request_failed.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class APIEntreprise::API::Error::RequestFailed < APIEntreprise::API::Error end diff --git a/app/lib/api_entreprise/api/error/resource_not_found.rb b/app/lib/api_entreprise/api/error/resource_not_found.rb index f6cb118f7..77108ec07 100644 --- a/app/lib/api_entreprise/api/error/resource_not_found.rb +++ b/app/lib/api_entreprise/api/error/resource_not_found.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::API::Error::ResourceNotFound < APIEntreprise::API::Error def network_error? false diff --git a/app/lib/api_entreprise/api/error/service_unavailable.rb b/app/lib/api_entreprise/api/error/service_unavailable.rb index 51cdf5254..81140ffd0 100644 --- a/app/lib/api_entreprise/api/error/service_unavailable.rb +++ b/app/lib/api_entreprise/api/error/service_unavailable.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class APIEntreprise::API::Error::ServiceUnavailable < APIEntreprise::API::Error end diff --git a/app/lib/api_entreprise/api/error/timed_out.rb b/app/lib/api_entreprise/api/error/timed_out.rb index 7e4b27590..53e1d1581 100644 --- a/app/lib/api_entreprise/api/error/timed_out.rb +++ b/app/lib/api_entreprise/api/error/timed_out.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class APIEntreprise::API::Error::TimedOut < APIEntreprise::API::Error end diff --git a/app/lib/api_entreprise/attestation_fiscale_adapter.rb b/app/lib/api_entreprise/attestation_fiscale_adapter.rb index 13d296170..3051548ca 100644 --- a/app/lib/api_entreprise/attestation_fiscale_adapter.rb +++ b/app/lib/api_entreprise/attestation_fiscale_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::AttestationFiscaleAdapter < APIEntreprise::Adapter # Doc métier : https://entreprise.api.gouv.fr/catalogue/dgfip/attestations_fiscales # Swagger : https://entreprise.api.gouv.fr/developpeurs/openapi#tag/Attestations-sociales-et-fiscales/paths/~1v4~1dgfip~1unites_legales~1%7Bsiren%7D~1attestation_fiscale/get diff --git a/app/lib/api_entreprise/attestation_sociale_adapter.rb b/app/lib/api_entreprise/attestation_sociale_adapter.rb index b9a5c47a6..e5e636c77 100644 --- a/app/lib/api_entreprise/attestation_sociale_adapter.rb +++ b/app/lib/api_entreprise/attestation_sociale_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::AttestationSocialeAdapter < APIEntreprise::Adapter # Doc métier : https://entreprise.api.gouv.fr/catalogue/urssaf/attestation_vigilance # Swagger : https://entreprise.api.gouv.fr/developpeurs/openapi#tag/Attestations-sociales-et-fiscales/paths/~1v4~1urssaf~1unites_legales~1%7Bsiren%7D~1attestation_vigilance/get diff --git a/app/lib/api_entreprise/bilans_bdf_adapter.rb b/app/lib/api_entreprise/bilans_bdf_adapter.rb index 6d05f2455..5b440ac52 100644 --- a/app/lib/api_entreprise/bilans_bdf_adapter.rb +++ b/app/lib/api_entreprise/bilans_bdf_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::BilansBdfAdapter < APIEntreprise::Adapter def initialize(siret, procedure_id) @siret = siret diff --git a/app/lib/api_entreprise/effectifs_adapter.rb b/app/lib/api_entreprise/effectifs_adapter.rb index a8f660fbe..60a14ad75 100644 --- a/app/lib/api_entreprise/effectifs_adapter.rb +++ b/app/lib/api_entreprise/effectifs_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::EffectifsAdapter < APIEntreprise::Adapter def initialize(siret, procedure_id, annee, mois) @siret = siret diff --git a/app/lib/api_entreprise/effectifs_annuels_adapter.rb b/app/lib/api_entreprise/effectifs_annuels_adapter.rb index ccfa1e8f9..936c2edd9 100644 --- a/app/lib/api_entreprise/effectifs_annuels_adapter.rb +++ b/app/lib/api_entreprise/effectifs_annuels_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::EffectifsAnnuelsAdapter < APIEntreprise::Adapter def initialize(siret, procedure_id, year = default_year) @siret = siret diff --git a/app/lib/api_entreprise/entreprise_adapter.rb b/app/lib/api_entreprise/entreprise_adapter.rb index 41df33da0..d8d7136dc 100644 --- a/app/lib/api_entreprise/entreprise_adapter.rb +++ b/app/lib/api_entreprise/entreprise_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::EntrepriseAdapter < APIEntreprise::Adapter # Doc métier : https://entreprise.api.gouv.fr/catalogue/insee/unites_legales # Swagger : https://entreprise.api.gouv.fr/developpeurs/openapi#tag/Informations-generales/paths/~1v3~1insee~1sirene~1unites_legales~1%7Bsiren%7D/get diff --git a/app/lib/api_entreprise/etablissement_adapter.rb b/app/lib/api_entreprise/etablissement_adapter.rb index df9211bfa..5a5be0d1d 100644 --- a/app/lib/api_entreprise/etablissement_adapter.rb +++ b/app/lib/api_entreprise/etablissement_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::EtablissementAdapter < APIEntreprise::Adapter # Doc Métier : https://entreprise.api.gouv.fr/catalogue/insee/etablissements # Swagger : https://entreprise.api.gouv.fr/developpeurs/openapi#tag/Informations-generales/paths/~1v3~1insee~1sirene~1etablissements~1%7Bsiret%7D/get @@ -23,7 +25,12 @@ class APIEntreprise::EtablissementAdapter < APIEntreprise::Adapter params.merge!(params[:adresse].slice(*address_attr_to_fetch)) params[:nom_voie] = raw_data[:adresse][:libelle_voie] params[:code_insee_localite] = raw_data[:adresse][:code_commune] - params[:localite] = raw_data[:adresse][:libelle_commune] + if raw_data[:adresse][:libelle_pays_etranger].present? + params[:localite] = raw_data[:adresse][:libelle_commune_etranger] + params[:nom_pays] = raw_data[:adresse][:libelle_pays_etranger] + else + params[:localite] = raw_data[:adresse][:libelle_commune] + end params[:adresse] = adresse_line params else diff --git a/app/lib/api_entreprise/exercices_adapter.rb b/app/lib/api_entreprise/exercices_adapter.rb index e34d4200c..b42150f65 100644 --- a/app/lib/api_entreprise/exercices_adapter.rb +++ b/app/lib/api_entreprise/exercices_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::ExercicesAdapter < APIEntreprise::Adapter # Doc métier : https://entreprise.api.gouv.fr/catalogue/dgfip/chiffres_affaires # Swagger : https://entreprise.api.gouv.fr/developpeurs/openapi#tag/Informations-financieres/paths/~1v3~1dgfip~1etablissements~1%7Bsiret%7D~1chiffres_affaires/get diff --git a/app/lib/api_entreprise/extrait_kbis_adapter.rb b/app/lib/api_entreprise/extrait_kbis_adapter.rb index afa4559b1..bef98ecba 100644 --- a/app/lib/api_entreprise/extrait_kbis_adapter.rb +++ b/app/lib/api_entreprise/extrait_kbis_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::ExtraitKbisAdapter < APIEntreprise::Adapter # Doc métier : https://entreprise.api.gouv.fr/catalogue/infogreffe/rcs/extrait # Swagger : https://entreprise.api.gouv.fr/developpeurs/openapi#tag/Informations-generales/paths/~1v3~1infogreffe~1rcs~1unites_legales~1%7Bsiren%7D~1extrait_kbis/get diff --git a/app/lib/api_entreprise/privileges_adapter.rb b/app/lib/api_entreprise/privileges_adapter.rb index 7c4d9ad76..a5c61262b 100644 --- a/app/lib/api_entreprise/privileges_adapter.rb +++ b/app/lib/api_entreprise/privileges_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::PrivilegesAdapter < APIEntreprise::Adapter def initialize(token) @token = token diff --git a/app/lib/api_entreprise/rna_adapter.rb b/app/lib/api_entreprise/rna_adapter.rb index e4b5d061c..0581fc6f3 100644 --- a/app/lib/api_entreprise/rna_adapter.rb +++ b/app/lib/api_entreprise/rna_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::RNAAdapter < APIEntreprise::Adapter # Doc métier : https://entreprise.api.gouv.fr/catalogue/djepva/associations_open_data # Swagger : https://entreprise.api.gouv.fr/developpeurs/openapi#tag/Informations-generales/paths/~1v4~1djepva~1api-association~1associations~1open_data~1%7Bsiren_or_rna%7D/get diff --git a/app/lib/api_entreprise/service_adapter.rb b/app/lib/api_entreprise/service_adapter.rb index a37fa553a..1b586b7f2 100644 --- a/app/lib/api_entreprise/service_adapter.rb +++ b/app/lib/api_entreprise/service_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::ServiceAdapter < APIEntreprise::EtablissementAdapter def initialize(siret, service_id) @siret = siret diff --git a/app/lib/api_entreprise/tva_adapter.rb b/app/lib/api_entreprise/tva_adapter.rb index cfe6dfc56..d19fac079 100644 --- a/app/lib/api_entreprise/tva_adapter.rb +++ b/app/lib/api_entreprise/tva_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntreprise::TvaAdapter < APIEntreprise::Adapter # Doc métier : https://entreprise.api.gouv.fr/catalogue/commission_europeenne/numero_tva # Swagger : https://entreprise.api.gouv.fr/developpeurs/openapi#tag/Informations-generales/paths/~1v3~1european_commission~1unites_legales~1%7Bsiren%7D~1numero_tva/get diff --git a/app/lib/api_particulier/api.rb b/app/lib/api_particulier/api.rb index 8813f8948..84a1a99e2 100644 --- a/app/lib/api_particulier/api.rb +++ b/app/lib/api_particulier/api.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIParticulier::API include APIParticulier::Error diff --git a/app/lib/api_particulier/cnaf_adapter.rb b/app/lib/api_particulier/cnaf_adapter.rb index 5377fecaf..26394e9ea 100644 --- a/app/lib/api_particulier/cnaf_adapter.rb +++ b/app/lib/api_particulier/cnaf_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIParticulier::CnafAdapter class InvalidSchemaError < ::StandardError def initialize(errors) diff --git a/app/lib/api_particulier/dgfip_adapter.rb b/app/lib/api_particulier/dgfip_adapter.rb index e11cb5535..7b4e13fb3 100644 --- a/app/lib/api_particulier/dgfip_adapter.rb +++ b/app/lib/api_particulier/dgfip_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIParticulier::DgfipAdapter class InvalidSchemaError < ::StandardError def initialize(errors) diff --git a/app/lib/api_particulier/error.rb b/app/lib/api_particulier/error.rb index 4224bcad7..61c4dd461 100644 --- a/app/lib/api_particulier/error.rb +++ b/app/lib/api_particulier/error.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module APIParticulier module Error class HttpError < ::StandardError diff --git a/app/lib/api_particulier/mesri_adapter.rb b/app/lib/api_particulier/mesri_adapter.rb index ea27f4168..85e6a884c 100644 --- a/app/lib/api_particulier/mesri_adapter.rb +++ b/app/lib/api_particulier/mesri_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIParticulier::MesriAdapter class InvalidSchemaError < ::StandardError def initialize(errors) diff --git a/app/lib/api_particulier/pole_emploi_adapter.rb b/app/lib/api_particulier/pole_emploi_adapter.rb index 1ecc12cb8..ea96ce375 100644 --- a/app/lib/api_particulier/pole_emploi_adapter.rb +++ b/app/lib/api_particulier/pole_emploi_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIParticulier::PoleEmploiAdapter class InvalidSchemaError < ::StandardError def initialize(errors) diff --git a/app/lib/api_particulier/services/sources_service.rb b/app/lib/api_particulier/services/sources_service.rb index 72aa605e1..15ed26f61 100644 --- a/app/lib/api_particulier/services/sources_service.rb +++ b/app/lib/api_particulier/services/sources_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module APIParticulier module Services class SourcesService diff --git a/app/lib/api_tchap/api.rb b/app/lib/api_tchap/api.rb index 0dec71d53..de3c64e87 100644 --- a/app/lib/api_tchap/api.rb +++ b/app/lib/api_tchap/api.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APITchap::API class ResourceNotFound < StandardError end diff --git a/app/lib/api_tchap/hs_adapter.rb b/app/lib/api_tchap/hs_adapter.rb index cdf2aced2..c99e08e29 100644 --- a/app/lib/api_tchap/hs_adapter.rb +++ b/app/lib/api_tchap/hs_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APITchap::HsAdapter def initialize(email) @email = email diff --git a/app/lib/asn1/timestamp.rb b/app/lib/asn1/timestamp.rb index 6dd65e4db..384f00ad6 100644 --- a/app/lib/asn1/timestamp.rb +++ b/app/lib/asn1/timestamp.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ASN1::Timestamp ## Poor man’s rfc3161 timestamp decoding # This works, as of 2019-05, for timestamps delivered by the universign POST api. diff --git a/app/lib/balancer_delivery_method.rb b/app/lib/balancer_delivery_method.rb index 2f9774af0..c1a1931d4 100644 --- a/app/lib/balancer_delivery_method.rb +++ b/app/lib/balancer_delivery_method.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # A Mail delivery method that randomly balances the actual delivery between different # methods. # @@ -14,6 +16,7 @@ # # Be sure to restart your server when you modify this file. class BalancerDeliveryMethod + BYPASS_UNVERIFIED_MAIL_PROTECTION = 'BYPASS_UNVERIFIED_MAIL_PROTECTION'.freeze FORCE_DELIVERY_METHOD_HEADER = 'X-deliver-with' # Allows configuring the random number generator used for selecting a delivery method, # mostly for testing purposes. @@ -24,6 +27,8 @@ class BalancerDeliveryMethod end def deliver!(mail) + return if prevent_delivery?(mail) + balanced_delivery_method = delivery_method(mail) ApplicationMailer.wrap_delivery_behavior(mail, balanced_delivery_method) @@ -40,6 +45,19 @@ class BalancerDeliveryMethod private + def prevent_delivery?(mail) + return false if mail[BYPASS_UNVERIFIED_MAIL_PROTECTION].present? + return false if mail.to.blank? # bcc list + + user = User.find_by(email: mail.to.first) + return user.unverified_email? if user.present? + + individual = Individual.find_by(email: mail.to.first) + return individual.unverified_email? if individual.present? + + true + end + def force_delivery_method?(mail) @delivery_methods.keys.map(&:to_s).include?(mail[FORCE_DELIVERY_METHOD_HEADER]&.value) end diff --git a/app/lib/certigna/api.rb b/app/lib/certigna/api.rb index e49a4f7e5..8cfaed3f6 100644 --- a/app/lib/certigna/api.rb +++ b/app/lib/certigna/api.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Certigna::API ## Certigna Timestamp POST API # the CAfile used to controle the timestamp token is build: diff --git a/app/lib/code_insee.rb b/app/lib/code_insee.rb index 358af2279..22044dee0 100644 --- a/app/lib/code_insee.rb +++ b/app/lib/code_insee.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CodeInsee def initialize(code_insee) @code_insee = code_insee diff --git a/app/lib/data_fixer/champs_phone_invalid.rb b/app/lib/data_fixer/champs_phone_invalid.rb index 6c6574a11..64c599e9d 100644 --- a/app/lib/data_fixer/champs_phone_invalid.rb +++ b/app/lib/data_fixer/champs_phone_invalid.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class DataFixer::ChampsPhoneInvalid def self.fix(phones_string) phone_candidates = phones_string diff --git a/app/lib/data_fixer/dossier_champs_missing.rb b/app/lib/data_fixer/dossier_champs_missing.rb deleted file mode 100644 index 64e8eefb1..000000000 --- a/app/lib/data_fixer/dossier_champs_missing.rb +++ /dev/null @@ -1,81 +0,0 @@ -# some race condition (regarding double submit of dossier.passer_en_construction!) might remove champs -# until now we haven't decided to push a stronger fix than an UI change -# so we might have to recreate some deleted champs and notify administration -class DataFixer::DossierChampsMissing - def fix - fixed_on_origin = apply_fix(@original_dossier) - - fixed_on_other = Dossier.where(editing_fork_origin_id: @original_dossier.id) - .map(&method(:apply_fix)) - - [fixed_on_origin, fixed_on_other.sum].sum - end - - private - - attr_reader :original_dossier - - def initialize(dossier:) - @original_dossier = dossier - end - - def apply_fix(dossier) - added_champs_root = fix_champs_root(dossier) - added_champs_in_repetition = fix_champs_in_repetition(dossier) - - added_champs = added_champs_root + added_champs_in_repetition - if !added_champs.empty? - dossier.save! - log_champs_added(dossier, added_champs) - added_champs.size - else - 0 - end - end - - def fix_champs_root(dossier) - champs_root, _ = dossier.champs.partition { _1.parent_id.blank? } - expected_tdcs = dossier.revision.revision_types_de_champ.filter { _1.parent.blank? }.map(&:type_de_champ) - - expected_tdcs.filter { !champs_root.map(&:stable_id).include?(_1.stable_id) } - .map do |missing_tdc| - champ_root_missing = missing_tdc.build_champ - - dossier.champs_public << champ_root_missing - champ_root_missing - end - end - - def fix_champs_in_repetition(dossier) - champs_repetition, _ = dossier.champs.partition(&:repetition?) - - champs_repetition.flat_map do |champ_repetition| - champ_repetition_missing = champ_repetition.rows.flat_map do |row| - row_id = row.first.row_id - expected_tdcs = dossier.revision.children_of(champ_repetition.type_de_champ) - row_tdcs = row.map(&:type_de_champ) - - (expected_tdcs - row_tdcs).map do |missing_tdc| - champ_repetition_missing = missing_tdc.build_champ(row_id: row_id) - champ_repetition.champs << champ_repetition_missing - champ_repetition_missing - end - end - end - end - - def log_champs_added(dossier, added_champs) - app_traces = caller.reject { _1.match?(%r{/ruby/.+/gems/}) }.map { _1.sub(Rails.root.to_s, "") } - - payload = { - message: "DataFixer::DossierChampsMissing", - dossier_id: dossier.id, - champs_ids: added_champs.map(&:id).join(","), - caller: app_traces - } - - logger = Lograge.logger || Rails.logger - - logger.info payload.to_json - end -end diff --git a/app/lib/database/migration_helpers.rb b/app/lib/database/migration_helpers.rb index e0a224d83..33b44533d 100644 --- a/app/lib/database/migration_helpers.rb +++ b/app/lib/database/migration_helpers.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Some of this file is lifted from Gitlab's `lib/gitlab/database/migration_helpers.rb` # Copyright (c) 2011-present GitLab B.V. diff --git a/app/lib/dolist/api.rb b/app/lib/dolist/api.rb index 87e401a0a..8713e93bf 100644 --- a/app/lib/dolist/api.rb +++ b/app/lib/dolist/api.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "support/jsv" module Dolist @@ -59,61 +61,57 @@ module Dolist end def send_email(mail) - if mail.attachments.any? { !_1.inline? } - return send_email_with_attachment(mail) - end - body = { "TransactionalSending": prepare_mail_body(mail) } url = format_url(EMAIL_SENDING_TRANSACTIONAL) post(url, body.to_json) end - def send_email_with_attachment(mail) - uri = URI(format_url(EMAIL_SENDING_TRANSACTIONAL_ATTACHMENT)) + # def send_email_with_attachment(mail) + # uri = URI(format_url(EMAIL_SENDING_TRANSACTIONAL_ATTACHMENT)) - request = Net::HTTP::Post.new(uri) + # request = Net::HTTP::Post.new(uri) - default_headers.each do |key, value| - next if key.to_s == "Content-Type" - request[key] = value - end + # default_headers.each do |key, value| + # next if key.to_s == "Content-Type" + # request[key] = value + # end - boundary = "---011000010111000001101001" # any random string not present in the body - request.content_type = "multipart/form-data; boundary=#{boundary}" + # boundary = "---011000010111000001101001" # any random string not present in the body + # request.content_type = "multipart/form-data; boundary=#{boundary}" - body = "--#{boundary}\r\n" + # body = "--#{boundary}\r\n" - base64_files(mail.attachments).each do |file| - body << "Content-Disposition: form-data; name=\"#{file.field_name}\"; filename=\"#{file.filename}\"\r\n" - body << "Content-Type: #{file.mime_type}\r\n" - body << "\r\n" - body << file.content - body << "\r\n" - end + # base64_files(mail.attachments).each do |file| + # body << "Content-Disposition: form-data; name=\"#{file.field_name}\"; filename=\"#{file.filename}\"\r\n" + # body << "Content-Type: #{file.mime_type}\r\n" + # body << "\r\n" + # body << file.content + # body << "\r\n" + # end - body << "\r\n--#{boundary}\r\n" - body << "Content-Disposition: form-data; name=\"TransactionalSending\"\r\n" - body << "Content-Type: text/plain; charset=utf-8\r\n" - body << "\r\n" - body << prepare_mail_body(mail).to_jsv + # body << "\r\n--#{boundary}\r\n" + # body << "Content-Disposition: form-data; name=\"TransactionalSending\"\r\n" + # body << "Content-Type: text/plain; charset=utf-8\r\n" + # body << "\r\n" + # body << prepare_mail_body(mail).to_jsv - body << "\r\n--#{boundary}--\r\n" - body << "\r\n" + # body << "\r\n--#{boundary}--\r\n" + # body << "\r\n" - request.body = body + # request.body = body - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true + # http = Net::HTTP.new(uri.host, uri.port) + # http.use_ssl = true - response = http.request(request) + # response = http.request(request) - if response.body.empty? - fail "Dolist API returned an empty response" - else - JSON.parse(response.body) - end - end + # if response.body.empty? + # fail "Dolist API returned an empty response" + # else + # JSON.parse(response.body) + # end + # end def sent_mails(email_address) contact_id = fetch_contact_id(email_address) @@ -190,9 +188,11 @@ module Dolist format(base, account_id: account_id) end - def sender_id - Rails.cache.fetch("dolist_api_sender_id", expires_in: 1.hour) do - senders.dig("ItemList", 0, "Sender", "ID") + def sender_id(domain) + if domain == "demarches.gouv.fr" + Rails.application.secrets.dolist[:gouv_sender_id] + else + Rails.application.secrets.dolist[:default_sender_id] end end @@ -267,7 +267,7 @@ module Dolist "Message": { "Name": mail['X-Dolist-Message-Name'].value, "Subject": mail.subject, - "SenderID": sender_id, + "SenderID": sender_id(mail.from_address.domain), "ForceHttp": false, # ForceHttp : force le tracking http non sécurisé (True/False). "Format": "html", "DisableOpenTracking": true, # DisableOpenTracking : désactivation du tracking d'ouverture (True/False). diff --git a/app/lib/dolist/api_sender.rb b/app/lib/dolist/api_sender.rb index 7177df39b..89b2d6977 100644 --- a/app/lib/dolist/api_sender.rb +++ b/app/lib/dolist/api_sender.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Dolist class APISender def initialize(mail); end diff --git a/app/lib/dolist/base64_file.rb b/app/lib/dolist/base64_file.rb index 8afcf2024..d449a1f92 100644 --- a/app/lib/dolist/base64_file.rb +++ b/app/lib/dolist/base64_file.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Dolist Base64File = Struct.new(:field_name, :filename, :mime_type, :content, keyword_init: true) end diff --git a/app/lib/dolist/ignorable_error.rb b/app/lib/dolist/ignorable_error.rb index 18ae5d64a..711fead59 100644 --- a/app/lib/dolist/ignorable_error.rb +++ b/app/lib/dolist/ignorable_error.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Dolist class IgnorableError < StandardError; end end diff --git a/app/lib/dossier_with_reference_date.rb b/app/lib/dossier_with_reference_date.rb index 49c7b2645..c2c0aaa6c 100644 --- a/app/lib/dossier_with_reference_date.rb +++ b/app/lib/dossier_with_reference_date.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class DossierWithReferenceDate def self.assign(dossier, state: nil, reference_date: nil) created_at = reference_date.presence || default_created_at(dossier) diff --git a/app/lib/download_manager/parallel_download_queue.rb b/app/lib/download_manager/parallel_download_queue.rb index 16dcbd762..ebab592cd 100644 --- a/app/lib/download_manager/parallel_download_queue.rb +++ b/app/lib/download_manager/parallel_download_queue.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DownloadManager class ParallelDownloadQueue DOWNLOAD_MAX_PARALLEL = ENV.fetch('DOWNLOAD_MAX_PARALLEL') { 10 } diff --git a/app/lib/download_manager/procedure_attachments_export.rb b/app/lib/download_manager/procedure_attachments_export.rb index 3eef6ad82..bdba09b5f 100644 --- a/app/lib/download_manager/procedure_attachments_export.rb +++ b/app/lib/download_manager/procedure_attachments_export.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DownloadManager class ProcedureAttachmentsExport delegate :destination, to: :@queue diff --git a/app/lib/email_checker.rb b/app/lib/email_checker.rb new file mode 100644 index 000000000..efa9be6ee --- /dev/null +++ b/app/lib/email_checker.rb @@ -0,0 +1,656 @@ +# frozen_string_literal: true + +class EmailChecker + # Extracted 100 most used domain on our users table [june 2024] + # + all .gouv.fr domain on our users table + # + all .ac-xxx on our users table + KNOWN_DOMAINS = [ + 'gmail.com', + 'hotmail.fr', + 'orange.fr', + 'yahoo.fr', + 'hotmail.com', + 'outlook.fr', + 'wanadoo.fr', + 'free.fr', + 'yahoo.com', + 'icloud.com', + 'laposte.net', + 'live.fr', + 'sfr.fr', + 'outlook.com', + 'neuf.fr', + 'aol.com', + 'bbox.fr', + 'msn.com', + 'me.com', + 'gmx.fr', + 'protonmail.com', + 'club-internet.fr', + 'live.com', + 'ymail.com', + 'ars.sante.fr', + 'mail.ru', + 'cegetel.net', + 'numericable.fr', + 'aliceadsl.fr', + 'comcast.net', + 'assurance-maladie.fr', + 'mac.com', + 'naver.com', + 'airbus.com', + 'justice.fr', + 'pole-emploi.fr', + 'educagri.fr', + 'aphp.fr', + 'netcourrier.com', + 'dbmail.com', + 'aol.fr', + 'qq.com', + 'hotmail.co.uk', + 'yahoo.co.uk', + 'proxima-mail.fr', + 'yahoo.com.br', + 'sciencespo.fr', + 'gmx.com', + 'etu.univ-st-etienne.fr', + 'yahoo.ca', + '163.com', + 'francetravail.fr', + 'mail.pf', + 'nantesmetropole.fr', + 'hotmail.it', + 'sbcglobal.net', + 'noos.fr', + 'ird.fr', + 'safrangroup.com', + 'croix-rouge.fr', + 'eiffage.com', + 'veolia.com', + 'notaires.fr', + 'nordnet.fr', + 'videotron.ca', + 'paris.fr', + 'lilo.org', + 'mfr.asso.fr', + 'yopmail.com', + 'ukr.net', + 'onf.fr', + 'stellantis.com', + '9online.fr', + 'atmp50.fr', + 'engie.com', + 'libertysurf.fr', + 'mailo.com', + 'auchan.fr', + 'verizon.net', + 'rocketmail.com', + 'mpsa.com', + 'entrepreneur.fr', + 'googlemail.com', + 'arcelormittal.com', + 'groupe-sos.org', + 'proton.me', + 'att.net', + 'pm.me', + 'orange.com', + 'abv.bg', + 'yahoo.es', + 'creditmutuel.fr', + 'yandex.ru', + 'essec.edu', + 'urssaf.fr', + 'bpifrance.fr', + 'uol.com.br', + 'suez.com', + 'univ-st-etienne.fr', + 'korian.fr', + 'developpement-durable.gouv.fr', + 'modernisation.gouv.fr', + 'social.gouv.fr', + 'emploi.gouv.fr', + 'agriculture.gouv.fr', + 'intradef.gouv.fr', + 'interieur.gouv.fr', + 'oise.gouv.fr', + 'direccte.gouv.fr', + 'culture.gouv.fr', + 'pas-de-calais.gouv.fr', + 'finances.gouv.fr', + 'drieets.gouv.fr', + 'drjscs.gouv.fr', + 'sg.social.gouv.fr', + 'martinique.pref.gouv.fr', + 'beta.gouv.fr', + 'dieccte.gouv.fr', + 'cotes-darmor.gouv.fr', + 'vosges.gouv.fr', + 'developppement-durable.gouv.fr', + 'mayenne.gouv.fr', + 'aviation-civile.gouv.fr', + 'data.gouv.fr', + 'recherche.gouv.fr', + 'sante.gouv.fr', + 'paris-idf.gouv.fr', + 'guyane.gouv.fr', + 'douane.finances.gouv.fr', + 'cget.gouv.fr', + 'herault.gouv.fr', + 'loire-atlantique.gouv.fr', + 'manche.gouv.fr', + 'seine-maritime.gouv.fr', + 'dgccrf.finances.gouv.fr', + 'tarn-et-garonne.gouv.fr', + 'dila.gouv.fr', + 'diplomatie.gouv.fr', + 'haut-rhin.gouv.fr', + 'nord.gouv.fr', + 'bouches-du-rhone.gouv.fr', + 'alpes-de-haute-provence.gouv.fr', + 'hautes-alpes.gouv.fr', + 'alpes-maritimes.gouv.fr', + 'var.gouv.fr', + 'vaucluse.gouv.fr', + 'rhone.gouv.fr', + 'occitanie.gouv.fr', + 'ille-et-vilaine.gouv.fr', + 'finistere.gouv.fr', + 'aisne.gouv.fr', + 'indre.gouv.fr', + 'yvelines.gouv.fr', + 'bas-rhin.gouv.fr', + 'landes.gouv.fr', + 'haute-marne.gouv.fr', + 'correze.gouv.fr', + 'val-doise.gouv.fr', + 'seine-et-marne.gouv.fr', + 'essonne.gouv.fr', + 'calvados.gouv.fr', + 'charente-maritime.gouv.fr', + 'corse-du-sud.gouv.fr', + 'gironde.gouv.fr', + 'haute-corse.gouv.fr', + 'morbihan.gouv.fr', + 'pyrenees-atlantiques.gouv.fr', + 'pyrenees-orientales.gouv.fr', + 'somme.gouv.fr', + 'vendee.gouv.fr', + 'dgtresor.gouv.fr', + 'marne.gouv.fr', + 'auvergne-rhone-alpes.gouv.fr', + 'meurthe-et-moselle.gouv.fr', + 'pm.gouv.fr', + 'oncfs.gouv.fr', + 'orne.gouv.fr', + 'charente.gouv.fr', + 'travail.gouv.fr', + 'gard.gouv.fr', + 'maine-et-loire.gouv.fr', + 'moselle.gouv.fr', + 'outre-mer.gouv.fr', + 'jscs.gouv.fr', + 'haute-garonne.gouv.fr', + 'vienne.gouv.fr', + 'dordogne.gouv.fr', + 'eure.gouv.fr', + 'meuse.gouv.fr', + 'savoie.gouv.fr', + 'doubs.gouv.fr', + 'bfc.gouv.fr', + 'education.gouv.fr', + 'ariege.gouv.fr', + 'normandie.gouv.fr', + 'gendarmerie.interieur.gouv.fr', + 'ain.gouv.fr', + 'ardennes.gouv.fr', + 'drome.gouv.fr', + 'bretagne.gouv.fr', + 'paca.gouv.fr', + 'haute-saone.gouv.fr', + 'lot.gouv.fr', + 'dgfip.finances.gouv.fr', + 'aveyron.gouv.fr', + 'gers.gouv.fr', + 'tarn.gouv.fr', + 'aude.gouv.fr', + 'lozere.gouv.fr', + 'hautes-pyrenees.gouv.fr', + 'jeunesse-sports.gouv.fr', + 'alpes.maritimes.gouv.fr', + 'dreets.gouv.fr', + 'justice.gouv.fr', + 'sports.gouv.fr', + 'nouvelle-aquitaine.gouv.fr', + 'jura.gouv.fr', + 'haute-savoie.gouv.fr', + 'creuse.gouv.fr', + 'creps-poitiers.sports.gouv.fr', + 'equipement-agriculture.gouv.fr', + 'ira-metz.gouv.fr', + 'loire.gouv.fr', + 'defense.gouv.fr', + 'paris.gouv.fr', + 'ensm.sports.gouv.fr', + 'isere.gouv.fr', + 'haute-loire.gouv.fr', + 'cantal.gouv.fr', + 'lot-et-garonne.gouv.fr', + 'reunion.pref.gouv.fr', + 'loiret.gouv.fr', + 'indre-et-loire.gouv.fr', + 'eleve.ira-metz.gouv.fr', + 'deux-sevres.gouv.fr', + 'inao.gouv.fr', + 'franceconnect.gouv.fr', + 'essone.gouv.fr', + 'workinfrance.beta.gouv.fr', + 'seine-saint-denis.gouv.fr', + 'val-de-marne.gouv.fr', + 'morbihan.pref.gouv.fr', + 'externes.justice.gouv.fr', + 'haute-vienne.gouv.fr', + 'territoire-de-belfort.gouv.fr', + 'creps-reunion.sports.gouv.fr', + 'creps-centre.sports.gouv.fr', + 'creps-rhonealpes.sports.gouv.fr', + 'creps-montpellier.sports.gouv.fr', + 'nord.pref.gouv.fr', + 'charente-maritime.pref.gouv.fr', + 'cher.gouv.fr', + 'cote-dor.gouv.fr', + 'ssi.gouv.fr', + 'ira.gouv.fr', + 'pays-de-la-loire.gouv.fr', + 'loir-et-cher.gouv.fr', + 'saone-et-loire.gouv.fr', + 'enseignementsup.gouv.fr', + 'eure-et-loir.gouv.fr', + 'yonne.gouv.fr', + 'guadeloupe.pref.gouv.fr', + 'centre-val-de-loire.gouv.fr', + 'entreprise.api.gouv.fr', + 'grand-est.gouv.fr', + 'sarthe.gouv.fr', + 'sarthe.pref.gouv.fr', + 'puy-de-dome.gouv.fr', + 'externes.sante.gouv.fr', + 'allier.gouv.fr', + 'aube.gouv.fr', + 'nievre.gouv.fr', + 'ardeche.gouv.fr', + 'api.gouv.fr', + 'hauts-de-seine.gouv.fr', + 'hauts-de-france.gouv.fr', + 'temp-beta.gouv.fr', + 'def.gouv.fr', + 'particulier.api.gouv.fr', + 'ira-lille.gouv.fr', + 'haute-saone.pref.gouv.fr', + 'yvelines.pref.gouv.fr', + 'sgg.pm.gouv.fr', + 'anah.gouv.fr', + 'corse.gouv.fr', + 'mayenne.pref.gouv.fr', + 'cote-dor.pref.gouv.fr', + 'guyane.pref.gouv.fr', + 'ira-nantes.gouv.fr', + 'igas.gouv.fr', + 'tarn.pref.gouv.fr', + 'martinique.gouv.fr', + 'creps-paca.sports.gouv.fr', + 'ofb.gouv.fr', + 'loir-et-cher.pref.gouv.fr', + 'indre-et-loire.pref.gouv.fr', + 'polynesie-francaise.pref.gouv.fr', + 'scl.finances.gouv.fr', + 'numerique.gouv.fr', + 'cantal.pref.gouv.fr', + 'territoire-de-belfort.pref.gouv.fr', + 'creps-wattignies.sports.gouv.fr', + 'vienne.pref.gouv.fr', + 'ardennes.pref.gouv.fr', + 'creps-strasbourg.sports.gouv.fr', + 'creps-dijon.sports.gouv.fr', + 'ara.gouv.fr', + 'sgdsn.gouv.fr', + 'pays-de-la-loire.pref.gouv.fr', + 'anct.gouv.fr', + 'creps-pap.sports.gouv.fr', + 'sgae.gouv.fr', + 'esnm.sports.gouv.fr', + 'nouvelle-caledonie.gouv.fr', + 'deets.gouv.fr', + 'mayotte.gouv.fr', + 'creps-bordeaux.sports.gouv.fr', + 'civs.gouv.fr', + 'iga.interieur.gouv.fr', + 'cab.travail.gouv.fr', + 'ira-bastia.gouv.fr', + 'ira-lyon.gouv.fr', + 'creps-lorraine.sports.gouv.fr', + 'dihal.gouv.fr', + 'ofpra.gouv.fr', + 'mayotte.pref.gouv.fr', + 'strategie.gouv.fr', + 'territoires.gouv.fr', + 'dgcl.gouv.fr', + 'doubs.pref.gouv.fr', + 'service-civique.gouv.fr', + 'maine-et-loire.pref.gouv.fr', + 'envsn.sports.gouv.fr', + 'wallis-et-futuna.pref.gouv.fr', + 'gendarmerie.defense.gouv.fr', + 'anlci.gouv.fr', + 'cabinets.finances.gouv.fr', + 'seine-maritime.pref.gouv.fr', + 'promo46.ira-metz.gouv.fr', + 'aisne.pref.gouv.fr', + 'sportsdenature.gouv.fr', + 'loire-atlantique.pref.gouv.fr', + 'aude.pref.gouv.fr', + 'premier-ministre.gouv.fr', + 'igf.finances.gouv.fr', + 'eleves.ira-bastia.gouv.fr', + 'igesr.gouv.fr', + 'alpc.gouv.fr', + 'externes.emploi.gouv.fr', + 'prestataire.finances.gouv.fr', + 'gironde.pref.gouv.fr', + 'premar-atlantique.gouv.fr', + 'creps-toulouse.sports.gouv.fr', + 'guadeloupe.gouv.fr', + 'cybermalveillance.gouv.fr', + 'dicod.defense.gouv.fr', + 'creps-vichy.sports.gouv.fr', + 'aft.gouv.fr', + 'equipement.gouv.fr', + 'academie.defense.gouv.fr', + 'aube.pref.gouv.fr', + 'seine-et-marne.pref.gouv.fr', + 'pyrenees-orientales.pref.gouv.fr', + 'haute-garonne.pref.gouv.fr', + 'haut-rhin.pref.gouv.fr', + 'seine-saint-denis.pref.gouv.fr', + 'dcstep.gouv.fr', + 'promo47.ira-metz.gouv.fr', + 'trackdechets.beta.gouv.fr', + 'val-de-marne.pref.gouv.fr', + 'fabrique.social.gouv.fr', + 'agrasc.gouv.fr', + 'indre.pref.gouv.fr', + 'tarn-et-garonne.pref.gouv.fr', + 'corse.pref.gouv.fr', + 'bas-rhin.pref.gouv.fr', + 'inclusion.beta.gouv.fr', + 'hauts-de-seine.pref.gouv.fr', + 'loiret.pref.gouv.fr', + 'essonne.pref.gouv.fr', + 'territoires-industrie.gouv.fr', + 'spm975.gouv.fr', + 'saint-barth-saint-martin.gouv.fr', + 'judiciaire.interieur.gouv.fr', + 'mer.gouv.fr', + 'premar-manche.gouv.fr', + 'haute-normandie.pref.gouv.fr', + 'prestataire.modernisation.gouv.fr', + 'covoiturage.beta.gouv.fr', + 'promo48.ira-metz.gouv.fr', + 'france-services.gouv.fr', + 'ddets.gouv.fr', + 'afa.gouv.fr', + 'externes.social.gouv.fr', + 'vosges.pref.gouv.fr', + 'reunion.gouv.fr', + 'rhone.pref.gouv.fr', + 'alpes-maritimes.pref.gouv.fr', + 'gard.pref.gouv.fr', + 'oise.pref.gouv.fr', + 'creps-reims.sports.gouv.fr', + 'bouches-du-rhone.pref.gouv.fr', + 'esante.gouv.fr', + 'rhone-alpes.pref.gouv.fr', + 'finistere.pref.gouv.fr', + 'ops-bss.defense.gouv.fr', + 'orne.pref.gouv.fr', + 'transformation.gouv.fr', + 'cbcm.social.gouv.fr', + 'recosante.beta.gouv.fr', + 'pas-de-calais.pref.gouv.fr', + 'promo49.ira-metz.gouv.fr', + 'paca.pref.gouv.fr', + 'meurthe-et-moselle.pref.gouv.fr', + 'externes.sg.social.gouv.fr', + 'puy-de-dome.pref.gouv.fr', + 'academie.def.gouv.fr', + 'tarn.gouv.frd81intranet.ddcspp.tarn.gouv.fr', + 'agriculture-equipement.gouv.fr', + 'creps-idf.sports.gouv.fr', + 'eleve.ira-nantes.gouv.fr', + 'cohesion-territoires.gouv.fr', + 'ariege.pref.gouv.fr', + 'pyrenees-atlantiques.pref.gouv.fr', + 'hautes-pyrenees.pref.gouv.fr', + 'lot-et-garonne.pref.gouv.fr', + 'loire.pref.gouv.fr', + 'info-routiere.gouv.fr', + 'diges.gouv.fr', + 'insp.gouv.fr', + 'creps-pdl.sports.gouv.fr', + 'ddc.social.gouv.fr', + 'eleve.insp.gouv.fr', + 'val-doise.pref.gouv.fr', + 'montsaintmichel.gouv.fr', + 'st-cyr.terre-net.defense.gouv.fr', + '.finances.gouv.fr', + 'logement.gouv.fr', + 'cotes-darmor.pref.gouv.fr', + 'marne.pref.gouv.fr', + 'herault.pref.gouv.fr', + 'viennne.gouv.fr', + 'landes.pref.gouv.fr', + 'moselle.pref.gouv.fr', + 'saone-et-loire.pref.gouv.fr', + 'bmpm.gouv.fr', + 'ecologie-territoires.gouv.fr', + 'nievre.pref.gouv.fr', + 'hautes-pyrénées.gouv.fr', + 'gic.gouv.fr', + 'industrie.gouv.fr', + 'lot.pref.gouv.fr', + 'plan.gouv.fr', + 'internet.gouv.fr', + 'mesads.beta.gouv.fr', + 'gers.pref.gouv.fr', + 'dordogne.pref.gouv.fr', + 'somme.pref.gouv.fr', + 'datasubvention.beta.gouv.fr', + 'anc.gouv.fr', + 'premar-mediterranee.gouv.fr', + 'ille-et-vilaine.pref.gouv.fr', + 'eure-et-loir.pref.gouv.fr', + 'prestataires.pm.gouv.fr', + 'snu.gouv.fr', + 'code.gouv.fr', + 'alsace.pref.gouv.fr', + 'haute-vienne.pref.gouv.fr', + 'yonne.pref.gouv.fr', + 'bretagne.pref.gouv.fr', + 'mastere.insp.gouv.fr', + 'cada.pm.gouv.fr', + 'creuse.pref.gouv.fr', + 'ecologie.gouv.fr', + 'midi-pyrenees.pref.gouv.fr', + 'promo54.ira-metz.gouv.fr', + 'var.pref.gouv.fr', + 'alpes-de-haute-provence.pref.gouv.fr', + 'mail.numerique.gouv.fr', + 'france-identite.gouv.fr', + 'transport.data.gouv.fr', + 'allier.pref.gouv.fr', + 'dilhal.gouv.fr', + 'ardeche.pref.gouv.fr', + 'haute-corse.pref.gouv.fr', + 'intérieur.gouv.fr', + 'ddfip.gouv.fr', + 'calvados.pref.gouv.fr', + 'territoir-de-belfort.gouv.fr', + 'nor.gouv.fr', + 'creps-occitanie.sports.gouv.fr', + 'developpement-durabe.gouv.fr', + 'educ.nat.gouv.fr', + 'developpement-duable.gouv.fr', + 'dgfip.finanes.gouv.fr', + 'loire-atlantqieu.gouv.fr', + 'promo55.ira-metz.gouv.fr', + 'haute-saône.gouv.fr', + 'developpement.durable.gouv.fr', + 'dreet.gouv.fr', + 'miprof.gouv.fr', + 'pref.guyane.gouv.fr', + 'developpement.gouv.fr', + 'gendamrerie.interieur.gouv.fr', + 'pyrenees-atlantique.gouv.fr', + 'apprentissage.beta.gouv.fr', + 'yveliens.gouv.fr', + 'justiice.gouv.fr', + 'cutlure.gouv.fr', + 'aidantsconnect.beta.gouv.fr', + 'developpement-durbale.gouv.fr', + 'sine-et-marne.gouv.fr', + 'sociale.gouv.fr', + 'develeoppement-durable.gouv.fr', + 'draaf.gouv.fr', + 'drets.gouv.fr', + 'ancli.gouv.fr', + 'finistrere.gouv.fr', + 'bourgogne.pref.gouv.fr', + 'ac-polynesie.pf', + 'ac-lille.fr', + 'ac-nantes.fr', + 'ac-martinique.fr', + 'ac-creteil.fr', + 'ac-toulouse.fr', + 'ac-amiensfr', + 'ac-amiens.fr', + 'ac-rennes.fr', + 'ac-strasbourg.fr', + 'ac-lyon.fr', + 'ac-versailles.fr', + 'ac-audit.fr', + 'ac-rouen.fr', + 'ac-reunion.fr', + 'ac-poitiers.fr', + 'ac-caen.fr', + 'ac-montpellier.fr', + 'ac-paris.fr', + 'ac-besancon.fr', + 'ac-nancy-metz.fr', + 'ac-aix-marseille.fr', + 'ac-grenoble.fr', + 'ac-corse.fr', + 'ac-nice.fr', + 'ac-orleans-tours.fr', + 'ac-guadeloupe.fr', + 'ac-reims.fr', + 'ac-mayotte.fr', + 'ac-clermont.fr', + 'ac-bordeaux.fr', + 'ac-limoges.fr', + 'ac-normandie.fr', + 'ac-dijon.fr', + 'ac-guyane.fr', + 'ac-transports.fr', + 'ac-arpajonnais.com', + 'ac-cned.fr', + 'ac-nettoyage.com', + 'ac-architectes.fr', + 'ac-ajaccio.corsica', + 'ac-noumea.nc', + 'ac-spm.fr', + 'ac-versailes.fr', + 'ac-polynesie.fr', + 'ac-experts.fr', + 'ac-creteil.com', + 'ac-smart-relocation.com', + 'ac-ec.pro', + 'ac-sas.fr', + 'ac-derma.de', + 'ac-or.com', + 'ac-baugeois.fr', + 'ac-5.ru', + 'ac-arles.fr', + 'ac-holding.net', + 'ac-mb.fr', + 'ac-wf.wf', + 'ac-brest-finistere.fr', + 'ac-leman.com', + 'ac-darboussier.fr', + 'ac-si.fr', + 'ac-bordeau.fr', + 'ac-gatinais.com', + 'ac-cheminots.fr', + 'ac-seyssinet.com', + 'ac-cannes.fr', + 'ac-prev.com', + 'ac-sologne.fr', + 'ac-rennes', + 'ac-courbevoie.com', + 'ac-ce.fr', + 'ac-architecte.fr', + 'ac-tions.org', + 'ac-pm.fr', + 'ac-avocats.com', + 'ac-talents-rh.com', + 'ac-louis.com', + 'ac-internet.fr', + 'ac-toulouse.com', + 'ac-escial.fr', + 'ac-environnement.com', + 'ac-academie.fr', + 'ac-poiters.fr', + 'ac-bordeux.fr', + 'ac-verseilles.fr', + 'ac-ais-marseille.fr', + 'ac-horizon.fr', + 'ac-bordeaux.ft', + 'ac-toulouses.fr', + 'ac-toulous.fr' + ].freeze + + def self.check(email:) + return { success: false } if email.blank? + + parsed_email = Mail::Address.new(EmailSanitizableConcern::EmailSanitizer.sanitize(email)) + return { success: false } if parsed_email.domain.blank? + + return { success: true } if KNOWN_DOMAINS.any? { _1 == parsed_email.domain } + + similar_domains = closest_domains(domain: parsed_email.domain) + return { success: true } if similar_domains.empty? + + { success: true, suggestions: suggestions(parsed_email:, similar_domains:) } + rescue Mail::Field::IncompleteParseError + return { success: false } + end + + private + + def self.closest_domains(domain:) + KNOWN_DOMAINS.filter do |known_domain| + close_by_distance_of(domain, known_domain, distance: 1) || + with_same_chars_and_close_by_distance_of(domain, known_domain, distance: 2) + end + end + + def self.close_by_distance_of(a, b, distance:) + String::Similarity.levenshtein_distance(a, b) == distance + end + + def self.with_same_chars_and_close_by_distance_of(a, b, distance:) + close_by_distance_of(a, b, distance: 2) && a.chars.sort == b.chars.sort + end + + def self.suggestions(parsed_email:, similar_domains:) + similar_domains.map { Mail::Address.new("#{parsed_email.local}@#{_1}").to_s } + end +end diff --git a/app/lib/helpscout/api.rb b/app/lib/helpscout/api.rb index c0538c7a9..bf5e9ed8b 100644 --- a/app/lib/helpscout/api.rb +++ b/app/lib/helpscout/api.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Helpscout::API MAILBOXES = 'mailboxes' CONVERSATIONS = 'conversations' @@ -7,6 +9,10 @@ class Helpscout::API PHONES = 'phones' OAUTH2_TOKEN = 'oauth2/token' + RATELIMIT_KEY = "helpscout-rate-limit-remaining" + + class RateLimitError < StandardError; end; + def ready? required_secrets = [ Rails.application.secrets.helpscout[:mailbox_id], @@ -22,7 +28,7 @@ class Helpscout::API }) end - def create_conversation(email, subject, text, file) + def create_conversation(email, subject, text, blob) body = { subject: subject, customer: customer(email), @@ -34,7 +40,7 @@ class Helpscout::API type: 'customer', customer: customer(email), text: text, - attachments: attachments(file) + attachments: attachments(blob) } ] }.compact @@ -42,6 +48,53 @@ class Helpscout::API call_api(:post, CONVERSATIONS, body) end + def list_old_conversations(status, before, page: 1) + body = { + page:, + status:, # active, open, closed, pending, spam. "all" does not work + query: "( + modifiedAt:[* TO #{before.iso8601}] + )", + sortField: "modifiedAt", + sortOrder: "desc" + } + + response = call_api(:get, "#{CONVERSATIONS}?#{body.to_query}") + if !response.success? + raise StandardError, "Error while listing conversations: #{response.response_code} '#{response.body}'" + end + + body = parse_response_body(response) + [body[:_embedded][:conversations], body[:page]] + end + + def list_old_customers(before, page: 1) + body = { + page:, + query: "( + modifiedAt:[* TO #{before.iso8601}] + )", + sortField: "modifiedAt", + sortOrder: "desc" + } + + response = call_api(:get, "#{CUSTOMERS}?#{body.to_query}") + if !response.success? + raise StandardError, "Error while listing customers: #{response.response_code} '#{response.body}'" + end + + body = parse_response_body(response) + [body[:_embedded][:customers], body[:page]] + end + + def delete_conversation(conversation_id) + call_api(:delete, "#{CONVERSATIONS}/#{conversation_id}") + end + + def delete_customer(customer_id) + call_api(:delete, "#{CUSTOMERS}/#{customer_id}") + end + def add_phone_number(email, phone) query = CGI.escape("(email:#{email})") response = call_api(:get, "#{CUSTOMERS}?mailbox=#{user_support_mailbox_id}&query=#{query}") @@ -76,13 +129,13 @@ class Helpscout::API private - def attachments(file) - if file.present? + def attachments(blob) + if blob.present? [ { - fileName: file.original_filename, - mimeType: file.content_type, - data: Base64.strict_encode64(file.read) + fileName: blob.filename, + mimeType: blob.content_type, + data: Base64.strict_encode64(blob.download) } ] else @@ -129,6 +182,17 @@ class Helpscout::API body: body.to_json, headers: headers }) + when :delete + Typhoeus.delete(url, { + body: body.to_json, + headers: headers + }) + end.tap do |response| + Rails.cache.write(RATELIMIT_KEY, response.headers["X-Ratelimit-Remaining-Minute"], expires_in: 1.minute) + + if response.response_code.to_i == 429 + raise RateLimitError + end end end diff --git a/app/lib/helpscout/form_adapter.rb b/app/lib/helpscout/form_adapter.rb deleted file mode 100644 index 03c168f08..000000000 --- a/app/lib/helpscout/form_adapter.rb +++ /dev/null @@ -1,79 +0,0 @@ -class Helpscout::FormAdapter - attr_reader :params - - def self.options - [ - [I18n.t(:question, scope: [:support, :index, TYPE_INFO]), TYPE_INFO, I18n.t("links.common.faq.contacter_service_en_charge_url")], - [I18n.t(:question, scope: [:support, :index, TYPE_PERDU]), TYPE_PERDU, LISTE_DES_DEMARCHES_URL], - [I18n.t(:question, scope: [:support, :index, TYPE_INSTRUCTION]), TYPE_INSTRUCTION, I18n.t("links.common.faq.ou_en_est_mon_dossier_url")], - [I18n.t(:question, scope: [:support, :index, TYPE_AMELIORATION]), TYPE_AMELIORATION, FEATURE_UPVOTE_URL], - [I18n.t(:question, scope: [:support, :index, TYPE_AUTRE]), TYPE_AUTRE] - ] - end - - def self.admin_options - [ - [I18n.t(:question, scope: [:support, :admin, ADMIN_TYPE_QUESTION], app_name: Current.application_name), ADMIN_TYPE_QUESTION], - [I18n.t(:question, scope: [:support, :admin, ADMIN_TYPE_RDV], app_name: Current.application_name), ADMIN_TYPE_RDV], - [I18n.t(:question, scope: [:support, :admin, ADMIN_TYPE_SOUCIS], app_name: Current.application_name), ADMIN_TYPE_SOUCIS], - [I18n.t(:question, scope: [:support, :admin, ADMIN_TYPE_PRODUIT]), ADMIN_TYPE_PRODUIT], - [I18n.t(:question, scope: [:support, :admin, ADMIN_TYPE_DEMANDE_COMPTE]), ADMIN_TYPE_DEMANDE_COMPTE], - [I18n.t(:question, scope: [:support, :admin, ADMIN_TYPE_AUTRE]), ADMIN_TYPE_AUTRE] - ] - end - - def initialize(params = {}, api = nil) - @params = params - @api = api || Helpscout::API.new - end - - TYPE_INFO = 'procedure_info' - TYPE_PERDU = 'lost_user' - TYPE_INSTRUCTION = 'instruction_info' - TYPE_AMELIORATION = 'product' - TYPE_AUTRE = 'other' - - ADMIN_TYPE_RDV = 'admin demande rdv' - ADMIN_TYPE_QUESTION = 'admin question' - ADMIN_TYPE_SOUCIS = 'admin soucis' - ADMIN_TYPE_PRODUIT = 'admin suggestion produit' - ADMIN_TYPE_DEMANDE_COMPTE = 'admin demande compte' - ADMIN_TYPE_AUTRE = 'admin autre' - - def send_form - conversation_id = create_conversation - - if conversation_id.present? - add_tags(conversation_id) - true - else - false - end - end - - private - - def add_tags(conversation_id) - @api.add_tags(conversation_id, tags) - end - - def tags - (params[:tags].presence || []) + ['contact form'] - end - - def create_conversation - response = @api.create_conversation( - params[:email], - params[:subject], - params[:text], - params[:file] - ) - - if response.success? - if params[:phone].present? - @api.add_phone_number(params[:email], params[:phone]) - end - response.headers['Resource-ID'] - end - end -end diff --git a/app/lib/helpscout/user_conversations_adapter.rb b/app/lib/helpscout/user_conversations_adapter.rb index 12ed81dc1..6ba171a52 100644 --- a/app/lib/helpscout/user_conversations_adapter.rb +++ b/app/lib/helpscout/user_conversations_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Fetch and compute monthly reports about the users conversations on Helpscout class Helpscout::UserConversationsAdapter def initialize(from, to) diff --git a/app/lib/recovery/align_champ_with_dossier_revision.rb b/app/lib/recovery/align_champ_with_dossier_revision.rb deleted file mode 100644 index 8fdba8dc9..000000000 --- a/app/lib/recovery/align_champ_with_dossier_revision.rb +++ /dev/null @@ -1,69 +0,0 @@ -class Recovery::AlignChampWithDossierRevision - def initialize(dossiers, progress: nil) - @dossiers = dossiers - @progress = progress - @logs = [] - end - - attr_reader :logs - - def run(destroy_extra_champs: false) - @logs = [] - bad_dossier_ids = find_broken_dossier_ids - - Dossier - .where(id: bad_dossier_ids) - .includes(:procedure, champs: { type_de_champ: :revisions }) - .find_each do |dossier| - bad_champs = dossier.champs.filter { !dossier.revision_id.in?(_1.type_de_champ.revisions.ids) } - bad_champs.each do |champ| - type_de_champ = dossier.revision.types_de_champ.find { _1.stable_id == champ.stable_id } - state = { - champ_id: champ.id, - champ_type_de_champ_id: champ.type_de_champ_id, - dossier_id: dossier.id, - dossier_revision_id: dossier.revision_id, - procedure_id: dossier.procedure.id - } - if type_de_champ.present? - logs << state.merge(status: :updated, type_de_champ_id: type_de_champ.id) - champ.update_column(:type_de_champ_id, type_de_champ.id) - else - logs << state.merge(status: :not_found) - champ.destroy! if destroy_extra_champs - end - end - end - end - - def find_broken_dossier_ids - bad_dossier_ids = [] - - @dossiers.in_batches(of: 15_000) do |dossiers| - dossier_ids_revision_ids = dossiers.pluck(:id, :revision_id) - dossier_ids = dossier_ids_revision_ids.map(&:first) - dossier_ids_type_de_champ_ids = Champ.where(dossier_id: dossier_ids).pluck(:dossier_id, :type_de_champ_id) - type_de_champ_ids = dossier_ids_type_de_champ_ids.map(&:second).uniq - revision_ids_by_type_de_champ_id = ProcedureRevisionTypeDeChamp - .where(type_de_champ_id: type_de_champ_ids) - .pluck(:type_de_champ_id, :revision_id) - .group_by(&:first).transform_values { _1.map(&:second).uniq } - - type_de_champ_ids_by_dossier_id = dossier_ids_type_de_champ_ids - .group_by(&:first) - .transform_values { _1.map(&:second).uniq } - - bad_dossier_ids += dossier_ids_revision_ids.filter do |(dossier_id, revision_id)| - type_de_champ_ids_by_dossier_id.fetch(dossier_id, []).any? do |type_de_champ_id| - !revision_id.in?(revision_ids_by_type_de_champ_id.fetch(type_de_champ_id, [])) - end - end.map(&:first) - - @progress.inc(dossiers.count) if @progress - end - - @progress.finish if @progress - - bad_dossier_ids - end -end diff --git a/app/lib/recovery/exporter.rb b/app/lib/recovery/exporter.rb index 4781896f1..cf05482bc 100644 --- a/app/lib/recovery/exporter.rb +++ b/app/lib/recovery/exporter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Recovery class Exporter FILE_PATH = Rails.root.join('lib', 'data', 'export.dump') @@ -10,7 +12,7 @@ module Recovery :invites, :traitements, :transfer_logs, - commentaires: { piece_jointe_attachment: :blob }, + commentaires: { piece_jointe_attachments: :blob }, avis: { introduction_file_attachment: :blob, piece_justificative_file_attachment: :blob }, dossier_operation_logs: { serialized_attachment: :blob }, attestation: { pdf_attachment: :blob }, @@ -18,7 +20,7 @@ module Recovery etablissement: :exercices, revision: :procedure) @dossiers = DossierPreloader.new(dossier_with_data, - includes_for_dossier: [:geo_areas, etablissement: :exercices], + includes_for_champ: [:geo_areas, etablissement: :exercices], includes_for_etablissement: [:exercices]).all @file_path = file_path end diff --git a/app/lib/recovery/importer.rb b/app/lib/recovery/importer.rb index 0edc4b6a7..92c7dbaad 100644 --- a/app/lib/recovery/importer.rb +++ b/app/lib/recovery/importer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Recovery class Importer attr_reader :dossiers @@ -112,9 +114,12 @@ module Recovery end end - def import(pj) - ActiveStorage::Blob.insert(pj.blob.attributes) - ActiveStorage::Attachment.insert(pj.attributes) + def import(pjs) + attachments = pjs.respond_to?(:each) ? pjs : [pjs] + attachments.each do |pj| + ActiveStorage::Blob.insert(pj.blob.attributes) + ActiveStorage::Attachment.insert(pj.attributes) + end end end end diff --git a/app/lib/recovery/revision_exporter.rb b/app/lib/recovery/revision_exporter.rb index fdbbfb40d..b14df75e1 100644 --- a/app/lib/recovery/revision_exporter.rb +++ b/app/lib/recovery/revision_exporter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Recovery class RevisionExporter FILE_PATH = Rails.root.join('lib', 'data', 'revision', 'export.dump') diff --git a/app/lib/recovery/revision_importer.rb b/app/lib/recovery/revision_importer.rb index ab13d7040..8ba441d22 100644 --- a/app/lib/recovery/revision_importer.rb +++ b/app/lib/recovery/revision_importer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Recovery class RevisionImporter attr_reader :revisions diff --git a/app/lib/redcarpet/bare_renderer.rb b/app/lib/redcarpet/bare_renderer.rb index d8944374c..6989a6ebf 100644 --- a/app/lib/redcarpet/bare_renderer.rb +++ b/app/lib/redcarpet/bare_renderer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Redcarpet class BareRenderer < Redcarpet::Render::HTML include ActionView::Helpers::TagHelper @@ -33,7 +35,7 @@ module Redcarpet when :url link(link, nil, link) when :email - # NOTE: As of Redcarpet 3.6.0, autolinking email containing is broken https://github.com/vmg/redcarpet/issues/402 + # NOTE: As of Redcarpet 3.6.0, autolinking email containing underscore is broken https://github.com/vmg/redcarpet/issues/402 content_tag(:a, link, { href: "mailto:#{link}" }) else link diff --git a/app/lib/redcarpet/trusted_renderer.rb b/app/lib/redcarpet/trusted_renderer.rb new file mode 100644 index 000000000..bdfa95d83 --- /dev/null +++ b/app/lib/redcarpet/trusted_renderer.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Redcarpet + class TrustedRenderer < Redcarpet::Render::HTML + include ActionView::Helpers::TagHelper + include Sprockets::Rails::Helper + include ApplicationHelper + + attr_reader :view_context + + def initialize(view_context, extensions = {}) + @view_context = view_context + + super extensions + end + + def link(href, title, content) + html_options = { + href: href + } + + unless href.starts_with?('/') + html_options.merge!(title: new_tab_suffix(content), **external_link_attributes) + end + + content_tag(:a, content, html_options, false) + end + + def autolink(link, link_type) + case link_type + when :url + link(link, nil, link) + when :email + # NOTE: As of Redcarpet 3.6.0, autolinking email containing underscore is broken https://github.com/vmg/redcarpet/issues/402 + content_tag(:a, link, { href: "mailto:#{link}" }) + end + end + + def image(link, title, alt_text) + # Extrait les attributs personnalisés s'ils existent sous la forme { aria-hidden=true } dans les [] + custom_attributes = {} + if alt_text =~ /\s*\{(.+)\}$/ + attr_string = Regexp.last_match(1) + alt_text = alt_text.sub(/\s*\{.+\}$/, '').strip + attr_string.split(' ').each do |attr| + key, value = attr.split('=') + custom_attributes[key.strip] = value.strip.delete('"') + end + end + + # Combine les attributs standard et personnalisés + image_options = { + alt: alt_text, + title:, + loading: :lazy + }.merge(custom_attributes) + + view_context.image_tag(link, image_options) + end + + # rubocop:disable Rails/OutputSafety + def block_quote(raw_html) + if raw_html =~ /^

    \[!(INFO|WARNING)\]\n/ + state = Regexp.last_match(1).downcase.to_sym + content = raw_html.sub(/^

    \[!(?:INFO|WARNING)\]\n/, '

    ') + component = Dsfr::AlertComponent.new(state:, heading_level: "h2", extra_class_names: "fr-my-3w") + component.render_in(view_context) do |c| + c.with_body { content.html_safe } + end + else + view_context.content_tag(:blockquote, raw_html.html_safe) + end + end + # rubocop:enable Rails/OutputSafety + end +end diff --git a/app/lib/sanitizers/mail_scrubber.rb b/app/lib/sanitizers/mail_scrubber.rb index 89a1eeaf5..4a1da83b2 100644 --- a/app/lib/sanitizers/mail_scrubber.rb +++ b/app/lib/sanitizers/mail_scrubber.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Sanitizers class MailScrubber < Rails::Html::PermitScrubber def initialize diff --git a/app/lib/sendinblue/api.rb b/app/lib/sendinblue/api.rb index 522e96d34..ce5b92a0d 100644 --- a/app/lib/sendinblue/api.rb +++ b/app/lib/sendinblue/api.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Sendinblue::API def self.new_properly_configured! api = self.new diff --git a/app/lib/sent_mail.rb b/app/lib/sent_mail.rb index 828dd62e1..a08e47e9d 100644 --- a/app/lib/sent_mail.rb +++ b/app/lib/sent_mail.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Represent an email sent using an external API class SentMail < Struct.new(:from, :to, :subject, :delivered_at, :status, :service_name, :external_url, keyword_init: true) end diff --git a/app/lib/typhoeus/cache/successful_requests_rails_cache.rb b/app/lib/typhoeus/cache/successful_requests_rails_cache.rb index 5d95f6539..98be17b6f 100644 --- a/app/lib/typhoeus/cache/successful_requests_rails_cache.rb +++ b/app/lib/typhoeus/cache/successful_requests_rails_cache.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Typhoeus module Cache # Cache successful Typhoeus requests in the Rails cache diff --git a/app/lib/universign/api.rb b/app/lib/universign/api.rb index eeb987ca1..6e273b4be 100644 --- a/app/lib/universign/api.rb +++ b/app/lib/universign/api.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Universign::API ## Universign Timestamp POST API # Official documentation is at https://help.universign.com/hc/fr/articles/360000898965-Guide-d-intégration-horodatage diff --git a/app/mailers/administrateur_mailer.rb b/app/mailers/administrateur_mailer.rb index 0ed0d6598..f3fdf264d 100644 --- a/app/mailers/administrateur_mailer.rb +++ b/app/mailers/administrateur_mailer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Preview all emails at http://localhost:3000/rails/mailers/administrateur_mailer class AdministrateurMailer < ApplicationMailer layout 'mailers/layout' @@ -8,6 +10,8 @@ class AdministrateurMailer < ApplicationMailer @expiration_date = @user.reset_password_sent_at + Devise.reset_password_within @subject = "N'oubliez pas d’activer votre compte administrateur" + bypass_unverified_mail_protection! + mail(to: user.email, subject: @subject, reply_to: CONTACT_EMAIL) diff --git a/app/mailers/administration_mailer.rb b/app/mailers/administration_mailer.rb index baa26e321..e1dc84ae9 100644 --- a/app/mailers/administration_mailer.rb +++ b/app/mailers/administration_mailer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Preview all emails at http://localhost:3000/rails/mailers/administration_mailer class AdministrationMailer < ApplicationMailer layout 'mailers/layout' @@ -8,6 +10,8 @@ class AdministrationMailer < ApplicationMailer @author_name = "Équipe de #{APPLICATION_NAME}" subject = "Activez votre compte administrateur" + bypass_unverified_mail_protection! + mail(to: user.email, subject: subject, reply_to: CONTACT_EMAIL) @@ -16,6 +20,8 @@ class AdministrationMailer < ApplicationMailer def refuse_admin(admin_email) subject = "Votre demande de compte a été refusée" + bypass_unverified_mail_protection! + mail(to: admin_email, subject: subject, reply_to: CONTACT_EMAIL) diff --git a/app/mailers/api_token_mailer.rb b/app/mailers/api_token_mailer.rb index 0ab3f6dea..f9f906bc2 100644 --- a/app/mailers/api_token_mailer.rb +++ b/app/mailers/api_token_mailer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Preview all emails at http://localhost:3000/rails/mailers/api_token_mailer class APITokenMailer < ApplicationMailer helper MailerHelper diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index cb5124753..48c6d8180 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ApplicationMailer < ActionMailer::Base include MailerDefaultsConfigurableConcern include MailerDolistConcern diff --git a/app/mailers/avis_mailer.rb b/app/mailers/avis_mailer.rb index 1efa8116f..d3018920b 100644 --- a/app/mailers/avis_mailer.rb +++ b/app/mailers/avis_mailer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Preview all emails at http://localhost:3000/rails/mailers/avis_mailer class AvisMailer < ApplicationMailer helper MailerHelper @@ -20,6 +22,25 @@ class AvisMailer < ApplicationMailer end end + def avis_invitation_and_confirm_email(user, token, avis, targeted_user_link = nil) # ensure re-entrance if existing AvisMailer.avis_invitation in queue + if avis.dossier.visible_by_administration? + targeted_user_link = avis.targeted_user_links + .find_or_create_by(target_context: 'avis', + target_model_type: Avis.name, + target_model_id: avis.id, + user: avis.expert.user) + email = user.email + @token = token + @avis = avis + @url = targeted_user_link_url(targeted_user_link) + subject = "Donnez votre avis sur le dossier nº #{@avis.dossier.id} (#{@avis.dossier.procedure.libelle})" + + bypass_unverified_mail_protection! + + mail(to: email, subject: subject) + end + end + # i18n-tasks-use t("avis_mailer.#{action}.subject") def notify_new_commentaire_to_expert(dossier, avis, expert) I18n.with_locale(dossier.user_locale) do diff --git a/app/mailers/concerns/balanced_delivery_concern.rb b/app/mailers/concerns/balanced_delivery_concern.rb index 486aadac4..c2974f92b 100644 --- a/app/mailers/concerns/balanced_delivery_concern.rb +++ b/app/mailers/concerns/balanced_delivery_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module BalancedDeliveryConcern extend ActiveSupport::Concern @@ -8,6 +10,10 @@ module BalancedDeliveryConcern self.class.critical_email?(action_name) end + def bypass_unverified_mail_protection! + headers[BalancerDeliveryMethod::BYPASS_UNVERIFIED_MAIL_PROTECTION] = true + end + private def forced_delivery_provider? diff --git a/app/mailers/concerns/mailer_dolist_concern.rb b/app/mailers/concerns/mailer_dolist_concern.rb index 6db7c74d7..33681ca12 100644 --- a/app/mailers/concerns/mailer_dolist_concern.rb +++ b/app/mailers/concerns/mailer_dolist_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module MailerDolistConcern extend ActiveSupport::Concern diff --git a/app/mailers/concerns/mailer_monitoring_concern.rb b/app/mailers/concerns/mailer_monitoring_concern.rb index dca983bcc..0c8b12455 100644 --- a/app/mailers/concerns/mailer_monitoring_concern.rb +++ b/app/mailers/concerns/mailer_monitoring_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module MailerMonitoringConcern extend ActiveSupport::Concern diff --git a/app/mailers/concerns/priority_delivery_concern.rb b/app/mailers/concerns/priority_delivery_concern.rb index f8e68dd6f..21d9f4b7c 100644 --- a/app/mailers/concerns/priority_delivery_concern.rb +++ b/app/mailers/concerns/priority_delivery_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module PriorityDeliveryConcern extend ActiveSupport::Concern included do diff --git a/app/mailers/devise_user_mailer.rb b/app/mailers/devise_user_mailer.rb index d9acb25c3..bfeb738c5 100644 --- a/app/mailers/devise_user_mailer.rb +++ b/app/mailers/devise_user_mailer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Preview all emails at http://localhost:3000/rails/mailers/devise_user_mailer class DeviseUserMailer < Devise::Mailer helper :application # gives access to all helpers defined within `application_helper`. @@ -34,11 +36,19 @@ class DeviseUserMailer < Devise::Mailer @procedure = opts[:procedure_after_confirmation] || nil @prefill_token = opts[:prefill_token] + bypass_unverified_mail_protection! + I18n.with_locale(record.locale) do super end end + def reset_password_instructions(record, token, opts = {}) + bypass_unverified_mail_protection! + + super + end + def self.critical_email?(action_name) true end diff --git a/app/mailers/dossier_mailer.rb b/app/mailers/dossier_mailer.rb index 1064af740..aa4fb081d 100644 --- a/app/mailers/dossier_mailer.rb +++ b/app/mailers/dossier_mailer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Preview all emails at http://localhost:3000/rails/mailers/dossier_mailer class DossierMailer < ApplicationMailer class AbortDeliveryError < StandardError; end @@ -129,32 +131,32 @@ class DossierMailer < ApplicationMailer mail(to: to_email, subject: @subject) end - def notify_deletion_to_administration(deleted_dossier, to_email) + def notify_deletion_to_administration(hidden_dossier, to_email) configure_defaults_for_email(to_email) - @subject = default_i18n_subject(dossier_id: deleted_dossier.dossier_id) - @deleted_dossier = deleted_dossier + @subject = default_i18n_subject(dossier_id: hidden_dossier.id) + @hidden_dossier = hidden_dossier mail(to: to_email, subject: @subject) end - def notify_automatic_deletion_to_user(deleted_dossiers, to_email) + def notify_automatic_deletion_to_user(hidden_dossiers, to_email) configure_defaults_for_email(to_email) - I18n.with_locale(deleted_dossiers.first.user_locale) do - @state = deleted_dossiers.first.state - @subject = default_i18n_subject(count: deleted_dossiers.size) - @deleted_dossiers = deleted_dossiers + I18n.with_locale(hidden_dossiers.first.user_locale) do + @state = hidden_dossiers.first.state + @subject = default_i18n_subject(count: hidden_dossiers.size) + @hidden_dossiers = hidden_dossiers mail(to: to_email, subject: @subject) end end - def notify_automatic_deletion_to_administration(deleted_dossiers, to_email) + def notify_automatic_deletion_to_administration(hidden_dossiers, to_email) configure_defaults_for_email(to_email) - @subject = default_i18n_subject(count: deleted_dossiers.size) - @deleted_dossiers = deleted_dossiers + @subject = default_i18n_subject(count: hidden_dossiers.size) + @hidden_dossiers = hidden_dossiers mail(to: to_email, subject: @subject) end @@ -204,6 +206,8 @@ class DossierMailer < ApplicationMailer def notify_transfer @transfer = params[:dossier_transfer] + @user = User.find_by(email: @transfer.email) + configure_defaults_for_email(@transfer.email) I18n.with_locale(@transfer.user_locale) do diff --git a/app/mailers/expert_mailer.rb b/app/mailers/expert_mailer.rb index de871bea4..64dd4bd43 100644 --- a/app/mailers/expert_mailer.rb +++ b/app/mailers/expert_mailer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ExpertMailer < ApplicationMailer helper MailerHelper layout 'mailers/layout' diff --git a/app/mailers/groupe_gestionnaire_mailer.rb b/app/mailers/groupe_gestionnaire_mailer.rb index 1dc5f54bd..defbf2a91 100644 --- a/app/mailers/groupe_gestionnaire_mailer.rb +++ b/app/mailers/groupe_gestionnaire_mailer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GroupeGestionnaireMailer < ApplicationMailer helper MailerHelper layout 'mailers/layout' diff --git a/app/mailers/groupe_instructeur_mailer.rb b/app/mailers/groupe_instructeur_mailer.rb index 76f0e917c..9c00a8011 100644 --- a/app/mailers/groupe_instructeur_mailer.rb +++ b/app/mailers/groupe_instructeur_mailer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GroupeInstructeurMailer < ApplicationMailer layout 'mailers/layout' @@ -20,9 +22,9 @@ class GroupeInstructeurMailer < ApplicationMailer @current_instructeur_email = current_instructeur_email subject = if group.procedure.groupe_instructeurs.many? - "Vous avez été ajouté(e) au groupe \"#{group.label}\" de la démarche \"#{group.procedure.libelle}\"" + "Vous avez été ajouté(e) au groupe « #{group.label} » de la démarche « #{group.procedure.libelle} »" else - "Vous avez été affecté(e) à la démarche \"#{group.procedure.libelle}\"" + "Vous avez été affecté(e) à la démarche « #{group.procedure.libelle} »" end mail(bcc: added_instructeur_emails, subject: subject) diff --git a/app/mailers/instructeur_mailer.rb b/app/mailers/instructeur_mailer.rb index f2d4f6239..7e971b9bd 100644 --- a/app/mailers/instructeur_mailer.rb +++ b/app/mailers/instructeur_mailer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Preview all emails at http://localhost:3000/rails/mailers/instructeur_mailer class InstructeurMailer < ApplicationMailer helper MailerHelper @@ -34,6 +36,8 @@ class InstructeurMailer < ApplicationMailer @login_token = login_token subject = "Connexion sécurisée à #{Current.application_name}" + bypass_unverified_mail_protection! + mail(to: instructeur.email, subject: subject) end @@ -47,4 +51,21 @@ class InstructeurMailer < ApplicationMailer def self.critical_email?(action_name) action_name == "send_login_token" end + + def confirm_and_notify_added_instructeur(instructeur, group, current_instructeur_email) + @instructeur = instructeur + @group = group + @current_instructeur_email = current_instructeur_email + @reset_password_token = instructeur.user.send(:set_reset_password_token) + + subject = if group.procedure.groupe_instructeurs.many? + "Vous avez été ajouté(e) au groupe \"#{group.label}\" de la démarche \"#{group.procedure.libelle}\"" + else + "Vous avez été affecté(e) à la démarche \"#{group.procedure.libelle}\"" + end + + bypass_unverified_mail_protection! + + mail(to: instructeur.email, subject: subject) + end end diff --git a/app/mailers/invite_mailer.rb b/app/mailers/invite_mailer.rb index a769d5458..f94eb30b8 100644 --- a/app/mailers/invite_mailer.rb +++ b/app/mailers/invite_mailer.rb @@ -1,8 +1,12 @@ +# frozen_string_literal: true + # Preview all emails at http://localhost:3000/rails/mailers/invite_mailer class InviteMailer < ApplicationMailer layout 'mailers/layout' def invite_user(invite) + bypass_unverified_mail_protection! + subject = "Participez à l'élaboration d’un dossier" targeted_user_link = invite.targeted_user_link || invite.create_targeted_user_link(target_context: 'invite', target_model: invite, @@ -14,6 +18,8 @@ class InviteMailer < ApplicationMailer end def invite_guest(invite) + bypass_unverified_mail_protection! + subject = "#{invite.email_sender} vous invite à consulter un dossier" targeted_user_link = invite.targeted_user_link || invite.create_targeted_user_link(target_context: 'invite', target_model: invite) diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 4459e741d..230553d13 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Preview all emails at http://localhost:3000/rails/mailers/notification_mailer # A Notification is attached as a Comment to the relevant discussion, @@ -8,6 +10,7 @@ class NotificationMailer < ApplicationMailer before_action :set_dossier, except: [:send_notification_for_tiers, :send_accuse_lecture_notification] before_action :set_services_publics_plus, only: :send_notification + before_action :set_jdma, only: :send_notification helper ServiceHelper helper MailerHelper @@ -88,6 +91,12 @@ class NotificationMailer < ApplicationMailer @services_publics_plus_url = ENV['SERVICES_PUBLICS_PLUS_URL'].presence end + def set_jdma + if params[:state] == Dossier.states.fetch(:en_construction) && @dossier.procedure.monavis_embed + @jdma_html = @dossier.procedure.monavis_embed_html_source("email") + end + end + def set_dossier @dossier = params[:dossier] configure_defaults_for_user(@dossier.user) diff --git a/app/mailers/phishing_alert_mailer.rb b/app/mailers/phishing_alert_mailer.rb new file mode 100644 index 000000000..d2b8a6281 --- /dev/null +++ b/app/mailers/phishing_alert_mailer.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class PhishingAlertMailer < ApplicationMailer + helper MailerHelper + + layout 'mailers/layout' + + def notify(user) + @user = user + @subject = "Détection d'une possible usurpation de votre compte" + + mail(to: user.email, subject: @subject) + end + + def self.critical_email?(action_name) = false +end diff --git a/app/mailers/preactivate_users_mailer.rb b/app/mailers/preactivate_users_mailer.rb index d957b2fba..015293be1 100644 --- a/app/mailers/preactivate_users_mailer.rb +++ b/app/mailers/preactivate_users_mailer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PreactivateUsersMailer < ApplicationMailer layout 'mailers/layout' diff --git a/app/mailers/resend_attestation_mailer.rb b/app/mailers/resend_attestation_mailer.rb index 395eb8062..07600dea5 100644 --- a/app/mailers/resend_attestation_mailer.rb +++ b/app/mailers/resend_attestation_mailer.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + class ResendAttestationMailer < ApplicationMailer include Rails.application.routes.url_helpers def resend_attestation(dossier) - to = dossier.user.email + to = dossier.user_email_for(:notification) subject = "Nouvelle attestation pour votre dossier nº #{dossier.id}" mail(to: to, subject: subject, body: body(dossier)) diff --git a/app/mailers/super_admin_mailer.rb b/app/mailers/super_admin_mailer.rb index edbb050b6..6789ee3bc 100644 --- a/app/mailers/super_admin_mailer.rb +++ b/app/mailers/super_admin_mailer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class SuperAdminMailer < ApplicationMailer def dolist_report(to, csv_path) attachments["dolist_report.csv"] = File.read(csv_path) diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index f45892699..9a0a3afe1 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Preview all emails at http://localhost:3000/rails/mailers/user_mailer class UserMailer < ApplicationMailer helper MailerHelper @@ -34,6 +36,12 @@ class UserMailer < ApplicationMailer mail(to: email, subject: @subject) end + def custom_confirmation_instructions(user, token) + @user = user + @token = token + mail(to: @user.email, subject: 'Confirmez votre email') + end + def invite_instructeur(user, reset_password_token) @reset_password_token = reset_password_token @user = user @@ -41,6 +49,37 @@ class UserMailer < ApplicationMailer configure_defaults_for_user(user) + bypass_unverified_mail_protection! + + mail(to: user.email, + subject: subject, + reply_to: Current.contact_email) + end + + def invite_tiers(user, token, dossier) + @token = token + @user = user + @dossier = dossier + subject = "Vérification de votre mail" + + configure_defaults_for_user(user) + + bypass_unverified_mail_protection! + + mail(to: user.email, + subject: subject, + reply_to: Current.contact_email) + end + + def resend_confirmation_email(user, token) + @token = token + @user = user + subject = "Vérification de votre mail" + + configure_defaults_for_user(user) + + bypass_unverified_mail_protection! + mail(to: user.email, subject: subject, reply_to: Current.contact_email) @@ -54,6 +93,8 @@ class UserMailer < ApplicationMailer configure_defaults_for_user(user) + bypass_unverified_mail_protection! + mail(to: user.email, subject: subject, reply_to: Current.contact_email) @@ -90,7 +131,7 @@ class UserMailer < ApplicationMailer def notify_after_closing(user, content, procedure = nil) @user = user - @subject = "Clôture d'une démarche sur Démarches simplifiées" + @subject = "Clôture d'une démarche sur #{Current.application_name}" @procedure = procedure @content = content @@ -104,7 +145,8 @@ class UserMailer < ApplicationMailer 'france_connect_merge_confirmation', "new_account_warning", "ask_for_merge", - "invite_instructeur" + "invite_instructeur", + "custom_confirmation_instructions" ].include?(action_name) end end diff --git a/app/models/KeyableModel.rb b/app/models/KeyableModel.rb index 846bde92b..ea9dd7563 100644 --- a/app/models/KeyableModel.rb +++ b/app/models/KeyableModel.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + KeyableModel = Struct.new(:model_name, :to_key, :param_key, keyword_init: true) diff --git a/app/models/address_proxy.rb b/app/models/address_proxy.rb new file mode 100644 index 000000000..e8463b5d6 --- /dev/null +++ b/app/models/address_proxy.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class AddressProxy + ADDRESS_PARTS = [ + :street_address, + :city_name, + :postal_code, + :city_code, + :departement_name, + :departement_code, + :region_name, + :region_code + ] + + class ChampAddressPresenter + ADDRESS_PARTS.each do |address_part| + define_method(address_part) do + @data[address_part] + end + end + + def initialize(champ) + @data = champ.value_json&.with_indifferent_access || {} + end + end + + class EtablissementAddressPresenter + attr_reader(*ADDRESS_PARTS) + + def initialize(etablissement) + @street_address = [etablissement.numero_voie, etablissement.type_voie, etablissement.nom_voie].compact.join(" ") + @city_name = etablissement.localite + @postal_code = etablissement.code_postal + @city_code = etablissement.code_insee_localite + if @postal_code + @departement_name = APIGeoService.departement_name_by_postal_code(@postal_code) + @departement_code = APIGeoService.departement_code(@departement_name) + @region_code = APIGeoService.region_code_by_departement(@departement_code) + @region_name = APIGeoService.region_name(@region_code) + else # adresse without postal_code, ex: + @departement_name, @departement_code, @region_code, @region_name = nil + end + end + end + + delegate(*ADDRESS_PARTS, to: :@presenter) + + def initialize(champ_or_etablissement) + @presenter = make(champ_or_etablissement) + end + + def make(champ_or_etablissement) + case champ_or_etablissement + when Champ then ChampAddressPresenter.new(champ_or_etablissement) + when Etablissement then EtablissementAddressPresenter.new(champ_or_etablissement) + else raise NotImplementedError("Unsupported address from #{champ_or_etablissement.class.name}") + end + end +end diff --git a/app/models/administrateur.rb b/app/models/administrateur.rb index 48b38840c..6913d469d 100644 --- a/app/models/administrateur.rb +++ b/app/models/administrateur.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Administrateur < ApplicationRecord include UserFindByConcern UNUSED_ADMIN_THRESHOLD = ENV.fetch('UNUSED_ADMIN_THRESHOLD') { 6 }.to_i.months diff --git a/app/models/administrateurs_instructeur.rb b/app/models/administrateurs_instructeur.rb index 099f1bbc9..3e611bc7a 100644 --- a/app/models/administrateurs_instructeur.rb +++ b/app/models/administrateurs_instructeur.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AdministrateursInstructeur < ApplicationRecord belongs_to :administrateur belongs_to :instructeur diff --git a/app/models/administrateurs_procedure.rb b/app/models/administrateurs_procedure.rb index 084a18781..4147f81ef 100644 --- a/app/models/administrateurs_procedure.rb +++ b/app/models/administrateurs_procedure.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AdministrateursProcedure < ApplicationRecord belongs_to :administrateur belongs_to :procedure diff --git a/app/models/agent_connect_information.rb b/app/models/agent_connect_information.rb index fd341b4a6..e3321c37c 100644 --- a/app/models/agent_connect_information.rb +++ b/app/models/agent_connect_information.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AgentConnectInformation < ApplicationRecord belongs_to :instructeur end diff --git a/app/models/api_entreprise_token.rb b/app/models/api_entreprise_token.rb index 0ebf50c2c..b3c1e5b7b 100644 --- a/app/models/api_entreprise_token.rb +++ b/app/models/api_entreprise_token.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntrepriseToken TokenError = Class.new(StandardError) @@ -15,6 +17,10 @@ class APIEntrepriseToken decoded_token.key?("exp") && decoded_token["exp"] <= Time.zone.now.to_i end + def expiration + decoded_token.key?("exp") && Time.zone.at(decoded_token["exp"]) + end + def role?(role) roles.include?(role) end diff --git a/app/models/api_token.rb b/app/models/api_token.rb index 9183c67c7..069990caa 100644 --- a/app/models/api_token.rb +++ b/app/models/api_token.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIToken < ApplicationRecord include ActiveRecord::SecureToken diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 1a770302e..c4690ad80 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ApplicationRecord < ActiveRecord::Base self.abstract_class = true diff --git a/app/models/archive.rb b/app/models/archive.rb index e64c96a0b..a04710756 100644 --- a/app/models/archive.rb +++ b/app/models/archive.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # == Schema Information class Archive < ApplicationRecord include TransientModelsWithPurgeableJobConcern diff --git a/app/models/assign_to.rb b/app/models/assign_to.rb index a2f178235..19d1fd847 100644 --- a/app/models/assign_to.rb +++ b/app/models/assign_to.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AssignTo < ApplicationRecord belongs_to :instructeur, optional: false belongs_to :groupe_instructeur, optional: false @@ -8,28 +10,33 @@ class AssignTo < ApplicationRecord def procedure_presentation_or_default_and_errors errors = reset_procedure_presentation_if_invalid + if self.procedure_presentation.nil? - self.procedure_presentation = build_procedure_presentation - self.procedure_presentation.save if procedure_presentation.valid? && !procedure_presentation.persisted? + self.procedure_presentation = create_procedure_presentation! end + [self.procedure_presentation, errors] end private def reset_procedure_presentation_if_invalid - if procedure_presentation&.invalid? - # This is a last defense against invalid `ProcedurePresentation`s persistently - # hindering instructeurs. Whenever this gets triggered, it means that there is - # a bug somewhere else that we need to fix. + errors = begin + procedure_presentation.errors if procedure_presentation&.invalid? + rescue ActiveRecord::RecordNotFound => e + errors = ActiveModel::Errors.new(self) + errors.add(:procedure_presentation, e.message) + errors + end - errors = procedure_presentation.errors + if errors.present? Sentry.capture_message( "Destroying invalid ProcedurePresentation", - extra: { procedure_presentation: procedure_presentation.as_json } + extra: { procedure_presentation_id: procedure_presentation.id, errors: errors.full_messages } ) self.procedure_presentation = nil - errors end + + errors end end diff --git a/app/models/attestation.rb b/app/models/attestation.rb index 1bb486633..dad64b744 100644 --- a/app/models/attestation.rb +++ b/app/models/attestation.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Attestation < ApplicationRecord belongs_to :dossier, optional: false diff --git a/app/models/attestation_template.rb b/app/models/attestation_template.rb index eea88355c..8aba5d352 100644 --- a/app/models/attestation_template.rb +++ b/app/models/attestation_template.rb @@ -1,12 +1,19 @@ +# frozen_string_literal: true + class AttestationTemplate < ApplicationRecord include ActionView::Helpers::NumberHelper include TagsSubstitutionConcern - belongs_to :procedure, inverse_of: :attestation_template_v2 + belongs_to :procedure, inverse_of: :attestation_template has_one_attached :logo has_one_attached :signature + enum state: { + draft: 'draft', + published: 'published' + } + validates :title, tags: true, if: -> { procedure.present? && version == 1 } validates :body, tags: true, if: -> { procedure.present? && version == 1 } validates :json_body, tags: true, if: -> { procedure.present? && version == 2 } @@ -67,9 +74,10 @@ class AttestationTemplate < ApplicationRecord }.freeze def attestation_for(dossier) - attestation = Attestation.new(title: replace_tags(title, dossier, escape: false)) + attestation = Attestation.new + attestation.title = replace_tags(title, dossier, escape: false) if version == 1 attestation.pdf.attach( - io: build_pdf(dossier), + io: StringIO.new(build_pdf(dossier)), filename: "attestation-dossier-#{dossier.id}.pdf", content_type: 'application/pdf', # we don't want to run virus scanner on this file @@ -79,26 +87,27 @@ class AttestationTemplate < ApplicationRecord end def unspecified_champs_for_dossier(dossier) - champs_by_stable_id = dossier.champs_for_revision(root: true).index_by { "tdc#{_1.stable_id}" } + types_de_champ_by_tag_id = dossier.revision.types_de_champ.index_by { "tdc#{_1.stable_id}" } used_tags.filter_map do |used_tag| - corresponding_champ = champs_by_stable_id[used_tag] + corresponding_type_de_champ = types_de_champ_by_tag_id[used_tag] - if corresponding_champ && corresponding_champ.blank? - corresponding_champ + if corresponding_type_de_champ && dossier.project_champ(corresponding_type_de_champ, nil).blank? + corresponding_type_de_champ end end end def dup - attestation_template = AttestationTemplate.new(title: title, body: body, footer: footer, activated: activated) + attestation_template = super ClonePiecesJustificativesService.clone_attachments(self, attestation_template) attestation_template end def logo_url if logo.attached? - Rails.application.routes.url_helpers.url_for(logo) + logo_variant = logo.variant(resize_to_limit: [400, 400]) + logo_variant.key.present? ? logo_variant.processed.url : Rails.application.routes.url_helpers.url_for(logo) end end @@ -179,7 +188,7 @@ class AttestationTemplate < ApplicationRecord if dossier.present? # 2x faster this way than with `replace_tags` which would reparse text - used_tags = tiptap.used_tags_and_libelle_for(json.deep_symbolize_keys) + used_tags = TiptapService.used_tags_and_libelle_for(json.deep_symbolize_keys) substitutions = tags_substitutions(used_tags, dossier, escape: false) body = tiptap.to_html(json, substitutions) @@ -202,17 +211,41 @@ class AttestationTemplate < ApplicationRecord end def used_tags - used_tags_for(title) + used_tags_for(body) + if version == 2 + json = json_body&.deep_symbolize_keys + TiptapService.used_tags_and_libelle_for(json.deep_symbolize_keys).map(&:first) + else + used_tags_for(title) + used_tags_for(body) + end end def build_pdf(dossier) + if version == 2 + build_v2_pdf(dossier) + else + build_v1_pdf(dossier) + end + end + + def build_v1_pdf(dossier) attestation = render_attributes_for(dossier: dossier) - attestation_view = ApplicationController.render( + ApplicationController.render( template: 'administrateurs/attestation_templates/show', formats: :pdf, assigns: { attestation: attestation } ) + end - StringIO.new(attestation_view) + def build_v2_pdf(dossier) + body = render_attributes_for(dossier:).fetch(:body) + + html = ApplicationController.render( + template: '/administrateurs/attestation_template_v2s/show', + formats: [:html], + layout: 'attestation', + assigns: { attestation_template: self, body: body } + ) + + WeasyprintService.generate_pdf(html, { procedure_id: procedure.id, dossier_id: dossier.id }) end end diff --git a/app/models/avis.rb b/app/models/avis.rb index 04f240270..4fb8d9ac3 100644 --- a/app/models/avis.rb +++ b/app/models/avis.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Avis < ApplicationRecord include EmailSanitizableConcern diff --git a/app/models/batch_operation.rb b/app/models/batch_operation.rb index 07e3c113d..b00d8b919 100644 --- a/app/models/batch_operation.rb +++ b/app/models/batch_operation.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class BatchOperation < ApplicationRecord enum operation: { accepter: 'accepter', @@ -82,13 +84,13 @@ class BatchOperation < ApplicationRecord when BatchOperation.operations.fetch(:desarchiver) dossier.desarchiver! when BatchOperation.operations.fetch(:passer_en_instruction) - dossier.passer_en_instruction(instructeur: instructeur) + dossier.passer_en_instruction!(instructeur: instructeur) when BatchOperation.operations.fetch(:accepter) - dossier.accepter(instructeur: instructeur, motivation: motivation, justificatif: justificatif_motivation) + dossier.accepter!(instructeur: instructeur, motivation: motivation, justificatif: justificatif_motivation) when BatchOperation.operations.fetch(:refuser) - dossier.refuser(instructeur: instructeur, motivation: motivation, justificatif: justificatif_motivation) + dossier.refuser!(instructeur: instructeur, motivation: motivation, justificatif: justificatif_motivation) when BatchOperation.operations.fetch(:classer_sans_suite) - dossier.classer_sans_suite(instructeur: instructeur, motivation: motivation, justificatif: justificatif_motivation) + dossier.classer_sans_suite!(instructeur: instructeur, motivation: motivation, justificatif: justificatif_motivation) when BatchOperation.operations.fetch(:follow) instructeur.follow(dossier) when BatchOperation.operations.fetch(:repousser_expiration) diff --git a/app/models/bill_signature.rb b/app/models/bill_signature.rb index e5778a912..3a17ab4fd 100644 --- a/app/models/bill_signature.rb +++ b/app/models/bill_signature.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class BillSignature < ApplicationRecord has_many :dossier_operation_logs diff --git a/app/models/bulk_message.rb b/app/models/bulk_message.rb index 5ec93da52..40d91d195 100644 --- a/app/models/bulk_message.rb +++ b/app/models/bulk_message.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class BulkMessage < ApplicationRecord belongs_to :instructeur belongs_to :procedure diff --git a/app/models/champ.rb b/app/models/champ.rb index b3672d9f0..e58dd4fb9 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -1,27 +1,35 @@ +# frozen_string_literal: true + class Champ < ApplicationRecord include ChampConditionalConcern include ChampsValidateConcern + self.ignored_columns += [:type_de_champ_id, :parent_id] + + attr_readonly :stable_id + belongs_to :dossier, inverse_of: false, touch: true, optional: false - belongs_to :type_de_champ, inverse_of: :champ, optional: false - belongs_to :parent, class_name: 'Champ', optional: true has_many_attached :piece_justificative_file # We declare champ specific relationships (Champs::CarteChamp, Champs::SiretChamp and Champs::RepetitionChamp) # here because otherwise we can't easily use includes in our queries. has_many :geo_areas, -> { order(:created_at) }, dependent: :destroy, inverse_of: :champ belongs_to :etablissement, optional: true, dependent: :destroy - has_many :champs, foreign_key: :parent_id, inverse_of: :parent delegate :procedure, to: :dossier + def type_de_champ + @type_de_champ ||= dossier.revision + .types_de_champ + .find(-> { raise "Type De Champ #{stable_id} not found in Revision #{dossier.revision_id}" }) { _1.stable_id == stable_id } + end + delegate :libelle, :type_champ, :description, - :drop_down_list_options, + :drop_down_options, :drop_down_other?, - :drop_down_list_options?, - :drop_down_list_enabled_non_empty_options, + :drop_down_options_with_other, :drop_down_secondary_libelle, :drop_down_secondary_description, :collapsible_explanation_enabled?, @@ -30,51 +38,28 @@ class Champ < ApplicationRecord :current_section_level, :exclude_from_export?, :exclude_from_view?, - :repetition?, - :block?, - :dossier_link?, - :departement?, - :region?, - :textarea?, - :titre_identite?, - :header_section?, - :checkbox?, - :simple_drop_down_list?, - :linked_drop_down_list?, :non_fillable?, :fillable?, - :cnaf?, - :dgfip?, - :pole_emploi?, - :mesri?, - :rna?, - :siret?, - :carte?, - :datetime?, :mandatory?, :prefillable?, :refresh_after_update?, :character_limit?, :character_limit, - :yes_no?, :expression_reguliere, :expression_reguliere_exemple_text, :expression_reguliere_error_message, to: :type_de_champ + delegate(*TypeDeChamp.type_champs.values.map { "#{_1}?".to_sym }, to: :type_de_champ) + delegate :piece_justificative_or_titre_identite?, :any_drop_down_list?, to: :type_de_champ + delegate :to_typed_id, :to_typed_id_for_query, to: :type_de_champ, prefix: true delegate :revision, to: :dossier, prefix: true - delegate :used_by_routing_rules?, to: :type_de_champ scope :updated_since?, -> (date) { where('champs.updated_at > ?', date) } - scope :public_only, -> { where(private: false) } - scope :private_only, -> { where(private: true) } - scope :root, -> { where(parent_id: nil) } scope :prefilled, -> { where(prefilled: true) } - before_create :set_dossier_id, if: :needs_dossier_id? - before_validation :set_dossier_id, if: :needs_dossier_id? before_save :cleanup_if_empty before_save :normalize after_update_commit :fetch_external_data_later @@ -84,7 +69,7 @@ class Champ < ApplicationRecord end def child? - parent_id.present? + row_id.present? end # used for the `required` html attribute @@ -95,11 +80,15 @@ class Champ < ApplicationRecord end def mandatory_blank? - mandatory? && blank? + type_de_champ.mandatory_blank?(self) end def blank? - value.blank? + type_de_champ.champ_blank?(self) + end + + def used_by_routing_rules? + procedure.used_by_routing_rules?(type_de_champ) end def search_terms @@ -107,23 +96,15 @@ class Champ < ApplicationRecord end def to_s - value.present? ? value.to_s : '' + type_de_champ.champ_value(self) end - def for_export(path = :value) - path == :value ? value.presence : nil + def last_write_type_champ + TypeDeChamp::CHAMP_TYPE_TO_TYPE_CHAMP.fetch(type) end - def for_api - value - end - - def for_api_v2 - to_s - end - - def for_tag(path = :value) - path == :value && value.present? ? value.to_s : '' + def is_type?(type_champ) + last_write_type_champ == type_champ end def main_value_name @@ -174,13 +155,13 @@ class Champ < ApplicationRecord # However the field index makes it difficult to render a single field, independent from the ordering of the others. # # Luckily, this is only used to make the name unique, but the actual value is ignored when Rails parses nested - # attributes. So instead of the field index, this method uses the champ id; which gives us an independent and + # attributes. So instead of the field index, this method uses the champ public_id; which gives us an independent and # predictable input name. def input_name if private? - "dossier[champs_private_attributes][#{id}]" + "dossier[champs_private_attributes][#{public_id}]" else - "dossier[champs_public_attributes][#{id}]" + "dossier[champs_public_attributes][#{public_id}]" end end @@ -221,7 +202,7 @@ class Champ < ApplicationRecord end def clone(fork = false) - champ_attributes = [:parent_id, :private, :row_id, :type, :type_de_champ_id, :stable_id, :stream] + champ_attributes = [:private, :row_id, :type, :stable_id, :stream] value_attributes = fork || !private? ? [:value, :value_json, :data, :external_id] : [] relationships = fork || !private? ? [:etablissement, :geo_areas] : [] @@ -230,7 +211,7 @@ class Champ < ApplicationRecord kopy.write_attribute(:stable_id, original.stable_id) kopy.write_attribute(:stream, 'main') end - ClonePiecesJustificativesService.clone_attachments(original, kopy) + ClonePiecesJustificativesService.clone_attachments(original, kopy) if fork || !private? end end @@ -251,15 +232,7 @@ class Champ < ApplicationRecord end def html_id - "champ-#{public_id}" - end - - def needs_dossier_id? - !dossier_id && parent_id - end - - def set_dossier_id - self.dossier_id = parent.dossier_id + type_de_champ.html_id(row_id) end def cleanup_if_empty @@ -279,7 +252,7 @@ class Champ < ApplicationRecord return if value.nil? return if value.present? && !value.include?("\u0000") - self.value = value.delete("\u0000") + write_attribute(:value, value.delete("\u0000")) end class NotImplemented < ::StandardError diff --git a/app/models/champ_presentations/base_presentation.rb b/app/models/champ_presentations/base_presentation.rb new file mode 100644 index 000000000..6b043219b --- /dev/null +++ b/app/models/champ_presentations/base_presentation.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module ChampPresentations + class BasePresentation + def block_level? + true + end + end +end diff --git a/app/models/champ_presentations/multiple_drop_down_list_presentation.rb b/app/models/champ_presentations/multiple_drop_down_list_presentation.rb new file mode 100644 index 000000000..8660b06a2 --- /dev/null +++ b/app/models/champ_presentations/multiple_drop_down_list_presentation.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class ChampPresentations::MultipleDropDownListPresentation < ChampPresentations::BasePresentation + attr_reader :selected_options + + def initialize(selected_options) + @selected_options = selected_options + end + + def to_s + selected_options.join(', ') + end + + def to_tiptap_node + { + type: 'bulletList', + content: selected_options.map do |text| + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: text + } + ] + } + ] + } + end + } + end +end diff --git a/app/models/champ_presentations/repetition_presentation.rb b/app/models/champ_presentations/repetition_presentation.rb new file mode 100644 index 000000000..000f74430 --- /dev/null +++ b/app/models/champ_presentations/repetition_presentation.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class ChampPresentations::RepetitionPresentation < ChampPresentations::BasePresentation + attr_reader :libelle + attr_reader :rows + + def initialize(libelle, rows) + @libelle = libelle + @rows = rows + end + + def to_s + ([libelle] + rows.map do |champs| + champs.map do |champ| + "#{champ.libelle} : #{champ}" + end.join("\n") + end).join("\n\n") + end + + def to_tiptap_node + { + type: 'orderedList', + attrs: { class: 'tdc-repetition' }, + content: rows.map do |champs| + { + type: 'listItem', + content: [ + { + type: 'descriptionList', + content: champs.map do |champ| + [ + { + type: 'descriptionTerm', + attrs: champ.blank? ? { class: 'invisible' } : nil, # still render libelle so width & alignment are preserved + content: [ + { + type: 'text', + text: champ.libelle + } + ] + }.compact, + { + type: 'descriptionDetails', + content: [ + { + type: 'text', + text: champ.to_s + } + ] + } + ] + end.flatten + } + ] + } + end + } + end +end diff --git a/app/models/champs/address_champ.rb b/app/models/champs/address_champ.rb index 751b9844a..f0be9ab33 100644 --- a/app/models/champs/address_champ.rb +++ b/app/models/champs/address_champ.rb @@ -1,12 +1,10 @@ +# frozen_string_literal: true + class Champs::AddressChamp < Champs::TextChamp def full_address? data.present? end - def feature - data.to_json if full_address? - end - def feature=(value) if value.blank? self.data = nil @@ -22,6 +20,14 @@ class Champs::AddressChamp < Champs::TextChamp self.data = nil end + def selected_items + if value.present? + [{ value:, label: value, data: full_address? ? data : nil }] + else + [] + end + end + def address full_address? ? data : nil end @@ -38,36 +44,6 @@ class Champs::AddressChamp < Champs::TextChamp end end - def to_s - address_label.presence || '' - end - - def for_tag(path = :value) - case path - when :value - address_label - when :departement - departement_code_and_name || '' - when :commune - commune_name || '' - end - end - - def for_export(path = :value) - case path - when :value - value.present? ? address_label : nil - when :departement - departement_code_and_name - when :commune - commune_name - end - end - - def for_api - address_label - end - def code_departement if full_address? address.fetch('department_code') diff --git a/app/models/champs/annuaire_education_champ.rb b/app/models/champs/annuaire_education_champ.rb index e691b707b..3d5534e6d 100644 --- a/app/models/champs/annuaire_education_champ.rb +++ b/app/models/champs/annuaire_education_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Champs::AnnuaireEducationChamp < Champs::TextChamp def fetch_external_data? true @@ -7,14 +9,11 @@ class Champs::AnnuaireEducationChamp < Champs::TextChamp APIEducation::AnnuaireEducationAdapter.new(external_id).to_params end - def update_with_external_data!(data:) - if data&.is_a?(Hash) && data['nom_etablissement'].present? && data['nom_commune'].present? && data['identifiant_de_l_etablissement'].present? - update!( - data: data, - value: "#{data['nom_etablissement']}, #{data['nom_commune']} (#{data['identifiant_de_l_etablissement']})" - ) + def selected_items + if external_id.present? + [{ value: external_id, label: value }] else - update!(data: data) + [] end end end diff --git a/app/models/champs/boolean_champ.rb b/app/models/champs/boolean_champ.rb index 745a3536d..1d4f8f63c 100644 --- a/app/models/champs/boolean_champ.rb +++ b/app/models/champs/boolean_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Champs::BooleanChamp < Champ TRUE_VALUE = 'true' FALSE_VALUE = 'false' @@ -17,28 +19,8 @@ class Champs::BooleanChamp < Champ end end - def to_s - processed_value - end - - def for_tag(path = :value) - processed_value - end - - def for_export(path = :value) - processed_value - end - - def for_api_v2 - true? ? 'true' : 'false' - end - private - def processed_value - true? ? 'Oui' : 'Non' - end - def set_value_to_nil self.value = nil end diff --git a/app/models/champs/carte_champ.rb b/app/models/champs/carte_champ.rb index c19d2d913..d5eb51fb2 100644 --- a/app/models/champs/carte_champ.rb +++ b/app/models/champs/carte_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Champs::CarteChamp < Champ # Default map location. Center of the World, ahm, France... DEFAULT_LON = 2.428462 @@ -14,8 +16,10 @@ class Champs::CarteChamp < Champ # We are not using scopes here as we want to access # the following collections on unsaved records. def cadastres - geo_areas.filter do |area| - area.source == GeoArea.sources.fetch(:cadastre) + if cadastres? + geo_areas.filter { _1.source == GeoArea.sources.fetch(:cadastre) } + else + [] end end @@ -83,18 +87,6 @@ class Champs::CarteChamp < Champ end end - def for_api - nil - end - - def for_export(path = :value) - geo_areas.map(&:label).join("\n") - end - - def blank? - geo_areas.blank? - end - private def selection_utilisateur_legacy_geometry diff --git a/app/models/champs/checkbox_champ.rb b/app/models/champs/checkbox_champ.rb index f58bc76d0..111eaeb00 100644 --- a/app/models/champs/checkbox_champ.rb +++ b/app/models/champs/checkbox_champ.rb @@ -1,12 +1,6 @@ +# frozen_string_literal: true + class Champs::CheckboxChamp < Champs::BooleanChamp - def for_export(path = :value) - true? ? 'on' : 'off' - end - - def mandatory_blank? - mandatory? && (blank? || !true?) - end - def legend_label? false end @@ -15,11 +9,6 @@ class Champs::CheckboxChamp < Champs::BooleanChamp [[I18n.t('activerecord.attributes.type_de_champ.type_champs.checkbox_true'), true], [I18n.t('activerecord.attributes.type_de_champ.type_champs.checkbox_false'), false]] end - # TODO remove when normalize_checkbox_values is over - def true? - value_with_legacy == TRUE_VALUE - end - def html_label? false end @@ -27,11 +16,4 @@ class Champs::CheckboxChamp < Champs::BooleanChamp def single_checkbox? true end - - private - - # TODO remove when normalize_checkbox_values is over - def value_with_legacy - value == 'on' ? TRUE_VALUE : value - end end diff --git a/app/models/champs/civilite_champ.rb b/app/models/champs/civilite_champ.rb index 6e7be24f9..7a1f2a254 100644 --- a/app/models/champs/civilite_champ.rb +++ b/app/models/champs/civilite_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Champs::CiviliteChamp < Champ validates :value, inclusion: ["M.", "Mme"], allow_nil: true, allow_blank: false, if: :validate_champ_value_or_prefill? diff --git a/app/models/champs/cnaf_champ.rb b/app/models/champs/cnaf_champ.rb index e3bd048e9..c0cdc9383 100644 --- a/app/models/champs/cnaf_champ.rb +++ b/app/models/champs/cnaf_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Champs::CnafChamp < Champs::TextChamp # see https://github.com/betagouv/api-particulier/blob/master/src/presentation/middlewares/cnaf-input-validation.middleware.ts @@ -6,10 +8,6 @@ class Champs::CnafChamp < Champs::TextChamp store_accessor :value_json, :numero_allocataire, :code_postal - def blank? - external_id.nil? - end - def fetch_external_data? true end diff --git a/app/models/champs/cojo_champ.rb b/app/models/champs/cojo_champ.rb index be2ab2311..a770521eb 100644 --- a/app/models/champs/cojo_champ.rb +++ b/app/models/champs/cojo_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Champs::COJOChamp < Champ store_accessor :value_json, :accreditation_number, :accreditation_birthdate store_accessor :data, :accreditation_success, :accreditation_first_name, :accreditation_last_name @@ -18,10 +20,6 @@ class Champs::COJOChamp < Champ accreditation_success == false end - def blank? - accreditation_success != true - end - def fetch_external_data? true end @@ -34,10 +32,6 @@ class Champs::COJOChamp < Champ COJOService.new.(accreditation_number:, accreditation_birthdate:) end - def to_s - "#{accreditation_number} – #{accreditation_birthdate}" - end - def accreditation_number_input_id "#{input_id}-accreditation_number" end @@ -54,7 +48,7 @@ class Champs::COJOChamp < Champ def update_external_id if accreditation_number_changed? || accreditation_birthdate_changed? - if accreditation_number.present? && accreditation_birthdate.present? && /\A\d+\z/.match?(accreditation_number) + if accreditation_number.present? && accreditation_birthdate.present? && /\A[\d-]+\z/.match?(accreditation_number) self.external_id = { accreditation_number:, accreditation_birthdate: }.to_json else self.external_id = nil diff --git a/app/models/champs/commune_champ.rb b/app/models/champs/commune_champ.rb index 8dc7c9903..a1e69ba48 100644 --- a/app/models/champs/commune_champ.rb +++ b/app/models/champs/commune_champ.rb @@ -1,28 +1,11 @@ +# frozen_string_literal: true + class Champs::CommuneChamp < Champs::TextChamp store_accessor :value_json, :code_departement, :code_postal, :code_region before_save :on_codes_change, if: :should_refresh_after_code_change? - def for_export(path = :value) - case path - when :value - to_s - when :departement - departement_code_and_name || '' - when :code - code || '' - end - end - - def for_tag(path = :value) - case path - when :value - to_s - when :departement - departement_code_and_name || '' - when :code - code || '' - end - end + validates :external_id, presence: true, if: -> { validate_champ_value_or_prefill? && value.present? } + after_validation :instrument_external_id_error, if: -> { errors.include?(:external_id) } def departement_name APIGeoService.departement_name(code_departement) @@ -50,26 +33,47 @@ class Champs::CommuneChamp < Champs::TextChamp code_postal.present? end - def code_postal=(value) - super(value&.gsub(/[[:space:]]/, '')) - end - alias postal_code code_postal def name APIGeoService.safely_normalize_city_name(code_departement, code, safe_to_s) end - def to_s - code_postal? ? "#{name} (#{code_postal})" : name - end - def code external_id end def selected - code + code? ? "#{code}-#{code_postal}" : nil + end + + def selected_items + if code? + [{ label: to_s, value: selected }] + else + [] + end + end + + def code=(code) + if code.blank? + self.code_departement = nil + self.code_postal = nil + self.external_id = nil + self.value = nil + elsif code.match?(/-/) + codes = code.split('-') + self.external_id = codes.first + self.code_postal = codes.second + else + self.external_id = code + end + end + + private + + def safe_to_s + value.present? ? value.to_s : '' end def communes @@ -80,12 +84,6 @@ class Champs::CommuneChamp < Champs::TextChamp end end - private - - def safe_to_s - value.present? ? value.to_s : '' - end - def on_codes_change return if !code? @@ -106,4 +104,11 @@ class Champs::CommuneChamp < Champs::TextChamp def should_refresh_after_code_change? !departement? || code_postal_changed? || external_id_changed? end + + def instrument_external_id_error + Sentry.capture_message( + "Commune with value and no external id Edge case reached", + extra: { request_id: Current.request_id } + ) + end end diff --git a/app/models/champs/date_champ.rb b/app/models/champs/date_champ.rb index 4a9d1a215..5f6f447f4 100644 --- a/app/models/champs/date_champ.rb +++ b/app/models/champs/date_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Champs::DateChamp < Champ before_validation :convert_to_iso8601, unless: -> { validation_context == :prefill } validate :iso_8601 @@ -6,16 +8,6 @@ class Champs::DateChamp < Champ # Text search is pretty useless for dates so we’re not including these champs end - def to_s - value.present? ? I18n.l(Time.zone.parse(value), format: '%d %B %Y') : "" - rescue ArgumentError - value.presence || "" # old dossiers can have not parseable dates - end - - def for_tag(path = :value) - to_s if path == :value - end - private def convert_to_iso8601 diff --git a/app/models/champs/datetime_champ.rb b/app/models/champs/datetime_champ.rb index 93983b5dd..2627fcde0 100644 --- a/app/models/champs/datetime_champ.rb +++ b/app/models/champs/datetime_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Champs::DatetimeChamp < Champ before_validation :convert_to_iso8601, unless: -> { validation_context == :prefill } validate :iso_8601 @@ -6,14 +8,6 @@ class Champs::DatetimeChamp < Champ # Text search is pretty useless for datetimes so we’re not including these champs end - def to_s - value.present? ? I18n.l(Time.zone.parse(value)) : "" - end - - def for_tag(path = :value) - value.present? ? I18n.l(Time.zone.parse(value)) : "" - end - private def convert_to_iso8601 diff --git a/app/models/champs/decimal_number_champ.rb b/app/models/champs/decimal_number_champ.rb index 856c92a4c..04e1d9af5 100644 --- a/app/models/champs/decimal_number_champ.rb +++ b/app/models/champs/decimal_number_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Champs::DecimalNumberChamp < Champ before_validation :format_value @@ -17,25 +19,11 @@ class Champs::DecimalNumberChamp < Champ } }, if: :validate_champ_value_or_prefill? - def for_export(path = :value) - processed_value - end - - def for_api - processed_value - end - private def format_value return if value.blank? - self.value = value.tr(",", ".") - end - - def processed_value - return unless valid_champ_value? - - value&.to_f + self.value = value.tr(",", ".").gsub(/[[:space:]]/, "") end end diff --git a/app/models/champs/departement_champ.rb b/app/models/champs/departement_champ.rb index d599e6249..b6a84f421 100644 --- a/app/models/champs/departement_champ.rb +++ b/app/models/champs/departement_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Champs::DepartementChamp < Champs::TextChamp store_accessor :value_json, :code_region @@ -5,36 +7,6 @@ class Champs::DepartementChamp < Champs::TextChamp validate :external_id_in_departement_codes, if: -> { validate_champ_value_or_prefill? && !external_id.nil? } before_save :store_code_region - def for_export(path = :value) - case path - when :code - code - when :value - name - end - end - - def to_s - formatted_value - end - - def for_tag(path = :value) - case path - when :code - code - when :value - formatted_value - end - end - - def for_api - formatted_value - end - - def for_api_v2 - formatted_value.tr('–', '-') - end - def selected code end @@ -71,10 +43,6 @@ class Champs::DepartementChamp < Champs::TextChamp private - def formatted_value - blank? ? "" : "#{code} – #{name}" - end - def value_in_departement_names return if value.in?(APIGeoService.departements.pluck(:name)) diff --git a/app/models/champs/dgfip_champ.rb b/app/models/champs/dgfip_champ.rb index fa5107343..c64e55b9a 100644 --- a/app/models/champs/dgfip_champ.rb +++ b/app/models/champs/dgfip_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Champs::DgfipChamp < Champs::TextChamp # see https://github.com/betagouv/api-particulier/blob/master/src/presentation/middlewares/dgfip-input-validation.middleware.ts validates :numero_fiscal, format: { with: /\A\w{13,14}\z/ }, if: -> { reference_avis.present? && validate_champ_value_or_prefill? } @@ -5,10 +7,6 @@ class Champs::DgfipChamp < Champs::TextChamp store_accessor :value_json, :numero_fiscal, :reference_avis - def blank? - external_id.nil? - end - def fetch_external_data? true end diff --git a/app/models/champs/dossier_link_champ.rb b/app/models/champs/dossier_link_champ.rb index ba73ddce6..110cb3ce4 100644 --- a/app/models/champs/dossier_link_champ.rb +++ b/app/models/champs/dossier_link_champ.rb @@ -1,8 +1,17 @@ +# frozen_string_literal: true + class Champs::DossierLinkChamp < Champ validate :value_integerable, if: -> { value.present? }, on: :prefill + validate :dossier_exists, if: -> { validate_champ_value? && !value.nil? } private + def dossier_exists + if mandatory? && !Dossier.exists?(value) + errors.add(:value, :not_found) + end + end + def value_integerable Integer(value) rescue ArgumentError diff --git a/app/models/champs/drop_down_list_champ.rb b/app/models/champs/drop_down_list_champ.rb index 6cdd094d5..4c5306f6c 100644 --- a/app/models/champs/drop_down_list_champ.rb +++ b/app/models/champs/drop_down_list_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Champs::DropDownListChamp < Champ store_accessor :value_json, :other THRESHOLD_NB_OPTIONS_AS_RADIO = 5 @@ -7,15 +9,11 @@ class Champs::DropDownListChamp < Champ validate :value_is_in_options, if: -> { !(value.blank? || drop_down_other?) && validate_champ_value_or_prefill? } def render_as_radios? - enabled_non_empty_options.size <= THRESHOLD_NB_OPTIONS_AS_RADIO + drop_down_options.size <= THRESHOLD_NB_OPTIONS_AS_RADIO end def render_as_combobox? - enabled_non_empty_options.size >= THRESHOLD_NB_OPTIONS_AS_AUTOCOMPLETE - end - - def options? - drop_down_list_options? + drop_down_options.size >= THRESHOLD_NB_OPTIONS_AS_AUTOCOMPLETE end def html_label? @@ -30,12 +28,8 @@ class Champs::DropDownListChamp < Champ other? ? OTHER : value end - def enabled_non_empty_options(other: false) - drop_down_list_enabled_non_empty_options(other:) - end - def other? - drop_down_other? && (other || (value.present? && enabled_non_empty_options.exclude?(value))) + drop_down_other? && (other || (value.present? && drop_down_options.exclude?(value))) end def value=(value) @@ -62,18 +56,10 @@ class Champs::DropDownListChamp < Champ options.include?(value) end - def remove_option(options, touch = false) - if touch - update(value: nil) - else - update_column(:value, nil) - end - end - private def value_is_in_options - return if enabled_non_empty_options.include?(value) + return if drop_down_options.include?(value) errors.add(:value, :not_in_options) end diff --git a/app/models/champs/email_champ.rb b/app/models/champs/email_champ.rb index 1564e5ba0..6a1f50306 100644 --- a/app/models/champs/email_champ.rb +++ b/app/models/champs/email_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Champs::EmailChamp < Champs::TextChamp include EmailSanitizableConcern before_validation -> { sanitize_email(:value) } diff --git a/app/models/champs/engagement_juridique_champ.rb b/app/models/champs/engagement_juridique_champ.rb index a9241eb04..31118063a 100644 --- a/app/models/champs/engagement_juridique_champ.rb +++ b/app/models/champs/engagement_juridique_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Champs::EngagementJuridiqueChamp < Champ # cf: https://communaute.chorus-pro.gouv.fr/documentation/creer-un-engagement/#1522314752186-a34f3662-0644b5d1-16c22add-8ea097de-3a0a validates_with ExpressionReguliereValidator, diff --git a/app/models/champs/epci_champ.rb b/app/models/champs/epci_champ.rb index 6f11b462a..bdedee60a 100644 --- a/app/models/champs/epci_champ.rb +++ b/app/models/champs/epci_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Champs::EpciChamp < Champs::TextChamp store_accessor :value_json, :code_departement, :code_region before_validation :on_departement_change @@ -7,28 +9,6 @@ class Champs::EpciChamp < Champs::TextChamp validate :external_id_in_departement_epci_codes, if: -> { !(code_departement.nil? || external_id.nil?) && validate_champ_value_or_prefill? } validate :value_in_departement_epci_names, if: -> { !(code_departement.nil? || external_id.nil? || value.nil?) && validate_champ_value_or_prefill? } - def for_export(path = :value) - case path - when :value - value - when :code - code - when :departement - departement_code_and_name - end - end - - def for_tag(path = :value) - case path - when :value - value - when :code - code - when :departement - departement_code_and_name - end - end - def departement_name APIGeoService.departement_name(code_departement) end diff --git a/app/models/champs/explication_champ.rb b/app/models/champs/explication_champ.rb index d15bd2e1d..1e38b8174 100644 --- a/app/models/champs/explication_champ.rb +++ b/app/models/champs/explication_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Champs::ExplicationChamp < Champs::TextChamp def search_terms # The user cannot enter any information here so it doesn’t make much sense to search diff --git a/app/models/champs/expression_reguliere_champ.rb b/app/models/champs/expression_reguliere_champ.rb index c8bc48008..9fb44375f 100644 --- a/app/models/champs/expression_reguliere_champ.rb +++ b/app/models/champs/expression_reguliere_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Champs::ExpressionReguliereChamp < Champ validates_with ExpressionReguliereValidator, if: :validate_champ_value_or_prefill? end diff --git a/app/models/champs/header_section_champ.rb b/app/models/champs/header_section_champ.rb index d1b4d21d1..a01c57949 100644 --- a/app/models/champs/header_section_champ.rb +++ b/app/models/champs/header_section_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Champs::HeaderSectionChamp < Champ def search_terms # The user cannot enter any information here so it doesn’t make much sense to search diff --git a/app/models/champs/iban_champ.rb b/app/models/champs/iban_champ.rb index 0d17f86e7..cc26eafa8 100644 --- a/app/models/champs/iban_champ.rb +++ b/app/models/champs/iban_champ.rb @@ -1,15 +1,9 @@ +# frozen_string_literal: true + class Champs::IbanChamp < Champ validates_with IbanValidator, if: :validate_champ_value_or_prefill? after_validation :format_iban - def for_api - to_s.gsub(/\s+/, '') - end - - def for_api_v2 - for_api - end - private def format_iban diff --git a/app/models/champs/integer_number_champ.rb b/app/models/champs/integer_number_champ.rb index 69f95adc4..0456ba1b6 100644 --- a/app/models/champs/integer_number_champ.rb +++ b/app/models/champs/integer_number_champ.rb @@ -1,4 +1,8 @@ +# frozen_string_literal: true + class Champs::IntegerNumberChamp < Champ + before_validation :format_value + validates :value, numericality: { only_integer: true, allow_nil: true, @@ -9,19 +13,9 @@ class Champs::IntegerNumberChamp < Champ } }, if: :validate_champ_value_or_prefill? - def for_export(path = :value) - processed_value - end + def format_value + return if value.blank? - def for_api - processed_value - end - - private - - def processed_value - return unless valid_champ_value? - - value&.to_i + self.value = value.gsub(/[[:space:]]/, "") end end diff --git a/app/models/champs/linked_drop_down_list_champ.rb b/app/models/champs/linked_drop_down_list_champ.rb index 6a1edcaef..9254cfca0 100644 --- a/app/models/champs/linked_drop_down_list_champ.rb +++ b/app/models/champs/linked_drop_down_list_champ.rb @@ -1,23 +1,21 @@ +# frozen_string_literal: true + class Champs::LinkedDropDownListChamp < Champ delegate :primary_options, :secondary_options, to: :type_de_champ - def options? - drop_down_list_options? - end - def primary_value - if value.present? - JSON.parse(value)[0] - else + if type_de_champ.champ_blank?(self) '' + else + JSON.parse(value)[0] end end def secondary_value - if value.present? - JSON.parse(value)[1] - else + if type_de_champ.champ_blank?(self) '' + else + JSON.parse(value)[1] end end @@ -37,41 +35,6 @@ class Champs::LinkedDropDownListChamp < Champ :primary_value end - def to_s - value.present? ? [primary_value, secondary_value].filter(&:present?).join(' / ') : "" - end - - def for_tag(path = :value) - case path - when :primary - primary_value - when :secondary - secondary_value - when :value - value.present? ? [primary_value, secondary_value].filter(&:present?).join(' / ') : "" - end - end - - def for_export(path = :value) - case path - when :primary - primary_value - when :secondary - secondary_value - when :value - value.present? ? "#{primary_value || ''};#{secondary_value || ''}" : nil - end - end - - def for_api - value.present? ? { primary: primary_value, secondary: secondary_value } : nil - end - - def blank? - primary_value.blank? || - (has_secondary_options_for_primary? && secondary_value.blank?) - end - def search_terms [primary_value, secondary_value] end @@ -84,14 +47,6 @@ class Champs::LinkedDropDownListChamp < Champ options.include?(primary_value) || options.include?(secondary_value) end - def remove_option(options, touch = false) - if touch - update(value: nil) - else - update_column(:value, nil) - end - end - private def pack_value(primary, secondary) diff --git a/app/models/champs/mesri_champ.rb b/app/models/champs/mesri_champ.rb index 145f251a3..2d8bc2114 100644 --- a/app/models/champs/mesri_champ.rb +++ b/app/models/champs/mesri_champ.rb @@ -1,11 +1,9 @@ +# frozen_string_literal: true + class Champs::MesriChamp < Champs::TextChamp # see https://github.com/betagouv/api-particulier/blob/master/src/presentation/middlewares/mesri-input-validation.middleware.ts store_accessor :value_json, :ine - def blank? - external_id.nil? - end - def fetch_external_data? true end diff --git a/app/models/champs/multiple_drop_down_list_champ.rb b/app/models/champs/multiple_drop_down_list_champ.rb index 60e8be104..46bbabfb8 100644 --- a/app/models/champs/multiple_drop_down_list_champ.rb +++ b/app/models/champs/multiple_drop_down_list_champ.rb @@ -1,14 +1,8 @@ +# frozen_string_literal: true + class Champs::MultipleDropDownListChamp < Champ validate :values_are_in_options, if: -> { value.present? && validate_champ_value_or_prefill? } - def options? - drop_down_list_options? - end - - def enabled_non_empty_options - drop_down_list_enabled_non_empty_options - end - THRESHOLD_NB_OPTIONS_AS_CHECKBOX = 5 def search_terms @@ -19,20 +13,8 @@ class Champs::MultipleDropDownListChamp < Champ value.blank? ? [] : JSON.parse(value) end - def to_s - selected_options.join(', ') - end - - def for_tag(path = :value) - selected_options.join(', ') - end - - def for_export(path = :value) - value.present? ? selected_options.join(', ') : nil - end - def render_as_checkboxes? - enabled_non_empty_options.size <= THRESHOLD_NB_OPTIONS_AS_CHECKBOX + drop_down_options.size <= THRESHOLD_NB_OPTIONS_AS_CHECKBOX end def html_label? @@ -47,25 +29,12 @@ class Champs::MultipleDropDownListChamp < Champ render_as_checkboxes? end - def blank? - selected_options.blank? - end - def in?(options) (selected_options - options).size != selected_options.size end - def remove_option(options, touch = false) - value = (selected_options - options).to_json - if touch - update(value:) - else - update_columns(value:) - end - end - def focusable_input_id - render_as_checkboxes? ? checkbox_id(enabled_non_empty_options.first) : input_id + render_as_checkboxes? ? checkbox_id(drop_down_options.first) : input_id end def checkbox_id(value) @@ -81,11 +50,11 @@ class Champs::MultipleDropDownListChamp < Champ end def unselected_options - enabled_non_empty_options - selected_options + drop_down_options - selected_options end def value=(value) - return super(nil) if value.nil? + return super(nil) if value.blank? values = if value.is_a?(Array) value @@ -107,7 +76,7 @@ class Champs::MultipleDropDownListChamp < Champ def values_are_in_options json = selected_options.compact_blank return if json.empty? - return if (json - enabled_non_empty_options).empty? + return if (json - drop_down_options).empty? errors.add(:value, :not_in_options) end diff --git a/app/models/champs/number_champ.rb b/app/models/champs/number_champ.rb index 8903284e2..9bd625a6d 100644 --- a/app/models/champs/number_champ.rb +++ b/app/models/champs/number_champ.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class Champs::NumberChamp < Champ end diff --git a/app/models/champs/pays_champ.rb b/app/models/champs/pays_champ.rb index 4dc5c0a6d..a1a6e3d37 100644 --- a/app/models/champs/pays_champ.rb +++ b/app/models/champs/pays_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Champs::PaysChamp < Champs::TextChamp with_options if: :validate_champ_value? do validates :external_id, inclusion: APIGeoService.countries.pluck(:code), allow_nil: true, allow_blank: false @@ -11,28 +13,6 @@ class Champs::PaysChamp < Champs::TextChamp validates :value, inclusion: APIGeoService.countries.pluck(:name), allow_nil: false, allow_blank: false end - def for_export(path = :value) - case path - when :code - code - when :value - name - end - end - - def to_s - name - end - - def for_tag(path = :value) - case path - when :code - code - when :value - name - end - end - def selected code || value end diff --git a/app/models/champs/phone_champ.rb b/app/models/champs/phone_champ.rb index e22e70682..a29dc7f19 100644 --- a/app/models/champs/phone_champ.rb +++ b/app/models/champs/phone_champ.rb @@ -1,41 +1,11 @@ +# frozen_string_literal: true + class Champs::PhoneChamp < Champs::TextChamp - # We want to allow: - # * international (e164) phone numbers - # * “french format” (ten digits with a leading 0) - # * DROM numbers - # - # However, we need to special-case some ten-digit numbers, - # because the ARCEP assigns some blocks of "O6 XX XX XX XX" numbers to DROM operators. - # Guadeloupe | GP | +590 | 0690XXXXXX, 0691XXXXXX - # Guyane | GF | +594 | 0694XXXXXX - # Martinique | MQ | +596 | 0696XXXXXX, 0697XXXXXX - # Réunion | RE | +262 | 0692XXXXXX, 0693XXXXXX - # Mayotte | YT | +262 | 0692XXXXXX, 0693XXXXXX - # Nouvelle-Calédonie | NC | +687 | - # Polynésie française | PF | +689 | 40XXXXXX, 45XXXXXX, 87XXXXXX, 88XXXXXX, 89XXXXXX - # - # Cf: Plan national de numérotation téléphonique, - # https://www.arcep.fr/uploads/tx_gsavis/05-1085.pdf “Numéros mobiles à 10 chiffres”, page 6 - # - # See issue #6996. - DEFAULT_COUNTRY_CODES = [:FR, :GP, :GF, :MQ, :RE, :YT, :NC, :PF].freeze validates :value, phone: { possible: true, allow_blank: true, message: I18n.t(:not_a_phone, scope: 'activerecord.errors.messages') }, - if: -> { !Phonelib.valid_for_countries?(value, DEFAULT_COUNTRY_CODES) && validate_champ_value_or_prefill? } - - def to_s - return '' if value.blank? - - if Phonelib.valid_for_countries?(value, DEFAULT_COUNTRY_CODES) - Phonelib.parse_for_countries(value, DEFAULT_COUNTRY_CODES).full_national - else - # When he phone number is possible for the default countries, but not strictly valid, - # `full_national` could mess up the formatting. In this case just return the original. - value - end - end + if: -> { !Phonelib.valid_for_countries?(value, TypesDeChamp::PhoneTypeDeChamp::DEFAULT_COUNTRY_CODES) && validate_champ_value_or_prefill? } end diff --git a/app/models/champs/piece_justificative_champ.rb b/app/models/champs/piece_justificative_champ.rb index 0708460e2..b282aee35 100644 --- a/app/models/champs/piece_justificative_champ.rb +++ b/app/models/champs/piece_justificative_champ.rb @@ -1,6 +1,10 @@ +# frozen_string_literal: true + class Champs::PieceJustificativeChamp < Champ FILE_MAX_SIZE = 200.megabytes + has_many_attached :piece_justificative_file + # TODO: if: -> { validate_champ_value? || validation_context == :prefill } validates :piece_justificative_file, size: { less_than: FILE_MAX_SIZE }, @@ -17,28 +21,4 @@ class Champs::PieceJustificativeChamp < Champ def search_terms # We don’t know how to search inside documents yet end - - def mandatory_blank? - mandatory? && !piece_justificative_file.attached? - end - - def blank? - piece_justificative_file.blank? - end - - def for_export(path = :value) - piece_justificative_file.map { _1.filename.to_s }.join(', ') - end - - def for_api - return nil unless piece_justificative_file.attached? - - # API v1 don't support multiple PJ - attachment = piece_justificative_file.first - return nil if attachment.nil? - - if attachment.virus_scanner.safe? || attachment.virus_scanner.pending? - attachment.url - end - end end diff --git a/app/models/champs/pole_emploi_champ.rb b/app/models/champs/pole_emploi_champ.rb index eec697860..5a5d917f4 100644 --- a/app/models/champs/pole_emploi_champ.rb +++ b/app/models/champs/pole_emploi_champ.rb @@ -1,11 +1,9 @@ +# frozen_string_literal: true + class Champs::PoleEmploiChamp < Champs::TextChamp # see https://github.com/betagouv/api-particulier/blob/master/src/presentation/middlewares/pole-emploi-input-validation.middleware.ts store_accessor :value_json, :identifiant - def blank? - external_id.nil? - end - def fetch_external_data? true end diff --git a/app/models/champs/region_champ.rb b/app/models/champs/region_champ.rb index 04fab515c..9bbff77b6 100644 --- a/app/models/champs/region_champ.rb +++ b/app/models/champs/region_champ.rb @@ -1,25 +1,9 @@ +# frozen_string_literal: true + class Champs::RegionChamp < Champs::TextChamp validate :value_in_region_names, if: -> { !value.nil? && validate_champ_value_or_prefill? } validate :external_id_in_region_codes, if: -> { !external_id.nil? && validate_champ_value_or_prefill? } - def for_export(path = :value) - case path - when :value - name - when :code - code - end - end - - def for_tag(path = :value) - case path - when :value - name - when :code - code - end - end - def selected code end diff --git a/app/models/champs/repetition_champ.rb b/app/models/champs/repetition_champ.rb index 6bb189c49..0e9e8ef64 100644 --- a/app/models/champs/repetition_champ.rb +++ b/app/models/champs/repetition_champ.rb @@ -1,52 +1,32 @@ +# frozen_string_literal: true + class Champs::RepetitionChamp < Champ - accepts_nested_attributes_for :champs + delegate :libelle_for_export, to: :type_de_champ def rows - dossier - .champs_for_revision(scope: type_de_champ) - .group_by(&:row_id) - .sort - .map(&:second) + dossier.project_rows_for(type_de_champ) end def row_ids - rows.map { _1.first.row_id } + dossier.repetition_row_ids(type_de_champ) end - def add_row(revision) - added_champs = [] - transaction do - row_id = ULID.generate - revision.children_of(type_de_champ).each do |type_de_champ| - added_champs << type_de_champ.build_champ(row_id:) - end - self.champs << added_champs - end - added_champs + def add_row(updated_by:) + dossier.repetition_add_row(type_de_champ, updated_by:) end - def blank? - champs.empty? + def remove_row(row_id, updated_by:) + dossier.repetition_remove_row(type_de_champ, row_id, updated_by:) + end + + def focusable_input_id + rows.last&.first&.focusable_input_id end def search_terms # The user cannot enter any information here so it doesn’t make much sense to search end - def for_tag(path = :value) - ([libelle] + rows.map do |champs| - champs.map do |champ| - "#{champ.libelle} : #{champ}" - end.join("\n") - end).join("\n\n") - end - - def rows_for_export - row_ids.map.with_index(1) do |row_id, index| - Champs::RepetitionChamp::Row.new(index:, row_id:, dossier:) - end - end - class Row < Hashie::Dash property :index property :row_id @@ -60,11 +40,11 @@ class Champs::RepetitionChamp < Champ self[attribute] end - def spreadsheet_columns(types_de_champ) + def spreadsheet_columns(types_de_champ, export_template: nil, format:) [ ['Dossier ID', :dossier_id], ['Ligne', :index] - ] + dossier.champs_for_export(types_de_champ, row_id) + ] + dossier.champ_values_for_export(types_de_champ, row_id:, export_template:, format:) end end end diff --git a/app/models/champs/rna_champ.rb b/app/models/champs/rna_champ.rb index 5905d35fa..b738922d8 100644 --- a/app/models/champs/rna_champ.rb +++ b/app/models/champs/rna_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Champs::RNAChamp < Champ include RNAChampAssociationFetchableConcern @@ -11,12 +13,12 @@ class Champs::RNAChamp < Champ data&.dig("association_titre") end - def identifier - title.present? ? "#{value} (#{title})" : value + def update_with_external_data!(data:) + update!(data:, value_json: extract_value_json(data:)) end - def for_export(path = :value) - identifier + def identifier + title.present? ? "#{value} (#{title})" : value end def search_terms @@ -43,4 +45,11 @@ class Champs::RNAChamp < Champ city_code: address["code_insee"] }.with_indifferent_access end + + private + + def extract_value_json(data:) + h = APIGeoService.parse_rna_address(data['adresse']) + h.merge(title: data['association_titre']) + end end diff --git a/app/models/champs/rnf_champ.rb b/app/models/champs/rnf_champ.rb index cb0de4e8d..0dab98707 100644 --- a/app/models/champs/rnf_champ.rb +++ b/app/models/champs/rnf_champ.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + class Champs::RNFChamp < Champ - store_accessor :data, :title, :email, :phone, :createdAt, :updatedAt, :dissolvedAt, :address, :status + store_accessor :data, :title, :email, :phone, :createdAt, :updatedAt, :dissolvedAt, :address def rnf_id - external_id + external_id&.gsub(/[[:space:]]/, '') end def value @@ -13,6 +15,10 @@ class Champs::RNFChamp < Champ RNFService.new.(rnf_id:) end + def update_with_external_data!(data:) + update!(data:, value_json: extract_value_json(data:)) + end + def fetch_external_data? true end @@ -21,40 +27,6 @@ class Champs::RNFChamp < Champ true end - def blank? - rnf_id.blank? - end - - def for_export(path = :value) - case path - when :value - rnf_id - when :departement - departement_code_and_name - when :code_insee - commune&.fetch(:code) - when :address - full_address - when :nom - title - end - end - - def for_tag(path = :value) - case path - when :value - rnf_id - when :departement - departement_code_and_name || '' - when :code_insee - commune&.fetch(:code) || '' - when :address - full_address || '' - when :nom - title || '' - end - end - def code_departement address.present? && address['departmentCode'] end @@ -137,4 +109,11 @@ class Champs::RNFChamp < Champ address['label'] end end + + private + + def extract_value_json(data:) + h = APIGeoService.parse_rnf_address(data[:address]) + h.merge(title: data[:title]) + end end diff --git a/app/models/champs/siret_champ.rb b/app/models/champs/siret_champ.rb index 54c350544..f124d218d 100644 --- a/app/models/champs/siret_champ.rb +++ b/app/models/champs/siret_champ.rb @@ -1,11 +1,9 @@ +# frozen_string_literal: true + class Champs::SiretChamp < Champ include SiretChampEtablissementFetchableConcern def search_terms etablissement.present? ? etablissement.search_terms : [value] end - - def mandatory_blank? - mandatory? && Siret.new(siret: value).invalid? - end end diff --git a/app/models/champs/text_champ.rb b/app/models/champs/text_champ.rb index 8e24252ce..ee99e1819 100644 --- a/app/models/champs/text_champ.rb +++ b/app/models/champs/text_champ.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class Champs::TextChamp < Champ end diff --git a/app/models/champs/textarea_champ.rb b/app/models/champs/textarea_champ.rb index abcb2644e..9b62f415c 100644 --- a/app/models/champs/textarea_champ.rb +++ b/app/models/champs/textarea_champ.rb @@ -1,8 +1,6 @@ -class Champs::TextareaChamp < Champs::TextChamp - def for_export(path = :value) - value.present? ? ActionView::Base.full_sanitizer.sanitize(value) : nil - end +# frozen_string_literal: true +class Champs::TextareaChamp < Champs::TextChamp def remaining_characters character_limit_base - character_count if character_count >= character_limit_threshold_75 end diff --git a/app/models/champs/titre_identite_champ.rb b/app/models/champs/titre_identite_champ.rb index ae57491bd..790259f7c 100644 --- a/app/models/champs/titre_identite_champ.rb +++ b/app/models/champs/titre_identite_champ.rb @@ -1,6 +1,11 @@ +# frozen_string_literal: true + class Champs::TitreIdentiteChamp < Champ FILE_MAX_SIZE = 20.megabytes ACCEPTED_FORMATS = ['image/png', 'image/jpeg'] + + has_many_attached :piece_justificative_file + # TODO: if: -> { validate_champ_value? || validation_context == :prefill } validates :piece_justificative_file, content_type: ACCEPTED_FORMATS, size: { less_than: FILE_MAX_SIZE } @@ -11,20 +16,4 @@ class Champs::TitreIdentiteChamp < Champ def search_terms # We don’t know how to search inside documents yet end - - def mandatory_blank? - mandatory? && !piece_justificative_file.attached? - end - - def blank? - piece_justificative_file.blank? - end - - def for_export(path = :value) - piece_justificative_file.attached? ? "présent" : "absent" - end - - def for_api - nil - end end diff --git a/app/models/champs/yes_no_champ.rb b/app/models/champs/yes_no_champ.rb index 8f9763811..80b5a499e 100644 --- a/app/models/champs/yes_no_champ.rb +++ b/app/models/champs/yes_no_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Champs::YesNoChamp < Champs::BooleanChamp def legend_label? true diff --git a/app/models/chorus_configuration.rb b/app/models/chorus_configuration.rb index e4042eccd..f2f4a4db4 100644 --- a/app/models/chorus_configuration.rb +++ b/app/models/chorus_configuration.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ChorusConfiguration include ActiveModel::Model include ActiveModel::Attributes diff --git a/app/models/column.rb b/app/models/column.rb new file mode 100644 index 000000000..b02feda10 --- /dev/null +++ b/app/models/column.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class Column + # include validations to enable procedure_presentation.validate_associate, + # which enforces the deserialization of columns in the displayed_columns attribute + # and raises an error if a column is not found + include ActiveModel::Validations + + TYPE_DE_CHAMP_TABLE = 'type_de_champ' + + attr_reader :table, :column, :label, :type, :filterable, :displayable + attr_accessor :options_for_select + + def initialize(procedure_id:, table:, column:, label: nil, type: :text, filterable: true, displayable: true, options_for_select: []) + @procedure_id = procedure_id + @table = table + @column = column + @label = label || I18n.t(column, scope: [:activerecord, :attributes, :procedure_presentation, :fields, table]) + @type = type + @filterable = filterable + @displayable = displayable + @options_for_select = options_for_select + end + + # the id is a String to be used in forms + def id = h_id.to_json + + # the h_id is a Hash and hold enough information to find the column + # in the ColumnType class, aka be able to do the h_id -> column conversion + def h_id = { procedure_id: @procedure_id, column_id: } + + def ==(other) = h_id == other.h_id # using h_id instead of id to avoid inversion of keys + + def notifications? = [table, column] == ['notifications', 'notifications'] + def dossier_id? = [table, column] == ['self', 'id'] + def dossier_state? = [table, column] == ['self', 'state'] + def groupe_instructeur? = [table, column] == ['groupe_instructeur', 'id'] + def dossier_labels? = [table, column] == ['dossier_labels', 'label_id'] + def type_de_champ? = table == TYPE_DE_CHAMP_TABLE + + def self.find(h_id) + begin + procedure = Procedure.with_discarded.find(h_id[:procedure_id]) + rescue ActiveRecord::RecordNotFound + raise ActiveRecord::RecordNotFound.new("Column: unable to find procedure #{h_id[:procedure_id]} from h_id #{h_id}") + end + + procedure.find_column(h_id: h_id) + end + + def dossier_column? = false + def champ_column? = false + + private + + def column_id = "#{table}/#{column}" +end diff --git a/app/models/columns/attached_many_column.rb b/app/models/columns/attached_many_column.rb new file mode 100644 index 000000000..439f7adc5 --- /dev/null +++ b/app/models/columns/attached_many_column.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Columns::AttachedManyColumn < Columns::ChampColumn + private + + def typed_value(champ) + champ.piece_justificative_file.to_a + end +end diff --git a/app/models/columns/champ_column.rb b/app/models/columns/champ_column.rb new file mode 100644 index 000000000..2d3859774 --- /dev/null +++ b/app/models/columns/champ_column.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +class Columns::ChampColumn < Column + attr_reader :stable_id, :tdc_type + + def initialize(procedure_id:, label:, stable_id:, tdc_type:, displayable: true, filterable: true, type: :text, options_for_select: []) + @stable_id = stable_id + @tdc_type = tdc_type + column = tdc_type.in?(['departements', 'regions']) ? :external_id : :value + + super( + procedure_id:, + table: 'type_de_champ', + column:, + label:, + type:, + displayable:, + filterable:, + options_for_select: + ) + end + + def value(champ) + return if champ.nil? + + # nominal case + if champ.is_type?(@tdc_type) + typed_value(champ) + else + cast_value(champ) + end + end + + def filtered_ids(dossiers, search_terms) + relation = dossiers.with_type_de_champ(stable_id) + + if type == :enum + relation.where(champs: { column => search_terms }).ids + elsif type == :enums + # in a multiple drop down list, the value are stored as '["v1", "v2"]' + quoted_search_terms = search_terms.map { %{"#{_1}"} } + relation.filter_ilike(:champs, column, quoted_search_terms).ids + else + relation.filter_ilike(:champs, column, search_terms).ids + end + end + + def champ_column? = true + + private + + def column_id = "type_de_champ/#{stable_id}" + + def string_value(champ) = champ.public_send(column) + + def typed_value(champ) + value = string_value(champ) + + return if value.blank? + + case type + when :boolean + parse_boolean(value) + when :integer + value.to_i + when :decimal + value.to_f + when :datetime + parse_datetime(value) + when :date + parse_datetime(value)&.to_date + when :enums + parse_enums(value) + else + value + end + end + + def cast_value(champ) + value = string_value(champ) + + return if value.blank? + + case [champ.last_write_type_champ, @tdc_type] + when ['integer_number', 'decimal_number'] # recast numbers automatically + value.to_f + when ['decimal_number', 'integer_number'] # may lose some data, but who cares ? + value.to_i + when ['integer_number', 'text'], ['decimal_number', 'text'] # number to text + value + when ['drop_down_list', 'multiple_drop_down_list'] # single list can become multi + [value] + when ['drop_down_list', 'text'] # single list can become text + value + when ['multiple_drop_down_list', 'drop_down_list'] # multi list can become single + parse_enums(value).first + when ['multiple_drop_down_list', 'text'] # single list can become text + parse_enums(value).join(', ') + when ['date', 'datetime'] # date <=> datetime + parse_datetime(value)&.to_datetime + when ['datetime', 'date'] # may lose some data, but who cares ? + parse_datetime(value)&.to_date + else + nil + end + end + + def parse_boolean(value) + case value + when 'true', 'on', '1' + true + when 'false' + false + end + end + + def parse_enums(value) = JSON.parse(value) rescue nil + + def parse_datetime(value) = Time.zone.parse(value) rescue nil +end diff --git a/app/models/columns/dossier_column.rb b/app/models/columns/dossier_column.rb new file mode 100644 index 000000000..82730b2a9 --- /dev/null +++ b/app/models/columns/dossier_column.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class Columns::DossierColumn < Column + def value(dossier) + case table + when 'self' + dossier.public_send(column) + when 'etablissement' + dossier.etablissement.public_send(column) + when 'individual' + dossier.individual.public_send(column) + when 'groupe_instructeur' + dossier.groupe_instructeur.label + when 'followers_instructeurs' + dossier.followers_instructeurs.map(&:email).join(' ') + end + end + + def dossier_column? = true +end diff --git a/app/models/columns/json_path_column.rb b/app/models/columns/json_path_column.rb new file mode 100644 index 000000000..a60d5d872 --- /dev/null +++ b/app/models/columns/json_path_column.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class Columns::JSONPathColumn < Columns::ChampColumn + attr_reader :jsonpath + + def initialize(procedure_id:, label:, stable_id:, tdc_type:, jsonpath:, options_for_select: [], displayable:, type: :text) + @jsonpath = quote_string(jsonpath) + + super( + procedure_id:, + label:, + stable_id:, + tdc_type:, + displayable:, + type:, + options_for_select: + ) + end + + def filtered_ids(dossiers, search_terms) + value = quote_string(search_terms.join('|')) + + condition = %{champs.value_json @? '#{jsonpath} ? (@ like_regex "#{value}" flag "i")'} + + dossiers.with_type_de_champ(stable_id) + .where(condition) + .ids + end + + private + + def column_id = "type_de_champ/#{stable_id}-#{jsonpath}" + + def typed_value(champ) + champ.value_json&.dig(*jsonpath.split('.')[1..]) + end + + def quote_string(string) = ActiveRecord::Base.connection.quote_string(string) +end diff --git a/app/models/columns/linked_drop_down_column.rb b/app/models/columns/linked_drop_down_column.rb new file mode 100644 index 000000000..c7611168e --- /dev/null +++ b/app/models/columns/linked_drop_down_column.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +class Columns::LinkedDropDownColumn < Columns::ChampColumn + attr_reader :path + + def initialize(procedure_id:, label:, stable_id:, tdc_type:, path:, options_for_select: [], displayable:, type: :text) + @path = path + + super( + procedure_id:, + label:, + stable_id:, + tdc_type:, + displayable:, + type:, + options_for_select: + ) + end + + def filtered_ids(dossiers, search_terms) + relation = dossiers.with_type_de_champ(@stable_id) + + case path + when :value + search_terms.flat_map do |search_term| + # when looking for "section 1 / option A", + # the value must contain both "section 1" and "option A" + primary, *secondary = search_term.split(%r{[[:space:]]*/[[:space:]]*}) + safe_terms = [primary, *secondary].map { "%#{safe_like(_1)}%" } + + relation.where("champs.value ILIKE ALL (ARRAY[?])", safe_terms).ids + end.uniq + when :primary + primary_terms = search_terms.map { |term| %{["#{safe_like(term)}","%"]} } + + relation.where("champs.value ILIKE ANY (array[?])", primary_terms).ids + when :secondary + secondary_terms = search_terms.map { |term| %{["%","#{safe_like(term)}"]} } + + relation.where("champs.value ILIKE ANY (array[?])", secondary_terms).ids + end + end + + private + + def column_id = "type_de_champ/#{stable_id}.#{path}" + + def typed_value(champ) + primary_value, secondary_value = unpack_values(champ.value) + case path + when :value + "#{primary_value} / #{secondary_value}" + when :primary + primary_value + when :secondary + secondary_value + end + end + + def unpack_values(value) + JSON.parse(value) + rescue JSON::ParserError + [] + end + + def safe_like(q) = ActiveRecord::Base.sanitize_sql_like(q) +end diff --git a/app/models/columns/piece_justificative_column.rb b/app/models/columns/piece_justificative_column.rb new file mode 100644 index 000000000..6b29ae35b --- /dev/null +++ b/app/models/columns/piece_justificative_column.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Columns::PieceJustificativeColumn < Column + private + + def typed_value(champ) + champ.piece_justificative_file.map { _1.blob.filename.to_s }.join(', ') + end +end diff --git a/app/models/commentaire.rb b/app/models/commentaire.rb index cab033348..6570c9094 100644 --- a/app/models/commentaire.rb +++ b/app/models/commentaire.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Commentaire < ApplicationRecord include Discard::Model belongs_to :dossier, inverse_of: :commentaires, touch: true, optional: false @@ -7,7 +9,7 @@ class Commentaire < ApplicationRecord validate :messagerie_available?, on: :create, unless: -> { dossier.brouillon? } - has_one_attached :piece_jointe + has_many_attached :piece_jointe validates :body, presence: { message: "ne peut être vide" }, unless: :discarded? @@ -37,7 +39,7 @@ class Commentaire < ApplicationRecord def redacted_email if sent_by_instructeur? - if dossier.procedure.feature_enabled?(:hide_instructeur_email) + if dossier.procedure.hide_instructeurs_email? "Instructeur n° #{instructeur.id}" else instructeur.email.split('@').first @@ -67,12 +69,6 @@ class Commentaire < ApplicationRecord sent_by?(connected_user) && (sent_by_instructeur? || sent_by_expert?) && !discarded? end - def file_url - if piece_jointe.attached? && piece_jointe.virus_scanner.safe? - Rails.application.routes.url_helpers.url_for(piece_jointe) - end - end - def soft_delete! transaction do discard! @@ -80,7 +76,7 @@ class Commentaire < ApplicationRecord update! body: '' end - piece_jointe.purge_later if piece_jointe.attached? + piece_jointe.each(&:purge_later) if piece_jointe.attached? end def flagged_pending_correction? diff --git a/app/models/commentaire_groupe_gestionnaire.rb b/app/models/commentaire_groupe_gestionnaire.rb index f6602bef9..2a944d2e0 100644 --- a/app/models/commentaire_groupe_gestionnaire.rb +++ b/app/models/commentaire_groupe_gestionnaire.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CommentaireGroupeGestionnaire < ApplicationRecord include Discard::Model belongs_to :groupe_gestionnaire diff --git a/app/models/concerns/addressable_column_concern.rb b/app/models/concerns/addressable_column_concern.rb new file mode 100644 index 000000000..b3a6edf73 --- /dev/null +++ b/app/models/concerns/addressable_column_concern.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module AddressableColumnConcern + extend ActiveSupport::Concern + + included do + def addressable_columns(procedure:, displayable: true, prefix: nil) + [ + ["code postal (5 chiffres)", '$.postal_code', :text, []], + ["commune", '$.city_name', :text, []], + ["département", '$.departement_code', :enum, APIGeoService.departement_options], + ["region", '$.region_name', :enum, APIGeoService.region_options] + ].map do |(label, jsonpath, type, options_for_select)| + Columns::JSONPathColumn.new( + procedure_id: procedure.id, + stable_id:, + tdc_type: type_champ, + label: "#{libelle_with_prefix(prefix)} – #{label}", + jsonpath:, + displayable:, + options_for_select:, + type: + ) + end + end + end +end diff --git a/app/models/concerns/api_entreprise_token_concern.rb b/app/models/concerns/api_entreprise_token_concern.rb new file mode 100644 index 000000000..3750d35ef --- /dev/null +++ b/app/models/concerns/api_entreprise_token_concern.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module APIEntrepriseTokenConcern + extend ActiveSupport::Concern + + SOON_TO_EXPIRE_DELAY = 1.month + + included do + validates :api_entreprise_token, jwt_token: true, allow_blank: true + + before_save :set_api_entreprise_token_expires_at, if: :will_save_change_to_api_entreprise_token? + + def api_entreprise_role?(role) + APIEntrepriseToken.new(api_entreprise_token).role?(role) + end + + def api_entreprise_token + self[:api_entreprise_token].presence || Rails.application.secrets.api_entreprise[:key] + end + + def api_entreprise_token_expired_or_expires_soon? + api_entreprise_token_expires_at && api_entreprise_token_expires_at <= SOON_TO_EXPIRE_DELAY.from_now + end + + def has_api_entreprise_token? + self[:api_entreprise_token].present? + end + + def set_api_entreprise_token_expires_at + self.api_entreprise_token_expires_at = has_api_entreprise_token? ? APIEntrepriseToken.new(api_entreprise_token).expiration : nil + end + end +end diff --git a/app/models/concerns/attachment_image_processor_concern.rb b/app/models/concerns/attachment_image_processor_concern.rb new file mode 100644 index 000000000..1457122a5 --- /dev/null +++ b/app/models/concerns/attachment_image_processor_concern.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Run a virus scan on all attachments after they are analyzed. +# +# We're using a class extension to ensure that all attachments get scanned, +# regardless on how they were created. This could be an ActiveStorage::Analyzer, +# but as of Rails 6.1 only the first matching analyzer is ever run on +# a blob (and we may want to analyze the dimension of a picture as well +# as scanning it). +module AttachmentImageProcessorConcern + extend ActiveSupport::Concern + + included do + after_create_commit :process_image + end + + private + + def process_image + return if blob.nil? + return if blob.attachments.size != 1 + return if blob.attachments.any? { _1.record_type == "Export" } + return if !blob.content_type.in?(PROCESSABLE_TYPES) + return if blob.byte_size.zero? # some empty files may be considered as image depending on filename + + ImageProcessorJob.perform_later(blob) + end +end diff --git a/app/models/concerns/attachment_titre_identite_watermark_concern.rb b/app/models/concerns/attachment_titre_identite_watermark_concern.rb index 2091a850d..05aadb7c8 100644 --- a/app/models/concerns/attachment_titre_identite_watermark_concern.rb +++ b/app/models/concerns/attachment_titre_identite_watermark_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Request a watermark on files attached to a `Champs::TitreIdentiteChamp`. # # We're using a class extension here, but we could as well have a periodic diff --git a/app/models/concerns/attachment_virus_scanner_concern.rb b/app/models/concerns/attachment_virus_scanner_concern.rb index 832d15a98..43e9e4f26 100644 --- a/app/models/concerns/attachment_virus_scanner_concern.rb +++ b/app/models/concerns/attachment_virus_scanner_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Run a virus scan on all attachments after they are analyzed. # # We're using a class extension to ensure that all attachments get scanned, diff --git a/app/models/concerns/blob_image_processor_concern.rb b/app/models/concerns/blob_image_processor_concern.rb new file mode 100644 index 000000000..7e07b17dc --- /dev/null +++ b/app/models/concerns/blob_image_processor_concern.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module BlobImageProcessorConcern + def watermark_pending? + watermark_required? && !watermark_done? + end + + def watermark_done? + watermarked_at.present? + end + + def representation_required? + from_champ? || from_messagerie? || logo? || from_action_text? || from_avis? || from_justificatif_motivation? + end + + private + + def from_champ? + attachments.any? { _1.record.class == Champs::TitreIdentiteChamp || _1.record.class == Champs::PieceJustificativeChamp } + end + + def from_messagerie? + attachments.any? { _1.record.class == Commentaire } + end + + def logo? + attachments.any? { _1.name == 'logo' } + end + + def from_action_text? + attachments.any? { _1.record.class == ActionText::RichText } + end + + def from_avis? + attachments.any? { _1.record.class == Avis } + end + + def watermark_required? + attachments.any? { _1.record.class == Champs::TitreIdentiteChamp } + end + + def from_justificatif_motivation? + attachments.any? { _1.name == 'justificatif_motivation' } + end +end diff --git a/app/models/concerns/blob_signed_id_concern.rb b/app/models/concerns/blob_signed_id_concern.rb index f12b2616d..e05e71697 100644 --- a/app/models/concerns/blob_signed_id_concern.rb +++ b/app/models/concerns/blob_signed_id_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module BlobSignedIdConcern extend ActiveSupport::Concern diff --git a/app/models/concerns/blob_titre_identite_watermark_concern.rb b/app/models/concerns/blob_titre_identite_watermark_concern.rb deleted file mode 100644 index 152c512f9..000000000 --- a/app/models/concerns/blob_titre_identite_watermark_concern.rb +++ /dev/null @@ -1,21 +0,0 @@ -module BlobTitreIdentiteWatermarkConcern - def watermark_pending? - watermark_required? && !watermark_done? - end - - def watermark_done? - watermarked_at.present? - end - - def watermark_later - if watermark_pending? - TitreIdentiteWatermarkJob.perform_later(self) - end - end - - private - - def watermark_required? - attachments.any? { _1.record.class == Champs::TitreIdentiteChamp } - end -end diff --git a/app/models/concerns/blob_virus_scanner_concern.rb b/app/models/concerns/blob_virus_scanner_concern.rb index dd5fbdb51..f4d45309a 100644 --- a/app/models/concerns/blob_virus_scanner_concern.rb +++ b/app/models/concerns/blob_virus_scanner_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module BlobVirusScannerConcern extend ActiveSupport::Concern diff --git a/app/models/concerns/champ_conditional_concern.rb b/app/models/concerns/champ_conditional_concern.rb index 9e6559be9..91fbd97be 100644 --- a/app/models/concerns/champ_conditional_concern.rb +++ b/app/models/concerns/champ_conditional_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ChampConditionalConcern extend ActiveSupport::Concern @@ -21,10 +23,14 @@ module ChampConditionalConcern end end + def reset_visible # recompute after a dossier update + remove_instance_variable :@visible if instance_variable_defined? :@visible + end + private def champs_for_condition - dossier.champs.filter { _1.row_id.nil? || _1.row_id == row_id } + dossier.filled_champs.filter { _1.row_id.nil? || _1.row_id == row_id } end end end diff --git a/app/models/concerns/champs_validate_concern.rb b/app/models/concerns/champs_validate_concern.rb index e9f040908..ac8d0e1aa 100644 --- a/app/models/concerns/champs_validate_concern.rb +++ b/app/models/concerns/champs_validate_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ChampsValidateConcern extend ActiveSupport::Concern @@ -12,13 +14,11 @@ module ChampsValidateConcern private def validate_champ_value? - return false unless visible? - case validation_context when :champs_public_value - public? + public? && in_dossier_revision? && visible? when :champs_private_value - private? + private? && in_dossier_revision? && visible? else false end @@ -27,5 +27,9 @@ module ChampsValidateConcern def validate_champ_value_or_prefill? validate_champ_value? || validation_context == :prefill end + + def in_dossier_revision? + dossier.revision.types_de_champ.any? { _1.stable_id == stable_id } && is_type?(type_de_champ.type_champ) + end end end diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb new file mode 100644 index 000000000..d845bd7d7 --- /dev/null +++ b/app/models/concerns/columns_concern.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +module ColumnsConcern + extend ActiveSupport::Concern + + included do + # we cannot use column.id ( == { procedure_id, column_id }.to_json) + # as the order of the keys is not guaranteed + # instead, we are using h_id == { procedure_id:, column_id: } + # another way to find a column is to look for its label + def find_column(h_id: nil, label: nil) + column = columns.find { _1.h_id == h_id } if h_id.present? + column = columns.find { _1.label == label } if label.present? + + # TODO: to remove after linked_drop_down column column_id migration + if column.nil? && h_id.is_a?(Hash) && h_id[:column_id].present? + h_id[:column_id].gsub!('->', '.') + + column = columns.find { _1.h_id == h_id } + end + + raise ActiveRecord::RecordNotFound.new("Column: unable to find h_id: #{h_id} or label: #{label} for procedure_id #{id}") if column.nil? + + column + end + + def columns + Current.procedure_columns ||= {} + + Current.procedure_columns[id] ||= begin + columns = dossier_columns + columns.concat(standard_columns) + columns.concat(individual_columns) if for_individual + columns.concat(moral_columns) if !for_individual + columns.concat(procedure_chorus_columns) if chorusable? && chorus_configuration.complete? + columns.concat(types_de_champ_columns) + end + end + + def usager_columns_for_export + columns = [dossier_id_column, user_email_for_display_column, user_france_connected_column] + columns.concat(individual_columns) if for_individual + columns.concat(moral_columns) if !for_individual + columns.concat(procedure_chorus_columns) if chorusable? && chorus_configuration.complete? + + # ensure the columns exist in main list + # otherwise, they will be found by the find_column method + columns.filter { _1.id.in?(self.columns.map(&:id)) } + end + + def dossier_columns_for_export + columns = [dossier_state_column, dossier_archived_column] + columns.concat(dossier_dates_columns) + columns.concat([dossier_motivation_column]) + columns.concat(sva_svr_columns(for_export: true)) if sva_svr_enabled? + columns.concat([groupe_instructeurs_id_column, followers_instructeurs_email_column]) + + # ensure the columns exist in main list + # otherwise, they will be found by the find_column method + columns.filter { _1.id.in?(self.columns.map(&:id)) } + end + + def dossier_id_column = dossier_col(table: 'self', column: 'id', type: :integer) + + def dossier_state_column + options_for_select = I18n.t('instructeurs.dossiers.filterable_state').map(&:to_a).map(&:reverse) + + dossier_col(table: 'self', column: 'state', type: :enum, options_for_select:, displayable: false) + end + + def notifications_column = dossier_col(table: 'notifications', column: 'notifications', label: "notifications", filterable: false) + + def sva_svr_columns(for_export: false) + scope = [:activerecord, :attributes, :procedure_presentation, :fields, :self] + + columns = [ + dossier_col(table: 'self', column: 'sva_svr_decision_on', type: :date, + label: I18n.t("#{sva_svr_decision}_decision_on", scope:, type: sva_svr_configuration.human_decision)) + ] + if !for_export + columns << dossier_col(table: 'self', column: 'sva_svr_decision_before', type: :date, displayable: false, + label: I18n.t("#{sva_svr_decision}_decision_before", scope:)) + end + columns + end + + def default_sorted_column + SortedColumn.new(column: notifications_column, order: 'desc') + end + + def default_displayed_columns = [email_column] + + private + + def groupe_instructeurs_id_column = dossier_col(table: 'groupe_instructeur', column: 'id', type: :enum) + + def followers_instructeurs_email_column = dossier_col(table: 'followers_instructeurs', column: 'email') + + def dossier_archived_column = dossier_col(table: 'self', column: 'archived', type: :boolean, displayable: false, filterable: false); + + def dossier_motivation_column = dossier_col(table: 'self', column: 'motivation', type: :text, displayable: false, filterable: false); + + def user_email_for_display_column = dossier_col(table: 'self', column: 'user_email_for_display', filterable: false, displayable: false) + + def user_france_connected_column = dossier_col(table: 'self', column: 'user_from_france_connect?', type: :boolean, filterable: false, displayable: false) + + def dossier_labels_column = dossier_col(table: 'dossier_labels', column: 'label_id', type: :enum, options_for_select: labels.map { [_1.name, _1.id] }) + + def procedure_chorus_columns + ['domaine_fonctionnel', 'referentiel_prog', 'centre_de_cout'] + .map { |column| dossier_col(table: 'procedure', column:, displayable: false, filterable: false) } + end + + def dossier_non_displayable_dates_columns + ['updated_since', 'depose_since', 'en_construction_since', 'en_instruction_since', 'processed_since'] + .map { |column| dossier_col(table: 'self', column:, type: :date, displayable: false) } + end + + def dossier_dates_columns + ['created_at', 'updated_at', 'last_champ_updated_at', 'depose_at', 'en_construction_at', 'en_instruction_at', 'processed_at'] + .map { |column| dossier_col(table: 'self', column:, type: :datetime) } + end + + def email_column + dossier_col(table: 'user', column: 'email') + end + + def dossier_columns + columns = [dossier_id_column, notifications_column] + columns.concat([dossier_state_column]) + columns.concat([dossier_archived_column]) + columns.concat(dossier_dates_columns) + columns.concat([dossier_motivation_column]) + columns.concat(sva_svr_columns(for_export: false)) if sva_svr_enabled? + columns.concat(dossier_non_displayable_dates_columns) + end + + def standard_columns + [ + email_column, + user_email_for_display_column, + followers_instructeurs_email_column, + groupe_instructeurs_id_column, + dossier_col(table: 'avis', column: 'question_answer', filterable: false), + user_france_connected_column, + dossier_labels_column + ] + end + + def individual_columns + ['gender', 'nom', 'prenom'].map { |column| dossier_col(table: 'individual', column:) } + .concat ['mandataire_last_name', 'mandataire_first_name'].map { |column| dossier_col(table: 'self', column:) } + .concat ['for_tiers'].map { |column| dossier_col(table: 'self', column:, type: :boolean) } + end + + def moral_columns + etablissements = ['entreprise_forme_juridique', 'entreprise_siren', 'entreprise_nom_commercial', 'entreprise_raison_sociale', 'entreprise_siret_siege_social'] + .map { |column| dossier_col(table: 'etablissement', column:) } + + etablissement_dates = ['entreprise_date_creation'].map { |column| dossier_col(table: 'etablissement', column:, type: :date) } + + for_export = ["siege_social", "naf", "adresse", "numero_voie", "type_voie", "nom_voie", "complement_adresse", "localite", "code_insee_localite", "entreprise_siren", "entreprise_capital_social", "entreprise_numero_tva_intracommunautaire", "entreprise_forme_juridique_code", "entreprise_code_effectif_entreprise", "entreprise_etat_administratif", "entreprise_nom", "entreprise_prenom", "association_rna", "association_titre", "association_objet", "association_date_creation", "association_date_declaration", "association_date_publication"] + .map { |column| dossier_col(table: 'etablissement', column:, displayable: false, filterable: false) } + + other = ['siret', 'libelle_naf', 'code_postal'].map { |column| dossier_col(table: 'etablissement', column:) } + + [etablissements, etablissement_dates, other, for_export].flatten + end + + def types_de_champ_columns + all_revisions_types_de_champ.flat_map { _1.columns(procedure: self) } + end + + def dossier_col(**args) = Columns::DossierColumn.new(**(args.merge(procedure_id: id))) + end +end diff --git a/app/models/concerns/domain_migratable_concern.rb b/app/models/concerns/domain_migratable_concern.rb index 65be5f723..034314c06 100644 --- a/app/models/concerns/domain_migratable_concern.rb +++ b/app/models/concerns/domain_migratable_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DomainMigratableConcern extend ActiveSupport::Concern diff --git a/app/models/concerns/dossier_champs_concern.rb b/app/models/concerns/dossier_champs_concern.rb new file mode 100644 index 000000000..f6e9baf0a --- /dev/null +++ b/app/models/concerns/dossier_champs_concern.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +module DossierChampsConcern + extend ActiveSupport::Concern + + def project_champ(type_de_champ, row_id) + check_valid_row_id?(type_de_champ, row_id) + champ = champs_by_public_id[type_de_champ.public_id(row_id)] + if champ.nil? || !champ.is_type?(type_de_champ.type_champ) + value = type_de_champ.champ_blank?(champ) ? nil : champ.value + updated_at = champ&.updated_at || depose_at || created_at + rebased_at = champ&.rebased_at + type_de_champ.build_champ(dossier: self, row_id:, updated_at:, rebased_at:, value:) + else + champ + end + end + + def project_champs_public + @project_champs_public ||= revision.types_de_champ_public.map { project_champ(_1, nil) } + end + + def project_champs_private + @project_champs_private ||= revision.types_de_champ_private.map { project_champ(_1, nil) } + end + + def filled_champs_public + @filled_champs_public ||= project_champs_public.flat_map do |champ| + if champ.repetition? + champ.rows.flatten.filter { _1.persisted? && _1.fillable? } + elsif champ.persisted? && champ.fillable? + champ + else + [] + end + end + end + + def filled_champs_private + @filled_champs_private ||= project_champs_private.flat_map do |champ| + if champ.repetition? + champ.rows.flatten.filter { _1.persisted? && _1.fillable? } + elsif champ.persisted? && champ.fillable? + champ + else + [] + end + end + end + + def filled_champs + filled_champs_public + filled_champs_private + end + + def project_rows_for(type_de_champ) + return [] if !type_de_champ.repetition? + + children = revision.children_of(type_de_champ) + row_ids = repetition_row_ids(type_de_champ) + + row_ids.map do |row_id| + children.map { project_champ(_1, row_id) } + end + end + + def find_type_de_champ_by_stable_id(stable_id, scope = nil) + case scope + when :public + revision.types_de_champ.public_only + when :private + revision.types_de_champ.private_only + else + revision.types_de_champ + end.find_by!(stable_id:) + end + + def champs_for_prefill(stable_ids) + revision + .types_de_champ + .filter { _1.stable_id.in?(stable_ids) } + .filter { !_1.child?(revision) } + .map { _1.repetition? ? project_champ(_1, nil) : champ_for_update(_1, nil, updated_by: nil) } + end + + def champ_value_for_tag(type_de_champ, path = :value) + champ = filled_champ(type_de_champ, nil) + type_de_champ.champ_value_for_tag(champ, path) + end + + def champ_for_update(type_de_champ, row_id, updated_by:) + champ, attributes = champ_with_attributes_for_update(type_de_champ, row_id, updated_by:) + champ.assign_attributes(attributes) + champ + end + + def update_champs_attributes(attributes, scope, updated_by:) + champs_attributes = attributes.to_h.map do |public_id, attributes| + champ_attributes_by_public_id(public_id, attributes, scope, updated_by:) + end + + assign_attributes(champs_attributes:) + end + + def repetition_rows_for_export(type_de_champ) + repetition_row_ids(type_de_champ).map.with_index(1) do |row_id, index| + Champs::RepetitionChamp::Row.new(index:, row_id:, dossier: self) + end + end + + def repetition_row_ids(type_de_champ) + return [] if !type_de_champ.repetition? + + stable_ids = revision.children_of(type_de_champ).map(&:stable_id) + champs.filter { _1.stable_id.in?(stable_ids) && _1.row_id.present? } + .map(&:row_id) + .uniq + .sort + end + + def repetition_add_row(type_de_champ, updated_by:) + raise "Can't add row to non-repetition type de champ" if !type_de_champ.repetition? + + row_id = ULID.generate + types_de_champ = revision.children_of(type_de_champ) + self.champs += types_de_champ.map { _1.build_champ(row_id:, updated_by:) } + reload_champs_cache + row_id + end + + def repetition_remove_row(type_de_champ, row_id, updated_by:) + raise "Can't remove row from non-repetition type de champ" if !type_de_champ.repetition? + + champs.where(row_id:).destroy_all + reload_champs_cache + end + + def reload + super.tap { reset_champs_cache } + end + + private + + def champs_by_public_id + @champs_by_public_id ||= champs.sort_by(&:id).index_by(&:public_id) + end + + def filled_champ(type_de_champ, row_id) + champ = champs_by_public_id[type_de_champ.public_id(row_id)] + if type_de_champ.champ_blank?(champ) || !champ.visible? + nil + else + champ + end + end + + def champ_attributes_by_public_id(public_id, attributes, scope, updated_by:) + stable_id, row_id = public_id.split('-') + type_de_champ = find_type_de_champ_by_stable_id(stable_id, scope) + champ_with_attributes_for_update(type_de_champ, row_id, updated_by:).last.merge(attributes) + end + + def champ_with_attributes_for_update(type_de_champ, row_id, updated_by:) + check_valid_row_id?(type_de_champ, row_id) + attributes = type_de_champ.params_for_champ + # TODO: Once we have the right index in place, we should change this to use `create_or_find_by` instead of `find_or_create_by` + champ = champs + .create_with(**attributes) + .find_or_create_by!(stable_id: type_de_champ.stable_id, row_id:) + + attributes[:id] = champ.id + attributes[:updated_by] = updated_by + + # Needed when a revision change the champ type in this case, we reset the champ data + if champ.type != attributes[:type] + attributes[:value] = nil + attributes[:value_json] = nil + attributes[:external_id] = nil + attributes[:data] = nil + end + + reset_champs_cache + + [champ, attributes] + end + + def check_valid_row_id?(type_de_champ, row_id) + if type_de_champ.child?(revision) + if row_id.blank? + raise "type_de_champ #{type_de_champ.stable_id} in revision #{revision_id} must have a row_id because it is part of a repetition" + end + elsif row_id.present? && type_de_champ.in_revision?(revision) + raise "type_de_champ #{type_de_champ.stable_id} in revision #{revision_id} can not have a row_id because it is not part of a repetition" + end + end + + def reset_champs_cache + @champs_by_public_id = nil + @filled_champs_public = nil + @filled_champs_private = nil + @project_champs_public = nil + @project_champs_private = nil + end + + def reload_champs_cache + champs.reload if persisted? + reset_champs_cache + end +end diff --git a/app/models/concerns/dossier_clone_concern.rb b/app/models/concerns/dossier_clone_concern.rb index 537afd8d4..193561fe4 100644 --- a/app/models/concerns/dossier_clone_concern.rb +++ b/app/models/concerns/dossier_clone_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DossierCloneConcern extend ActiveSupport::Concern @@ -42,10 +44,10 @@ module DossierCloneConcern end def make_diff(editing_fork) - origin_champs_index = champs_for_revision(scope: :public).index_by(&:public_id) - forked_champs_index = editing_fork.champs_for_revision(scope: :public).index_by(&:public_id) + origin_champs_index = project_champs_public_all.index_by(&:public_id) + forked_champs_index = editing_fork.project_champs_public_all.index_by(&:public_id) updated_champs_index = editing_fork - .champs_for_revision(scope: :public) + .project_champs_public_all .filter { _1.updated_at > editing_fork.created_at } .index_by(&:public_id) @@ -69,9 +71,10 @@ module DossierCloneConcern diff = make_diff(editing_fork) apply_diff(diff) touch(:last_champ_updated_at) + touch(:last_champ_piece_jointe_updated_at) if diff[:updated].any? { |c| c.class.in?([Champs::PieceJustificativeChamp, Champs::TitreIdentiteChamp]) } end reload - update_search_terms_later + index_search_terms_later editing_fork.destroy_editing_fork! end @@ -80,7 +83,7 @@ module DossierCloneConcern dossier_attributes += [:groupe_instructeur_id] if fork relationships = [:individual, :etablissement] - cloned_champs = champs_for_revision + cloned_champs = champs .index_by(&:id) .transform_values { [_1, _1.clone(fork)] } @@ -98,7 +101,6 @@ module DossierCloneConcern kopy.state = Dossier.states.fetch(:brouillon) kopy.champs = cloned_champs.values.map do |(_, champ)| champ.dossier = kopy - champ.parent = cloned_champs[champ.parent_id].second if champ.child? champ end end @@ -120,6 +122,7 @@ module DossierCloneConcern end end + cloned_dossier.index_search_terms_later if !fork cloned_dossier.reload end @@ -146,35 +149,34 @@ module DossierCloneConcern end def apply_diff(diff) - champs_index = (champs_for_revision(scope: :public) + diff[:added]).index_by(&:public_id) + champs_added = diff[:added].filter(&:persisted?) + champs_updated = diff[:updated].filter(&:persisted?) + champs_removed = diff[:removed].filter(&:persisted?) - diff[:added].each do |champ| - if champ.child? - champ.update_columns(dossier_id: id, parent_id: champs_index.fetch(champ.parent.public_id).id) - else + champs_added.each { _1.update_column(:dossier_id, id) } + + if champs_updated.present? + champs_index = filled_champs_public.index_by(&:public_id) + champs_updated.each do |champ| + champs_index[champ.public_id]&.destroy! champ.update_column(:dossier_id, id) end end - champs_to_remove = [] - diff[:updated].each do |champ| - old_champ = champs_index.fetch(champ.public_id) - champs_to_remove << old_champ + champs_removed.each(&:destroy!) + end - if champ.child? - # we need to do that in order to avoid a foreign key constraint - old_champ.update(row_id: nil) - champ.update_columns(dossier_id: id, parent_id: champs_index.fetch(champ.parent.public_id).id) + protected + + # This is a temporary method that is only used by diff/merge algorithm. Once it's gone, this method should be removed. + def project_champs_public_all + revision.types_de_champ_public.flat_map do |type_de_champ| + champ = project_champ(type_de_champ, nil) + if type_de_champ.repetition? + [champ] + project_rows_for(type_de_champ).flatten else - champ.update_column(:dossier_id, id) + champ end end - - champs_to_remove += diff[:removed] - children_champs_to_remove, root_champs_to_remove = champs_to_remove.partition(&:child?) - - children_champs_to_remove.each(&:destroy!) - Champ.where(parent_id: root_champs_to_remove.map(&:id)).destroy_all - root_champs_to_remove.each(&:destroy!) end end diff --git a/app/models/concerns/dossier_correctable_concern.rb b/app/models/concerns/dossier_correctable_concern.rb index 0e5876985..04d8f6c7f 100644 --- a/app/models/concerns/dossier_correctable_concern.rb +++ b/app/models/concerns/dossier_correctable_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DossierCorrectableConcern extend ActiveSupport::Concern diff --git a/app/models/concerns/dossier_empty_concern.rb b/app/models/concerns/dossier_empty_concern.rb new file mode 100644 index 000000000..af2acbbab --- /dev/null +++ b/app/models/concerns/dossier_empty_concern.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module DossierEmptyConcern + extend ActiveSupport::Concern + + included do + scope :empty_brouillon, -> (created_at) do + dossiers_ids = Dossier.brouillon.where(created_at:).ids + + dossiers_with_value = Dossier.select('id').includes(:champs) + .where.not(champs: { value: nil }) + .where(id: dossiers_ids) + + dossier_with_geo_areas = Dossier.select('id').includes(champs: :geo_areas) + .where.not(geo_areas: { id: nil }) + .where(id: dossiers_ids) + + dossier_with_pj = Dossier.select('id') + .joins(champs: :piece_justificative_file_attachments) + .where(id: dossiers_ids) + + brouillon + .where.not(id: dossiers_with_value) + .where.not(id: dossier_with_geo_areas) + .where.not(id: dossier_with_pj) + .where(id: dossiers_ids) + end + end +end diff --git a/app/models/concerns/dossier_export_concern.rb b/app/models/concerns/dossier_export_concern.rb new file mode 100644 index 000000000..887b48207 --- /dev/null +++ b/app/models/concerns/dossier_export_concern.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module DossierExportConcern + extend ActiveSupport::Concern + + def spreadsheet_columns_csv(types_de_champ:, export_template: nil) + spreadsheet_columns(with_etablissement: true, types_de_champ:, export_template:, format: :csv) + end + + def spreadsheet_columns_xlsx(types_de_champ:, export_template: nil) + spreadsheet_columns(types_de_champ:, export_template:, format: :xlsx) + end + + def spreadsheet_columns_ods(types_de_champ:, export_template: nil) + spreadsheet_columns(types_de_champ:, export_template:, format: :ods) + end + + def champ_values_for_export(types_de_champ, row_id: nil, export_template: nil, format:) + types_de_champ.flat_map do |type_de_champ| + champ = filled_champ(type_de_champ, row_id) + if export_template.present? + export_template + .columns_for_stable_id(type_de_champ.stable_id) + .map { |exported_column| exported_column.libelle_with_value(champ, format:) } + else + type_de_champ.libelles_for_export.map do |(libelle, path)| + [libelle, type_de_champ.champ_value_for_export(champ, path)] + end + end + end + end + + def spreadsheet_columns(types_de_champ:, with_etablissement: false, export_template: nil, format: nil) + dossier_values_for_export(with_etablissement:, export_template:, format:) + champ_values_for_export(types_de_champ, export_template:, format:) + end + + private + + def dossier_values_for_export(with_etablissement: false, export_template: nil, format:) + if export_template.present? + return export_template.dossier_exported_columns.map { _1.libelle_with_value(self, format:) } + end + + columns = [ + ['ID', id.to_s], + ['Email', user_email_for(:display)], + ['FranceConnect ?', user_from_france_connect?] + ] + + if procedure.for_individual? + columns += [ + ['Civilité', individual&.gender], + ['Nom', individual&.nom], + ['Prénom', individual&.prenom], + ['Dépôt pour un tiers', :for_tiers], + ['Nom du mandataire', :mandataire_last_name], + ['Prénom du mandataire', :mandataire_first_name] + ] + if procedure.ask_birthday + columns += [['Date de naissance', individual&.birthdate]] + end + elsif with_etablissement + columns += [ + ['Établissement SIRET', etablissement&.siret], + ['Établissement siège social', etablissement&.siege_social], + ['Établissement NAF', etablissement&.naf], + ['Établissement libellé NAF', etablissement&.libelle_naf], + ['Établissement Adresse', etablissement&.adresse], + ['Établissement numero voie', etablissement&.numero_voie], + ['Établissement type voie', etablissement&.type_voie], + ['Établissement nom voie', etablissement&.nom_voie], + ['Établissement complément adresse', etablissement&.complement_adresse], + ['Établissement code postal', etablissement&.code_postal], + ['Établissement localité', etablissement&.localite], + ['Établissement code INSEE localité', etablissement&.code_insee_localite], + ['Entreprise SIREN', etablissement&.entreprise_siren], + ['Entreprise capital social', etablissement&.entreprise_capital_social], + ['Entreprise numero TVA intracommunautaire', etablissement&.entreprise_numero_tva_intracommunautaire], + ['Entreprise forme juridique', etablissement&.entreprise_forme_juridique], + ['Entreprise forme juridique code', etablissement&.entreprise_forme_juridique_code], + ['Entreprise nom commercial', etablissement&.entreprise_nom_commercial], + ['Entreprise raison sociale', etablissement&.entreprise_raison_sociale], + ['Entreprise SIRET siège social', etablissement&.entreprise_siret_siege_social], + ['Entreprise code effectif entreprise', etablissement&.entreprise_code_effectif_entreprise], + ['Entreprise date de création', etablissement&.entreprise_date_creation], + ['Entreprise état administratif', etablissement&.entreprise_etat_administratif], + ['Entreprise nom', etablissement&.entreprise_nom], + ['Entreprise prénom', etablissement&.entreprise_prenom], + ['Association RNA', etablissement&.association_rna], + ['Association titre', etablissement&.association_titre], + ['Association objet', etablissement&.association_objet], + ['Association date de création', etablissement&.association_date_creation], + ['Association date de déclaration', etablissement&.association_date_declaration], + ['Association date de publication', etablissement&.association_date_publication] + ] + else + columns << ['Entreprise raison sociale', etablissement&.entreprise_raison_sociale] + end + if procedure.chorusable? && procedure.chorus_configuration.complete? + columns += [ + ['Domaine Fonctionnel', procedure.chorus_configuration.domaine_fonctionnel&.fetch("code") { '' }], + ['Référentiel De Programmation', procedure.chorus_configuration.referentiel_de_programmation&.fetch("code") { '' }], + ['Centre De Coût', procedure.chorus_configuration.centre_de_cout&.fetch("code") { '' }] + ] + end + columns += [ + ['Archivé', :archived], + ['État du dossier', Dossier.human_attribute_name("state.#{state}")], + ['Dernière mise à jour le', :updated_at], + ['Dernière mise à jour du dossier le', :last_champ_updated_at], + ['Déposé le', :depose_at], + ['Passé en instruction le', :en_instruction_at], + procedure.sva_svr_enabled? ? ["Date décision #{procedure.sva_svr_configuration.human_decision}", :sva_svr_decision_on] : nil, + ['Traité le', :processed_at], + ['Motivation de la décision', :motivation], + ['Instructeurs', followers_instructeurs.map(&:email).join(' ')] + ].compact + + if procedure.routing_enabled? + columns << ['Groupe instructeur', groupe_instructeur.label] + end + + columns + end +end diff --git a/app/models/concerns/dossier_filtering_concern.rb b/app/models/concerns/dossier_filtering_concern.rb index 8c46a11e8..7883ae700 100644 --- a/app/models/concerns/dossier_filtering_concern.rb +++ b/app/models/concerns/dossier_filtering_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DossierFilteringConcern extend ActiveSupport::Concern @@ -26,10 +28,13 @@ module DossierFilteringConcern end } - scope :filter_ilike, lambda { |table, column, values| - table_column = ProcedurePresentation.sanitized_column(table, column) - q = Array.new(values.count, "(#{table_column} ILIKE ?)").join(' OR ') - where(q, *(values.map { |value| "%#{value}%" })) + scope :filter_ilike, lambda { |table, column, search_terms| + safe_quoted_terms = search_terms.map { "%#{sanitize_sql_like(_1)}%" } + table_column = DossierFilterService.sanitized_column(table, column) + + where("#{table_column} LIKE ANY (ARRAY[?])", safe_quoted_terms) } + + def sanitize_sql_like(q) = ActiveRecord::Base.sanitize_sql_like(q) end end diff --git a/app/models/concerns/dossier_prefillable_concern.rb b/app/models/concerns/dossier_prefillable_concern.rb index bbcfdc8e1..d0c21a2bf 100644 --- a/app/models/concerns/dossier_prefillable_concern.rb +++ b/app/models/concerns/dossier_prefillable_concern.rb @@ -13,8 +13,4 @@ module DossierPrefillableConcern assign_attributes(attributes) save(validate: false) end - - def find_champs_by_stable_ids(stable_ids) - champs.joins(:type_de_champ).where(types_de_champ: { stable_id: stable_ids.compact.uniq }) - end end diff --git a/app/models/concerns/dossier_rebase_concern.rb b/app/models/concerns/dossier_rebase_concern.rb index dd6395dc2..675bd6117 100644 --- a/app/models/concerns/dossier_rebase_concern.rb +++ b/app/models/concerns/dossier_rebase_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DossierRebaseConcern extend ActiveSupport::Concern @@ -22,7 +24,7 @@ module DossierRebaseConcern end def pending_changes - procedure.published_revision.present? ? revision.compare(procedure.published_revision) : [] + procedure.published_revision.present? ? revision.compare_types_de_champ(procedure.published_revision) : [] end def can_rebase_mandatory_change?(stable_id) @@ -50,7 +52,7 @@ module DossierRebaseConcern # index published types de champ coordinates by stable_id target_coordinates_by_stable_id = target_revision .revision_types_de_champ - .includes(:type_de_champ, :parent) + .includes(:parent) .index_by(&:stable_id) changes_by_op = pending_changes @@ -58,96 +60,45 @@ module DossierRebaseConcern .tap { _1.default = [] } champs_by_stable_id = champs - .includes(:type_de_champ) .group_by(&:stable_id) .transform_values { Champ.where(id: _1) } .tap { _1.default = Champ.none } - # add champ - changes_by_op[:add] - .map { target_coordinates_by_stable_id[_1.stable_id] } - # add parent champs first so we can then add children - .sort_by { _1.child? ? 1 : 0 } - .each { add_new_champs_for_revision(_1) } - # remove champ - children_champ, root_champ = changes_by_op[:remove].partition(&:child?) - children_champ.each { champs_by_stable_id[_1.stable_id].destroy_all } - root_champ.each { champs_by_stable_id[_1.stable_id].destroy_all } + changes_by_op[:remove].each { champs_by_stable_id[_1.stable_id].destroy_all } # update champ - changes_by_op[:update].each { apply(_1, champs_by_stable_id[_1.stable_id]) } - - # due to repetition tdc clone on update or erase - # we must reassign tdc to the latest version - champs_by_stable_id.each do |stable_id, champs| - if target_coordinates_by_stable_id[stable_id].present? && champs.present? - champs.update_all(type_de_champ_id: target_coordinates_by_stable_id[stable_id].type_de_champ_id) - end - end + changes_by_op[:update].each { champs_by_stable_id[_1.stable_id].update_all(rebased_at: Time.zone.now) } # update dossier revision update_column(:revision_id, target_revision.id) - end - def apply(change, champs) - case change.attribute - when :type_champ - champs.each { purge_piece_justificative_file(_1) } - GeoArea.where(champ: champs).destroy_all - Etablissement.where(champ: champs).destroy_all - champs.update_all(type: "Champs::#{change.to.classify}Champ", - value: nil, - value_json: nil, - external_id: nil, - data: nil, - rebased_at: Time.zone.now) - when :drop_down_options - # we are removing options, we need to remove the value if it contains one of the removed options - removed_options = change.from - change.to - if removed_options.present? && champs.any? { _1.in?(removed_options) } - champs.filter { _1.in?(removed_options) }.each do - _1.remove_option(removed_options) - _1.update_column(:rebased_at, Time.zone.now) - end - end - when :carte_layers - # if we are removing cadastres layer, we need to remove cadastre geo areas - if change.from.include?(:cadastres) && !change.to.include?(:cadastres) - champs.filter { _1.cadastres.present? }.each do - _1.cadastres.each(&:destroy) - _1.update_column(:rebased_at, Time.zone.now) - end - end - else - champs.update_all(rebased_at: Time.zone.now) - end + # add champ (after changing dossier revision to avoid errors) + changes_by_op[:add] + .map { target_coordinates_by_stable_id[_1.stable_id] } + .each { add_new_champs_for_revision(_1) } end def add_new_champs_for_revision(target_coordinate) if target_coordinate.child? - # If this type de champ is a child, we create a new champ for each row of the parent - parent_stable_id = target_coordinate.parent.stable_id + row_ids = repetition_row_ids(target_coordinate.parent.type_de_champ) - champs.filter { _1.stable_id == parent_stable_id }.each do |champ_repetition| - if champ_repetition.champs.present? - champ_repetition.champs.map(&:row_id).uniq.each do |row_id| - champs << create_champ(target_coordinate, champ_repetition, row_id:) - end - elsif champ_repetition.mandatory? - champs << create_champ(target_coordinate, champ_repetition, row_id: ULID.generate) + if row_ids.present? + row_ids.each do |row_id| + create_champ(target_coordinate, row_id:) end + elsif target_coordinate.parent.mandatory? + create_champ(target_coordinate, row_id: ULID.generate) end else - create_champ(target_coordinate, self) + create_champ(target_coordinate) end end - def create_champ(target_coordinate, parent, row_id: nil) - target_coordinate + def create_champ(target_coordinate, row_id: nil) + self.champs << target_coordinate .type_de_champ .build_champ(rebased_at: Time.zone.now, row_id:) - .tap { parent.champs << _1 } end def purge_piece_justificative_file(champ) diff --git a/app/models/concerns/dossier_searchable_concern.rb b/app/models/concerns/dossier_searchable_concern.rb index 1bb9ef424..13f65a602 100644 --- a/app/models/concerns/dossier_searchable_concern.rb +++ b/app/models/concerns/dossier_searchable_concern.rb @@ -1,23 +1,40 @@ +# frozen_string_literal: true + module DossierSearchableConcern extend ActiveSupport::Concern included do - before_save :update_search_terms + after_commit :index_search_terms_later, if: -> { previously_new_record? || user_previously_changed? || mandataire_first_name_previously_changed? || mandataire_last_name_previously_changed? } - def update_search_terms - self.search_terms = [ + SEARCH_TERMS_DEBOUNCE = 5.minutes + + kredis_flag :debounce_index_search_terms_flag + + def index_search_terms + DossierPreloader.load_one(self) + + search_terms = [ user&.email, - *champs_public.flat_map(&:search_terms), + *project_champs_public.flat_map(&:search_terms), *etablissement&.search_terms, individual&.nom, - individual&.prenom + individual&.prenom, + mandataire_first_name, + mandataire_last_name ].compact_blank.join(' ') - self.private_search_terms = champs_private.flat_map(&:search_terms).compact_blank.join(' ') + private_search_terms = project_champs_private.flat_map(&:search_terms).compact_blank.join(' ') + + sql = "UPDATE dossiers SET search_terms = :search_terms, private_search_terms = :private_search_terms WHERE id = :id" + sanitized_sql = self.class.sanitize_sql_array([sql, search_terms:, private_search_terms:, id:]) + self.class.connection.execute(sanitized_sql) end - def update_search_terms_later - DossierUpdateSearchTermsJob.perform_later(self) + def index_search_terms_later + return if debounce_index_search_terms_flag.marked? + + debounce_index_search_terms_flag.mark(expires_in: SEARCH_TERMS_DEBOUNCE) + DossierIndexSearchTermsJob.set(wait: SEARCH_TERMS_DEBOUNCE).perform_later(self) end end end diff --git a/app/models/concerns/dossier_sections_concern.rb b/app/models/concerns/dossier_sections_concern.rb index d49a1e2d7..c4644dba3 100644 --- a/app/models/concerns/dossier_sections_concern.rb +++ b/app/models/concerns/dossier_sections_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DossierSectionsConcern extend ActiveSupport::Concern @@ -17,7 +19,7 @@ module DossierSectionsConcern end def auto_numbering_section_headers_for?(type_de_champ) - return false if revision.child?(type_de_champ) + return false if type_de_champ.child?(revision) sections_for(type_de_champ)&.none? { _1.libelle =~ /^\d/ } end diff --git a/app/models/concerns/dossier_state_concern.rb b/app/models/concerns/dossier_state_concern.rb index 1c99ffb65..7b60de6ad 100644 --- a/app/models/concerns/dossier_state_concern.rb +++ b/app/models/concerns/dossier_state_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module DossierStateConcern extend ActiveSupport::Concern @@ -13,11 +15,14 @@ module DossierStateConcern MailTemplatePresenterService.create_commentaire_for_state(self, Dossier.states.fetch(:en_construction)) procedure.compute_dossiers_count + + index_search_terms_later end def after_commit_passer_en_construction NotificationMailer.send_en_construction_notification(self).deliver_later NotificationMailer.send_notification_for_tiers(self).deliver_later if self.for_tiers? + remove_piece_justificative_file_not_visible! end def after_passer_en_instruction(h) @@ -60,7 +65,6 @@ module DossierStateConcern MailTemplatePresenterService.create_commentaire_for_state(self, Dossier.states.fetch(:en_instruction)) if procedure.sva_svr_enabled? - # TODO: handle serialization errors when SIRET demandeur was not completed log_automatic_dossier_operation(:passer_en_instruction, self) else log_automatic_dossier_operation(:passer_en_instruction) diff --git a/app/models/concerns/email_sanitizable_concern.rb b/app/models/concerns/email_sanitizable_concern.rb index d143c7c7d..605d10519 100644 --- a/app/models/concerns/email_sanitizable_concern.rb +++ b/app/models/concerns/email_sanitizable_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module EmailSanitizableConcern extend ActiveSupport::Concern @@ -8,6 +10,26 @@ module EmailSanitizableConcern end end + def generate_emails_suggestions_message(suggestions) + return if suggestions.empty? + + typo_list = suggestions.map(&:first).join(', ') + verification_link = view_context.link_to("vérifier l’orthographe", "#maybe_typos_errors") + + "Attention, nous pensons avoir identifié une faute de frappe dans les invitations : #{typo_list}. Veuillez #{verification_link} des invitations." + end + + def check_if_typo(emails) + emails = emails.map { EmailSanitizer.sanitize(_1) } + @maybe_typos, no_suggestions = emails + .map { |email| [email, EmailChecker.check(email:)[:suggestions]&.first] } + .partition { _1[1].present? } + + emails = no_suggestions.map(&:first) + emails << EmailSanitizer.sanitize(params['final_email']) if params['final_email'].present? + emails + end + class EmailSanitizer def self.sanitize(value) value.gsub(/[[:space:]]/, ' ').strip.downcase diff --git a/app/models/concerns/encryptable_concern.rb b/app/models/concerns/encryptable_concern.rb index ebb9ff09c..8fd653ae9 100644 --- a/app/models/concerns/encryptable_concern.rb +++ b/app/models/concerns/encryptable_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module EncryptableConcern extend ActiveSupport::Concern diff --git a/app/models/concerns/initiation_procedure_concern.rb b/app/models/concerns/initiation_procedure_concern.rb index 089379b39..7f67c2481 100644 --- a/app/models/concerns/initiation_procedure_concern.rb +++ b/app/models/concerns/initiation_procedure_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module InitiationProcedureConcern extend ActiveSupport::Concern @@ -28,7 +30,7 @@ module InitiationProcedureConcern telephone: '1234', horaires: 'de 9 h à 18 h', adresse: 'adresse', - siret: '35600082800018', + siret: Service::SIRET_TEST, etablissement_infos: { adresse: "75 rue du Louvre\n75002\nPARIS\nFRANCE" }, etablissement_lat: 48.87, etablissement_lng: 2.34, diff --git a/app/models/concerns/mail_template_concern.rb b/app/models/concerns/mail_template_concern.rb index 4310a5ce4..0325d7239 100644 --- a/app/models/concerns/mail_template_concern.rb +++ b/app/models/concerns/mail_template_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module MailTemplateConcern extend ActiveSupport::Concern @@ -48,6 +50,6 @@ module MailTemplateConcern end def dossier_tags - TagsSubstitutionConcern::DOSSIER_TAGS + TagsSubstitutionConcern::DOSSIER_TAGS_FOR_MAIL + super + TagsSubstitutionConcern::DOSSIER_TAGS_FOR_MAIL end end diff --git a/app/models/concerns/password_complexity_concern.rb b/app/models/concerns/password_complexity_concern.rb index bbcef17fc..47f053d45 100644 --- a/app/models/concerns/password_complexity_concern.rb +++ b/app/models/concerns/password_complexity_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module PasswordComplexityConcern extend ActiveSupport::Concern diff --git a/app/models/concerns/pieces_jointes_list_concern.rb b/app/models/concerns/pieces_jointes_list_concern.rb new file mode 100644 index 000000000..5fa84792d --- /dev/null +++ b/app/models/concerns/pieces_jointes_list_concern.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module PiecesJointesListConcern + extend ActiveSupport::Concern + + included do + def public_wrapped_partionned_pjs + pieces_jointes(public_only: true, wrap_with_parent: true) + .partition { |(pj, _)| pj.condition.nil? } + end + + def exportables_pieces_jointes + pieces_jointes(exclude_titre_identite: true) + end + + def exportables_pieces_jointes_for_all_versions + pieces_jointes( + exclude_titre_identite: true, + revision: revisions + ).sort_by { - _1.id }.uniq(&:stable_id) + end + + def outdated_exportables_pieces_jointes + exportables_pieces_jointes_for_all_versions - exportables_pieces_jointes + end + + private + + def pieces_jointes( + exclude_titre_identite: false, + public_only: false, + wrap_with_parent: false, + revision: active_revision + ) + coordinates = ProcedureRevisionTypeDeChamp.where(revision:) + .includes(:type_de_champ, revision_types_de_champ: :type_de_champ) + + coordinates = coordinates.public_only if public_only + + type_champ = ['piece_justificative'] + type_champ << 'titre_identite' if !exclude_titre_identite + + coordinates = coordinates.where(types_de_champ: { type_champ: }) + + return coordinates.map(&:type_de_champ) if !wrap_with_parent + + # we want pj in the form of [[pj1], [pj2, repetition], [pj3, repetition]] + coordinates + .map { |c| c.child? ? [c, c.parent] : [c] } + .map { |a| a.map(&:type_de_champ) } + end + end +end diff --git a/app/models/concerns/prefillable_from_service_public_concern.rb b/app/models/concerns/prefillable_from_service_public_concern.rb new file mode 100644 index 000000000..bcf4f8475 --- /dev/null +++ b/app/models/concerns/prefillable_from_service_public_concern.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module PrefillableFromServicePublicConcern + extend ActiveSupport::Concern + + included do + def prefill_from_siret + future_sp = Concurrent::Future.execute { AnnuaireServicePublicService.new.call(siret:) } + future_api_ent = Concurrent::Future.execute { APIRechercheEntreprisesService.new.call(siret:) } + + result_sp = future_sp.value! + result_api_ent = future_api_ent.value! + + prefill_from_service_public(result_sp) + prefill_from_api_entreprise(result_api_ent) + + [result_sp, result_api_ent] + end + + private + + def prefill_from_service_public(result) + case result + in Dry::Monads::Success(data) + self.nom = data[:nom] if nom.blank? + self.email = data[:adresse_courriel] if email.blank? + self.telephone = data[:telephone]&.first&.dig("valeur") if telephone.blank? + self.horaires = denormalize_plage_ouverture(data[:plage_ouverture]) if horaires.blank? + self.adresse = APIGeoService.inline_service_public_address(data[:adresse]&.first) if adresse.blank? + else + # NOOP + end + end + + def prefill_from_api_entreprise(result) + case result + in Dry::Monads::Success(data) + self.type_organisme = detect_type_organisme(data) if type_organisme.blank? + self.nom = data[:nom_complet] if nom.blank? + self.adresse = data.dig(:siege, :geo_adresse) if adresse.blank? + else + # NOOP + end + end + + def denormalize_plage_ouverture(data) + return if data.blank? + + data.map do |range| + day_range = range.values_at('nom_jour_debut', 'nom_jour_fin').uniq.join(' au ') + + hours_range = (1..2).each_with_object([]) do |i, hours| + start_hour = range["valeur_heure_debut_#{i}"] + end_hour = range["valeur_heure_fin_#{i}"] + + if start_hour.present? && end_hour.present? + hours << "de #{format_time(start_hour)} à #{format_time(end_hour)}" + end + end + + result = day_range + result += " : #{hours_range.join(' et ')}" if hours_range.present? + result += " (#{range['commentaire']})" if range['commentaire'].present? + result + end.join("\n") + end + + def detect_type_organisme(data) + # Cf https://recherche-entreprises.api.gouv.fr/docs/#tag/Recherche-textuelle/paths/~1search/get + type = if data.dig(:complements, :collectivite_territoriale).present? + :collectivite_territoriale + elsif data.dig(:complements, :est_association) + :association + elsif data[:section_activite_principale] == "P" + :etablissement_enseignement + elsif data[:nom_complet].match?(/MINISTERE|MINISTERIEL/) + :administration_centrale + else # we can't differentiate between operateur d'état, administration centrale and service déconcentré de l'état, set the most frequent + :service_deconcentre_de_l_etat + end + + Service.type_organismes[type] + end + + def format_time(str_time) + Time.zone + .parse(str_time) + .strftime("%-H:%M") + end + end +end diff --git a/app/models/concerns/procedure_chorus_concern.rb b/app/models/concerns/procedure_chorus_concern.rb index d940dfafd..1d638c43a 100644 --- a/app/models/concerns/procedure_chorus_concern.rb +++ b/app/models/concerns/procedure_chorus_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ProcedureChorusConcern extend ActiveSupport::Concern diff --git a/app/models/concerns/procedure_groupe_instructeur_api_hack_concern.rb b/app/models/concerns/procedure_groupe_instructeur_api_hack_concern.rb index cb89bbd6e..86af559bb 100644 --- a/app/models/concerns/procedure_groupe_instructeur_api_hack_concern.rb +++ b/app/models/concerns/procedure_groupe_instructeur_api_hack_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ProcedureGroupeInstructeurAPIHackConcern extend ActiveSupport::Concern diff --git a/app/models/concerns/procedure_publish_concern.rb b/app/models/concerns/procedure_publish_concern.rb new file mode 100644 index 000000000..85e0fe48c --- /dev/null +++ b/app/models/concerns/procedure_publish_concern.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +module ProcedurePublishConcern + extend ActiveSupport::Concern + + def publish_or_reopen!(administrateur) + Procedure.transaction do + if brouillon? + reset! + end + + other_procedure = other_procedure_with_path(path) + if other_procedure.present? && administrateur.owns?(other_procedure) + other_procedure.unpublish! + publish!(other_procedure.canonical_procedure || other_procedure) + else + publish! + end + end + end + + def publish_revision! + reset! + + transaction { publish_new_revision } + + dossiers + .state_not_termine + .find_each(&:rebase_later) + end + + def reset_draft_revision! + if published_revision.present? && draft_changed? + reset! + transaction do + draft_revision.types_de_champ.filter(&:only_present_on_draft?).each(&:destroy) + draft_revision.update(dossier_submitted_message: nil) + draft_revision.destroy + update!(draft_revision: create_new_revision(published_revision)) + end + end + end + + def reset! + if !locked? || draft_changed? + dossier_ids_to_destroy = draft_revision.dossiers.ids + if dossier_ids_to_destroy.present? + Rails.logger.info("Resetting #{dossier_ids_to_destroy.size} dossiers on procedure #{id}: #{dossier_ids_to_destroy}") + draft_revision.dossiers.destroy_all + end + end + end + + def before_publish + assign_attributes(closed_at: nil, unpublished_at: nil) + end + + def after_publish(canonical_procedure = nil) + self.canonical_procedure = canonical_procedure + touch(:published_at) + publish_new_revision + end + + def after_republish(canonical_procedure = nil) + touch(:published_at) + end + + def after_close + touch(:closed_at) + end + + def after_unpublish + touch(:unpublished_at) + end + + def create_new_revision(revision = nil) + transaction do + new_revision = (revision || draft_revision) + .deep_clone(include: [:revision_types_de_champ]) + .tap { |revision| revision.published_at = nil } + .tap(&:save!) + + move_new_children_to_new_parent_coordinate(new_revision) + + # they are not aware of the new tdcs + new_revision.types_de_champ_public.reset + new_revision.types_de_champ_private.reset + + new_revision + end + end + + private + + def publish_new_revision + cleanup_types_de_champ_options! + cleanup_types_de_champ_children! + self.published_revision = draft_revision + self.draft_revision = create_new_revision + save!(context: :publication) + published_revision.touch(:published_at) + end + + def move_new_children_to_new_parent_coordinate(new_draft) + children = new_draft.revision_types_de_champ + .includes(parent: :type_de_champ) + .where.not(parent_id: nil) + coordinates_by_stable_id = new_draft.revision_types_de_champ + .includes(:type_de_champ) + .index_by(&:stable_id) + + children.each do |child| + child.update!(parent: coordinates_by_stable_id.fetch(child.parent.stable_id)) + end + new_draft.reload + end + + def cleanup_types_de_champ_options! + draft_revision.types_de_champ.each do |type_de_champ| + type_de_champ.update!(options: type_de_champ.clean_options) + end + end + + def cleanup_types_de_champ_children! + draft_revision.types_de_champ.reject(&:repetition?).each do |type_de_champ| + draft_revision.remove_children_of(type_de_champ) + end + end +end diff --git a/app/models/concerns/procedure_stats_concern.rb b/app/models/concerns/procedure_stats_concern.rb index 6fdacd7d6..a842213d4 100644 --- a/app/models/concerns/procedure_stats_concern.rb +++ b/app/models/concerns/procedure_stats_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ProcedureStatsConcern extend ActiveSupport::Concern diff --git a/app/models/concerns/procedure_sva_svr_concern.rb b/app/models/concerns/procedure_sva_svr_concern.rb index b0a92cfd4..09c3316f9 100644 --- a/app/models/concerns/procedure_sva_svr_concern.rb +++ b/app/models/concerns/procedure_sva_svr_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ProcedureSVASVRConcern extend ActiveSupport::Concern diff --git a/app/models/concerns/rna_champ_association_fetchable_concern.rb b/app/models/concerns/rna_champ_association_fetchable_concern.rb index 1a17691dc..8a5f322e8 100644 --- a/app/models/concerns/rna_champ_association_fetchable_concern.rb +++ b/app/models/concerns/rna_champ_association_fetchable_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module RNAChampAssociationFetchableConcern extend ActiveSupport::Concern @@ -10,10 +12,14 @@ module RNAChampAssociationFetchableConcern return clear_association!(:invalid) unless valid_champ_value? return clear_association!(:not_found) if (data = APIEntreprise::RNAAdapter.new(rna, procedure_id).to_params).blank? - update!(data: data) - rescue APIEntreprise::API::Error => error - error_key = :network_error if error.try(:network_error?) && !APIEntrepriseService.api_djepva_up? - clear_association!(error_key) + update_with_external_data!(data:) + rescue APIEntreprise::API::Error, APIEntrepriseToken::TokenError => error + if APIEntrepriseService.service_unavailable_error?(error, target: :djepva) + clear_association!(:network_error) + else + Sentry.capture_exception(error, extra: { dossier_id:, rna: }) + clear_association!(nil) + end end private diff --git a/app/models/concerns/siret_champ_etablissement_fetchable_concern.rb b/app/models/concerns/siret_champ_etablissement_fetchable_concern.rb index 333d58ad8..e6dbba3e3 100644 --- a/app/models/concerns/siret_champ_etablissement_fetchable_concern.rb +++ b/app/models/concerns/siret_champ_etablissement_fetchable_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module SiretChampEtablissementFetchableConcern extend ActiveSupport::Concern @@ -9,17 +11,16 @@ module SiretChampEtablissementFetchableConcern return clear_etablissement!(:invalid_checksum) if invalid_because?(siret, :checksum) # i18n-tasks-use t('errors.messages.invalid_siret_checksum') return clear_etablissement!(:not_found) unless (etablissement = APIEntrepriseService.create_etablissement(self, siret, user&.id)) # i18n-tasks-use t('errors.messages.siret_not_found') - update!(etablissement: etablissement) - rescue => error - if error.try(:network_error?) && !APIEntrepriseService.api_insee_up? - # TODO: notify ops + update!(etablissement:) + rescue APIEntreprise::API::Error, APIEntrepriseToken::TokenError => error + if APIEntrepriseService.service_unavailable_error?(error, target: :insee) update!( etablissement: APIEntrepriseService.create_etablissement_as_degraded_mode(self, siret, user.id) ) @etablissement_fetch_error_key = :api_entreprise_down false else - Sentry.capture_exception(error, extra: { dossier_id: dossier_id, siret: siret }) + Sentry.capture_exception(error, extra: { dossier_id:, siret: }) clear_etablissement!(:network_error) # i18n-tasks-use t('errors.messages.siret_network_error') end end diff --git a/app/models/concerns/tags_substitution_concern.rb b/app/models/concerns/tags_substitution_concern.rb index 8bdc51705..d24f945e8 100644 --- a/app/models/concerns/tags_substitution_concern.rb +++ b/app/models/concerns/tags_substitution_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module TagsSubstitutionConcern extend ActiveSupport::Concern @@ -10,7 +12,7 @@ module TagsSubstitutionConcern extend self def parse(io) - doc.parse io + doc.parse(+io) # parsby mutates the StringIO during parsing! end def self.normalize(str) @@ -61,6 +63,15 @@ module TagsSubstitutionConcern end end + DOSSIER_ID_TAG = { + id: 'dossier_number', + label: 'numéro du dossier', + libelle: 'numéro du dossier', + description: '', + lambda: -> (d) { d.id }, + available_for_states: Dossier::SOUMIS + } + DOSSIER_TAGS = [ { id: 'dossier_motivation', @@ -91,6 +102,13 @@ module TagsSubstitutionConcern lambda: -> (d) { format_date(d.processed_at) }, available_for_states: Dossier::TERMINE }, + { + id: 'dossier_last_champ_updated_at', + libelle: 'date de mise à jour', + description: 'Date de dernière mise à jour d’un champ du dossier', + lambda: -> (d) { format_date(d.last_champ_updated_at) }, + available_for_states: Dossier::SOUMIS + }, { id: 'dossier_procedure_libelle', libelle: 'libellé démarche', @@ -98,13 +116,6 @@ module TagsSubstitutionConcern lambda: -> (d) { d.procedure.libelle }, available_for_states: Dossier::SOUMIS }, - { - id: 'dossier_number', - libelle: 'numéro du dossier', - description: '', - target: :id, - available_for_states: Dossier::SOUMIS - }, { id: 'dossier_service_name', libelle: 'nom du service', @@ -112,7 +123,7 @@ module TagsSubstitutionConcern lambda: -> (d) { d.procedure.organisation_name || '' }, available_for_states: Dossier::SOUMIS } - ] + ].push(DOSSIER_ID_TAG) DOSSIER_TAGS_FOR_MAIL = [ { @@ -147,26 +158,34 @@ module TagsSubstitutionConcern } ] + DOSSIER_SVA_SVR_DECISION_DATE_TAG = { + id: 'dossier_sva_svr_decision_on', + libelle: 'date prévisionnelle SVA/SVR', + description: 'Date prévisionnelle de décision automatique par le SVA/SVR', + lambda: -> (d) { format_date(d.sva_svr_decision_on) }, + available_for_states: Dossier.states.fetch(:en_instruction) + } + INDIVIDUAL_TAGS = [ { id: 'individual_gender', libelle: 'civilité', description: 'M., Mme', - target: :gender, + lambda: -> (d) { d.individual&.gender }, available_for_states: Dossier::SOUMIS }, { id: 'individual_last_name', libelle: 'nom', description: "nom de l'usager", - target: :nom, + lambda: -> (d) { d.individual&.nom }, available_for_states: Dossier::SOUMIS }, { id: 'individual_first_name', libelle: 'prénom', description: "prénom de l'usager", - target: :prenom, + lambda: -> (d) { d.individual&.prenom }, available_for_states: Dossier::SOUMIS } ] @@ -176,35 +195,35 @@ module TagsSubstitutionConcern id: 'entreprise_siren', libelle: 'SIREN', description: '', - target: :siren, + lambda: -> (d) { d.etablissement&.entreprise&.siren }, available_for_states: Dossier::SOUMIS }, { id: 'entreprise_numero_tva_intracommunautaire', libelle: 'numéro de TVA intracommunautaire', description: '', - target: :numero_tva_intracommunautaire, + lambda: -> (d) { d.etablissement&.entreprise&.numero_tva_intracommunautaire }, available_for_states: Dossier::SOUMIS }, { id: 'entreprise_siret_siege_social', libelle: 'SIRET du siège social', description: '', - target: :siret_siege_social, + lambda: -> (d) { d.etablissement&.entreprise&.siret_siege_social }, available_for_states: Dossier::SOUMIS }, { id: 'entreprise_raison_sociale', libelle: 'raison sociale', description: '', - target: :raison_sociale, + lambda: -> (d) { d.etablissement&.entreprise&.raison_sociale }, available_for_states: Dossier::SOUMIS }, { id: 'entreprise_adresse', libelle: 'adresse', description: '', - target: :inline_adresse, + lambda: -> (d) { d.etablissement&.entreprise&.inline_adresse }, available_for_states: Dossier::SOUMIS } ] @@ -255,7 +274,7 @@ module TagsSubstitutionConcern def used_type_de_champ_tags(text_or_tiptap) used_tags = if text_or_tiptap.respond_to?(:deconstruct_keys) # hash pattern matching - TiptapService.new.used_tags_and_libelle_for(text_or_tiptap.deep_symbolize_keys) + TiptapService.used_tags_and_libelle_for(text_or_tiptap.deep_symbolize_keys) else used_tags_and_libelle_for(text_or_tiptap.to_s) end @@ -273,7 +292,7 @@ module TagsSubstitutionConcern used_tags_and_libelle_for(text).map { _1.first.nil? ? _1.second : _1.first } end - def tags_substitutions(tags_and_libelles, dossier, escape: true) + def tags_substitutions(tags_and_libelles, dossier, escape: true, memoize: false) # NOTE: # - tags_and_libelles est un simple Set de couples (tag_id, libelle) (pas la même structure que dans replace_tags) # - dans `replace_tags`, on fait référence à des tags avec ou sans id, mais pas ici, @@ -281,20 +300,20 @@ module TagsSubstitutionConcern @escape_unsafe_tags = escape - flat_tags = tags_and_datas_list(dossier).each_with_object({}) do |(tags, data), result| - next if data.nil? - - valid_tags = tags_for_dossier_state(tags) - - valid_tags.each do |tag| - result[tag[:id]] = [tag, data] - end + flat_tags = if memoize && @flat_tags.present? + @flat_tags + else + available_tags(dossier) + .flatten + .then { tags_for_dossier_state(_1) } + .index_by { _1[:id] } end + @flat_tags = flat_tags if memoize + tags_and_libelles.each_with_object({}) do |(tag_id, libelle), substitutions| - substitutions[tag_id] = case flat_tags[tag_id] - in tag, data - replace_tag(tag, data) + substitutions[tag_id] = if flat_tags[tag_id].present? + replace_tag(flat_tags[tag_id], dossier) else # champ not in dossier, for example during preview on draft revision libelle end @@ -305,7 +324,8 @@ module TagsSubstitutionConcern def format_date(date) if date.present? - date.strftime('%d/%m/%Y') + format = defined?(self.class::FORMAT_DATE) ? self.class::FORMAT_DATE : '%d/%m/%Y' + date.strftime(format) else '' end @@ -323,7 +343,13 @@ module TagsSubstitutionConcern def dossier_tags # Overridden by MailTemplateConcern - DOSSIER_TAGS + DOSSIER_TAGS + contextual_dossier_tags + end + + def contextual_dossier_tags + tags = [] + tags << DOSSIER_SVA_SVR_DECISION_DATE_TAG if respond_to?(:procedure) && procedure.sva_svr_enabled? + tags end def tags_for_dossier_state(tags) @@ -370,8 +396,8 @@ module TagsSubstitutionConcern tokens = parse_tags(text) - tags_and_datas = tags_and_datas_list(dossier).filter_map do |(tags, data)| - data && [tags_for_dossier_state(tags).index_by { _1[:id] }, data] + tags_and_datas = available_tags(dossier).filter_map do |tags| + dossier && [tags_for_dossier_state(tags).index_by { _1[:id] }, dossier] end tags_and_datas.reduce(tokens) do |tokens, (tags, data)| @@ -397,12 +423,8 @@ module TagsSubstitutionConcern end.join('') end - def replace_tag(tag, data) - value = if tag.key?(:target) - data.public_send(tag[:target]) - else - instance_exec(data, &tag[:lambda]) - end + def replace_tag(tag, dossier) + value = instance_exec(dossier, &tag[:lambda]) if escape_unsafe_tags? && tag.fetch(:escapable, true) escape_once(value) @@ -449,14 +471,14 @@ module TagsSubstitutionConcern end end - def tags_and_datas_list(dossier) + def available_tags(dossier) [ - [champ_public_tags(dossier:), dossier], - [champ_private_tags(dossier:), dossier], - [dossier_tags, dossier], - [ROUTAGE_TAGS, dossier], - [INDIVIDUAL_TAGS, dossier.individual], - [ENTREPRISE_TAGS, dossier.etablissement&.entreprise] + champ_public_tags(dossier:), + champ_private_tags(dossier:), + dossier_tags, + ROUTAGE_TAGS, + INDIVIDUAL_TAGS, + ENTREPRISE_TAGS ] end end diff --git a/app/models/concerns/transient_models_with_purgeable_job_concern.rb b/app/models/concerns/transient_models_with_purgeable_job_concern.rb index 72e5d1c64..04b5bd765 100644 --- a/app/models/concerns/transient_models_with_purgeable_job_concern.rb +++ b/app/models/concerns/transient_models_with_purgeable_job_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Archive and Export models are generated in background # those models being are destroy after an expiration period # but, it might take more time to process than the expiration period diff --git a/app/models/concerns/treeable_concern.rb b/app/models/concerns/treeable_concern.rb index 174a54304..c7df40a64 100644 --- a/app/models/concerns/treeable_concern.rb +++ b/app/models/concerns/treeable_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module TreeableConcern extend ActiveSupport::Concern diff --git a/app/models/concerns/trusted_device_concern.rb b/app/models/concerns/trusted_device_concern.rb index 2aa895893..5edbc776d 100644 --- a/app/models/concerns/trusted_device_concern.rb +++ b/app/models/concerns/trusted_device_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module TrustedDeviceConcern extend ActiveSupport::Concern @@ -8,7 +10,8 @@ module TrustedDeviceConcern cookies.encrypted[TRUSTED_DEVICE_COOKIE_NAME] = { value: JSON.generate({ created_at: start_at }), expires: start_at + TRUSTED_DEVICE_PERIOD, - httponly: true + httponly: true, + secure: Rails.env.production? } end diff --git a/app/models/concerns/user_find_by_concern.rb b/app/models/concerns/user_find_by_concern.rb index 7f3290695..6efb7f14d 100644 --- a/app/models/concerns/user_find_by_concern.rb +++ b/app/models/concerns/user_find_by_concern.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module UserFindByConcern extend ActiveSupport::Concern diff --git a/app/models/condition_form.rb b/app/models/condition_form.rb index 14e5b4dc9..7b6aca161 100644 --- a/app/models/condition_form.rb +++ b/app/models/condition_form.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ConditionForm include ActiveModel::Model include Logic diff --git a/app/models/contact_form.rb b/app/models/contact_form.rb new file mode 100644 index 000000000..82b79feec --- /dev/null +++ b/app/models/contact_form.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +class ContactForm < ApplicationRecord + attr_reader :options + + belongs_to :user, optional: true + + after_initialize :set_options + before_validation :normalize_strings + before_validation :sanitize_email + before_save :add_default_tags + + validates :email, presence: true, strict_email: true, if: :require_email? + validates :subject, presence: true + validates :text, presence: true + validates :question_type, presence: true + + has_one_attached :piece_jointe + + TYPE_INFO = 'procedure_info' + TYPE_PERDU = 'lost_user' + TYPE_INSTRUCTION = 'instruction_info' + TYPE_AMELIORATION = 'product' + TYPE_AUTRE = 'other' + + ADMIN_TYPE_RDV = 'admin_demande_rdv' + ADMIN_TYPE_QUESTION = 'admin_question' + ADMIN_TYPE_SOUCIS = 'admin_soucis' + ADMIN_TYPE_PRODUIT = 'admin_suggestion_produit' + ADMIN_TYPE_DEMANDE_COMPTE = 'admin_demande_compte' + ADMIN_TYPE_AUTRE = 'admin_autre' + + def self.default_options + [ + [I18n.t(:question, scope: [:contact, :index, TYPE_INFO]), TYPE_INFO, I18n.t("links.common.faq.contacter_service_en_charge_url")], + [I18n.t(:question, scope: [:contact, :index, TYPE_PERDU]), TYPE_PERDU, LISTE_DES_DEMARCHES_URL], + [I18n.t(:question, scope: [:contact, :index, TYPE_INSTRUCTION]), TYPE_INSTRUCTION, I18n.t("links.common.faq.ou_en_est_mon_dossier_url")], + [I18n.t(:question, scope: [:contact, :index, TYPE_AMELIORATION]), TYPE_AMELIORATION, FEATURE_UPVOTE_URL], + [I18n.t(:question, scope: [:contact, :index, TYPE_AUTRE]), TYPE_AUTRE] + ] + end + + def self.admin_options + [ + [I18n.t(:question, scope: [:contact, :admin, ADMIN_TYPE_QUESTION], app_name: Current.application_name), ADMIN_TYPE_QUESTION], + [I18n.t(:question, scope: [:contact, :admin, ADMIN_TYPE_RDV], app_name: Current.application_name), ADMIN_TYPE_RDV], + [I18n.t(:question, scope: [:contact, :admin, ADMIN_TYPE_SOUCIS], app_name: Current.application_name), ADMIN_TYPE_SOUCIS], + [I18n.t(:question, scope: [:contact, :admin, ADMIN_TYPE_PRODUIT]), ADMIN_TYPE_PRODUIT], + [I18n.t(:question, scope: [:contact, :admin, ADMIN_TYPE_DEMANDE_COMPTE]), ADMIN_TYPE_DEMANDE_COMPTE], + [I18n.t(:question, scope: [:contact, :admin, ADMIN_TYPE_AUTRE]), ADMIN_TYPE_AUTRE] + ] + end + + def for_admin=(value) + super(value) + set_options + end + + def create_conversation_later + HelpscoutCreateConversationJob.perform_later(self) + end + + def require_email? = user.blank? + + private + + def normalize_strings + self.subject = subject&.strip + self.text = text&.strip + end + + def sanitize_email + self.email = EmailSanitizableConcern::EmailSanitizer.sanitize(email) if email.present? + end + + def add_default_tags + self.tags = tags.push('contact form', question_type).uniq + end + + def set_options + @options = for_admin? ? self.class.admin_options : self.class.default_options + end +end diff --git a/app/models/contact_information.rb b/app/models/contact_information.rb index 803e06c87..047de49e8 100644 --- a/app/models/contact_information.rb +++ b/app/models/contact_information.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ContactInformation < ApplicationRecord include EmailSanitizableConcern diff --git a/app/models/current.rb b/app/models/current.rb index 4b18adc10..77045cfff 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Current < ActiveSupport::CurrentAttributes attribute :application_base_url attribute :application_name @@ -7,4 +9,5 @@ class Current < ActiveSupport::CurrentAttributes attribute :no_reply_email attribute :request_id attribute :user + attribute :procedure_columns end diff --git a/app/models/current_confirmation.rb b/app/models/current_confirmation.rb index ce9853ee2..0530845fe 100644 --- a/app/models/current_confirmation.rb +++ b/app/models/current_confirmation.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CurrentConfirmation < ActiveSupport::CurrentAttributes attribute :procedure_after_confirmation attribute :prefill_token diff --git a/app/models/deleted_dossier.rb b/app/models/deleted_dossier.rb index b8caead0e..e9bd22c68 100644 --- a/app/models/deleted_dossier.rb +++ b/app/models/deleted_dossier.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class DeletedDossier < ApplicationRecord belongs_to :procedure, -> { with_discarded }, inverse_of: :deleted_dossiers, optional: false belongs_to :groupe_instructeur, inverse_of: :deleted_dossiers, optional: true diff --git a/app/models/demande.rb b/app/models/demande.rb index 132282727..8d45ce19f 100644 --- a/app/models/demande.rb +++ b/app/models/demande.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Demande def self.model_name self diff --git a/app/models/dossier.rb b/app/models/dossier.rb index d2aafd943..3d4b9ab9b 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class Dossier < ApplicationRecord - self.ignored_columns += [:re_instructed_at] + self.ignored_columns += [:search_terms, :private_search_terms] include DossierCloneConcern include DossierCorrectableConcern @@ -9,6 +11,9 @@ class Dossier < ApplicationRecord include DossierSearchableConcern include DossierSectionsConcern include DossierStateConcern + include DossierChampsConcern + include DossierEmptyConcern + include DossierExportConcern enum state: { brouillon: 'brouillon', @@ -42,25 +47,16 @@ class Dossier < ApplicationRecord has_one_attached :justificatif_motivation - has_many :champs - # We have to remove champs in a particular order - champs with a reference to a parent have to be - # removed first, otherwise we get a foreign key constraint error. - has_many :champs_to_destroy, -> { order(:parent_id) }, class_name: 'Champ', inverse_of: false, dependent: :destroy - has_many :champs_public, -> { root.public_only }, class_name: 'Champ', inverse_of: false - has_many :champs_private, -> { root.private_only }, class_name: 'Champ', inverse_of: false - has_many :champs_public_all, -> { public_only }, class_name: 'Champ', inverse_of: false - has_many :champs_private_all, -> { private_only }, class_name: 'Champ', inverse_of: false - has_many :prefilled_champs_public, -> { root.public_only.prefilled }, class_name: 'Champ', inverse_of: false - + has_many :champs, dependent: :destroy has_many :commentaires, inverse_of: :dossier, dependent: :destroy - has_many :preloaded_commentaires, -> { includes(:dossier_correction, piece_jointe_attachment: :blob) }, class_name: 'Commentaire', inverse_of: :dossier + has_many :preloaded_commentaires, -> { includes(:dossier_correction, piece_jointe_attachments: :blob) }, class_name: 'Commentaire', inverse_of: :dossier has_many :invites, dependent: :destroy has_many :follows, -> { active }, inverse_of: :dossier has_many :previous_follows, -> { inactive }, class_name: 'Follow', inverse_of: :dossier has_many :followers_instructeurs, through: :follows, source: :instructeur has_many :previous_followers_instructeurs, -> { distinct }, through: :previous_follows, source: :instructeur - has_many :avis, inverse_of: :dossier, dependent: :destroy + has_many :avis, -> { order(:created_at) }, inverse_of: :dossier, dependent: :destroy has_many :experts, through: :avis has_many :traitements, -> { order(:processed_at) }, inverse_of: :dossier, dependent: :destroy do def passer_en_construction(instructeur: nil, processed_at: Time.zone.now) @@ -138,14 +134,12 @@ class Dossier < ApplicationRecord belongs_to :transfer, class_name: 'DossierTransfer', foreign_key: 'dossier_transfer_id', optional: true, inverse_of: :dossiers has_many :transfer_logs, class_name: 'DossierTransferLog', dependent: :destroy + has_many :dossier_labels, dependent: :destroy + has_many :labels, through: :dossier_labels after_destroy_commit :log_destroy accepts_nested_attributes_for :champs - accepts_nested_attributes_for :champs_public - accepts_nested_attributes_for :champs_private - accepts_nested_attributes_for :champs_public_all - accepts_nested_attributes_for :champs_private_all accepts_nested_attributes_for :individual include AASM @@ -159,7 +153,7 @@ class Dossier < ApplicationRecord state :sans_suite event :passer_en_construction, after: :after_passer_en_construction, after_commit: :after_commit_passer_en_construction do - transitions from: :brouillon, to: :en_construction + transitions from: :brouillon, to: :en_construction, guard: :can_passer_en_construction? end event :passer_en_instruction, after: :after_passer_en_instruction, after_commit: :after_commit_passer_en_instruction do @@ -224,10 +218,12 @@ class Dossier < ApplicationRecord scope :prefilled, -> { where(prefilled: true) } scope :hidden_by_user, -> { where.not(hidden_by_user_at: nil) } scope :hidden_by_administration, -> { where.not(hidden_by_administration_at: nil) } - scope :visible_by_user, -> { where(for_procedure_preview: false).where(hidden_by_user_at: nil, editing_fork_origin_id: nil) } + scope :hidden_by_expired, -> { where.not(hidden_by_expired_at: nil) } + scope :visible_by_user, -> { where(for_procedure_preview: false, hidden_by_user_at: nil, editing_fork_origin_id: nil, hidden_by_expired_at: nil) } scope :visible_by_administration, -> { state_not_brouillon .where(hidden_by_administration_at: nil) + .where(hidden_by_expired_at: nil) .merge(visible_by_user.or(state_not_en_construction)) } scope :visible_by_user_or_administration, -> { visible_by_user.or(visible_by_administration) } @@ -245,10 +241,7 @@ class Dossier < ApplicationRecord scope :hidden_by_administration_since, -> (since) { where('dossiers.hidden_by_administration_at IS NOT NULL AND dossiers.hidden_by_administration_at >= ?', since) } scope :hidden_since, -> (since) { hidden_by_user_since(since).or(hidden_by_administration_since(since)) } - scope :with_type_de_champ, -> (stable_id) { - joins('INNER JOIN champs ON champs.dossier_id = dossiers.id INNER JOIN types_de_champ ON types_de_champ.id = champs.type_de_champ_id') - .where(types_de_champ: { stable_id: }) - } + scope :with_type_de_champ, -> (stable_id) { joins(:champs).where(champs: { stream: 'main', stable_id: }) } scope :all_state, -> { not_archived.state_not_brouillon } scope :en_construction, -> { not_archived.state_en_construction } @@ -272,35 +265,16 @@ class Dossier < ApplicationRecord scope :en_cours, -> { not_archived.state_en_construction_ou_instruction } scope :without_followers, -> { where.missing(:follows) } scope :with_followers, -> { left_outer_joins(:follows).where.not(follows: { id: nil }) } - scope :with_champs, -> { - includes(champs_public: [ - :type_de_champ, - :geo_areas, - piece_justificative_file_attachments: :blob, - champs: [:type_de_champ, piece_justificative_file_attachments: :blob] - ]) - } - scope :brouillons_recently_updated, -> { updated_since(2.days.ago).state_brouillon.order_by_updated_at } - scope :with_annotations, -> { - includes(champs_private: [ - :type_de_champ, - :geo_areas, - piece_justificative_file_attachments: :blob, - champs: [:type_de_champ, piece_justificative_file_attachments: :blob] - ]) - } scope :for_api, -> { - with_champs - .with_annotations - .includes(commentaires: { piece_jointe_attachment: :blob }, - justificatif_motivation_attachment: :blob, - attestation: [], - avis: { piece_justificative_file_attachment: :blob }, - traitement: [], - etablissement: [], - individual: [], - user: []) + includes(commentaires: { piece_jointe_attachments: :blob }, + justificatif_motivation_attachment: :blob, + attestation: [], + avis: { piece_justificative_file_attachment: :blob }, + traitement: [], + etablissement: [], + individual: [], + user: []) } scope :with_notifiable_procedure, -> (opts = { notify_on_closed: false }) do @@ -371,12 +345,12 @@ class Dossier < ApplicationRecord scope :without_brouillon_expiration_notice_sent, -> { where(brouillon_close_to_expiration_notice_sent_at: nil) } scope :without_en_construction_expiration_notice_sent, -> { where(en_construction_close_to_expiration_notice_sent_at: nil) } scope :without_termine_expiration_notice_sent, -> { where(termine_close_to_expiration_notice_sent_at: nil) } - scope :deleted_by_user_expired, -> { where('dossiers.hidden_by_user_at < ?', 1.week.ago) } scope :deleted_by_administration_expired, -> { where('dossiers.hidden_by_administration_at < ?', 1.week.ago) } - scope :en_brouillon_expired_to_delete, -> { state_brouillon.deleted_by_user_expired } - scope :en_construction_expired_to_delete, -> { state_en_construction.deleted_by_user_expired } - scope :termine_expired_to_delete, -> { state_termine.deleted_by_user_expired.deleted_by_administration_expired } + scope :deleted_by_automatic_expired, -> { where('dossiers.hidden_by_expired_at < ?', 1.week.ago) } + scope :en_brouillon_expired_to_delete, -> { state_brouillon.deleted_by_user_expired.or(state_brouillon.deleted_by_automatic_expired) } + scope :en_construction_expired_to_delete, -> { state_en_construction.deleted_by_user_expired.or(state_en_construction.deleted_by_automatic_expired) } + scope :termine_expired_to_delete, -> { state_termine.deleted_by_user_expired.deleted_by_administration_expired.or(state_termine.deleted_by_automatic_expired) } scope :brouillon_near_procedure_closing_date, -> do # select users who have submitted dossier for the given 'procedures.id' @@ -394,7 +368,7 @@ class Dossier < ApplicationRecord .where.not(user: users_who_submitted) end - scope :for_api_v2, -> { includes(:attestation_template, revision: [procedure: [:administrateurs]], etablissement: [], individual: [], traitement: []) } + scope :for_api_v2, -> { includes(:attestation_template, revision: [procedure: [:administrateurs]], etablissement: [], individual: [], traitement: [], procedure: [], user: [:france_connect_informations]) } scope :with_notifications, -> do joins(:follows) @@ -403,7 +377,10 @@ class Dossier < ApplicationRecord ' OR groupe_instructeur_updated_at > follows.demande_seen_at' \ ' OR last_champ_private_updated_at > follows.annotations_privees_seen_at' \ ' OR last_avis_updated_at > follows.avis_seen_at' \ - ' OR last_commentaire_updated_at > follows.messagerie_seen_at') + ' OR last_commentaire_updated_at > follows.messagerie_seen_at' \ + ' OR last_commentaire_piece_jointe_updated_at > follows.pieces_jointes_seen_at' \ + ' OR last_champ_piece_jointe_updated_at > follows.pieces_jointes_seen_at' \ + ' OR last_avis_piece_jointe_updated_at > follows.pieces_jointes_seen_at') .distinct end @@ -422,8 +399,8 @@ class Dossier < ApplicationRecord visible_by_administration.termine when 'tous' visible_by_administration.all_state - when 'supprimes_recemment' - hidden_by_administration.termine + when 'supprimes' + hidden_by_administration.state_termine.or(hidden_by_expired) when 'archives' visible_by_administration.archived when 'expirant' @@ -435,7 +412,6 @@ class Dossier < ApplicationRecord delegate :siret, :siren, to: :etablissement, allow_nil: true delegate :france_connected_with_one_identity?, to: :user, allow_nil: true - before_save :build_default_champs_for_new_dossier, if: Proc.new { revision_id_was.nil? && parent_dossier_id.nil? && editing_fork_origin_id.nil? } after_save :send_web_hook @@ -445,8 +421,6 @@ class Dossier < ApplicationRecord validates :mandataire_last_name, presence: true, if: :for_tiers? validates :for_tiers, inclusion: { in: [true, false] }, if: -> { revision&.procedure&.for_individual? } - validates_associated :prefilled_champs_public, on: :champs_public_value - def types_de_champ_public types_de_champ end @@ -481,6 +455,10 @@ class Dossier < ApplicationRecord end end + def user_email_for_display + user_email_for(:display) + end + def expiration_started? [ brouillon_close_to_expiration_notice_sent_at, @@ -495,29 +473,9 @@ class Dossier < ApplicationRecord end end - def build_default_champs_for_new_dossier - revision.build_champs_public.each do |champ| - champs_public << champ - end - revision.build_champs_private.each do |champ| - champs_private << champ - end - champs_public.filter { _1.repetition? && _1.mandatory? }.each do |champ| - champ.add_row(revision) - end - champs_private.filter(&:repetition?).each do |champ| - champ.add_row(revision) - end - end - - def build_default_individual - if procedure.for_individual? && individual.blank? - self.individual = if france_connected_with_one_identity? - Individual.from_france_connect(user.france_connect_informations.first) - else - Individual.new - end - end + def build_default_values + build_default_individual + build_default_champs end def en_construction_ou_instruction? @@ -561,8 +519,18 @@ class Dossier < ApplicationRecord false end + def blocked_with_pending_correction? + procedure.feature_enabled?(:blocking_pending_correction) && pending_correction? + end + + def can_passer_en_construction? + return true if !revision.ineligibilite_enabled || !revision.ineligibilite_rules + + !revision.ineligibilite_rules.compute(filled_champs_public) + end + def can_passer_en_instruction? - return false if procedure.feature_enabled?(:blocking_pending_correction) && pending_correction? + return false if blocked_with_pending_correction? true end @@ -598,13 +566,17 @@ class Dossier < ApplicationRecord termine? || reason == :procedure_removed end + def can_be_deleted_by_automatic?(reason) + reason == :expired && !en_instruction? + end + def can_terminer_automatiquement_by_sva_svr? sva_svr_decision_triggered_at.nil? && !pending_correction? && (sva_svr_decision_on.today? || sva_svr_decision_on.past?) end def any_etablissement_as_degraded_mode? return true if etablissement&.as_degraded_mode? - return true if champs_for_revision(scope: :public).any? { _1.etablissement&.as_degraded_mode? } + return true if filled_champs_public.any? { _1.etablissement&.as_degraded_mode? } false end @@ -643,7 +615,12 @@ class Dossier < ApplicationRecord def close_to_expiration? return false if en_instruction? - expiration_notification_date < Time.zone.now + expiration_notification_date < Time.zone.now && Expired::REMAINING_WEEKS_BEFORE_EXPIRATION.weeks.ago < expiration_notification_date + end + + def has_expired? + return false if en_instruction? + expiration_notification_date < Expired::REMAINING_WEEKS_BEFORE_EXPIRATION.weeks.ago end def after_notification_expiration_date @@ -675,6 +652,12 @@ class Dossier < ApplicationRecord termine_close_to_expiration_notice_sent_at: nil) end + def extend_conservation_and_restore(conservation_extension, author) + extend_conservation(conservation_extension) + update(hidden_by_expired_at: nil, hidden_by_reason: nil) + restore(author) + end + def show_procedure_state_warning? procedure.discarded? || (brouillon? && !procedure.dossier_can_transition_to_en_construction?) end @@ -764,6 +747,10 @@ class Dossier < ApplicationRecord !procedure.brouillon? && !brouillon? end + def hidden_by_expired? + hidden_by_expired_at.present? + end + def hidden_by_user? hidden_by_user_at.present? end @@ -818,37 +805,32 @@ class Dossier < ApplicationRecord end end - def expired_keep_track_and_destroy! - transaction do - DeletedDossier.create_from_dossier(self, :expired) - log_automatic_dossier_operation(:supprimer, self) - dossier_operation_logs.purge_discarded - destroy! - end - true - rescue - false - end - - def author_is_user(author) + def is_user?(author) author.is_a?(User) end - def author_is_administration(author) + def is_administration?(author) author.is_a?(Instructeur) || author.is_a?(Administrateur) || author.is_a?(SuperAdmin) end + def is_automatic?(author) + author == :automatic + end + def hide_and_keep_track!(author, reason) transaction do - if author_is_administration(author) && can_be_deleted_by_administration?(reason) + if is_administration?(author) && can_be_deleted_by_administration?(reason) update(hidden_by_administration_at: Time.zone.now, hidden_by_reason: reason) - elsif author_is_user(author) && can_be_deleted_by_user? + log_dossier_operation(author, :supprimer, self) + elsif is_user?(author) && can_be_deleted_by_user? update(hidden_by_user_at: Time.zone.now, dossier_transfer_id: nil, hidden_by_reason: reason) + log_dossier_operation(author, :supprimer, self) + elsif is_automatic?(author) && can_be_deleted_by_automatic?(reason) + update(hidden_by_expired_at: Time.zone.now, hidden_by_reason: reason) + log_automatic_dossier_operation(:supprimer, self) else raise "Unauthorized dossier hide attempt Dossier##{id} by #{author} for reason #{reason}" end - - log_dossier_operation(author, :supprimer, self) end if en_construction? && !hidden_by_administration? @@ -861,14 +843,18 @@ class Dossier < ApplicationRecord def restore(author) transaction do - if author_is_administration(author) + if is_administration?(author) update(hidden_by_administration_at: nil) - elsif author_is_user(author) + elsif is_user?(author) update(hidden_by_user_at: nil) end if !hidden_by_user? && !hidden_by_administration? update(hidden_by_reason: nil) + elsif hidden_by_user? + update(hidden_by_reason: :user_request) + elsif hidden_by_administration? + update(hidden_by_reason: :instructeur_request) end log_dossier_operation(author, :restaurer, self) @@ -887,6 +873,7 @@ class Dossier < ApplicationRecord resolve_pending_correction! process_sva_svr! + remove_piece_justificative_file_not_visible! end def process_declarative! @@ -924,132 +911,42 @@ class Dossier < ApplicationRecord end def remove_titres_identite! - champs_public.filter(&:titre_identite?).map(&:piece_justificative_file).each(&:purge_later) + champs.filter(&:titre_identite?).map(&:piece_justificative_file).each(&:purge_later) + end + + def remove_piece_justificative_file_not_visible! + champs.each do |champ| + next unless champ.piece_justificative_file.attached? + next if champ.visible? + + champ.piece_justificative_file.purge_later + end end def check_mandatory_and_visible_champs - champs_for_revision(scope: :public) - .filter { _1.child? ? _1.parent.visible? : true } - .filter(&:visible?) - .filter(&:mandatory_blank?) - .map do |champ| - champ.errors.add(:value, :missing) + project_champs_public.filter(&:visible?).each do |champ| + if champ.mandatory_blank? + error = champ.errors.add(:value, :missing) + errors.import(error) end + if champ.repetition? + champ.rows.each do |champs| + champs.filter(&:visible?).filter(&:mandatory_blank?).each do |champ| + error = champ.errors.add(:value, :missing) + errors.import(error) + end + end + end + end + errors end def demander_un_avis!(avis) log_dossier_operation(avis.claimant, :demander_un_avis, avis) end - def spreadsheet_columns_csv(types_de_champ:) - spreadsheet_columns(with_etablissement: true, types_de_champ: types_de_champ) - end - - def spreadsheet_columns_xlsx(types_de_champ:) - spreadsheet_columns(types_de_champ: types_de_champ) - end - - def spreadsheet_columns_ods(types_de_champ:) - spreadsheet_columns(types_de_champ: types_de_champ) - end - - def spreadsheet_columns(with_etablissement: false, types_de_champ:) - columns = [ - ['ID', id.to_s], - ['Email', user_email_for(:display)], - ['FranceConnect ?', user_from_france_connect?] - ] - - if procedure.for_individual? - columns += [ - ['Civilité', individual&.gender], - ['Nom', individual&.nom], - ['Prénom', individual&.prenom], - ['Dépôt pour un tiers', :for_tiers], - ['Nom du mandataire', :mandataire_last_name], - ['Prénom du mandataire', :mandataire_first_name] - ] - if procedure.ask_birthday - columns += [['Date de naissance', individual&.birthdate]] - end - elsif with_etablissement - columns += [ - ['Établissement SIRET', etablissement&.siret], - ['Établissement siège social', etablissement&.siege_social], - ['Établissement NAF', etablissement&.naf], - ['Établissement libellé NAF', etablissement&.libelle_naf], - ['Établissement Adresse', etablissement&.adresse], - ['Établissement numero voie', etablissement&.numero_voie], - ['Établissement type voie', etablissement&.type_voie], - ['Établissement nom voie', etablissement&.nom_voie], - ['Établissement complément adresse', etablissement&.complement_adresse], - ['Établissement code postal', etablissement&.code_postal], - ['Établissement localité', etablissement&.localite], - ['Établissement code INSEE localité', etablissement&.code_insee_localite], - ['Entreprise SIREN', etablissement&.entreprise_siren], - ['Entreprise capital social', etablissement&.entreprise_capital_social], - ['Entreprise numero TVA intracommunautaire', etablissement&.entreprise_numero_tva_intracommunautaire], - ['Entreprise forme juridique', etablissement&.entreprise_forme_juridique], - ['Entreprise forme juridique code', etablissement&.entreprise_forme_juridique_code], - ['Entreprise nom commercial', etablissement&.entreprise_nom_commercial], - ['Entreprise raison sociale', etablissement&.entreprise_raison_sociale], - ['Entreprise SIRET siège social', etablissement&.entreprise_siret_siege_social], - ['Entreprise code effectif entreprise', etablissement&.entreprise_code_effectif_entreprise], - ['Entreprise date de création', etablissement&.entreprise_date_creation], - ['Entreprise état administratif', etablissement&.entreprise_etat_administratif], - ['Entreprise nom', etablissement&.entreprise_nom], - ['Entreprise prénom', etablissement&.entreprise_prenom], - ['Association RNA', etablissement&.association_rna], - ['Association titre', etablissement&.association_titre], - ['Association objet', etablissement&.association_objet], - ['Association date de création', etablissement&.association_date_creation], - ['Association date de déclaration', etablissement&.association_date_declaration], - ['Association date de publication', etablissement&.association_date_publication] - ] - else - columns << ['Entreprise raison sociale', etablissement&.entreprise_raison_sociale] - end - - if procedure.chorusable? && procedure.chorus_configuration.complete? - columns += [ - ['Domaine Fonctionnel', procedure.chorus_configuration.domaine_fonctionnel&.fetch("code") { '' }], - ['Référentiel De Programmation', procedure.chorus_configuration.referentiel_de_programmation&.fetch("code") { '' }], - ['Centre De Coût', procedure.chorus_configuration.centre_de_cout&.fetch("code") { '' }] - ] - end - columns += [ - ['Archivé', :archived], - ['État du dossier', Dossier.human_attribute_name("state.#{state}")], - ['Dernière mise à jour le', :updated_at], - ['Dernière mise à jour du dossier le', :last_champ_updated_at], - ['Déposé le', :depose_at], - ['Passé en instruction le', :en_instruction_at], - procedure.sva_svr_enabled? ? ["Date décision #{procedure.sva_svr_configuration.human_decision}", :sva_svr_decision_on] : nil, - ['Traité le', :processed_at], - ['Motivation de la décision', :motivation], - ['Instructeurs', followers_instructeurs.map(&:email).join(' ')] - ].compact - - if procedure.routing_enabled? - columns << ['Groupe instructeur', groupe_instructeur.label] - end - columns + champs_for_export(types_de_champ) - end - - # Get all the champs values for the types de champ in the final list. - # Dossier might not have corresponding champ – display nil. - # To do so, we build a virtual champ when there is no value so we can call for_export with all indexes - def champs_for_export(types_de_champ, row_id = nil) - types_de_champ.flat_map do |type_de_champ| - champ = champ_for_export(type_de_champ, row_id) - type_de_champ.libelles_for_export.map do |(libelle, path)| - [libelle, champ&.for_export(path)] - end - end - end - def linked_dossiers_for(instructeur_or_expert) - dossier_ids = champs_for_revision.filter(&:dossier_link?).filter_map(&:value) + dossier_ids = filled_champs.filter(&:dossier_link?).filter_map(&:value) instructeur_or_expert.dossiers.where(id: dossier_ids) end @@ -1058,7 +955,7 @@ class Dossier < ApplicationRecord end def geo_data? - GeoArea.exists?(champ_id: champs_for_revision) + GeoArea.exists?(champ_id: filled_champs) end def to_feature_collection @@ -1070,13 +967,6 @@ class Dossier < ApplicationRecord } end - def self.to_feature_collection - { - type: 'FeatureCollection', - features: GeoArea.joins(:champ).where(champ: { dossier: ids }).map(&:to_feature) - } - end - def log_api_entreprise_job_exception(exception) exceptions = self.api_entreprise_job_exceptions ||= [] exceptions << exception.inspect @@ -1141,43 +1031,8 @@ class Dossier < ApplicationRecord user.france_connected_with_one_identity? end - def champs_for_revision(scope: nil, root: false) - champs_index = champs.group_by(&:stable_id) - # Due to some bad data we can have multiple copies of the same champ. Ignore extra copy. - .transform_values { _1.sort_by(&:id).uniq(&:row_id) } - - if scope.is_a?(TypeDeChamp) - revision - .children_of(scope) - .flat_map { champs_index[_1.stable_id] || [] } - .filter(&:child?) # TODO: remove once bad data (child champ without a row id) is cleaned - else - revision - .types_de_champ_for(scope:, root:) - .flat_map { champs_index[_1.stable_id] || [] } - end - end - def has_annotations? - revision.revision_types_de_champ_private.present? - end - - def project_champ(type_de_champ, row_id) - champ = champs_by_public_id[type_de_champ.public_id(row_id)] - if champ.nil? - type_de_champ.build_champ(dossier: self, row_id:) - else - champ - end - end - - def champ_for_export(type_de_champ, row_id) - champ = champs_by_public_id[type_de_champ.public_id(row_id)] - if champ.blank? || !champ.visible? - nil - else - champ - end + revision.types_de_champ_private.present? end def hide_info_with_accuse_lecture? @@ -1188,10 +1043,45 @@ class Dossier < ApplicationRecord procedure.accuse_lecture? && termine? end + def track_can_passer_en_construction + if !revision.ineligibilite_enabled + yield + [true, true] # without eligibilite rules, we never reach dossier.champs.visible?, don't cache anything + else + from = can_passer_en_construction? # with eligibilite rules, self.champ[x].visible is cached by passing thru conditions checks + yield + champs.map(&:reset_visible) # we must reset self.champs[x].visible?, because an update occurred and we should re-evaluate champs[x] visibility + [from, can_passer_en_construction?] + end + end + private - def champs_by_public_id - @champs_by_public_id ||= champs.sort_by(&:id).index_by(&:public_id) + def build_default_champs + build_default_champs_for(revision.types_de_champ_public) if !champs.any?(&:public?) + build_default_champs_for(revision.types_de_champ_private) if !champs.any?(&:private?) + end + + def build_default_champs_for(types_de_champ) + self.champs << types_de_champ.flat_map do |type_de_champ| + champ = type_de_champ.build_champ(dossier: self) + if type_de_champ.repetition? && (type_de_champ.private? || type_de_champ.mandatory?) + row_id = ULID.generate + [champ] + revision.children_of(type_de_champ).map { _1.build_champ(dossier: self, row_id:) } + else + champ + end + end + end + + def build_default_individual + if procedure.for_individual? && individual.blank? + self.individual = if france_connected_with_one_identity? + Individual.from_france_connect(user.france_connect_informations.first) + else + Individual.new + end + end end def create_missing_traitemets @@ -1213,7 +1103,7 @@ class Dossier < ApplicationRecord end def geo_areas - champs_for_revision.flat_map(&:geo_areas) + filled_champs.flat_map(&:geo_areas) end def bounding_box diff --git a/app/models/dossier_assignment.rb b/app/models/dossier_assignment.rb index 239a218d4..e08c8ea25 100644 --- a/app/models/dossier_assignment.rb +++ b/app/models/dossier_assignment.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class DossierAssignment < ApplicationRecord belongs_to :dossier diff --git a/app/models/dossier_batch_operation.rb b/app/models/dossier_batch_operation.rb index ca5582d22..a807be83f 100644 --- a/app/models/dossier_batch_operation.rb +++ b/app/models/dossier_batch_operation.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class DossierBatchOperation < ApplicationRecord belongs_to :dossier belongs_to :batch_operation diff --git a/app/models/dossier_correction.rb b/app/models/dossier_correction.rb index 2f442a385..6347832d7 100644 --- a/app/models/dossier_correction.rb +++ b/app/models/dossier_correction.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class DossierCorrection < ApplicationRecord belongs_to :dossier belongs_to :commentaire diff --git a/app/models/dossier_label.rb b/app/models/dossier_label.rb new file mode 100644 index 000000000..6e9cc96f5 --- /dev/null +++ b/app/models/dossier_label.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class DossierLabel < ApplicationRecord + belongs_to :dossier + belongs_to :label +end diff --git a/app/models/dossier_operation_log.rb b/app/models/dossier_operation_log.rb index 902626a85..5deba8cc4 100644 --- a/app/models/dossier_operation_log.rb +++ b/app/models/dossier_operation_log.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class DossierOperationLog < ApplicationRecord enum operation: { changer_groupe_instructeur: 'changer_groupe_instructeur', @@ -41,7 +43,9 @@ class DossierOperationLog < ApplicationRecord def self.purge_discarded not_deletion.destroy_all - with_data.each(&:move_to_cold_storage!) + + supprimer.map { _1.serialized.purge_later } + supprimer.update_all(data: nil) end def self.create_and_serialize(params) diff --git a/app/models/dossier_preloader.rb b/app/models/dossier_preloader.rb index 2f541ad93..920ef00bf 100644 --- a/app/models/dossier_preloader.rb +++ b/app/models/dossier_preloader.rb @@ -1,10 +1,12 @@ +# frozen_string_literal: true + class DossierPreloader DEFAULT_BATCH_SIZE = 2000 - def initialize(dossiers, includes_for_dossier: [], includes_for_etablissement: []) + def initialize(dossiers, includes_for_champ: [], includes_for_etablissement: []) @dossiers = dossiers @includes_for_etablissement = includes_for_etablissement - @includes_for_dossier = includes_for_dossier + @includes_for_champ = includes_for_champ end def in_batches(size = DEFAULT_BATCH_SIZE) @@ -13,6 +15,16 @@ class DossierPreloader dossiers end + def in_batches_with_block(size = DEFAULT_BATCH_SIZE, &block) + @dossiers.in_batches(of: size) do |batch| + data = Dossier.where(id: batch.ids).includes(:individual, :traitement, :etablissement, user: :france_connect_informations, avis: :expert, commentaires: [:instructeur, :expert]) + + dossiers = data.to_a + load_dossiers(dossiers) + yield(dossiers) + end + end + def all(pj_template: false) dossiers = @dossiers.to_a load_dossiers(dossiers, pj_template:) @@ -20,55 +32,39 @@ class DossierPreloader end def self.load_one(dossier, pj_template: false) - DossierPreloader.new([dossier]).all(pj_template: pj_template).first + DossierPreloader.new([dossier]).all(pj_template:).first end private - # returns: { revision_id : { type_de_champ_id : position } } - def positions - @positions ||= ProcedureRevisionTypeDeChamp - .where(revision_id: @dossiers.pluck(:revision_id).uniq) - .select(:revision_id, :type_de_champ_id, :position) - .group_by(&:revision_id) - .transform_values do |coordinates| - coordinates.index_by(&:type_de_champ_id).transform_values(&:position) - end + def revisions(pj_template: false) + @revisions ||= ProcedureRevision.where(id: @dossiers.pluck(:revision_id).uniq) + .includes(procedure: [], revision_types_de_champ: { parent: :type_de_champ }, types_de_champ_public: [], types_de_champ_private: [], types_de_champ: pj_template ? { piece_justificative_template_attachment: :blob } : []) + .index_by(&:id) end def load_dossiers(dossiers, pj_template: false) - to_include = @includes_for_dossier.dup + to_include = @includes_for_champ.dup to_include << [piece_justificative_file_attachments: :blob] - if pj_template - to_include << { type_de_champ: { piece_justificative_template_attachment: :blob } } - else - to_include << :type_de_champ - end - all_champs = Champ .includes(to_include) .where(dossier_id: dossiers) .to_a - load_etablissements(all_champs) - - children_champs, root_champs = all_champs.partition(&:child?) - champs_by_dossier = root_champs.group_by(&:dossier_id) - champs_by_dossier_by_parent = children_champs - .group_by(&:dossier_id) - .transform_values do |champs| - champs.group_by(&:parent_id) - end + champs_by_dossier = all_champs.group_by(&:dossier_id) dossiers.each do |dossier| - load_dossier(dossier, champs_by_dossier[dossier.id] || [], champs_by_dossier_by_parent[dossier.id] || {}) + load_dossier(dossier, champs_by_dossier[dossier.id] || [], pj_template:) end + + load_etablissements(all_champs) end def load_etablissements(champs) to_include = @includes_for_etablissement.dup - champs_siret = champs.filter(&:siret?) + # `champs.siret?` will delegate to type_de_champ; this is not what we want here + champs_siret = champs.filter { _1.type == 'Champs::SiretChamp' } etablissements_by_id = Etablissement.includes(to_include).where(id: champs_siret.map(&:etablissement_id).compact).index_by(&:id) champs_siret.each do |champ| etablissement = etablissements_by_id[champ.etablissement_id] @@ -79,14 +75,16 @@ class DossierPreloader end end - def load_dossier(dossier, champs, children_by_parent = {}) - champs_public, champs_private = champs.partition(&:public?) + def load_dossier(dossier, champs, pj_template: false) + revision = revisions(pj_template:)[dossier.revision_id] + if revision.present? + dossier.association(:revision).target = revision + end + dossier.association(:champs).target = champs - dossier.association(:champs).target = [] - dossier.association(:champs_public_all).target = [] - dossier.association(:champs_private_all).target = [] - load_champs(dossier, :champs_public, champs_public, dossier, children_by_parent) - load_champs(dossier, :champs_private, champs_private, dossier, children_by_parent) + champs.each do |champ| + champ.association(:dossier).target = dossier + end # We need to do this because of the check on `Etablissement#champ` in # `Etablissement#libelle_for_export`. By assigning `nil` to `target` we mark association @@ -94,40 +92,7 @@ class DossierPreloader if dossier.etablissement dossier.etablissement.association(:champ).target = nil end - end - def load_champs(parent, name, champs, dossier, children_by_parent) - if champs.empty? - parent.association(name).target = [] # tells to Rails association has been loaded - return - end - - champs.each do |champ| - champ.association(:dossier).target = dossier - - if parent.is_a?(Champ) - champ.association(:parent).target = parent - end - end - - dossier.association(:champs).target += champs - - if champs.first.public? - dossier.association(:champs_public_all).target += champs - else - dossier.association(:champs_private_all).target += champs - end - - parent.association(name).target = champs - .filter { positions[dossier.revision_id][_1.type_de_champ_id].present? } - .sort_by { [_1.row_id, positions[dossier.revision_id][_1.type_de_champ_id]] } - - # Load children champs - champs.filter(&:block?).each do |parent_champ| - champs = children_by_parent[parent_champ.id] || [] - parent_champ.association(:dossier).target = dossier - - load_champs(parent_champ, :champs, champs, dossier, children_by_parent) - end + dossier.send(:reset_champs_cache) end end diff --git a/app/models/dossier_submitted_message.rb b/app/models/dossier_submitted_message.rb index c87e7c10b..358e7df1d 100644 --- a/app/models/dossier_submitted_message.rb +++ b/app/models/dossier_submitted_message.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class DossierSubmittedMessage < ApplicationRecord has_many :revisions, class_name: 'ProcedureRevision', inverse_of: :dossier_submitted_message, dependent: :nullify end diff --git a/app/models/dossier_transfer.rb b/app/models/dossier_transfer.rb index 0783247fa..31e0448ca 100644 --- a/app/models/dossier_transfer.rb +++ b/app/models/dossier_transfer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class DossierTransfer < ApplicationRecord include EmailSanitizableConcern has_many :dossiers, dependent: :nullify @@ -28,7 +30,7 @@ class DossierTransfer < ApplicationRecord DossierTransferLog.create(transfer.dossiers.map do |dossier| { dossier: dossier, - from: dossier.user.email, + from: dossier.user_email_for(:notification), from_support: transfer.from_support, to: transfer.email } diff --git a/app/models/dossier_transfer_log.rb b/app/models/dossier_transfer_log.rb index 574a7bfb0..53eb88b57 100644 --- a/app/models/dossier_transfer_log.rb +++ b/app/models/dossier_transfer_log.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class DossierTransferLog < ApplicationRecord belongs_to :dossier end diff --git a/app/models/dossiers_filter.rb b/app/models/dossiers_filter.rb index c542c3cac..68e732a99 100644 --- a/app/models/dossiers_filter.rb +++ b/app/models/dossiers_filter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class DossiersFilter attr_reader :user, :params diff --git a/app/models/dubious_procedure.rb b/app/models/dubious_procedure.rb index 016456c29..792b1526d 100644 --- a/app/models/dubious_procedure.rb +++ b/app/models/dubious_procedure.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class DubiousProcedure extend ActiveModel::Naming extend ActiveModel::Translation @@ -17,11 +19,11 @@ class DubiousProcedure end def self.all - procedures_with_forbidden_tdcs_sql = TypeDeChamp - .joins(:procedure) + procedures_with_forbidden_tdcs_sql = ProcedureRevisionTypeDeChamp + .joins(:procedure, :type_de_champ) .select("string_agg(types_de_champ.libelle, ' - ') as dubious_champs, procedures.id as procedure_id, procedures.libelle as procedure_libelle, procedures.aasm_state as procedure_aasm_state, procedures.hidden_at_as_template as procedure_hidden_at_as_template") .where("unaccent(types_de_champ.libelle) ~* unaccent(?)", forbidden_regexp) - .where(type_champ: [TypeDeChamp.type_champs.fetch(:text), TypeDeChamp.type_champs.fetch(:textarea)]) + .where(types_de_champ: { type_champ: [TypeDeChamp.type_champs.fetch(:text), TypeDeChamp.type_champs.fetch(:textarea)] }) .where(procedures: { closed_at: nil, whitelisted_at: nil }) .group("procedures.id") .order("procedures.id asc") diff --git a/app/models/email_event.rb b/app/models/email_event.rb index 4831adbd6..a9a161f0f 100644 --- a/app/models/email_event.rb +++ b/app/models/email_event.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EmailEvent < ApplicationRecord RETENTION_DURATION = 1.month diff --git a/app/models/entreprise.rb b/app/models/entreprise.rb index c1f4a077d..a017301e2 100644 --- a/app/models/entreprise.rb +++ b/app/models/entreprise.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Entreprise < Hashie::Dash def read_attribute_for_serialization(attribute) self[attribute] diff --git a/app/models/etablissement.rb b/app/models/etablissement.rb index 811a0c0a9..ba5232598 100644 --- a/app/models/etablissement.rb +++ b/app/models/etablissement.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Etablissement < ApplicationRecord belongs_to :dossier, optional: true @@ -17,6 +19,8 @@ class Etablissement < ApplicationRecord fermé: "fermé" }, _prefix: true + after_commit -> { dossier&.index_search_terms_later } + def entreprise_raison_sociale read_attribute(:entreprise_raison_sociale).presence || raison_sociale_for_ei end @@ -48,7 +52,8 @@ class Etablissement < ApplicationRecord adresse, code_postal, localite, - code_insee_localite + code_insee_localite, + nom_pays ] end diff --git a/app/models/exercice.rb b/app/models/exercice.rb index 12169a663..b03ef9599 100644 --- a/app/models/exercice.rb +++ b/app/models/exercice.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Exercice < ApplicationRecord belongs_to :etablissement, optional: false diff --git a/app/models/expert.rb b/app/models/expert.rb index 53a08bd90..c15ada1f6 100644 --- a/app/models/expert.rb +++ b/app/models/expert.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Expert < ApplicationRecord belongs_to :user has_many :experts_procedures diff --git a/app/models/experts_procedure.rb b/app/models/experts_procedure.rb index 41c58594f..1a6813bde 100644 --- a/app/models/experts_procedure.rb +++ b/app/models/experts_procedure.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ExpertsProcedure < ApplicationRecord belongs_to :expert belongs_to :procedure diff --git a/app/models/export.rb b/app/models/export.rb index 3a7a1ac34..7cce92ca9 100644 --- a/app/models/export.rb +++ b/app/models/export.rb @@ -1,6 +1,10 @@ +# frozen_string_literal: true + class Export < ApplicationRecord include TransientModelsWithPurgeableJobConcern + self.ignored_columns += ["procedure_presentation_snapshot"] + MAX_DUREE_CONSERVATION_EXPORT = 32.hours MAX_DUREE_GENERATION = 16.hours @@ -22,7 +26,7 @@ class Export < ApplicationRecord suivis: 'suivis', traites: 'traites', tous: 'tous', - supprimes_recemment: 'supprimes_recemment', + supprimes: 'supprimes', archives: 'archives', expirant: 'expirant' } @@ -31,9 +35,13 @@ class Export < ApplicationRecord belongs_to :procedure_presentation, optional: true belongs_to :instructeur, optional: true belongs_to :user_profile, polymorphic: true, optional: true + belongs_to :export_template, optional: true has_one_attached :file + attribute :sorted_column, :sorted_column + attribute :filtered_columns, :filtered_column, array: true + validates :format, :groupe_instructeurs, :key, presence: true scope :ante_chronological, -> { order(updated_at: :desc) } @@ -53,7 +61,6 @@ class Export < ApplicationRecord def compute self.dossiers_count = dossiers_for_export.count - load_snapshot! file.attach(blob.signed_id) # attaching a blob directly might run identify/virus scanner and wipe it end @@ -62,16 +69,16 @@ class Export < ApplicationRecord time_span_type == Export.time_span_types.fetch(:monthly) ? 30.days.ago : nil end - def filtered? - procedure_presentation_id.present? - end + def self.find_or_create_fresh_export(format, groupe_instructeurs, user_profile, time_span_type: time_span_types.fetch(:everything), statut: statuts.fetch(:tous), procedure_presentation: nil, export_template: nil) + filtered_columns = Array.wrap(procedure_presentation&.filters_for(statut)) + sorted_column = procedure_presentation&.sorted_column - def self.find_or_create_fresh_export(format, groupe_instructeurs, user_profile, time_span_type: time_span_types.fetch(:everything), statut: statuts.fetch(:tous), procedure_presentation: nil) attributes = { format:, + export_template:, time_span_type:, statut:, - key: generate_cache_key(groupe_instructeurs.map(&:id), procedure_presentation) + key: generate_cache_key(groupe_instructeurs.map(&:id), filtered_columns, sorted_column) } recent_export = pending @@ -83,36 +90,30 @@ class Export < ApplicationRecord create!(**attributes, groupe_instructeurs:, user_profile:, - procedure_presentation:, - procedure_presentation_snapshot: procedure_presentation&.snapshot) + filtered_columns:, + sorted_column:) end def self.for_groupe_instructeurs(groupe_instructeurs_ids) joins(:groupe_instructeurs).where(groupe_instructeurs: groupe_instructeurs_ids).distinct(:id) end - def self.by_key(groupe_instructeurs_ids, procedure_presentation) - where(key: [ - generate_cache_key(groupe_instructeurs_ids), - generate_cache_key(groupe_instructeurs_ids, procedure_presentation) - ]) + def self.by_key(groupe_instructeurs_ids) + where(key: generate_cache_key(groupe_instructeurs_ids)) end - def self.generate_cache_key(groupe_instructeurs_ids, procedure_presentation = nil) - if procedure_presentation.present? - [ - groupe_instructeurs_ids.sort.join('-'), - procedure_presentation.id, - Digest::MD5.hexdigest(procedure_presentation.snapshot.slice(:filters, :sort).to_s) - ].join('--') - else - groupe_instructeurs_ids.sort.join('-') - end + def self.generate_cache_key(groupe_instructeurs_ids, filtered_columns = [], sorted_column = nil) + columns_key = ([sorted_column] + filtered_columns).compact.map(&:id).sort.join + + [ + groupe_instructeurs_ids.sort.join('-'), + Digest::MD5.hexdigest(columns_key) + ].join('--') end def count return dossiers_count if !dossiers_count.nil? # export generated - return dossiers_for_export.count if procedure_presentation_id.present? + return dossiers_for_export.count if built_from_procedure_presentation? nil end @@ -121,23 +122,21 @@ class Export < ApplicationRecord groupe_instructeurs.first.procedure end - private - - def load_snapshot! - if procedure_presentation_snapshot.present? - procedure_presentation.attributes = procedure_presentation_snapshot - end + def built_from_procedure_presentation? + sorted_column.present? # hack has we know that procedure_presentation always has a sorted_column end + private + def dossiers_for_export @dossiers_for_export ||= begin dossiers = Dossier.where(groupe_instructeur: groupe_instructeurs) if since.present? dossiers.visible_by_administration.where('dossiers.depose_at > ?', since) - elsif procedure_presentation.present? - filtered_sorted_ids = procedure_presentation - .filtered_sorted_ids(dossiers, statut) + elsif filtered_columns.present? || sorted_column.present? + instructeur = instructeur_from(user_profile) + filtered_sorted_ids = DossierFilterService.filtered_sorted_ids(dossiers, statut, filtered_columns, sorted_column, instructeur) dossiers.where(id: filtered_sorted_ids) else @@ -146,8 +145,17 @@ class Export < ApplicationRecord end end + def instructeur_from(user_profile) + case user_profile + when Administrateur + user_profile.instructeur + when Instructeur + user_profile + end + end + def blob - service = ProcedureExportService.new(procedure, dossiers_for_export, user_profile) + service = ProcedureExportService.new(procedure, dossiers_for_export, user_profile, export_template) case format.to_sym when :csv diff --git a/app/models/export_item.rb b/app/models/export_item.rb new file mode 100644 index 000000000..b0fb012ef --- /dev/null +++ b/app/models/export_item.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +class ExportItem + include TagsSubstitutionConcern + DOSSIER_STATE = Dossier.states.fetch(:en_construction) + FORMAT_DATE = "%Y-%m-%d".freeze + + attr_reader :template, :enabled, :stable_id + + def initialize(template:, enabled: true, stable_id: nil) + @template, @enabled, @stable_id = template, enabled, stable_id + end + + def self.default(prefix:, enabled: true, stable_id: nil) + new(template: prefix_dossier_id(prefix), enabled:, stable_id:) + end + + def self.default_pj(tdc) + default(prefix: tdc.libelle_as_filename, enabled: false, stable_id: tdc.stable_id) + end + + def enabled? = enabled + + def template_json = template.to_json + + def template_string = TiptapService.new.to_texts_and_tags(template) + + def path(dossier, attachment: nil, row_index: nil, index: nil) + used_tags = TiptapService.used_tags_and_libelle_for(template) + substitutions = tags_substitutions(used_tags, dossier, escape: false, memoize: true) + substitutions['original-filename'] = attachment.filename.base if attachment + + TiptapService.new.to_texts_and_tags(template, substitutions) + suffix(attachment, row_index, index) + end + + def ==(other) + self.class == other.class && + template == other.template && + enabled == other.enabled && + stable_id == other.stable_id + end + + private + + def self.prefix_dossier_id(prefix) + { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { text: "#{prefix}-", type: "text" }, + { type: "mention", attrs: DOSSIER_ID_TAG.slice(:id, :label) } + ] + } + ] + } + end + + def suffix(attachment, row_index, index) + suffix = "" + suffix += "-#{add_one_and_pad(row_index)}" if row_index.present? + suffix += "-#{add_one_and_pad(index)}" if index.present? + suffix += attachment.filename.extension_with_delimiter if attachment + + suffix + end + + def add_one_and_pad(number) + (number + 1).to_s.rjust(2, '0') if number.present? + end +end diff --git a/app/models/export_template.rb b/app/models/export_template.rb new file mode 100644 index 000000000..0328e16b4 --- /dev/null +++ b/app/models/export_template.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +class ExportTemplate < ApplicationRecord + include TagsSubstitutionConcern + + self.ignored_columns += ["content"] + + belongs_to :groupe_instructeur + has_one :procedure, through: :groupe_instructeur + has_many :exports, dependent: :nullify + + enum kind: { zip: 'zip', csv: 'csv', xlsx: 'xlsx', ods: 'ods' }, _prefix: :template + + attribute :dossier_folder, :export_item + attribute :export_pdf, :export_item + attribute :pjs, :export_item, array: true + + attribute :exported_columns, :exported_column, array: true + + before_validation :ensure_pjs_are_legit + + validates_with ExportTemplateValidator + + DOSSIER_STATE = Dossier.states.fetch(:en_construction) + + # when a pj has been added to a revision, it will not be present in the previous pjs + # a default value is provided. + def pj(tdc) + pjs.find { _1.stable_id == tdc.stable_id } || ExportItem.default_pj(tdc) + end + + def self.default(name: nil, kind: 'zip', groupe_instructeur:) + # TODO: remove default values for tabular export + dossier_folder = ExportItem.default(prefix: 'dossier') + export_pdf = ExportItem.default(prefix: 'export') + pjs = groupe_instructeur.procedure.exportables_pieces_jointes.map { |tdc| ExportItem.default_pj(tdc) } + + new(name:, kind:, groupe_instructeur:, dossier_folder:, export_pdf:, pjs:) + end + + def tabular? + kind != 'zip' + end + + def tags + tags_categorized.slice(:individual, :etablissement, :dossier).values.flatten + end + + def pj_tags + tags.push( + libelle: 'nom original du fichier', + id: 'original-filename' + ) + end + + def attachment_path(dossier, attachment, index: 0, row_index: nil, champ: nil) + file_path = if attachment.name == 'pdf_export_for_instructeur' + export_pdf.path(dossier, attachment:) + elsif attachment.record_type == 'Champ' && pj(champ.type_de_champ).enabled? + pj(champ.type_de_champ).path(dossier, attachment:, index:, row_index:) + else + nil + end + + File.join(dossier_folder.path(dossier), file_path) if file_path.present? + end + + def dossier_exported_columns = exported_columns.filter { _1.column.dossier_column? } + + def columns_for_stable_id(stable_id) + exported_columns + .filter { _1.column.champ_column? } + .filter { _1.column.stable_id == stable_id } + end + + def in_export?(exported_column) + @template_exported_columns ||= exported_columns.map(&:column) + @template_exported_columns.include?(exported_column.column) + end + + private + + def ensure_pjs_are_legit + legitimate_pj_stable_ids = procedure.exportables_pieces_jointes_for_all_versions.map(&:stable_id) + + self.pjs = pjs.filter { _1.stable_id.in?(legitimate_pj_stable_ids) } + end +end diff --git a/app/models/exported_column.rb b/app/models/exported_column.rb new file mode 100644 index 000000000..651407a71 --- /dev/null +++ b/app/models/exported_column.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class ExportedColumn + attr_reader :column, :libelle + + def initialize(column:, libelle:) + @column = column + @libelle = libelle + end + + def id = { id: column.id, libelle: }.to_json + + def libelle_with_value(champ_or_dossier, format:) + [libelle, ExportedColumnFormatter.format(column:, champ_or_dossier:, format:), spreadsheet_architect_type] + end + + def spreadsheet_architect_type + case @column.type + when :boolean + :boolean + when :decimal, :integer + :float + when :datetime + :time + when :date + :date + else + :string + end + end +end diff --git a/app/models/filtered_column.rb b/app/models/filtered_column.rb new file mode 100644 index 000000000..c861b7338 --- /dev/null +++ b/app/models/filtered_column.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class FilteredColumn + include ActiveModel::Validations + + FILTERS_VALUE_MAX_LENGTH = 4048 + # https://www.postgresql.org/docs/current/datatype-numeric.html + PG_INTEGER_MAX_VALUE = 2147483647 + + attr_reader :column, :filter + + delegate :label, to: :column + + validate :check_filter_max_length + validate :check_filter_max_integer + validates :filter, presence: { + message: -> (object, _data) { "Le filtre « #{object.label} » ne peut pas être vide" } + } + + def initialize(column:, filter:) + @column = column + @filter = filter + end + + def ==(other) + other&.column == column && other.filter == filter + end + + def id + column.h_id.merge(filter:).sort.to_json + end + + private + + def check_filter_max_length + if @filter.present? && @filter.length > FILTERS_VALUE_MAX_LENGTH + errors.add( + :base, + "Le filtre « #{label} » est trop long (maximum: #{FILTERS_VALUE_MAX_LENGTH} caractères)" + ) + end + end + + def check_filter_max_integer + if @column.column == 'id' && @filter.to_i > PG_INTEGER_MAX_VALUE + errors.add(:base, "Le filtre « #{label} » n'est pas un numéro de dossier possible") + end + end +end diff --git a/app/models/follow.rb b/app/models/follow.rb index 1ec50e371..7c2dcaed2 100644 --- a/app/models/follow.rb +++ b/app/models/follow.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + class Follow < ApplicationRecord belongs_to :instructeur, optional: false - belongs_to :dossier, optional: false + belongs_to :dossier, optional: false, touch: true validates :instructeur_id, uniqueness: { scope: [:dossier_id, :unfollowed_at] } @@ -16,5 +18,6 @@ class Follow < ApplicationRecord self.annotations_privees_seen_at ||= Time.zone.now self.avis_seen_at ||= Time.zone.now self.messagerie_seen_at ||= Time.zone.now + self.pieces_jointes_seen_at ||= Time.zone.now end end diff --git a/app/models/follow_commentaire_groupe_gestionnaire.rb b/app/models/follow_commentaire_groupe_gestionnaire.rb index 2179c326b..8f0f02da6 100644 --- a/app/models/follow_commentaire_groupe_gestionnaire.rb +++ b/app/models/follow_commentaire_groupe_gestionnaire.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class FollowCommentaireGroupeGestionnaire < ApplicationRecord belongs_to :gestionnaire belongs_to :groupe_gestionnaire diff --git a/app/models/france_connect_information.rb b/app/models/france_connect_information.rb index 81ea02f7a..82fad522c 100644 --- a/app/models/france_connect_information.rb +++ b/app/models/france_connect_information.rb @@ -1,27 +1,42 @@ +# frozen_string_literal: true + class FranceConnectInformation < ApplicationRecord MERGE_VALIDITY = 15.minutes + CONFIRMATION_EMAIL_VALIDITY = 2.days belongs_to :user, optional: true validates :france_connect_particulier_id, presence: true, allow_blank: false, allow_nil: false - def associate_user!(email) + def safely_associate_user!(email) begin user = User.create!( email: email.downcase, password: Devise.friendly_token[0, 20], confirmed_at: Time.zone.now ) - user.after_confirmation rescue ActiveRecord::RecordNotUnique - # ignore this exception because we check before is user is nil. + # ignore this exception because we check before if user is nil. # exception can be raised in race conditions, when FranceConnect calls callback 2 times. # At the 2nd call, user is nil but exception is raised at the creation of the user # because the first call has already created a user end + clean_tokens_and_requested_email update_attribute('user_id', user.id) - touch # needed to update updated_at column + save! + end + + def safely_update_user(user:) + self.user = user + clean_tokens_and_requested_email + save! + end + + def send_custom_confirmation_instructions + token = SecureRandom.hex(10) + user.update!(confirmation_token: token, confirmation_sent_at: Time.zone.now) + UserMailer.custom_confirmation_instructions(user, token).deliver_later end def create_merge_token! @@ -46,14 +61,18 @@ class FranceConnectInformation < ApplicationRecord (MERGE_VALIDITY.ago < email_merge_token_created_at) && user_id.nil? end - def delete_merge_token! - update(merge_token: nil, merge_token_created_at: nil) - end - def delete_email_merge_token! update(email_merge_token: nil, email_merge_token_created_at: nil) end + def clean_tokens_and_requested_email + self.merge_token = nil + self.merge_token_created_at = nil + self.email_merge_token = nil + self.email_merge_token_created_at = nil + self.requested_email = nil + end + def full_name [given_name, family_name].compact.join(" ") end diff --git a/app/models/france_connect_particulier_client.rb b/app/models/france_connect_particulier_client.rb index 6c8248f8d..df9b10bbb 100644 --- a/app/models/france_connect_particulier_client.rb +++ b/app/models/france_connect_particulier_client.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class FranceConnectParticulierClient < OpenIDConnect::Client def initialize(code = nil) config = FRANCE_CONNECT[:particulier].deep_dup diff --git a/app/models/geo_area.rb b/app/models/geo_area.rb index e34669759..6e847abac 100644 --- a/app/models/geo_area.rb +++ b/app/models/geo_area.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GeoArea < ApplicationRecord include ActionView::Helpers::NumberHelper belongs_to :champ, optional: false @@ -63,7 +65,7 @@ class GeoArea < ApplicationRecord def label case source when GeoArea.sources.fetch(:cadastre) - I18n.t("cadastre", scope: 'geo_area.label', numero: numero, prefixe: prefixe, section: section, surface: surface.round, commune: commune) + I18n.t("cadastre", scope: 'geo_area.label', numero: numero, prefixe: prefixe, section: section, surface: surface&.round, commune: commune) when GeoArea.sources.fetch(:selection_utilisateur) if polygon? if area > 0 diff --git a/app/models/gestionnaire.rb b/app/models/gestionnaire.rb index c0589ce4e..758cddd80 100644 --- a/app/models/gestionnaire.rb +++ b/app/models/gestionnaire.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Gestionnaire < ApplicationRecord include UserFindByConcern has_and_belongs_to_many :groupe_gestionnaires diff --git a/app/models/groupe_gestionnaire.rb b/app/models/groupe_gestionnaire.rb index f9f521da0..f893132ba 100644 --- a/app/models/groupe_gestionnaire.rb +++ b/app/models/groupe_gestionnaire.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GroupeGestionnaire < ApplicationRecord has_many :administrateurs has_many :commentaire_groupe_gestionnaires diff --git a/app/models/groupe_instructeur.rb b/app/models/groupe_instructeur.rb index b0b165b1e..b717090f8 100644 --- a/app/models/groupe_instructeur.rb +++ b/app/models/groupe_instructeur.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GroupeInstructeur < ApplicationRecord include Logic DEFAUT_LABEL = 'défaut' @@ -9,6 +11,7 @@ class GroupeInstructeur < ApplicationRecord has_many :batch_operations, through: :dossiers, source: :batch_operations has_many :assignments, class_name: 'DossierAssignment', dependent: :nullify, inverse_of: :groupe_instructeur has_many :previous_assignments, class_name: 'DossierAssignment', dependent: :nullify, inverse_of: :previous_groupe_instructeur + has_many :export_templates, dependent: :destroy has_and_belongs_to_many :exports, dependent: :destroy has_one :defaut_procedure, -> { with_discarded }, class_name: 'Procedure', foreign_key: :defaut_groupe_instructeur_id, dependent: :nullify, inverse_of: :defaut_groupe_instructeur @@ -57,7 +60,6 @@ class GroupeInstructeur < ApplicationRecord if not_found_emails.present? instructeurs_to_add += not_found_emails.map do |email| user = User.create_or_promote_to_instructeur(email, SecureRandom.hex, administrateurs: procedure.administrateurs) - user.invite! user.instructeur end end diff --git a/app/models/individual.rb b/app/models/individual.rb index c3d92b9eb..9c1c5315b 100644 --- a/app/models/individual.rb +++ b/app/models/individual.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Individual < ApplicationRecord enum notification_method: { email: 'email', @@ -15,7 +17,10 @@ class Individual < ApplicationRecord if: -> { dossier.for_tiers? }, on: :update - validates :email, presence: true, if: -> { dossier.for_tiers? && self.email? }, on: :update + validates :email, strict_email: true, presence: true, if: -> { dossier.for_tiers? && self.email? }, on: :update + validate :email_different_from_mandataire, on: :update + + after_commit -> { dossier.index_search_terms_later }, if: -> { nom_previously_changed? || prenom_previously_changed? } GENDER_MALE = "M." GENDER_FEMALE = 'Mme' @@ -27,4 +32,12 @@ class Individual < ApplicationRecord gender: fc_information.gender == 'female' ? GENDER_FEMALE : GENDER_MALE ) end + + def unverified_email? = !email_verified_at? + + def email_different_from_mandataire + if email.present? && email.casecmp?(dossier.user.email) + errors.add(:email, :must_be_different_from_mandataire) + end + end end diff --git a/app/models/instructeur.rb b/app/models/instructeur.rb index 707e4da98..b84e2c3b7 100644 --- a/app/models/instructeur.rb +++ b/app/models/instructeur.rb @@ -1,6 +1,6 @@ -class Instructeur < ApplicationRecord - self.ignored_columns += [:agent_connect_id] +# frozen_string_literal: true +class Instructeur < ApplicationRecord include UserFindByConcern has_and_belongs_to_many :administrateurs @@ -14,6 +14,7 @@ class Instructeur < ApplicationRecord has_many :batch_operations, dependent: :nullify has_many :assign_to_with_email_notifications, -> { with_email_notifications }, class_name: 'AssignTo', inverse_of: :instructeur has_many :groupe_instructeur_with_email_notifications, through: :assign_to_with_email_notifications, source: :groupe_instructeur + has_many :export_templates, through: :groupe_instructeurs has_many :commentaires, inverse_of: :instructeur, dependent: :nullify has_many :dossiers, -> { state_not_brouillon }, through: :unordered_groupe_instructeurs @@ -124,10 +125,11 @@ class Instructeur < ApplicationRecord annotations_privees = dossier.last_champ_private_updated_at&.>(follow.annotations_privees_seen_at) || false avis_notif = dossier.last_avis_updated_at&.>(follow.avis_seen_at) || false messagerie = dossier.last_commentaire_updated_at&.>(follow.messagerie_seen_at) || false + pieces_jointes = dossier.last_champ_piece_jointe_updated_at&.>(follow.pieces_jointes_seen_at) || dossier.last_commentaire_piece_jointe_updated_at&.>(follow.pieces_jointes_seen_at) || dossier.last_avis_piece_jointe_updated_at&.>(follow.pieces_jointes_seen_at) || false - annotations_hash(demande, annotations_privees, avis_notif, messagerie) + annotations_hash(demande, annotations_privees, avis_notif, messagerie, pieces_jointes) else - annotations_hash(false, false, false, false) + annotations_hash(false, false, false, false, false) end end @@ -211,6 +213,11 @@ class Instructeur < ApplicationRecord trusted_device_token&.token_young? end + def should_receive_email_activation? + # if was recently created or received an activation email more than 7 days ago + previously_new_record? || user.reset_password_sent_at.nil? || user.reset_password_sent_at < Devise.reset_password_within.ago + end + def can_be_deleted? user.administrateur.nil? && procedures.all? { |p| p.defaut_groupe_instructeur.instructeurs.count > 1 } end @@ -226,18 +233,18 @@ class Instructeur < ApplicationRecord def dossiers_count_summary(groupe_instructeur_ids) query = <<~EOF SELECT - COUNT(DISTINCT dossiers.id) FILTER (where dossiers.hidden_by_administration_at IS NULL AND not archived AND dossiers.state in ('en_construction', 'en_instruction') AND follows.id IS NULL) AS a_suivre, - COUNT(DISTINCT dossiers.id) FILTER (where dossiers.hidden_by_administration_at IS NULL AND not archived AND dossiers.state in ('en_construction', 'en_instruction') AND follows.instructeur_id = :instructeur_id) AS suivis, - COUNT(DISTINCT dossiers.id) FILTER (where dossiers.hidden_by_administration_at IS NULL AND not archived AND dossiers.state in ('accepte', 'refuse', 'sans_suite')) AS traites, - COUNT(DISTINCT dossiers.id) FILTER (where dossiers.hidden_by_administration_at IS NULL AND not archived) AS tous, - COUNT(DISTINCT dossiers.id) FILTER (where dossiers.hidden_by_administration_at IS NULL AND archived) AS archives, - COUNT(DISTINCT dossiers.id) FILTER (where dossiers.hidden_by_administration_at IS NOT NULL AND not archived AND dossiers.state in ('accepte', 'refuse', 'sans_suite')) AS supprimes_recemment, - COUNT(DISTINCT dossiers.id) FILTER (where dossiers.hidden_by_administration_at IS NULL AND procedures.procedure_expires_when_termine_enabled + COUNT(DISTINCT dossiers.id) FILTER (where dossiers.hidden_by_administration_at IS NULL AND dossiers.hidden_by_expired_at IS NULL AND not archived AND dossiers.state in ('en_construction', 'en_instruction') AND follows.id IS NULL) AS a_suivre, + COUNT(DISTINCT dossiers.id) FILTER (where dossiers.hidden_by_administration_at IS NULL AND dossiers.hidden_by_expired_at IS NULL AND not archived AND dossiers.state in ('en_construction', 'en_instruction') AND follows.instructeur_id = :instructeur_id) AS suivis, + COUNT(DISTINCT dossiers.id) FILTER (where dossiers.hidden_by_administration_at IS NULL AND dossiers.hidden_by_expired_at IS NULL AND not archived AND dossiers.state in ('accepte', 'refuse', 'sans_suite')) AS traites, + COUNT(DISTINCT dossiers.id) FILTER (where dossiers.hidden_by_administration_at IS NULL AND dossiers.hidden_by_expired_at IS NULL AND not archived) AS tous, + COUNT(DISTINCT dossiers.id) FILTER (where dossiers.hidden_by_administration_at IS NULL AND dossiers.hidden_by_expired_at IS NULL AND archived) AS archives, + COUNT(DISTINCT dossiers.id) FILTER (where dossiers.hidden_by_administration_at IS NOT NULL AND not archived OR dossiers.hidden_by_expired_at IS NOT NULL) AS supprimes, + COUNT(DISTINCT dossiers.id) FILTER (where dossiers.hidden_by_administration_at IS NULL AND dossiers.hidden_by_expired_at IS NULL AND procedures.procedure_expires_when_termine_enabled AND ( dossiers.state in ('accepte', 'refuse', 'sans_suite') AND dossiers.processed_at + dossiers.conservation_extension + (procedures.duree_conservation_dossiers_dans_ds * INTERVAL '1 month') - INTERVAL :expires_in < :now ) OR ( - dossiers.state in ('en_construction') + dossiers.state in ('en_construction') AND dossiers.hidden_by_expired_at IS NULL AND dossiers.en_construction_at + dossiers.conservation_extension + (duree_conservation_dossiers_dans_ds * INTERVAL '1 month') - INTERVAL :expires_in < :now ) ) AS expirant @@ -302,14 +309,19 @@ class Instructeur < ApplicationRecord agent_connect_information.order(updated_at: :desc).first end + def export_templates_for(procedure) + procedure.export_templates.where(groupe_instructeur: groupe_instructeurs).order(:name) + end + private - def annotations_hash(demande, annotations_privees, avis, messagerie) + def annotations_hash(demande, annotations_privees, avis, messagerie, pieces_jointes) { demande: demande, annotations_privees: annotations_privees, avis: avis, - messagerie: messagerie + messagerie: messagerie, + pieces_jointes: pieces_jointes } end diff --git a/app/models/invite.rb b/app/models/invite.rb index 3d96fc475..de4412e53 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Invite < ApplicationRecord include EmailSanitizableConcern diff --git a/app/models/label.rb b/app/models/label.rb new file mode 100644 index 000000000..aaeeebf0d --- /dev/null +++ b/app/models/label.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class Label < ApplicationRecord + belongs_to :procedure + has_many :dossier_labels, dependent: :destroy + + NAME_MAX_LENGTH = 30 + GENERIC_LABELS = [ + { name: 'À examiner', color: 'purple_glycine' }, + { name: 'À relancer', color: 'green_tilleul_verveine' }, + { name: 'Complet', color: 'green_emeraude' }, + { name: 'À signer', color: 'blue_ecume' }, + { name: 'Urgent', color: 'pink_macaron' } + ] + + enum color: { + green_tilleul_verveine: "green-tilleul-verveine", + green_bourgeon: "green-bourgeon", + green_emeraude: "green-emeraude", + green_menthe: "green-menthe", + blue_ecume: "blue-ecume", + purple_glycine: "purple-glycine", + pink_macaron: "pink-macaron", + yellow_tournesol: "yellow-tournesol", + brown_cafe_creme: "brown-cafe-creme", + beige_gris_galet: "beige-gris-galet" + } + + validates :name, :color, presence: true + validates :name, length: { maximum: NAME_MAX_LENGTH } + + def self.class_name(color) + Label.colors.fetch(color.underscore) + end +end diff --git a/app/models/label_model.rb b/app/models/label_model.rb index 4fb427394..49bb7fe06 100644 --- a/app/models/label_model.rb +++ b/app/models/label_model.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + LabelModel = Struct.new(:id, :label, keyword_init: true) diff --git a/app/models/logic.rb b/app/models/logic.rb index 8eced2658..7325b29f9 100644 --- a/app/models/logic.rb +++ b/app/models/logic.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Logic def self.from_h(h) class_from_name(h['term']).from_h(h) @@ -42,7 +44,7 @@ module Logic operator_class = EmptyOperator in [:enum, _] operator_class = Eq - in [:commune_enum, _] | [:epci_enum, _] + in [:commune_enum, _] | [:epci_enum, _] | [:address, _] operator_class = InDepartementOperator in [:departement_enum, _] operator_class = Eq @@ -59,7 +61,7 @@ module Logic Constant.new(true) when :empty Empty.new - when :enum, :enums, :commune_enum, :epci_enum, :departement_enum + when :enum, :enums, :commune_enum, :epci_enum, :departement_enum, :address Constant.new(left.options(type_de_champs).first.second) when :number Constant.new(0) @@ -73,7 +75,7 @@ module Logic case [left.type(type_de_champs), right.type(type_de_champs)] in [a, ^a] # syntax for same type true - in [:enum, :string] | [:enums, :string] | [:commune_enum, :string] | [:epci_enum, :string] | [:departement_enum, :string] + in [:enum, :string] | [:enums, :string] | [:commune_enum, :string] | [:epci_enum, :string] | [:departement_enum, :string] | [:address, :string] true else false diff --git a/app/models/logic/and.rb b/app/models/logic/and.rb index 51537235f..5bb97329c 100644 --- a/app/models/logic/and.rb +++ b/app/models/logic/and.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Logic::And < Logic::NAryOperator attr_reader :operands diff --git a/app/models/logic/binary_operator.rb b/app/models/logic/binary_operator.rb index 812fa0605..019836923 100644 --- a/app/models/logic/binary_operator.rb +++ b/app/models/logic/binary_operator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Logic::BinaryOperator < Logic::Term attr_reader :left, :right diff --git a/app/models/logic/champ_value.rb b/app/models/logic/champ_value.rb index efb46a55a..1d9c9858e 100644 --- a/app/models/logic/champ_value.rb +++ b/app/models/logic/champ_value.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Logic::ChampValue < Logic::Term MANAGED_TYPE_DE_CHAMP = TypeDeChamp.type_champs.slice( :yes_no, @@ -6,12 +8,19 @@ class Logic::ChampValue < Logic::Term :decimal_number, :drop_down_list, :multiple_drop_down_list, + :address, :communes, :epci, :departements, - :regions + :regions, + :pays ) + MANAGED_TYPE_DE_CHAMP_BY_CATEGORY = MANAGED_TYPE_DE_CHAMP.keys.map(&:to_sym) + .each_with_object(Hash.new { |h, k| h[k] = [] }) do |type, h| + h[TypeDeChamp::TYPE_DE_CHAMP_TO_CATEGORIE[type]] << type + end + CHAMP_VALUE_TYPE = { boolean: :boolean, # from yes_no or checkbox champ number: :number, # from integer or decimal number champ @@ -19,6 +28,7 @@ class Logic::ChampValue < Logic::Term commune_enum: :commune_enum, epci_enum: :epci_enum, departement_enum: :departement_enum, + address: :address, enums: :enums, # multiple choice from a dropdownlist (multipledropdownlist) empty: :empty, unmanaged: :unmanaged @@ -41,25 +51,25 @@ class Logic::ChampValue < Logic::Term return nil if !targeted_champ.visible? return nil if targeted_champ.blank? & !targeted_champ.drop_down_other? - # on dépense 22ms ici, à cause du map, mais on doit pouvoir passer par un champ type case targeted_champ.type when "Champs::YesNoChamp", "Champs::CheckboxChamp" targeted_champ.true? when "Champs::IntegerNumberChamp", "Champs::DecimalNumberChamp" - targeted_champ.for_api + # TODO expose raw typed value of champs + targeted_champ.type_de_champ.champ_value_for_api(targeted_champ, version: 1) when "Champs::DropDownListChamp" targeted_champ.selected when "Champs::MultipleDropDownListChamp" targeted_champ.selected_options - when "Champs::RegionChamp" + when "Champs::RegionChamp", "Champs::PaysChamp" targeted_champ.code when "Champs::DepartementChamp" { value: targeted_champ.code, code_region: targeted_champ.code_region } - when "Champs::CommuneChamp", "Champs::EpciChamp" + when "Champs::CommuneChamp", "Champs::EpciChamp", "Champs::AddressChamp" { code_departement: targeted_champ.code_departement, code_region: targeted_champ.code_region @@ -77,7 +87,7 @@ class Logic::ChampValue < Logic::Term when MANAGED_TYPE_DE_CHAMP.fetch(:integer_number), MANAGED_TYPE_DE_CHAMP.fetch(:decimal_number) CHAMP_VALUE_TYPE.fetch(:number) when MANAGED_TYPE_DE_CHAMP.fetch(:drop_down_list), - MANAGED_TYPE_DE_CHAMP.fetch(:regions) + MANAGED_TYPE_DE_CHAMP.fetch(:regions), MANAGED_TYPE_DE_CHAMP.fetch(:pays) CHAMP_VALUE_TYPE.fetch(:enum) when MANAGED_TYPE_DE_CHAMP.fetch(:communes) CHAMP_VALUE_TYPE.fetch(:commune_enum) @@ -85,6 +95,8 @@ class Logic::ChampValue < Logic::Term CHAMP_VALUE_TYPE.fetch(:epci_enum) when MANAGED_TYPE_DE_CHAMP.fetch(:departements) CHAMP_VALUE_TYPE.fetch(:departement_enum) + when MANAGED_TYPE_DE_CHAMP.fetch(:address) + CHAMP_VALUE_TYPE.fetch(:address) when MANAGED_TYPE_DE_CHAMP.fetch(:multiple_drop_down_list) CHAMP_VALUE_TYPE.fetch(:enums) else @@ -119,11 +131,13 @@ class Logic::ChampValue < Logic::Term tdc = type_de_champ(type_de_champs) if operator_name.in?([Logic::InRegionOperator.name, Logic::NotInRegionOperator.name]) || tdc.type_champ == MANAGED_TYPE_DE_CHAMP.fetch(:regions) - APIGeoService.regions.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } - elsif operator_name.in?([Logic::InDepartementOperator.name, Logic::NotInDepartementOperator.name]) || tdc.type_champ.in?([MANAGED_TYPE_DE_CHAMP.fetch(:communes), MANAGED_TYPE_DE_CHAMP.fetch(:epci), MANAGED_TYPE_DE_CHAMP.fetch(:departements)]) - APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } + APIGeoService.region_options + elsif operator_name.in?([Logic::InDepartementOperator.name, Logic::NotInDepartementOperator.name]) || tdc.type_champ.in?([MANAGED_TYPE_DE_CHAMP.fetch(:communes), MANAGED_TYPE_DE_CHAMP.fetch(:epci), MANAGED_TYPE_DE_CHAMP.fetch(:departements), MANAGED_TYPE_DE_CHAMP.fetch(:address)]) + APIGeoService.departement_options + elsif tdc.type_champ == MANAGED_TYPE_DE_CHAMP.fetch(:pays) + APIGeoService.countries.map { ["#{_1[:name]} – #{_1[:code]}", _1[:code]] } else - tdc.drop_down_list_enabled_non_empty_options(other: true).map { _1.is_a?(Array) ? _1 : [_1, _1] } + tdc.drop_down_options_with_other.map { _1.is_a?(Array) ? _1 : [_1, _1] } end end diff --git a/app/models/logic/constant.rb b/app/models/logic/constant.rb index e1042d580..bce68c2c7 100644 --- a/app/models/logic/constant.rb +++ b/app/models/logic/constant.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Logic::Constant < Logic::Term attr_reader :value diff --git a/app/models/logic/empty.rb b/app/models/logic/empty.rb index 605af6368..071dd8f4d 100644 --- a/app/models/logic/empty.rb +++ b/app/models/logic/empty.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Logic::Empty < Logic::Term def sources [] diff --git a/app/models/logic/empty_operator.rb b/app/models/logic/empty_operator.rb index 5315f0b87..27938d42a 100644 --- a/app/models/logic/empty_operator.rb +++ b/app/models/logic/empty_operator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Logic::EmptyOperator < Logic::BinaryOperator def to_s(_type_de_champs = []) = I18n.t('logic.empty_operator') diff --git a/app/models/logic/eq.rb b/app/models/logic/eq.rb index da07341ce..ba050d72d 100644 --- a/app/models/logic/eq.rb +++ b/app/models/logic/eq.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Logic::Eq < Logic::BinaryOperator def operation = :== diff --git a/app/models/logic/exclude_operator.rb b/app/models/logic/exclude_operator.rb index 8addb7d0a..78ae7e038 100644 --- a/app/models/logic/exclude_operator.rb +++ b/app/models/logic/exclude_operator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Logic::ExcludeOperator < Logic::IncludeOperator def operation = :exclude? end diff --git a/app/models/logic/greater_than.rb b/app/models/logic/greater_than.rb index 2ba94af0f..1039c49bb 100644 --- a/app/models/logic/greater_than.rb +++ b/app/models/logic/greater_than.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Logic::GreaterThan < Logic::BinaryOperator def operation = :> end diff --git a/app/models/logic/greater_than_eq.rb b/app/models/logic/greater_than_eq.rb index d452bceef..df594abff 100644 --- a/app/models/logic/greater_than_eq.rb +++ b/app/models/logic/greater_than_eq.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Logic::GreaterThanEq < Logic::BinaryOperator def operation = :>= end diff --git a/app/models/logic/in_departement_operator.rb b/app/models/logic/in_departement_operator.rb index ee9532b36..280ed3ce6 100644 --- a/app/models/logic/in_departement_operator.rb +++ b/app/models/logic/in_departement_operator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Logic::InDepartementOperator < Logic::BinaryOperator def operation :est_dans_le_departement diff --git a/app/models/logic/in_region_operator.rb b/app/models/logic/in_region_operator.rb index 54625e987..0cccd0ed8 100644 --- a/app/models/logic/in_region_operator.rb +++ b/app/models/logic/in_region_operator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Logic::InRegionOperator < Logic::BinaryOperator def operation :est_dans_la_region diff --git a/app/models/logic/include_operator.rb b/app/models/logic/include_operator.rb index 2e1f05c57..a76d7e15f 100644 --- a/app/models/logic/include_operator.rb +++ b/app/models/logic/include_operator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Logic::IncludeOperator < Logic::BinaryOperator def operation = :include? diff --git a/app/models/logic/less_than.rb b/app/models/logic/less_than.rb index b39282ee7..cf12f0402 100644 --- a/app/models/logic/less_than.rb +++ b/app/models/logic/less_than.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Logic::LessThan < Logic::BinaryOperator def operation = :< end diff --git a/app/models/logic/less_than_eq.rb b/app/models/logic/less_than_eq.rb index 79e4c7308..b1180d1e2 100644 --- a/app/models/logic/less_than_eq.rb +++ b/app/models/logic/less_than_eq.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Logic::LessThanEq < Logic::BinaryOperator def operation = :<= end diff --git a/app/models/logic/n_ary_operator.rb b/app/models/logic/n_ary_operator.rb index 02bc0cf42..c7006a082 100644 --- a/app/models/logic/n_ary_operator.rb +++ b/app/models/logic/n_ary_operator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Logic::NAryOperator < Logic::Term attr_reader :operands diff --git a/app/models/logic/not_eq.rb b/app/models/logic/not_eq.rb index b11ec1c92..d1ff226b9 100644 --- a/app/models/logic/not_eq.rb +++ b/app/models/logic/not_eq.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Logic::NotEq < Logic::Eq def operation = :!= end diff --git a/app/models/logic/not_in_departement_operator.rb b/app/models/logic/not_in_departement_operator.rb index 472ef6c18..5c3053a0c 100644 --- a/app/models/logic/not_in_departement_operator.rb +++ b/app/models/logic/not_in_departement_operator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Logic::NotInDepartementOperator < Logic::InDepartementOperator def operation :n_est_pas_dans_le_departement diff --git a/app/models/logic/not_in_region_operator.rb b/app/models/logic/not_in_region_operator.rb index 3bdac4d85..179203d21 100644 --- a/app/models/logic/not_in_region_operator.rb +++ b/app/models/logic/not_in_region_operator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Logic::NotInRegionOperator < Logic::InRegionOperator def operation :n_est_pas_dans_la_region diff --git a/app/models/logic/or.rb b/app/models/logic/or.rb index a0e2dfeae..bd886a813 100644 --- a/app/models/logic/or.rb +++ b/app/models/logic/or.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Logic::Or < Logic::NAryOperator attr_reader :operands diff --git a/app/models/logic/term.rb b/app/models/logic/term.rb index 3cbed35a6..86489a806 100644 --- a/app/models/logic/term.rb +++ b/app/models/logic/term.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Logic::Term def to_json to_h.to_json diff --git a/app/models/mails/closed_mail.rb b/app/models/mails/closed_mail.rb index 9e6ba017d..f155760ac 100644 --- a/app/models/mails/closed_mail.rb +++ b/app/models/mails/closed_mail.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # == Schema Information # # Table name: closed_mails diff --git a/app/models/mails/initiated_mail.rb b/app/models/mails/initiated_mail.rb index 9611ef125..a55edeab3 100644 --- a/app/models/mails/initiated_mail.rb +++ b/app/models/mails/initiated_mail.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # == Schema Information # # Table name: initiated_mails diff --git a/app/models/mails/re_instructed_mail.rb b/app/models/mails/re_instructed_mail.rb index 0a7dc35e0..d8fc4549b 100644 --- a/app/models/mails/re_instructed_mail.rb +++ b/app/models/mails/re_instructed_mail.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # == Schema Information # # Table name: re_instructed_mails diff --git a/app/models/mails/received_mail.rb b/app/models/mails/received_mail.rb index 69242d60d..cb137a699 100644 --- a/app/models/mails/received_mail.rb +++ b/app/models/mails/received_mail.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # == Schema Information # # Table name: received_mails diff --git a/app/models/mails/refused_mail.rb b/app/models/mails/refused_mail.rb index eff138c4f..565656084 100644 --- a/app/models/mails/refused_mail.rb +++ b/app/models/mails/refused_mail.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # == Schema Information # # Table name: refused_mails diff --git a/app/models/mails/without_continuation_mail.rb b/app/models/mails/without_continuation_mail.rb index bc9c71843..00b991d3a 100644 --- a/app/models/mails/without_continuation_mail.rb +++ b/app/models/mails/without_continuation_mail.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # == Schema Information # # Table name: without_continuation_mails diff --git a/app/models/map_filter.rb b/app/models/map_filter.rb index 06837bdb1..fb6c5510b 100644 --- a/app/models/map_filter.rb +++ b/app/models/map_filter.rb @@ -1,34 +1,23 @@ -class MapFilter - # https://api.rubyonrails.org/v7.1.1/classes/ActiveModel/Errors.html +# frozen_string_literal: true - include ActiveModel::Conversion - extend ActiveModel::Translation - extend ActiveModel::Naming +class MapFilter + include ActiveModel::Model + include ActiveModel::Attributes LEGEND = { - nb_demarches: { 'nothing': -1, 'small': 20, 'medium': 50, 'large': 100, 'xlarge': 500 }, - nb_dossiers: { 'nothing': -1, 'small': 500, 'medium': 2000, 'large': 10000, 'xlarge': 50000 } - } + "nb_demarches" => { 'nothing': -1, 'small': 20, 'medium': 50, 'large': 100, 'xlarge': 500 }, + "nb_dossiers" => { 'nothing': -1, 'small': 500, 'medium': 2000, 'large': 10000, 'xlarge': 50000 } + }.freeze + + YEARS_INTERVAL = 2018..Date.current.year attr_accessor :stats - attr_reader :errors - def initialize(params) - @params = params[:map_filter]&.permit(:kind, :year) || {} - @errors = ActiveModel::Errors.new(self) - end + attribute :year, :integer + validates :year, numericality: { only_integer: true, greater_than_or_equal_to: YEARS_INTERVAL.begin, less_than_or_equal_to: YEARS_INTERVAL.end } - def persisted? - false - end - - def kind - @params[:kind]&.to_sym || :nb_demarches - end - - def year - @params[:year].presence - end + attribute :kind, default: "nb_demarches" + validates :kind, inclusion: { in: LEGEND.keys } def kind_buttons LEGEND.keys.map do @@ -41,7 +30,7 @@ class MapFilter end def css_class_for_departement(departement) - if kind == :nb_demarches + if kind == "nb_demarches" kind_legend_keys.reverse.find do nb_demarches_for_departement(departement) > LEGEND[kind][_1] end diff --git a/app/models/merge_log.rb b/app/models/merge_log.rb index 5e4a14ade..6575cd62a 100644 --- a/app/models/merge_log.rb +++ b/app/models/merge_log.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class MergeLog < ApplicationRecord belongs_to :user end diff --git a/app/models/module_api_carto.rb b/app/models/module_api_carto.rb index ffc6d8dd4..fe4b07805 100644 --- a/app/models/module_api_carto.rb +++ b/app/models/module_api_carto.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ModuleAPICarto < ApplicationRecord belongs_to :procedure, optional: false end diff --git a/app/models/null_zone.rb b/app/models/null_zone.rb index 9ce32eacf..4349d2424 100644 --- a/app/models/null_zone.rb +++ b/app/models/null_zone.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class NullZone include ActiveModel::Model ReflectionAssociation = Struct.new(:class_name) diff --git a/app/models/outdated_procedure.rb b/app/models/outdated_procedure.rb index b74295060..6d8e08690 100644 --- a/app/models/outdated_procedure.rb +++ b/app/models/outdated_procedure.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class OutdatedProcedure extend ActiveModel::Naming extend ActiveModel::Translation diff --git a/app/models/path_rewrite.rb b/app/models/path_rewrite.rb new file mode 100644 index 000000000..7cb983ca8 --- /dev/null +++ b/app/models/path_rewrite.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class PathRewrite < ApplicationRecord +end diff --git a/app/models/prefill_champs.rb b/app/models/prefill_champs.rb index f013c96d4..a55fa7d9b 100644 --- a/app/models/prefill_champs.rb +++ b/app/models/prefill_champs.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PrefillChamps attr_reader :dossier, :params @@ -23,7 +25,7 @@ class PrefillChamps .to_h dossier - .find_champs_by_stable_ids(value_by_stable_id.keys) + .champs_for_prefill(value_by_stable_id.keys) .map { |champ| [champ, value_by_stable_id[champ.stable_id]] } .map { |champ, value| PrefillValue.new(champ:, value:, dossier:) } end diff --git a/app/models/prefill_description.rb b/app/models/prefill_description.rb index 58853f600..4da4e3479 100644 --- a/app/models/prefill_description.rb +++ b/app/models/prefill_description.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PrefillDescription < SimpleDelegator include Rails.application.routes.url_helpers diff --git a/app/models/prefill_identity.rb b/app/models/prefill_identity.rb index c4419b094..bbf66ed25 100644 --- a/app/models/prefill_identity.rb +++ b/app/models/prefill_identity.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PrefillIdentity attr_reader :dossier, :params diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 827f417d5..90344edc9 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -1,20 +1,19 @@ +# frozen_string_literal: true + class Procedure < ApplicationRecord + include APIEntrepriseTokenConcern include ProcedureStatsConcern include EncryptableConcern include InitiationProcedureConcern include ProcedureGroupeInstructeurAPIHackConcern include ProcedureSVASVRConcern include ProcedureChorusConcern + include ProcedurePublishConcern + include PiecesJointesListConcern + include ColumnsConcern include Discard::Model self.discard_column = :hidden_at - self.ignored_columns += [ - :direction, - :durees_conservation_required, - :cerfa_flag, - :test_started_at, - :lien_demarche - ] default_scope -> { kept } @@ -49,9 +48,9 @@ class Procedure < ApplicationRecord has_one :module_api_carto, dependent: :destroy has_many :attestation_templates, dependent: :destroy has_one :attestation_template_v1, -> { AttestationTemplate.v1 }, dependent: :destroy, class_name: "AttestationTemplate", inverse_of: :procedure - has_one :attestation_template_v2, -> { AttestationTemplate.v2 }, dependent: :destroy, class_name: "AttestationTemplate", inverse_of: :procedure + has_many :attestation_templates_v2, -> { AttestationTemplate.v2 }, dependent: :destroy, class_name: "AttestationTemplate", inverse_of: :procedure - has_one :attestation_template, -> { order(Arel.sql("CASE WHEN version = '1' THEN 0 ELSE 1 END")) }, dependent: :destroy, inverse_of: :procedure + has_one :attestation_template, -> { published }, dependent: :destroy, inverse_of: :procedure belongs_to :parent_procedure, class_name: 'Procedure', optional: true belongs_to :canonical_procedure, class_name: 'Procedure', optional: true @@ -59,8 +58,10 @@ class Procedure < ApplicationRecord belongs_to :service, optional: true belongs_to :zone, optional: true has_and_belongs_to_many :zones + has_and_belongs_to_many :procedure_tags has_many :bulk_messages, dependent: :destroy + has_many :labels, dependent: :destroy def active_dossier_submitted_message published_dossier_submitted_message || draft_dossier_submitted_message @@ -70,10 +71,11 @@ class Procedure < ApplicationRecord brouillon? ? draft_revision : published_revision end - def types_de_champ_for_procedure_presentation(parent = nil) + def all_revisions_types_de_champ(parent: nil, with_header_section: false) + types_de_champ_scope = with_header_section ? TypeDeChamp.with_header_section : TypeDeChamp.fillable if brouillon? if parent.nil? - TypeDeChamp.fillable + types_de_champ_scope .joins(:revision_types_de_champ) .where(revision_types_de_champ: { revision_id: draft_revision_id, parent_id: nil }) .order(:private, :position) @@ -81,45 +83,15 @@ class Procedure < ApplicationRecord draft_revision.children_of(parent) end else - # all published revisions - revision_ids = revisions.ids - [draft_revision_id] - # fetch all parent types de champ - parent_ids = if parent.present? - ProcedureRevisionTypeDeChamp - .where(revision_id: revision_ids) - .joins(:type_de_champ) - .where(type_de_champ: { stable_id: parent.stable_id }) - .ids - end - - # fetch all type_de_champ.stable_id for all the revisions expect draft - # and for each stable_id take the bigger (more recent) type_de_champ.id - recent_ids = TypeDeChamp - .fillable - .joins(:revision_types_de_champ) - .where(revision_types_de_champ: { revision_id: revision_ids, parent_id: parent_ids }) - .group(:stable_id).select('MAX(types_de_champ.id)') - - # fetch the more recent procedure_revision_types_de_champ - # which includes recents_ids - recents_prtdc = ProcedureRevisionTypeDeChamp - .where(type_de_champ_id: recent_ids) - .where.not(revision_id: draft_revision_id) - .group(:type_de_champ_id) - .select('MAX(id)') - - TypeDeChamp - .joins(:revision_types_de_champ) - .where(revision_types_de_champ: { id: recents_prtdc }).then do |relation| - if feature_enabled?(:export_order_by_revision) # Fonds Verts, en attente d'exports personnalisables - relation.order(:private, 'revision_types_de_champ.revision_id': :desc, position: :asc) - else - relation.order(:private, :position, 'revision_types_de_champ.revision_id': :desc) - end - end + cache_key = ['all_revisions_types_de_champ', published_revision, parent, with_header_section].compact + Rails.cache.fetch(cache_key, expires_in: 1.month) { published_revisions_types_de_champ(parent:, with_header_section:) } end end + def types_de_champ_for_procedure_export + all_revisions_types_de_champ.not_repetition + end + def types_de_champ_for_tags TypeDeChamp .fillable @@ -150,9 +122,10 @@ class Procedure < ApplicationRecord end has_many :administrateurs_procedures, dependent: :delete_all - has_many :administrateurs, through: :administrateurs_procedures, after_remove: -> (procedure, _admin) { procedure.validate! } + has_many :administrateurs, through: :administrateurs_procedures, before_remove: :check_administrateur_minimal_presence has_many :groupe_instructeurs, -> { order(:label) }, inverse_of: :procedure, dependent: :destroy has_many :instructeurs, through: :groupe_instructeurs + has_many :export_templates, through: :groupe_instructeurs has_many :active_groupe_instructeurs, -> { active }, class_name: 'GroupeInstructeur', inverse_of: false has_many :closed_groupe_instructeurs, -> { closed }, class_name: 'GroupeInstructeur', inverse_of: false @@ -171,9 +144,7 @@ class Procedure < ApplicationRecord belongs_to :defaut_groupe_instructeur, class_name: 'GroupeInstructeur', inverse_of: false, optional: true - has_one_attached :logo do |attachable| - attachable.variant :email, resize_to_limit: [450, 450] - end + has_one_attached :logo has_one_attached :notice has_one_attached :deliberation @@ -234,20 +205,6 @@ class Procedure < ApplicationRecord includes(:draft_revision, :published_revision, administrateurs: :user) } - scope :for_download, -> { - includes( - :groupe_instructeurs, - dossiers: { - champs_public: [ - piece_justificative_file_attachments: :blob, - champs: [ - piece_justificative_file_attachments: :blob - ] - ] - } - ) - } - validates :libelle, presence: true, allow_blank: false, allow_nil: false validates :description, presence: true, allow_blank: false, allow_nil: false validates :administrateurs, presence: true @@ -257,13 +214,19 @@ class Procedure < ApplicationRecord validates :lien_dpo, url: { no_local: true, allow_blank: true, accept_email: true } validates :draft_types_de_champ_public, + 'types_de_champ/condition': true, + 'types_de_champ/expression_reguliere': true, + 'types_de_champ/header_section_consistency': true, 'types_de_champ/no_empty_block': true, 'types_de_champ/no_empty_drop_down': true, - on: :publication + on: [:types_de_champ_public_editor, :publication] + validates :draft_types_de_champ_private, + 'types_de_champ/condition': true, + 'types_de_champ/header_section_consistency': true, 'types_de_champ/no_empty_block': true, 'types_de_champ/no_empty_drop_down': true, - on: :publication + on: [:types_de_champ_private_editor, :publication] validate :check_juridique, on: [:create, :publication] @@ -285,7 +248,7 @@ class Procedure < ApplicationRecord validates_with MonAvisEmbedValidator - validates_associated :draft_revision, on: :publication + validate :validates_associated_draft_revision_with_context validates_associated :initiated_mail, on: :publication validates_associated :received_mail, on: :publication validates_associated :closed_mail, on: :publication @@ -325,7 +288,6 @@ class Procedure < ApplicationRecord size: { less_than: LOGO_MAX_SIZE }, if: -> { new_record? || created_at > Date.new(2020, 11, 13) } - validates :api_entreprise_token, jwt_token: true, allow_blank: true validates :api_particulier_token, format: { with: /\A[A-Za-z0-9\-_=.]{15,}\z/ }, allow_blank: true validate :validate_auto_archive_on_in_the_future, if: :will_save_change_to_auto_archive_on? @@ -359,36 +321,16 @@ class Procedure < ApplicationRecord end end + def check_administrateur_minimal_presence(_object) + if self.administrateurs.count <= 1 + raise ActiveRecord::RecordNotDestroyed.new("Cannot remove the last administrateur of procedure #{self.libelle} (#{self.id})") + end + end + def dossiers_close_to_expiration dossiers.close_to_expiration.count end - def publish_or_reopen!(administrateur) - Procedure.transaction do - if brouillon? - reset! - end - - other_procedure = other_procedure_with_path(path) - if other_procedure.present? && administrateur.owns?(other_procedure) - other_procedure.unpublish! - publish!(other_procedure.canonical_procedure || other_procedure) - else - publish! - end - end - end - - def reset! - if !locked? || draft_changed? - dossier_ids_to_destroy = draft_revision.dossiers.ids - if dossier_ids_to_destroy.present? - Rails.logger.info("Resetting #{dossier_ids_to_destroy.size} dossiers on procedure #{id}: #{dossier_ids_to_destroy}") - draft_revision.dossiers.destroy_all - end - end - end - def suggested_path(administrateur) if path_customized? return path @@ -423,11 +365,15 @@ class Procedure < ApplicationRecord def draft_changed? preload_draft_and_published_revisions - !brouillon? && published_revision.different_from?(draft_revision) && revision_changes.present? + !brouillon? && (types_de_champ_revision_changes.present? || ineligibilite_rules_revision_changes.present?) end - def revision_changes - published_revision.compare(draft_revision) + def types_de_champ_revision_changes + published_revision.compare_types_de_champ(draft_revision) + end + + def ineligibilite_rules_revision_changes + published_revision.compare_ineligibilite_rules(draft_revision) end def preload_draft_and_published_revisions @@ -550,6 +496,9 @@ class Procedure < ApplicationRecord procedure.closing_details = nil procedure.closing_notification_brouillon = false procedure.closing_notification_en_cours = false + procedure.template = false + procedure.monavis_embed = nil + procedure.labels = labels.map(&:dup) if !procedure.valid? procedure.errors.attribute_names.each do |attribute| @@ -649,14 +598,6 @@ class Procedure < ApplicationRecord end end - def self.default_sort - { - 'table' => 'self', - 'column' => 'id', - 'order' => 'desc' - } - end - def whitelist! touch(:whitelisted_at) end @@ -689,6 +630,10 @@ class Procedure < ApplicationRecord result << :service end + if service_siret_test? + result << :service + end + if missing_instructeurs? result << :instructeurs end @@ -702,7 +647,8 @@ class Procedure < ApplicationRecord def logo_url if logo.attached? - Rails.application.routes.url_helpers.url_for(logo) + logo_variant = logo.variant(resize_to_limit: [400, 400]) + logo_variant.key.present? ? logo_variant.processed.url : Rails.application.routes.url_helpers.url_for(logo) else ActionController::Base.helpers.image_url(PROCEDURE_DEFAULT_LOGO_SRC) end @@ -720,6 +666,10 @@ class Procedure < ApplicationRecord end end + def service_siret_test? + service&.siret == Service::SIRET_TEST + end + def revised? revisions.size > 2 end @@ -738,7 +688,7 @@ class Procedure < ApplicationRecord end def routing_champs - active_revision.types_de_champ_public.filter(&:used_by_routing_rules?).map(&:libelle) + active_revision.revision_types_de_champ_public.filter(&:used_by_routing_rules?).map(&:libelle) end def can_be_deleted_by_administrateur? @@ -789,35 +739,6 @@ class Procedure < ApplicationRecord "Procedure;#{id}" end - def api_entreprise_role?(role) - APIEntrepriseToken.new(api_entreprise_token).role?(role) - end - - def api_entreprise_token - self[:api_entreprise_token].presence || Rails.application.secrets.api_entreprise[:key] - end - - def api_entreprise_token_expired? - APIEntrepriseToken.new(api_entreprise_token).expired? - end - - def create_new_revision(revision = nil) - transaction do - new_revision = (revision || draft_revision) - .deep_clone(include: [:revision_types_de_champ]) - .tap { |revision| revision.published_at = nil } - .tap(&:save!) - - move_new_children_to_new_parent_coordinate(new_revision) - - # they are not aware of the new tdcs - new_revision.types_de_champ_public.reset - new_revision.types_de_champ_private.reset - - new_revision - end - end - def average_dossier_weight if dossiers.termine.any? dossiers_sample = dossiers.termine.limit(100) @@ -832,31 +753,6 @@ class Procedure < ApplicationRecord end end - def publish_revision! - reset! - transaction do - self.published_revision = draft_revision - self.draft_revision = create_new_revision - save!(context: :publication) - published_revision.touch(:published_at) - end - dossiers - .state_not_termine - .find_each(&:rebase_later) - end - - def reset_draft_revision! - if published_revision.present? && draft_changed? - reset! - transaction do - draft_revision.types_de_champ.filter(&:only_present_on_draft?).each(&:destroy) - draft_revision.update(dossier_submitted_message: nil) - draft_revision.destroy - update!(draft_revision: create_new_revision(published_revision)) - end - end - end - def cnaf_enabled? api_particulier_sources['cnaf'].present? end @@ -895,45 +791,6 @@ class Procedure < ApplicationRecord end end - def move_new_children_to_new_parent_coordinate(new_draft) - children = new_draft.revision_types_de_champ - .includes(parent: :type_de_champ) - .where.not(parent_id: nil) - coordinates_by_stable_id = new_draft.revision_types_de_champ - .includes(:type_de_champ) - .index_by(&:stable_id) - - children.each do |child| - child.update!(parent: coordinates_by_stable_id.fetch(child.parent.stable_id)) - end - new_draft.reload - end - - def before_publish - assign_attributes(closed_at: nil, unpublished_at: nil) - end - - def after_publish(canonical_procedure = nil) - self.canonical_procedure = canonical_procedure - self.published_revision = draft_revision - self.draft_revision = create_new_revision - save!(context: :publication) - touch(:published_at) - published_revision.touch(:published_at) - end - - def after_republish(canonical_procedure = nil) - touch(:published_at) - end - - def after_close - touch(:closed_at) - end - - def after_unpublish - touch(:unpublished_at) - end - def update_juridique_required self.juridique_required ||= (cadre_juridique.present? || deliberation.attached?) true @@ -967,8 +824,14 @@ class Procedure < ApplicationRecord end end - def stable_ids_used_by_routing_rules - @stable_ids_used_by_routing_rules ||= groupe_instructeurs.flat_map { _1.routing_rule&.sources }.compact + def create_generic_labels + Label::GENERIC_LABELS.each do |label| + Label.create(name: label[:name], color: label[:color], procedure_id: self.id) + end + end + + def used_by_routing_rules?(type_de_champ) + type_de_champ.stable_id.in?(stable_ids_used_by_routing_rules) end # We need this to unfuck administrate + aasm @@ -980,22 +843,6 @@ class Procedure < ApplicationRecord end end - def pieces_jointes_list? - pieces_jointes_list_without_conditionnal.present? || pieces_jointes_list_with_conditionnal.present? - end - - def pieces_jointes_list_without_conditionnal - pieces_jointes_list do |base_scope| - base_scope.where(types_de_champ: { condition: nil }) - end - end - - def pieces_jointes_list_with_conditionnal - pieces_jointes_list do |base_scope| - base_scope.where.not(types_de_champ: { condition: nil }) - end - end - def toggle_routing update!(routing_enabled: self.groupe_instructeurs.active.many?) end @@ -1004,16 +851,14 @@ class Procedure < ApplicationRecord lien_dpo.present? && lien_dpo.match?(/@/) end - def header_sections - draft_revision.revision_types_de_champ_public.filter { _1.type_de_champ.header_section? } - end - def dossier_for_preview(user) # Try to use a preview or a dossier filled by current user - dossiers.where(for_procedure_preview: true).or(dossiers.not_brouillon) + dossiers.where(for_procedure_preview: true).or(dossiers.visible_by_administration) .order(Arel.sql("CASE WHEN user_id = #{user.id} THEN 1 ELSE 0 END DESC, CASE WHEN state = 'accepte' THEN 1 ELSE 0 END DESC, - CASE WHEN for_procedure_preview = True THEN 1 ELSE 0 END DESC")) \ + CASE WHEN state = 'brouillon' THEN 0 ELSE 1 END DESC, + CASE WHEN for_procedure_preview = True THEN 1 ELSE 0 END DESC, + id DESC")) \ .first end @@ -1021,22 +866,60 @@ class Procedure < ApplicationRecord update!(closing_reason: nil, closing_details: nil, replaced_by_procedure_id: nil, closing_notification_brouillon: false, closing_notification_en_cours: false) end + def monavis_embed_html_source(source) + monavis_embed.gsub('nd_source=button', "nd_source=#{source}").gsub(' ['notifications'], - 'self' => ['id', 'state'] - } - - TABLE = 'table' - COLUMN = 'column' - ORDER = 'order' - - SLASH = '/' - TYPE_DE_CHAMP = 'type_de_champ' - TYPE_DE_CHAMP_PRIVATE = 'type_de_champ_private' - - FILTERS_VALUE_MAX_LENGTH = 100 + self.ignored_columns += ["displayed_fields", "filters", "sort"] belongs_to :assign_to, optional: false has_many :exports, dependent: :destroy delegate :procedure, :instructeur, to: :assign_to - validate :check_allowed_displayed_fields - validate :check_allowed_sort_column - validate :check_allowed_sort_order - validate :check_allowed_filter_columns - validate :check_filters_max_length + attribute :displayed_columns, :column, array: true - def self_fields - [ - field_hash('self', 'created_at', type: :date), - field_hash('self', 'updated_at', type: :date), - field_hash('self', 'depose_at', type: :date), - field_hash('self', 'en_construction_at', type: :date), - field_hash('self', 'en_instruction_at', type: :date), - field_hash('self', 'processed_at', type: :date), - *sva_svr_fields(for_filters: true), - field_hash('self', 'updated_since', type: :date, virtual: true), - field_hash('self', 'depose_since', type: :date, virtual: true), - field_hash('self', 'en_construction_since', type: :date, virtual: true), - field_hash('self', 'en_instruction_since', type: :date, virtual: true), - field_hash('self', 'processed_since', type: :date, virtual: true), - field_hash('self', 'state', type: :enum, scope: 'instructeurs.dossiers.filterable_state', virtual: true) - ].compact_blank + attribute :sorted_column, :sorted_column + def sorted_column = super || procedure.default_sorted_column # Dummy override to set default value + + attribute :a_suivre_filters, :filtered_column, array: true + attribute :suivis_filters, :filtered_column, array: true + attribute :traites_filters, :filtered_column, array: true + attribute :tous_filters, :filtered_column, array: true + attribute :supprimes_filters, :filtered_column, array: true + attribute :supprimes_recemment_filters, :filtered_column, array: true + attribute :expirant_filters, :filtered_column, array: true + attribute :archives_filters, :filtered_column, array: true + + before_create { self.displayed_columns = procedure.default_displayed_columns } + + validates_associated :displayed_columns, :sorted_column, :a_suivre_filters, :suivis_filters, + :traites_filters, :tous_filters, :supprimes_filters, :expirant_filters, :archives_filters + + def filters_for(statut) + send(filters_name_for(statut)) end - def fields - fields = self_fields - - fields.push( - field_hash('user', 'email', type: :text), - field_hash('followers_instructeurs', 'email', type: :text), - field_hash('groupe_instructeur', 'id', type: :enum), - field_hash('avis', 'question_answer', filterable: false) - ) - - if procedure.for_individual - fields.push( - field_hash("individual", "prenom", type: :text), - field_hash("individual", "nom", type: :text), - field_hash("individual", "gender", type: :text) - ) - end - - if !procedure.for_individual - fields.push( - field_hash('etablissement', 'entreprise_siren', type: :text), - field_hash('etablissement', 'entreprise_forme_juridique', type: :text), - field_hash('etablissement', 'entreprise_nom_commercial', type: :text), - field_hash('etablissement', 'entreprise_raison_sociale', type: :text), - field_hash('etablissement', 'entreprise_siret_siege_social', type: :text), - field_hash('etablissement', 'entreprise_date_creation', type: :date) - ) - - fields.push( - field_hash('etablissement', 'siret', type: :text), - field_hash('etablissement', 'libelle_naf', type: :text), - field_hash('etablissement', 'code_postal', type: :text) - ) - end - - fields.concat(procedure.types_de_champ_for_procedure_presentation - .pluck(:type_champ, :libelle, :private, :stable_id) - .reject { |(type_champ)| type_champ == TypeDeChamp.type_champs.fetch(:repetition) } - .map do |(type_champ, libelle, is_private, stable_id)| - if is_private - field_hash_for_type_de_champ_private(type_champ, libelle, stable_id) - else - field_hash_for_type_de_champ_public(type_champ, libelle, stable_id) - end - end) - - fields - end - - def displayable_fields_for_select - [ - fields.reject { |field| field['virtual'] } - .map { |field| [field['label'], field_id(field)] }, - displayed_fields.map { |field| field_id(field) } - ] - end - - def filterable_fields_options - fields.filter_map do |field| - next if field['filterable'] == false - - [field['label'], field_id(field)] - end - end + def filters_name_for(statut) = statut.tr('-', '_').then { "#{_1}_filters" } def displayed_fields_for_headers - [ - field_hash('self', 'id', classname: 'number-col'), - *displayed_fields, - field_hash('self', 'state', classname: 'state-col'), - *sva_svr_fields + columns = [ + procedure.dossier_id_column, + *displayed_columns, + procedure.dossier_state_column ] - end - - def sva_svr_fields(for_filters: false) - return if !procedure.sva_svr_enabled? - - i18n_scope = [:activerecord, :attributes, :procedure_presentation, :fields, :self] - - fields = [] - fields << field_hash('self', 'sva_svr_decision_on', - type: :date, - label: I18n.t("#{procedure.sva_svr_decision}_decision_on", scope: i18n_scope), - classname: for_filters ? '' : 'sva-col') - - if for_filters - fields << field_hash('self', 'sva_svr_decision_before', - label: I18n.t("#{procedure.sva_svr_decision}_decision_before", scope: i18n_scope), - type: :date, virtual: true) - end - - fields - end - - def sorted_ids(dossiers, count) - table, column, order = sort.values_at(TABLE, COLUMN, 'order') - - case table - when 'notifications' - dossiers_id_with_notification = dossiers.merge(instructeur.followed_dossiers).with_notifications.ids - if order == 'desc' - dossiers_id_with_notification + - (dossiers.order('dossiers.updated_at desc').ids - dossiers_id_with_notification) - else - (dossiers.order('dossiers.updated_at asc').ids - dossiers_id_with_notification) + - dossiers_id_with_notification - end - when TYPE_DE_CHAMP - ids = dossiers - .with_type_de_champ(column) - .order("champs.value #{order}") - .pluck(:id) - if ids.size != count - rest = dossiers.where.not(id: ids).order(id: order).pluck(:id) - order == 'asc' ? ids + rest : rest + ids - else - ids - end - when TYPE_DE_CHAMP_PRIVATE - ids = dossiers - .with_type_de_champ(column) - .order("champs.value #{order}") - .pluck(:id) - if ids.size != count - rest = dossiers.where.not(id: ids).order(id: order).pluck(:id) - order == 'asc' ? ids + rest : rest + ids - else - ids - end - when 'followers_instructeurs' - assert_supported_column(table, column) - # LEFT OUTER JOIN allows to keep dossiers without assigned instructeurs yet - dossiers - .includes(:followers_instructeurs) - .joins('LEFT OUTER JOIN users instructeurs_users ON instructeurs_users.id = instructeurs.user_id') - .order("instructeurs_users.email #{order}") - .pluck(:id) - .uniq - when 'avis' - dossiers.includes(table) - .order("#{self.class.sanitized_column(table, column)} #{order}") - .pluck(:id) - .uniq - when 'self', 'user', 'individual', 'etablissement', 'groupe_instructeur' - (table == 'self' ? dossiers : dossiers.includes(table)) - .order("#{self.class.sanitized_column(table, column)} #{order}") - .pluck(:id) - end - end - - def filtered_ids(dossiers, statut) - filters.fetch(statut) - .group_by { |filter| filter.values_at(TABLE, COLUMN) } - .map do |(table, column), filters| - values = filters.pluck('value') - value_column = filters.pluck('value_column').compact.first || :value - case table - when 'self' - field = self_fields.find { |h| h['column'] == column } - if field['type'] == :date - dates = values - .filter_map { |v| Time.zone.parse(v).beginning_of_day rescue nil } - - dossiers.filter_by_datetimes(column, dates) - elsif field['column'] == "state" && values.include?("pending_correction") - dossiers.joins(:corrections).where(corrections: DossierCorrection.pending) - elsif field['column'] == "state" && values.include?("en_construction") - dossiers.where("dossiers.#{column} IN (?)", values).includes(:corrections).where.not(corrections: DossierCorrection.pending) - else - dossiers.where("dossiers.#{column} IN (?)", values) - end - when TYPE_DE_CHAMP - dossiers.with_type_de_champ(column) - .filter_ilike(:champs, value_column, values) - when TYPE_DE_CHAMP_PRIVATE - dossiers.with_type_de_champ(column) - .filter_ilike(:champs, value_column, values) - when 'etablissement' - if column == 'entreprise_date_creation' - dates = values - .filter_map { |v| v.to_date rescue nil } - - dossiers - .includes(table) - .where(table.pluralize => { column => dates }) - else - dossiers - .includes(table) - .filter_ilike(table, column, values) - end - when 'followers_instructeurs' - assert_supported_column(table, column) - dossiers - .includes(:followers_instructeurs) - .joins('INNER JOIN users instructeurs_users ON instructeurs_users.id = instructeurs.user_id') - .filter_ilike('instructeurs_users', :email, values) - when 'user', 'individual', 'avis' - dossiers - .includes(table) - .filter_ilike(table, column, values) - when 'groupe_instructeur' - assert_supported_column(table, column) - if column == 'label' - dossiers - .joins(:groupe_instructeur) - .filter_ilike(table, column, values) - else - dossiers - .joins(:groupe_instructeur) - .where(groupe_instructeur_id: values) - end - end.pluck(:id) - end.reduce(:&) - end - - def filtered_sorted_ids(dossiers, statut, count: nil) - dossiers_by_statut = dossiers.by_statut(statut, instructeur) - dossiers_sorted_ids = self.sorted_ids(dossiers_by_statut, count || dossiers_by_statut.size) - - if filters[statut].present? - dossiers_sorted_ids.intersection(filtered_ids(dossiers_by_statut, statut)) - else - dossiers_sorted_ids - end - end - - def human_value_for_filter(filter) - if [TYPE_DE_CHAMP, TYPE_DE_CHAMP_PRIVATE].include?(filter[TABLE]) - find_type_de_champ(filter[COLUMN]).dynamic_type.filter_to_human(filter['value']) - elsif filter['column'] == 'state' - if filter['value'] == 'pending_correction' - Dossier.human_attribute_name("pending_correction.for_instructeur") - else - Dossier.human_attribute_name("state.#{filter['value']}") - end - elsif filter['table'] == 'groupe_instructeur' && filter['column'] == 'id' - instructeur.groupe_instructeurs - .find { _1.id == filter['value'].to_i }&.label || filter['value'] - else - field = find_field(filter[TABLE], filter[COLUMN]) - - if field["type"] == :date - parsed_date = safe_parse_date(filter['value']) - - return parsed_date.present? ? I18n.l(parsed_date) : nil - end - - filter['value'] - end - end - - def safe_parse_date(string) - Date.parse(string) - rescue Date::Error - nil - end - - def add_filter(statut, field, value) - if value.present? - table, column = field.split(SLASH) - label, value_column = find_field(table, column).values_at('label', 'value_column') - - case table - when TYPE_DE_CHAMP, TYPE_DE_CHAMP_PRIVATE - value = find_type_de_champ(column).dynamic_type.human_to_filter(value) - end - - updated_filters = filters.dup - updated_filters[statut] << { - 'label' => label, - TABLE => table, - COLUMN => column, - 'value_column' => value_column, - 'value' => value - } - - update(filters: updated_filters) - end - end - - def remove_filter(statut, field, value) - table, column = field.split(SLASH) - - updated_filters = filters.dup - updated_filters[statut] = filters[statut].reject do |filter| - filter.values_at(TABLE, COLUMN, 'value') == [table, column, value] - end - - update!(filters: updated_filters) - end - - def update_displayed_fields(values) - if values.nil? - values = [] - end - - fields = values.map { |value| find_field(*value.split(SLASH)) } - - update!(displayed_fields: fields) - - if !values.include?(field_id(sort)) - update!(sort: Procedure.default_sort) - end - end - - def update_sort(table, column, order) - update!(sort: { - TABLE => table, - COLUMN => column, - ORDER => order.presence || opposite_order_for(table, column) - }) - end - - def opposite_order_for(table, column) - if sort.values_at(TABLE, COLUMN) == [table, column] - sort['order'] == 'asc' ? 'desc' : 'asc' - elsif [table, column] == ["notifications", "notifications"] - 'desc' # default order for notifications - else - 'asc' - end - end - - def snapshot - slice(:filters, :sort, :displayed_fields) - end - - def field_type(field_id) - find_field(*field_id.split(SLASH))['type'] - end - - def field_enum(field_id) - field = find_field(*field_id.split(SLASH)) - if field['scope'].present? - I18n.t(field['scope']).map(&:to_a).map(&:reverse) - elsif field['table'] == 'groupe_instructeur' - instructeur.groupe_instructeurs.filter_map do - if _1.procedure_id == procedure.id - [_1.label, _1.id] - end - end - else - find_type_de_champ(field['column']).options_for_select - end - end - - def sortable?(field) - sort['table'] == field['table'] && sort['column'] == field['column'] - end - - def aria_sort(order, field) - if sortable?(field) - if order == 'asc' - { "aria-sort": "ascending" } - elsif order == 'desc' - { "aria-sort": "descending" } - end - else - {} - end - end - - private - - def field_id(field) - field.values_at(TABLE, COLUMN).join(SLASH) - end - - def find_field(table, column) - fields.find { |field| field.values_at(TABLE, COLUMN) == [table, column] } - end - - def find_type_de_champ(column) - TypeDeChamp - .joins(:revision_types_de_champ) - .where(revision_types_de_champ: { revision_id: procedure.revisions }) - .order(created_at: :desc) - .find_by(stable_id: column) - end - - def check_allowed_displayed_fields - displayed_fields.each do |field| - check_allowed_field(:displayed_fields, field) - end - end - - def check_allowed_sort_column - check_allowed_field(:sort, sort, EXTRA_SORT_COLUMNS) - end - - def check_allowed_sort_order - order = sort['order'] - if !["asc", "desc"].include?(order) - errors.add(:sort, "#{order} n’est pas une ordre permis") - end - end - - def check_allowed_filter_columns - filters.each do |key, columns| - return true if key == 'migrated' - columns.each do |column| - check_allowed_field(:filters, column) - end - end - end - - def check_allowed_field(kind, field, extra_columns = {}) - table, column = field.values_at(TABLE, COLUMN) - if !valid_column?(table, column, extra_columns) - errors.add(kind, "#{table}.#{column} n’est pas une colonne permise") - end - end - - def check_filters_max_length - filters.values.flatten.each do |filter| - next if !filter.is_a?(Hash) - next if filter['value']&.length.to_i <= FILTERS_VALUE_MAX_LENGTH - - errors.add(:base, "Le filtre #{filter['label']} est trop long (maximum: #{FILTERS_VALUE_MAX_LENGTH} caractères)") - end - end - - def field_hash(table, column, label: nil, classname: '', virtual: false, type: :text, scope: '', value_column: :value, filterable: true) - { - 'label' => label || I18n.t(column, scope: [:activerecord, :attributes, :procedure_presentation, :fields, table]), - TABLE => table, - COLUMN => column, - 'classname' => classname, - 'virtual' => virtual, - 'type' => type, - 'scope' => scope, - 'value_column' => value_column, - 'filterable' => filterable - } - end - - def field_hash_for_type_de_champ_public(type_champ, libelle, stable_id) - field_hash(TYPE_DE_CHAMP, stable_id.to_s, - label: libelle, - type: TypeDeChamp.filter_hash_type(type_champ), - value_column: TypeDeChamp.filter_hash_value_column(type_champ)) - end - - def field_hash_for_type_de_champ_private(type_champ, libelle, stable_id) - field_hash(TYPE_DE_CHAMP_PRIVATE, stable_id.to_s, - label: libelle, - type: TypeDeChamp.filter_hash_type(type_champ), - value_column: TypeDeChamp.filter_hash_value_column(type_champ)) - end - - def valid_column?(table, column, extra_columns = {}) - valid_columns_for_table(table).include?(column) || - extra_columns[table]&.include?(column) - end - - def valid_columns_for_table(table) - @column_whitelist ||= fields - .group_by { |field| field[TABLE] } - .transform_values { |fields| Set.new(fields.pluck(COLUMN)) } - - @column_whitelist[table] || [] - end - - def self.sanitized_column(association, column) - table = if association == 'self' - Dossier.table_name - elsif (association_reflection = Dossier.reflect_on_association(association)) - association_reflection.klass.table_name - else - # Allow filtering on a joined table alias (which doesn’t exist - # in the ActiveRecord domain). - association - end - - [table, column] - .map { |name| ActiveRecord::Base.connection.quote_column_name(name) } - .join('.') - end - - def assert_supported_column(table, column) - if table == 'followers_instructeurs' && column != 'email' - raise ArgumentError, 'Table `followers_instructeurs` only supports the `email` column.' - end - if table == 'groupe_instructeur' && (column != 'label' && column != 'id') - raise ArgumentError, 'Table `groupe_instructeur` only supports the `label` or `id` column.' - end + columns.concat(procedure.sva_svr_columns.filter(&:displayable)) if procedure.sva_svr_enabled? + columns end end diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index c8daa1bec..6ed3cce75 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + class ProcedureRevision < ApplicationRecord + include Logic self.implicit_order_column = :created_at belongs_to :procedure, -> { with_discarded }, inverse_of: :revisions, optional: false belongs_to :dossier_submitted_message, inverse_of: :revisions, optional: true, dependent: :destroy @@ -17,21 +20,22 @@ class ProcedureRevision < ApplicationRecord scope :ordered, -> { order(:created_at) } - validate :conditions_are_valid? - validate :header_sections_are_valid? - validate :expressions_regulieres_are_valid? + validates :ineligibilite_message, presence: true, if: -> { ineligibilite_enabled? } delegate :path, to: :procedure, prefix: true - def build_champs_public - # reload: it can be out of sync in test if some tdcs are added wihtout using add_tdc - types_de_champ_public.reload.map(&:build_champ) - end + validate :ineligibilite_rules_are_valid?, + on: [:ineligibilite_rules_editor, :publication] + validates :ineligibilite_message, + presence: true, + if: -> { ineligibilite_enabled? }, + on: [:ineligibilite_rules_editor, :publication] + validates :ineligibilite_rules, + presence: true, + if: -> { ineligibilite_enabled? }, + on: [:ineligibilite_rules_editor, :publication] - def build_champs_private - # reload: it can be out of sync in test if some tdcs are added wihtout using add_tdc - types_de_champ_private.reload.map(&:build_champ) - end + serialize :ineligibilite_rules, LogicSerializer def add_type_de_champ(params) parent_stable_id = params.delete(:parent_stable_id) @@ -140,42 +144,39 @@ class ProcedureRevision < ApplicationRecord !draft? end - def different_from?(revision) - revision_types_de_champ != revision.revision_types_de_champ - end - - def compare(revision) + def compare_types_de_champ(revision) changes = [] changes += compare_revision_types_de_champ(revision_types_de_champ, revision.revision_types_de_champ) changes end + def compare_ineligibilite_rules(revision) + changes = [] + changes += compare_revision_ineligibilite_rules(revision) + changes + end + def dossier_for_preview(user) dossier = Dossier .create_with(autorisation_donnees: true) .find_or_initialize_by(revision: self, user: user, for_procedure_preview: true, state: Dossier.states.fetch(:brouillon)) if dossier.new_record? - dossier.build_default_individual + dossier.build_default_values dossier.save! end dossier end - def types_de_champ_for(scope: nil, root: false) - # We return an unordered collection - return types_de_champ if !root && scope.nil? - return types_de_champ.filter { scope == :public ? _1.public? : _1.private? } if !root - - # We return an ordered collection + def types_de_champ_for(scope: nil) case scope when :public - types_de_champ_public + types_de_champ.filter(&:public?) when :private - types_de_champ_private + types_de_champ.filter(&:private?) else - types_de_champ_public + types_de_champ_private + types_de_champ end end @@ -203,11 +204,6 @@ class ProcedureRevision < ApplicationRecord .find { _1.type_de_champ_id == tdc.id }.parent&.type_de_champ end - def child?(tdc) - revision_types_de_champ - .find { _1.type_de_champ_id == tdc.id }.child? - end - def remove_children_of(tdc) children_of(tdc).each do |child| remove_type_de_champ(child.stable_id) @@ -234,7 +230,7 @@ class ProcedureRevision < ApplicationRecord end def coordinate_for(tdc) - revision_types_de_champ.find_by!(type_de_champ: tdc) + revision_types_de_champ.find { _1.stable_id == tdc.stable_id } end def carte? @@ -251,8 +247,12 @@ class ProcedureRevision < ApplicationRecord [coordinate, coordinate&.type_de_champ] end - def routable_types_de_champ - types_de_champ_public.filter(&:routable?) + def simple_routable_types_de_champ + types_de_champ_public.filter(&:simple_routable?) + end + + def conditionable_types_de_champ + types_de_champ_for(scope: :public).filter(&:conditionable?) end private @@ -322,6 +322,29 @@ class ProcedureRevision < ApplicationRecord end end + def compare_revision_ineligibilite_rules(new_revision) + from_ineligibilite_rules = ineligibilite_rules + to_ineligibilite_rules = new_revision.ineligibilite_rules + changes = [] + + if from_ineligibilite_rules.present? && to_ineligibilite_rules.blank? + changes << ProcedureRevisionChange::RemoveEligibiliteRuleChange + end + if from_ineligibilite_rules.blank? && to_ineligibilite_rules.present? + changes << ProcedureRevisionChange::AddEligibiliteRuleChange + end + if from_ineligibilite_rules != to_ineligibilite_rules + changes << ProcedureRevisionChange::UpdateEligibiliteRuleChange + end + if ineligibilite_message != new_revision.ineligibilite_message + changes << ProcedureRevisionChange::UpdateEligibiliteMessageChange + end + if ineligibilite_enabled != new_revision.ineligibilite_enabled + changes << (new_revision.ineligibilite_enabled ? ProcedureRevisionChange::EligibiliteEnabledChange : ProcedureRevisionChange::EligibiliteDisabledChange) + end + changes.map { _1.new(self, new_revision) } + end + def compare_type_de_champ(from_type_de_champ, to_type_de_champ, from_coordinates, to_coordinates) changes = [] if from_type_de_champ.type_champ != to_type_de_champ.type_champ @@ -368,12 +391,12 @@ class ProcedureRevision < ApplicationRecord to_type_de_champ.condition&.to_s(to_coordinates.map(&:type_de_champ))) end - if to_type_de_champ.drop_down_list? - if from_type_de_champ.drop_down_list_options != to_type_de_champ.drop_down_list_options + if to_type_de_champ.any_drop_down_list? + if from_type_de_champ.drop_down_options != to_type_de_champ.drop_down_options changes << ProcedureRevisionChange::UpdateChamp.new(from_type_de_champ, :drop_down_options, - from_type_de_champ.drop_down_list_options, - to_type_de_champ.drop_down_list_options) + from_type_de_champ.drop_down_options, + to_type_de_champ.drop_down_options) end if to_type_de_champ.linked_drop_down_list? if from_type_de_champ.drop_down_secondary_libelle != to_type_de_champ.drop_down_secondary_libelle @@ -402,7 +425,7 @@ class ProcedureRevision < ApplicationRecord from_type_de_champ.carte_optional_layers, to_type_de_champ.carte_optional_layers) end - elsif to_type_de_champ.piece_justificative? + elsif to_type_de_champ.piece_justificative_or_titre_identite? if from_type_de_champ.checksum_for_attachment(:piece_justificative_template) != to_type_de_champ.checksum_for_attachment(:piece_justificative_template) changes << ProcedureRevisionChange::UpdateChamp.new(from_type_de_champ, :piece_justificative_template, @@ -417,7 +440,7 @@ class ProcedureRevision < ApplicationRecord to_type_de_champ.filename_for_attachement(:notice_explicative)) end elsif to_type_de_champ.textarea? - if from_type_de_champ.character_limit != to_type_de_champ.character_limit + if from_type_de_champ.character_limit.presence != to_type_de_champ.character_limit.presence changes << ProcedureRevisionChange::UpdateChamp.new(from_type_de_champ, :character_limit, from_type_de_champ.character_limit, @@ -446,6 +469,13 @@ class ProcedureRevision < ApplicationRecord changes end + def ineligibilite_rules_are_valid? + if ineligibilite_rules + ineligibilite_rules.errors(types_de_champ_for(scope: :public).to_a) + .each { errors.add(:ineligibilite_rules, :invalid) } + end + end + def replace_type_de_champ_by_clone(coordinate) cloned_type_de_champ = coordinate.type_de_champ.deep_clone do |original, kopy| ClonePiecesJustificativesService.clone_attachments(original, kopy) @@ -453,48 +483,4 @@ class ProcedureRevision < ApplicationRecord coordinate.update!(type_de_champ: cloned_type_de_champ) cloned_type_de_champ end - - def conditions_are_valid? - public_tdcs = types_de_champ_public.to_a - .flat_map { _1.repetition? ? children_of(_1) : _1 } - - public_tdcs - .map.with_index - .filter_map { |tdc, i| tdc.condition? ? [tdc, i] : nil } - .map do |tdc, i| - [tdc, tdc.condition.errors(public_tdcs.take(i))] - end - .filter { |_tdc, errors| errors.present? } - .each { |tdc, message| errors.add(:condition, message, type_de_champ: tdc) } - end - - def header_sections_are_valid? - public_tdcs = types_de_champ_public.to_a - - root_tdcs_errors = errors_for_header_sections_order(public_tdcs) - repetition_tdcs_errors = public_tdcs - .filter_map { _1.repetition? ? children_of(_1) : nil } - .map { errors_for_header_sections_order(_1) } - - repetition_tdcs_errors + root_tdcs_errors - end - - def expressions_regulieres_are_valid? - types_de_champ_public.to_a - .flat_map { _1.repetition? ? children_of(_1) : _1 } - .each do |tdc| - if tdc.expression_reguliere? && tdc.invalid_regexp? - errors.add(:expression_reguliere, type_de_champ: tdc) - end - end - end - - def errors_for_header_sections_order(tdcs) - tdcs - .map.with_index - .filter_map { |tdc, i| tdc.header_section? ? [tdc, i] : nil } - .map { |tdc, i| [tdc, tdc.check_coherent_header_level(tdcs.take(i))] } - .filter { |_tdc, errors| errors.present? } - .each { |tdc, message| errors.add(:header_section, message, type_de_champ: tdc) } - end end diff --git a/app/models/procedure_revision_change.rb b/app/models/procedure_revision_change.rb index fc412cc26..ddd52a7d0 100644 --- a/app/models/procedure_revision_change.rb +++ b/app/models/procedure_revision_change.rb @@ -1,17 +1,21 @@ +# frozen_string_literal: true + class ProcedureRevisionChange - attr_reader :type_de_champ - def initialize(type_de_champ) - @type_de_champ = type_de_champ + class TypeDeChange + attr_reader :type_de_champ + def initialize(type_de_champ) + @type_de_champ = type_de_champ + end + + def label = @type_de_champ.libelle + def stable_id = @type_de_champ.stable_id + def private? = @type_de_champ.private? + def child? = @type_de_champ.child? + + def to_h = { op:, stable_id:, label:, private: private? } end - def label = @type_de_champ.libelle - def stable_id = @type_de_champ.stable_id - def private? = @type_de_champ.private? - def child? = @type_de_champ.child? - - def to_h = { op:, stable_id:, label:, private: private? } - - class AddChamp < ProcedureRevisionChange + class AddChamp < TypeDeChange def initialize(type_de_champ) super(type_de_champ) end @@ -23,7 +27,7 @@ class ProcedureRevisionChange def to_h = super.merge(mandatory: mandatory?) end - class RemoveChamp < ProcedureRevisionChange + class RemoveChamp < TypeDeChange def initialize(type_de_champ) super(type_de_champ) end @@ -32,7 +36,7 @@ class ProcedureRevisionChange def can_rebase?(dossier = nil) = true end - class MoveChamp < ProcedureRevisionChange + class MoveChamp < TypeDeChange attr_reader :from, :to def initialize(type_de_champ, from, to) @@ -46,7 +50,7 @@ class ProcedureRevisionChange def to_h = super.merge(from:, to:) end - class UpdateChamp < ProcedureRevisionChange + class UpdateChamp < TypeDeChange attr_reader :attribute, :from, :to def initialize(type_de_champ, attribute, from, to) @@ -75,4 +79,48 @@ class ProcedureRevisionChange end end end + + class EligibiliteRulesChange + attr_reader :previous_revision, :new_revision + def initialize(previous_revision, new_revision) + @previous_revision = previous_revision + @new_revision = new_revision + @previous_ineligibilite_rules = @previous_revision.ineligibilite_rules + @new_ineligibilite_rules = @new_revision.ineligibilite_rules + end + + def i18n_params + { + previous_condition: @previous_ineligibilite_rules&.to_s(previous_revision.types_de_champ.filter { @previous_ineligibilite_rules.sources.include? _1.stable_id }), + new_condition: @new_ineligibilite_rules&.to_s(new_revision.types_de_champ.filter { @new_ineligibilite_rules.sources.include? _1.stable_id }) + } + end + end + + class AddEligibiliteRuleChange < EligibiliteRulesChange + def op = :add + end + + class RemoveEligibiliteRuleChange < EligibiliteRulesChange + def op = :remove + end + + class UpdateEligibiliteRuleChange < EligibiliteRulesChange + def op = :update + end + + class EligibiliteEnabledChange < EligibiliteRulesChange + def op = :enabled + def i18n_params = {} + end + + class EligibiliteDisabledChange < EligibiliteRulesChange + def op = :disabled + def i18n_params = {} + end + + class UpdateEligibiliteMessageChange < EligibiliteRulesChange + def op = :message_updated + def i18n_params = { ineligibilite_message: @new_revision.ineligibilite_message } + end end diff --git a/app/models/procedure_revision_preloader.rb b/app/models/procedure_revision_preloader.rb index bd85517ba..a5c35dac6 100644 --- a/app/models/procedure_revision_preloader.rb +++ b/app/models/procedure_revision_preloader.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ProcedureRevisionPreloader def initialize(revisions) @revisions = revisions diff --git a/app/models/procedure_revision_type_de_champ.rb b/app/models/procedure_revision_type_de_champ.rb index c4842da20..3963ae630 100644 --- a/app/models/procedure_revision_type_de_champ.rb +++ b/app/models/procedure_revision_type_de_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ProcedureRevisionTypeDeChamp < ApplicationRecord belongs_to :revision, class_name: 'ProcedureRevision' belongs_to :type_de_champ @@ -11,7 +13,7 @@ class ProcedureRevisionTypeDeChamp < ApplicationRecord scope :public_only, -> { joins(:type_de_champ).where(types_de_champ: { private: false }) } scope :private_only, -> { joins(:type_de_champ).where(types_de_champ: { private: true }) } - delegate :stable_id, :libelle, :description, :type_champ, :mandatory?, :private?, :to_typed_id, to: :type_de_champ + delegate :stable_id, :libelle, :description, :type_champ, :header_section?, :mandatory?, :private?, :to_typed_id, to: :type_de_champ def child? parent_id.present? @@ -30,8 +32,8 @@ class ProcedureRevisionTypeDeChamp < ApplicationRecord end def siblings - if parent_id.present? - revision.revision_types_de_champ.where(parent_id: parent_id).ordered + if child? + revision.revision_types_de_champ.where(parent_id:).ordered elsif private? revision.revision_types_de_champ_private else @@ -73,6 +75,10 @@ class ProcedureRevisionTypeDeChamp < ApplicationRecord end def used_by_routing_rules? - stable_id.in?(procedure.stable_ids_used_by_routing_rules) + procedure.used_by_routing_rules?(type_de_champ) + end + + def used_by_ineligibilite_rules? + revision.ineligibilite_enabled? && stable_id.in?(revision.ineligibilite_rules&.sources || []) end end diff --git a/app/models/procedure_tag.rb b/app/models/procedure_tag.rb new file mode 100644 index 000000000..37d770b43 --- /dev/null +++ b/app/models/procedure_tag.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ProcedureTag < ApplicationRecord + has_and_belongs_to_many :procedures + + validates :name, presence: true, uniqueness: { case_sensitive: false } +end diff --git a/app/models/procedures_filter.rb b/app/models/procedures_filter.rb index c590cd355..a993494e8 100644 --- a/app/models/procedures_filter.rb +++ b/app/models/procedures_filter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ProceduresFilter attr_reader :admin, :params diff --git a/app/models/published_procedure.rb b/app/models/published_procedure.rb new file mode 100644 index 000000000..3f55757a4 --- /dev/null +++ b/app/models/published_procedure.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class PublishedProcedure + extend ActiveModel::Naming + extend ActiveModel::Translation +end diff --git a/app/models/release_note.rb b/app/models/release_note.rb index a2a48f1f1..11a5a3ce3 100644 --- a/app/models/release_note.rb +++ b/app/models/release_note.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ReleaseNote < ApplicationRecord has_rich_text :body diff --git a/app/models/routing_engine.rb b/app/models/routing_engine.rb index 040becb72..6f2ceafab 100644 --- a/app/models/routing_engine.rb +++ b/app/models/routing_engine.rb @@ -1,9 +1,11 @@ +# frozen_string_literal: true + module RoutingEngine def self.compute(dossier, assignment_mode: DossierAssignment.modes.fetch(:auto)) return if dossier.forced_groupe_instructeur matching_groupe = dossier.procedure.groupe_instructeurs.active.reject(&:invalid_rule?).find do |gi| - gi.routing_rule&.compute(dossier.champs) + gi.routing_rule&.compute(dossier.filled_champs) end matching_groupe ||= dossier.procedure.defaut_groupe_instructeur diff --git a/app/models/safe_mailer.rb b/app/models/safe_mailer.rb index 9ccc83aef..668c57c66 100644 --- a/app/models/safe_mailer.rb +++ b/app/models/safe_mailer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class SafeMailer < ApplicationRecord before_create do raise if SafeMailer.count == 1 diff --git a/app/models/service.rb b/app/models/service.rb index 25f37ad3e..487bc15af 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -1,9 +1,15 @@ +# frozen_string_literal: true + class Service < ApplicationRecord + include PrefillableFromServicePublicConcern + has_many :procedures belongs_to :administrateur, optional: false scope :ordered, -> { order(nom: :asc) } + SIRET_TEST = '35600082800018' + enum type_organisme: { administration_centrale: 'administration_centrale', association: 'association', @@ -18,6 +24,7 @@ class Service < ApplicationRecord validates :nom, uniqueness: { scope: :administrateur, message: 'existe déjà' } validates :organisme, presence: { message: 'doit être renseigné' }, allow_nil: false validates :siret, siret_format: true + validates :siret, comparison: { other_than: SIRET_TEST, message: "n'est pas valide" }, on: :update validates :type_organisme, presence: { message: 'doit être renseigné' }, allow_nil: false validates :email, presence: { message: 'doit être renseigné' }, allow_nil: false validates :telephone, phone: { possible: true, allow_blank: true } diff --git a/app/models/siret.rb b/app/models/siret.rb index 6aff5363c..8562fdf64 100644 --- a/app/models/siret.rb +++ b/app/models/siret.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Siret include ActiveModel::Model include ActiveModel::Validations::Callbacks diff --git a/app/models/sorted_column.rb b/app/models/sorted_column.rb new file mode 100644 index 000000000..469c07559 --- /dev/null +++ b/app/models/sorted_column.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class SortedColumn + # include validations to enable procedure_presentation.validate_associate, + # which enforces the deserialization of columns in the sorted_column attribute + # and raises an error if a column is not found + include ActiveModel::Validations + + attr_reader :column, :order + + def initialize(column:, order:) + @column = column + @order = order + end + + def ascending? = @order == 'asc' + + def opposite_order = ascending? ? 'desc' : 'asc' + + def ==(other) + other&.column == column && other.order == order + end + + def sort_by_notifications? + @column.notifications? && @order == 'desc' + end + + def id + column.h_id.merge(order:).sort.to_json + end +end diff --git a/app/models/stat.rb b/app/models/stat.rb index d4cd331a2..9cfc9f63c 100644 --- a/app/models/stat.rb +++ b/app/models/stat.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Stat < ApplicationRecord class << self def update_stats diff --git a/app/models/super_admin.rb b/app/models/super_admin.rb index d6fa4a51f..b5c948b55 100644 --- a/app/models/super_admin.rb +++ b/app/models/super_admin.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class SuperAdmin < ApplicationRecord include PasswordComplexityConcern diff --git a/app/models/sva_svr_configuration.rb b/app/models/sva_svr_configuration.rb index 24b7edc56..fc5fc61a9 100644 --- a/app/models/sva_svr_configuration.rb +++ b/app/models/sva_svr_configuration.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class SVASVRConfiguration include ActiveModel::Model include ActiveModel::Attributes diff --git a/app/models/targeted_user_link.rb b/app/models/targeted_user_link.rb index 2d3d53660..398a8b512 100644 --- a/app/models/targeted_user_link.rb +++ b/app/models/targeted_user_link.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TargetedUserLink < ApplicationRecord belongs_to :user, optional: true belongs_to :target_model, polymorphic: true, optional: false @@ -31,9 +33,11 @@ class TargetedUserLink < ApplicationRecord url_helper.invite_path(invite, params: { email: invite.email }) when "avis" avis = target_model - avis.expert.user.active? ? - url_helper.expert_avis_path(avis.procedure, avis) : + if avis.expert.user.active? + url_helper.expert_avis_path(avis.procedure, avis) + else url_helper.sign_up_expert_avis_path(avis.procedure, avis, email: avis.expert.email) + end end end diff --git a/app/models/team_account.rb b/app/models/team_account.rb index 048019de0..4c705c81c 100644 --- a/app/models/team_account.rb +++ b/app/models/team_account.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TeamAccount extend ActiveModel::Naming extend ActiveModel::Translation diff --git a/app/models/traitement.rb b/app/models/traitement.rb index efff0bbfd..0a3929b57 100644 --- a/app/models/traitement.rb +++ b/app/models/traitement.rb @@ -1,6 +1,7 @@ +# frozen_string_literal: true + class Traitement < ApplicationRecord belongs_to :dossier, optional: false - scope :en_construction, -> { where(state: Dossier.states.fetch(:en_construction)) } scope :en_instruction, -> { where(state: Dossier.states.fetch(:en_instruction)) } scope :termine, -> { where(state: Dossier::TERMINE) } diff --git a/app/models/trusted_device_token.rb b/app/models/trusted_device_token.rb index ddb132961..0e02feab1 100644 --- a/app/models/trusted_device_token.rb +++ b/app/models/trusted_device_token.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TrustedDeviceToken < ApplicationRecord LOGIN_TOKEN_VALIDITY = 1.week LOGIN_TOKEN_YOUTH = 15.minutes diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index b4ff3ac51..b380dc9fe 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -1,6 +1,6 @@ -class TypeDeChamp < ApplicationRecord - self.ignored_columns += [:migrated_parent, :revision_id, :parent_id, :order_place] +# frozen_string_literal: true +class TypeDeChamp < ApplicationRecord FILE_MAX_SIZE = 200.megabytes FEATURE_FLAGS = { engagement_juridique: :engagement_juridique_type_de_champ, @@ -24,7 +24,6 @@ class TypeDeChamp < ApplicationRecord TYPE_DE_CHAMP_TO_CATEGORIE = { engagement_juridique: REFERENTIEL_EXTERNE, - header_section: STRUCTURE, repetition: STRUCTURE, dossier_link: STRUCTURE, @@ -66,7 +65,7 @@ class TypeDeChamp < ApplicationRecord expression_reguliere: STANDARD } - enum type_champs: { + enum type_champ: { engagement_juridique: 'engagement_juridique', header_section: 'header_section', @@ -110,12 +109,14 @@ class TypeDeChamp < ApplicationRecord expression_reguliere: 'expression_reguliere' } - ROUTABLE_TYPES = [ + SIMPLE_ROUTABLE_TYPES = [ type_champs.fetch(:drop_down_list), type_champs.fetch(:communes), type_champs.fetch(:departements), type_champs.fetch(:regions), - type_champs.fetch(:epci) + type_champs.fetch(:pays), + type_champs.fetch(:epci), + type_champs.fetch(:address) ] PRIVATE_ONLY_TYPES = [ @@ -140,13 +141,9 @@ class TypeDeChamp < ApplicationRecord :header_section_level has_many :revision_types_de_champ, -> { revision_ordered }, class_name: 'ProcedureRevisionTypeDeChamp', dependent: :destroy, inverse_of: :type_de_champ - has_one :revision_type_de_champ, -> { revision_ordered }, class_name: 'ProcedureRevisionTypeDeChamp', inverse_of: false has_many :revisions, -> { ordered }, through: :revision_types_de_champ - has_one :revision, through: :revision_type_de_champ - has_one :procedure, through: :revision - delegate :estimated_fill_duration, :estimated_read_duration, :tags_for_template, :libelles_for_export, :libelle_for_export, :primary_options, :secondary_options, to: :dynamic_type - delegate :used_by_routing_rules?, to: :revision_type_de_champ + delegate :estimated_fill_duration, :estimated_read_duration, :tags_for_template, :libelles_for_export, :libelle_for_export, :primary_options, :secondary_options, :columns, to: :dynamic_type class WithIndifferentAccess def self.load(options) @@ -173,22 +170,13 @@ class TypeDeChamp < ApplicationRecord scope :not_repetition, -> { where.not(type_champ: type_champs.fetch(:repetition)) } scope :not_condition, -> { where(condition: nil) } scope :fillable, -> { where.not(type_champ: [type_champs.fetch(:header_section), type_champs.fetch(:explication)]) } + scope :with_header_section, -> { where.not(type_champ: TypeDeChamp.type_champs[:explication]) } scope :dubious, -> { where("unaccent(types_de_champ.libelle) ~* unaccent(?)", DubiousProcedure.forbidden_regexp) .where(type_champ: [TypeDeChamp.type_champs.fetch(:text), TypeDeChamp.type_champs.fetch(:textarea)]) } - has_many :champ, inverse_of: :type_de_champ, dependent: :destroy do - def build(params = {}) - super(params.merge(proxy_association.owner.params_for_champ)) - end - - def create(params = {}) - super(params.merge(proxy_association.owner.params_for_champ)) - end - end - has_one_attached :piece_justificative_template validates :piece_justificative_template, size: { less_than: FILE_MAX_SIZE }, on: :update validates :piece_justificative_template, content_type: AUTHORIZED_CONTENT_TYPES, on: :update @@ -219,13 +207,8 @@ class TypeDeChamp < ApplicationRecord before_validation :check_mandatory before_validation :normalize_libelle - before_save :remove_piece_justificative_template, if: -> { type_champ_changed? } - before_validation :remove_drop_down_list, if: -> { type_champ_changed? } - before_save :remove_block, if: -> { type_champ_changed? } - - after_save if: -> { @remove_piece_justificative_template } do - piece_justificative_template.purge_later - end + before_save :remove_attachment, if: -> { type_champ_changed? } + before_validation :set_drop_down_list_options, if: -> { type_champ_changed? } def valid?(context = nil) super @@ -250,18 +233,18 @@ class TypeDeChamp < ApplicationRecord def params_for_champ { private: private?, - type: "Champs::#{type_champ.classify}Champ", + type: self.class.type_champ_to_champ_class_name(type_champ), stable_id:, stream: 'main' } end def build_champ(params = {}) - champ.build(params) + self.class.type_champ_to_champ_class_name(type_champ).constantize.new(params_for_champ.merge(params)) end def check_mandatory - if non_fillable? + if non_fillable? || private? self.mandatory = false else true @@ -307,10 +290,8 @@ class TypeDeChamp < ApplicationRecord TypeDeChamp.type_champs.fetch(:repetition), TypeDeChamp.type_champs.fetch(:multiple_drop_down_list), TypeDeChamp.type_champs.fetch(:epci), - TypeDeChamp.type_champs.fetch(:annuaire_education), TypeDeChamp.type_champs.fetch(:dossier_link), - TypeDeChamp.type_champs.fetch(:siret), - TypeDeChamp.type_champs.fetch(:rna) + TypeDeChamp.type_champs.fetch(:siret) ]) end @@ -342,129 +323,20 @@ class TypeDeChamp < ApplicationRecord ]) end - def self.is_choice_type_from(type_champ) - return false if type_champ == TypeDeChamp.type_champs.fetch(:linked_drop_down_list) # To remove when we stop using linked_drop_down_list - TYPE_DE_CHAMP_TO_CATEGORIE[type_champ.to_sym] == CHOICE || type_champ.in?([TypeDeChamp.type_champs.fetch(:departements), TypeDeChamp.type_champs.fetch(:regions)]) - end - - def drop_down_list? - type_champ.in?([ - TypeDeChamp.type_champs.fetch(:drop_down_list), - TypeDeChamp.type_champs.fetch(:multiple_drop_down_list), - TypeDeChamp.type_champs.fetch(:linked_drop_down_list) - ]) - end - - def simple_drop_down_list? - type_champ == TypeDeChamp.type_champs.fetch(:drop_down_list) - end - - def multiple_drop_down_list? - type_champ == TypeDeChamp.type_champs.fetch(:multiple_drop_down_list) - end - - def linked_drop_down_list? - type_champ == TypeDeChamp.type_champs.fetch(:linked_drop_down_list) - end - - def yes_no? - type_champ == TypeDeChamp.type_champs.fetch(:yes_no) - end - - def block? - type_champ == TypeDeChamp.type_champs.fetch(:repetition) - end - - def header_section? - type_champ == TypeDeChamp.type_champs.fetch(:header_section) - end - def exclude_from_view? type_champ == TypeDeChamp.type_champs.fetch(:explication) end - def explication? - type_champ == TypeDeChamp.type_champs.fetch(:explication) - end - - def repetition? - type_champ == TypeDeChamp.type_champs.fetch(:repetition) - end - - def dossier_link? - type_champ == TypeDeChamp.type_champs.fetch(:dossier_link) - end - - def siret? - type_champ == TypeDeChamp.type_champs.fetch(:siret) - end - - def piece_justificative? - type_champ == TypeDeChamp.type_champs.fetch(:piece_justificative) || type_champ == TypeDeChamp.type_champs.fetch(:titre_identite) - end - - def legacy_number? - type_champ == TypeDeChamp.type_champs.fetch(:number) - end - - def textarea? - type_champ == TypeDeChamp.type_champs.fetch(:textarea) - end - - def titre_identite? - type_champ == TypeDeChamp.type_champs.fetch(:titre_identite) - end - - def carte? - type_champ == TypeDeChamp.type_champs.fetch(:carte) - end - - def cnaf? - type_champ == TypeDeChamp.type_champs.fetch(:cnaf) - end - - def rna? - type_champ == TypeDeChamp.type_champs.fetch(:rna) - end - - def dgfip? - type_champ == TypeDeChamp.type_champs.fetch(:dgfip) - end - - def pole_emploi? - type_champ == TypeDeChamp.type_champs.fetch(:pole_emploi) - end - - def departement? - type_champ == TypeDeChamp.type_champs.fetch(:departements) - end - - def region? - type_champ == TypeDeChamp.type_champs.fetch(:regions) - end - - def mesri? - type_champ == TypeDeChamp.type_champs.fetch(:mesri) - end - - def datetime? - type_champ == TypeDeChamp.type_champs.fetch(:datetime) - end - - def checkbox? - type_champ == TypeDeChamp.type_champs.fetch(:checkbox) - end - - def expression_reguliere? - type_champ == TypeDeChamp.type_champs.fetch(:expression_reguliere) - end - def public? !private? end - def self.type_champ_to_class_name(type_champ) - "TypesDeChamp::#{type_champ.classify}TypeDeChamp" + def in_revision?(revision) + revision.types_de_champ.any? { _1.stable_id == stable_id } + end + + def child?(revision) + revision.revision_types_de_champ.find { _1.stable_id == stable_id }&.child? end def filename_for_attachement(attachment_sym) @@ -481,16 +353,20 @@ class TypeDeChamp < ApplicationRecord end end - def drop_down_list_value - if drop_down_list_options.present? - drop_down_list_options.reject(&:empty?).join("\r\n") - else - '' - end + def drop_down_options + Array.wrap(super) end - def drop_down_list_value=(value) - self.drop_down_options = parse_drop_down_list_value(value) + def drop_down_options_from_text=(text) + self.drop_down_options = text.to_s.lines.map(&:strip).reject(&:empty?) + end + + def drop_down_options_with_other + if drop_down_other? + drop_down_options + [[I18n.t('shared.champs.drop_down_list.other'), Champs::DropDownListChamp::OTHER]] + else + drop_down_options + end end def header_section_level_value @@ -509,15 +385,15 @@ class TypeDeChamp < ApplicationRecord end def check_coherent_header_level(upper_tdcs) - errs = [] previous_level = previous_section_level(upper_tdcs) - current_level = header_section_level_value.to_i + difference = current_level - previous_level if current_level > previous_level && difference != 1 - errs << I18n.t('activerecord.errors.type_de_champ.attributes.header_section_level.gap_error', level: current_level - previous_level - 1) + I18n.t('activerecord.errors.type_de_champ.attributes.header_section_level.gap_error', level: current_level - previous_level - 1) + else + nil end - errs end def current_section_level(revision) @@ -528,6 +404,7 @@ class TypeDeChamp < ApplicationRecord def level_for_revision(revision) rtdc = revision.revision_types_de_champ.find { |rtdc| rtdc.stable_id == stable_id } + if rtdc.child? header_section_level_value.to_i + rtdc.parent.type_de_champ.current_section_level(revision) elsif header_section_level_value @@ -537,57 +414,40 @@ class TypeDeChamp < ApplicationRecord end end - def self.filter_hash_type(type_champ) - if is_choice_type_from(type_champ) + def self.column_type(type_champ) + case type_champ + when type_champs.fetch(:datetime) + :datetime + when type_champs.fetch(:date) + :date + when type_champs.fetch(:integer_number) + :integer + when type_champs.fetch(:decimal_number) + :decimal + when type_champs.fetch(:multiple_drop_down_list) + :enums + when type_champs.fetch(:drop_down_list), type_champs.fetch(:departements), type_champs.fetch(:regions) :enum + when type_champs.fetch(:checkbox), type_champs.fetch(:yes_no) + :boolean + when type_champs.fetch(:titre_identite), type_champs.fetch(:piece_justificative) + :attachements else :text end end - def self.filter_hash_value_column(type_champ) - if type_champ.in?([TypeDeChamp.type_champs.fetch(:departements), TypeDeChamp.type_champs.fetch(:regions)]) - :external_id - else - :value - end - end - def options_for_select - if departement? - APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } - elsif region? - APIGeoService.regions.map { [_1[:name], _1[:code]] } - elsif choice_type? - if drop_down_list? - drop_down_list_enabled_non_empty_options - elsif yes_no? - Champs::YesNoChamp.options - elsif checkbox? - Champs::CheckboxChamp.options - end - end - end - - def drop_down_list_options? - drop_down_list_options.any? - end - - def drop_down_list_options - drop_down_options.presence || [] - end - - def drop_down_list_disabled_options - drop_down_list_options.filter { |v| (v =~ /^--.*--$/).present? } - end - - def drop_down_list_enabled_non_empty_options(other: false) - list_options = (drop_down_list_options - drop_down_list_disabled_options).reject(&:empty?) - - if other && drop_down_other? - list_options + [[I18n.t('shared.champs.drop_down_list.other'), Champs::DropDownListChamp::OTHER]] - else - list_options + if departements? + APIGeoService.departement_options + elsif regions? + APIGeoService.region_options + elsif any_drop_down_list? + drop_down_options + elsif yes_no? + Champs::YesNoChamp.options + elsif checkbox? + Champs::CheckboxChamp.options end end @@ -645,8 +505,7 @@ class TypeDeChamp < ApplicationRecord # We should refresh all champs after update except for champs using react or custom refresh # logic (RNA, SIRET, etc.) case type_champ - when type_champs.fetch(:annuaire_education), - type_champs.fetch(:carte), + when type_champs.fetch(:carte), type_champs.fetch(:piece_justificative), type_champs.fetch(:titre_identite), type_champs.fetch(:rna), @@ -657,8 +516,29 @@ class TypeDeChamp < ApplicationRecord end end - def routable? - type_champ.in?(ROUTABLE_TYPES) + def simple_routable? + type_champ.in?(SIMPLE_ROUTABLE_TYPES) + end + + def conditionable? + Logic::ChampValue::MANAGED_TYPE_DE_CHAMP.values.include?(type_champ) + end + + def self.humanized_conditionable_types_by_category + Logic::ChampValue::MANAGED_TYPE_DE_CHAMP_BY_CATEGORY + .map { |_, v| v.map { "« #{I18n.t(_1, scope: [:activerecord, :attributes, :type_de_champ, :type_champs])} »" } } + end + + def self.humanized_simple_routable_types_by_category + Logic::ChampValue::MANAGED_TYPE_DE_CHAMP_BY_CATEGORY + .map { |_, v| v.filter_map { "« #{I18n.t(_1, scope: [:activerecord, :attributes, :type_de_champ, :type_champs])} »" if _1.to_s.in?(SIMPLE_ROUTABLE_TYPES) } } + .reject(&:empty?) + end + + def self.humanized_custom_routable_types_by_category + Logic::ChampValue::MANAGED_TYPE_DE_CHAMP_BY_CATEGORY + .map { |_, v| v.filter_map { "« #{I18n.t(_1, scope: [:activerecord, :attributes, :type_de_champ, :type_champs])} »" if !_1.to_s.in?(SIMPLE_ROUTABLE_TYPES) } } + .reject(&:empty?) end def invalid_regexp? @@ -687,13 +567,129 @@ class TypeDeChamp < ApplicationRecord end end + def libelle_as_filename + libelle.gsub(/[[:space:]]+/, ' ') + .truncate(30, omission: '', separator: ' ') + .parameterize + end + + OPTS_BY_TYPE = { + type_champs.fetch(:header_section) => [:header_section_level], + type_champs.fetch(:explication) => [:collapsible_explanation_enabled, :collapsible_explanation_text], + type_champs.fetch(:textarea) => [:character_limit], + type_champs.fetch(:carte) => TypesDeChamp::CarteTypeDeChamp::LAYERS, + type_champs.fetch(:drop_down_list) => [:drop_down_other, :drop_down_options], + type_champs.fetch(:multiple_drop_down_list) => [:drop_down_options], + type_champs.fetch(:linked_drop_down_list) => [:drop_down_options, :drop_down_secondary_libelle, :drop_down_secondary_description], + type_champs.fetch(:piece_justificative) => [:old_pj, :skip_pj_validation, :skip_content_type_pj_validation], + type_champs.fetch(:titre_identite) => [:old_pj, :skip_pj_validation, :skip_content_type_pj_validation], + type_champs.fetch(:expression_reguliere) => [:expression_reguliere, :expression_reguliere_error_message, :expression_reguliere_exemple_text] + } + + def clean_options + kept_keys = OPTS_BY_TYPE.fetch(type_champ.to_s) { [] } + options.slice(*kept_keys.map(&:to_s)) + end + + def champ_value(champ) + if champ_blank?(champ) + dynamic_type.champ_default_value + else + dynamic_type.champ_value(champ) + end + end + + def champ_value_for_api(champ, version: 2) + if champ_blank?(champ) + dynamic_type.champ_default_api_value(version) + else + dynamic_type.champ_value_for_api(champ, version:) + end + end + + def champ_value_for_export(champ, path = :value) + if champ_blank?(champ) + dynamic_type.champ_default_export_value(path) + else + dynamic_type.champ_value_for_export(champ, path) + end + end + + def champ_value_for_tag(champ, path = :value) + if champ_blank?(champ) + '' + else + dynamic_type.champ_value_for_tag(champ, path) + end + end + + def champ_blank?(champ) + # no champ + return true if champ.nil? + # type de champ on the revision changed + if champ.is_type?(type_champ) || castable_on_change?(champ.last_write_type_champ, type_champ) + dynamic_type.champ_blank?(champ) + else + true + end + end + + def mandatory_blank?(champ) + # no champ + return true if champ.nil? + # type de champ on the revision changed + if champ.is_type?(type_champ) || castable_on_change?(champ.last_write_type_champ, type_champ) + mandatory? && dynamic_type.champ_blank_or_invalid?(champ) + else + true + end + end + + def html_id(row_id = nil) + "champ-#{public_id(row_id)}" + end + + class << self + def type_champ_to_champ_class_name(type_champ) + "Champs::#{type_champ.classify}Champ" + end + + def type_champ_to_class_name(type_champ) + "TypesDeChamp::#{type_champ.classify}TypeDeChamp" + end + end + + CHAMP_TYPE_TO_TYPE_CHAMP = type_champs.values.map { [type_champ_to_champ_class_name(_1), _1] }.to_h + + def piece_justificative_or_titre_identite? + type_champ.in?([ + TypeDeChamp.type_champs.fetch(:piece_justificative), + TypeDeChamp.type_champs.fetch(:titre_identite) + ]) + end + + def any_drop_down_list? + type_champ.in?([ + TypeDeChamp.type_champs.fetch(:drop_down_list), + TypeDeChamp.type_champs.fetch(:multiple_drop_down_list), + TypeDeChamp.type_champs.fetch(:linked_drop_down_list) + ]) + end + private - DEFAULT_EMPTY = [''] - def parse_drop_down_list_value(value) - value = value ? value.split("\r\n").map(&:strip).join("\r\n") : '' - result = value.split(/[\r\n]|[\r]|[\n]|[\n\r]/).reject(&:empty?) - result.blank? ? [] : DEFAULT_EMPTY + result + def castable_on_change?(from_type, to_type) + case [from_type, to_type] + when ['integer_number', 'decimal_number'], # recast numbers automatically + ['decimal_number', 'integer_number'], # may lose some data, but who cares ? + ['text', 'textarea'], # allow short text to long text + ['drop_down_list', 'multiple_drop_down_list'], # single list can become multi + ['date', 'datetime'], # date <=> datetime + ['datetime', 'date'] # may lose some data, but who cares ? + true + else + false + end end def populate_stable_id @@ -702,29 +698,19 @@ class TypeDeChamp < ApplicationRecord end end - def remove_piece_justificative_template - if !piece_justificative? && piece_justificative_template.attached? - @remove_piece_justificative_template = true + def remove_attachment + if !piece_justificative_or_titre_identite? && piece_justificative_template.attached? + piece_justificative_template.purge_later + elsif !explication? && notice_explicative.attached? + notice_explicative.purge_later end end - def remove_drop_down_list - if !drop_down_list? - self.drop_down_options = nil - elsif !drop_down_options_changed? - self.drop_down_options = if linked_drop_down_list? - ['', '--Fromage--', 'bleu de sassenage', 'picodon', '--Dessert--', 'éclair', 'tarte aux pommes'] - else - ['', 'Premier choix', 'Deuxième choix'] - end - end - end - - def remove_block - if !block? && procedure.present? - procedure - .draft_revision # action occurs only on draft - .remove_children_of(self) + def set_drop_down_list_options + if (drop_down_list? || multiple_drop_down_list?) && drop_down_options.empty? + self.drop_down_options = ['Fromage', 'Dessert'] + elsif linked_drop_down_list? && drop_down_options.none?(/^--.*--$/) + self.drop_down_options = ['--Fromage--', 'bleu de sassenage', 'picodon', '--Dessert--', 'éclair', 'tarte aux pommes'] end end diff --git a/app/models/types_de_champ/address_type_de_champ.rb b/app/models/types_de_champ/address_type_de_champ.rb index 710ca5f96..5173b4712 100644 --- a/app/models/types_de_champ/address_type_de_champ.rb +++ b/app/models/types_de_champ/address_type_de_champ.rb @@ -1,9 +1,41 @@ +# frozen_string_literal: true + class TypesDeChamp::AddressTypeDeChamp < TypesDeChamp::TextTypeDeChamp def libelles_for_export path = paths.first [[path[:libelle], path[:path]]] end + def champ_value(champ) + champ.address_label.presence || '' + end + + def champ_value_for_api(champ, version: 2) + champ_value(champ) + end + + def champ_value_for_tag(champ, path = :value) + case path + when :value + champ_value(champ) + when :departement + champ.departement_code_and_name || '' + when :commune + champ.commune_name || '' + end + end + + def champ_value_for_export(champ, path = :value) + case path + when :value + champ_value(champ) + when :departement + champ.departement_code_and_name + when :commune + champ.commune_name + end + end + private def paths diff --git a/app/models/types_de_champ/annuaire_education_type_de_champ.rb b/app/models/types_de_champ/annuaire_education_type_de_champ.rb index bce005c58..657d961b2 100644 --- a/app/models/types_de_champ/annuaire_education_type_de_champ.rb +++ b/app/models/types_de_champ/annuaire_education_type_de_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TypesDeChamp::AnnuaireEducationTypeDeChamp < TypesDeChamp::TextTypeDeChamp def estimated_fill_duration(revision) FILL_DURATION_MEDIUM diff --git a/app/models/types_de_champ/carte_type_de_champ.rb b/app/models/types_de_champ/carte_type_de_champ.rb index 054914bcf..fb7822353 100644 --- a/app/models/types_de_champ/carte_type_de_champ.rb +++ b/app/models/types_de_champ/carte_type_de_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TypesDeChamp::CarteTypeDeChamp < TypesDeChamp::TypeDeChampBase LAYERS = [ :unesco, @@ -17,4 +19,18 @@ class TypesDeChamp::CarteTypeDeChamp < TypesDeChamp::TypeDeChampBase end def tags_for_template = [].freeze + + def champ_value_for_api(champ, version: 2) + nil + end + + def champ_value_for_export(champ, path = :value) + champ.geo_areas.map(&:label).join("\n") + end + + def champ_blank?(champ) = champ.geo_areas.blank? + + def columns(procedure:, displayable: true, prefix: nil) + [] + end end diff --git a/app/models/types_de_champ/checkbox_type_de_champ.rb b/app/models/types_de_champ/checkbox_type_de_champ.rb index aa60db19d..9cf47a847 100644 --- a/app/models/types_de_champ/checkbox_type_de_champ.rb +++ b/app/models/types_de_champ/checkbox_type_de_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TypesDeChamp::CheckboxTypeDeChamp < TypesDeChamp::TypeDeChampBase def filter_to_human(filter_value) if filter_value == "true" @@ -8,4 +10,44 @@ class TypesDeChamp::CheckboxTypeDeChamp < TypesDeChamp::TypeDeChampBase filter_value end end + + def champ_value(champ) + champ_value_true?(champ) ? 'Oui' : 'Non' + end + + def champ_value_for_export(champ, path = :value) + champ_value_true?(champ) ? 'on' : 'off' + end + + def champ_value_for_api(champ, version: 2) + case version + when 2 + champ_value_true?(champ).to_s + else + super + end + end + + def champ_default_value + 'Non' + end + + def champ_default_export_value(path = :value) + 'off' + end + + def champ_default_api_value(version = 2) + case version + when 2 + 'false' + else + nil + end + end + + def champ_blank_or_invalid?(champ) = !champ_value_true?(champ) + + private + + def champ_value_true?(champ) = champ.value == 'true' end diff --git a/app/models/types_de_champ/civilite_type_de_champ.rb b/app/models/types_de_champ/civilite_type_de_champ.rb index 508c14f06..648cdaadc 100644 --- a/app/models/types_de_champ/civilite_type_de_champ.rb +++ b/app/models/types_de_champ/civilite_type_de_champ.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class TypesDeChamp::CiviliteTypeDeChamp < TypesDeChamp::TypeDeChampBase end diff --git a/app/models/types_de_champ/cnaf_type_de_champ.rb b/app/models/types_de_champ/cnaf_type_de_champ.rb index cd62bbfab..9a6d1b58e 100644 --- a/app/models/types_de_champ/cnaf_type_de_champ.rb +++ b/app/models/types_de_champ/cnaf_type_de_champ.rb @@ -1,5 +1,9 @@ +# frozen_string_literal: true + class TypesDeChamp::CnafTypeDeChamp < TypesDeChamp::TextTypeDeChamp def estimated_fill_duration(revision) FILL_DURATION_MEDIUM end + + def champ_blank?(champ) = champ.external_id.blank? end diff --git a/app/models/types_de_champ/cojo_type_de_champ.rb b/app/models/types_de_champ/cojo_type_de_champ.rb index 2747a34c7..5d65a80ff 100644 --- a/app/models/types_de_champ/cojo_type_de_champ.rb +++ b/app/models/types_de_champ/cojo_type_de_champ.rb @@ -1,2 +1,9 @@ +# frozen_string_literal: true + class TypesDeChamp::COJOTypeDeChamp < TypesDeChamp::TextTypeDeChamp + def champ_value(champ) + "#{champ.accreditation_number} – #{champ.accreditation_birthdate}" + end + + def champ_blank?(champ) = champ.accreditation_success != true end diff --git a/app/models/types_de_champ/commune_type_de_champ.rb b/app/models/types_de_champ/commune_type_de_champ.rb index 69be05959..2f3d639df 100644 --- a/app/models/types_de_champ/commune_type_de_champ.rb +++ b/app/models/types_de_champ/commune_type_de_champ.rb @@ -1,4 +1,57 @@ +# frozen_string_literal: true + class TypesDeChamp::CommuneTypeDeChamp < TypesDeChamp::TypeDeChampBase + def champ_value_for_export(champ, path = :value) + case path + when :value + champ_value(champ) + when :departement + champ.departement_code_and_name || '' + when :code + champ.code || '' + end + end + + def champ_value_for_tag(champ, path = :value) + case path + when :value + champ_value(champ) + when :departement + champ.departement_code_and_name || '' + when :code + champ.code || '' + end + end + + def champ_value(champ) + champ.code_postal? ? "#{champ.name} (#{champ.code_postal})" : champ.name + end + + def columns(procedure:, displayable: true, prefix: nil) + super.concat( + [ + Columns::JSONPathColumn.new( + procedure_id: procedure.id, + stable_id:, + tdc_type: type_champ, + label: "#{libelle_with_prefix(prefix)} - code postal (5 chiffres)", + jsonpath: '$.code_postal', + displayable:, + type: :text + ), + Columns::JSONPathColumn.new( + procedure_id: procedure.id, + stable_id:, + tdc_type: type_champ, + label: "#{libelle_with_prefix(prefix)} - département", + jsonpath: '$.code_departement', + displayable:, + type: :number + ) + ] + ) + end + private def paths diff --git a/app/models/types_de_champ/date_type_de_champ.rb b/app/models/types_de_champ/date_type_de_champ.rb index 65b0d5fc7..b332e34d7 100644 --- a/app/models/types_de_champ/date_type_de_champ.rb +++ b/app/models/types_de_champ/date_type_de_champ.rb @@ -1,2 +1,9 @@ +# frozen_string_literal: true + class TypesDeChamp::DateTypeDeChamp < TypesDeChamp::TypeDeChampBase + def champ_value(champ) + I18n.l(Time.zone.parse(champ.value), format: '%d %B %Y') + rescue ArgumentError + champ.value.presence || "" # old dossiers can have not parseable dates + end end diff --git a/app/models/types_de_champ/datetime_type_de_champ.rb b/app/models/types_de_champ/datetime_type_de_champ.rb index 2ccfec18f..e74423665 100644 --- a/app/models/types_de_champ/datetime_type_de_champ.rb +++ b/app/models/types_de_champ/datetime_type_de_champ.rb @@ -1,2 +1,7 @@ +# frozen_string_literal: true + class TypesDeChamp::DatetimeTypeDeChamp < TypesDeChamp::TypeDeChampBase + def champ_value(champ) + I18n.l(Time.zone.parse(champ.value)) + end end diff --git a/app/models/types_de_champ/decimal_number_type_de_champ.rb b/app/models/types_de_champ/decimal_number_type_de_champ.rb index 9a1363317..baf62f2b1 100644 --- a/app/models/types_de_champ/decimal_number_type_de_champ.rb +++ b/app/models/types_de_champ/decimal_number_type_de_champ.rb @@ -1,2 +1,26 @@ +# frozen_string_literal: true + class TypesDeChamp::DecimalNumberTypeDeChamp < TypesDeChamp::TypeDeChampBase + def champ_value_for_export(champ, path = :value) + champ_formatted_value(champ) + end + + def champ_value_for_api(champ, version: 2) + case version + when 1 + champ_formatted_value(champ) + else + super + end + end + + def champ_default_export_value(path = :value) + 0 + end + + private + + def champ_formatted_value(champ) + champ.value&.to_f + end end diff --git a/app/models/types_de_champ/departement_type_de_champ.rb b/app/models/types_de_champ/departement_type_de_champ.rb index 334286f78..cf296bac0 100644 --- a/app/models/types_de_champ/departement_type_de_champ.rb +++ b/app/models/types_de_champ/departement_type_de_champ.rb @@ -1,8 +1,41 @@ +# frozen_string_literal: true + class TypesDeChamp::DepartementTypeDeChamp < TypesDeChamp::TextTypeDeChamp def filter_to_human(filter_value) APIGeoService.departement_name(filter_value).presence || filter_value end + def champ_value(champ) + "#{champ.code} – #{champ.name}" + end + + def champ_value_for_export(champ, path = :value) + case path + when :code + champ.code + when :value + champ.name + end + end + + def champ_value_for_tag(champ, path = :value) + case path + when :code + champ.code + when :value + champ_value(champ) + end + end + + def champ_value_for_api(champ, version: 2) + case version + when 2 + champ_value(champ).tr('–', '-') + else + champ_value(champ) + end + end + private def paths diff --git a/app/models/types_de_champ/dgfip_type_de_champ.rb b/app/models/types_de_champ/dgfip_type_de_champ.rb index 666d80ef2..7c20a7c7d 100644 --- a/app/models/types_de_champ/dgfip_type_de_champ.rb +++ b/app/models/types_de_champ/dgfip_type_de_champ.rb @@ -1,5 +1,9 @@ +# frozen_string_literal: true + class TypesDeChamp::DgfipTypeDeChamp < TypesDeChamp::TextTypeDeChamp def estimated_fill_duration(revision) FILL_DURATION_MEDIUM end + + def champ_blank?(champ) = champ.external_id.blank? end diff --git a/app/models/types_de_champ/dossier_link_type_de_champ.rb b/app/models/types_de_champ/dossier_link_type_de_champ.rb index 5c91a820f..a208fe4eb 100644 --- a/app/models/types_de_champ/dossier_link_type_de_champ.rb +++ b/app/models/types_de_champ/dossier_link_type_de_champ.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class TypesDeChamp::DossierLinkTypeDeChamp < TypesDeChamp::TypeDeChampBase end diff --git a/app/models/types_de_champ/drop_down_list_type_de_champ.rb b/app/models/types_de_champ/drop_down_list_type_de_champ.rb index c930f3ff7..eee0c03b8 100644 --- a/app/models/types_de_champ/drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/drop_down_list_type_de_champ.rb @@ -1,2 +1,17 @@ +# frozen_string_literal: true + class TypesDeChamp::DropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBase + def champ_blank?(champ) + super || !champ_value_in_options?(champ) + end + + private + + def champ_value_in_options?(champ) + champ_with_other_value?(champ) || drop_down_options.include?(champ.value) + end + + def champ_with_other_value?(champ) + drop_down_other? && champ.value_json&.fetch('other', false) + end end diff --git a/app/models/types_de_champ/email_type_de_champ.rb b/app/models/types_de_champ/email_type_de_champ.rb index 022330d3c..7a9ff0bfc 100644 --- a/app/models/types_de_champ/email_type_de_champ.rb +++ b/app/models/types_de_champ/email_type_de_champ.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class TypesDeChamp::EmailTypeDeChamp < TypesDeChamp::TextTypeDeChamp end diff --git a/app/models/types_de_champ/engagement_juridique_type_de_champ.rb b/app/models/types_de_champ/engagement_juridique_type_de_champ.rb index bd076d2c8..c93da053f 100644 --- a/app/models/types_de_champ/engagement_juridique_type_de_champ.rb +++ b/app/models/types_de_champ/engagement_juridique_type_de_champ.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class TypesDeChamp::EngagementJuridiqueTypeDeChamp < TypesDeChamp::TypeDeChampBase end diff --git a/app/models/types_de_champ/epci_type_de_champ.rb b/app/models/types_de_champ/epci_type_de_champ.rb index 1ef14d66e..5fcc09ad4 100644 --- a/app/models/types_de_champ/epci_type_de_champ.rb +++ b/app/models/types_de_champ/epci_type_de_champ.rb @@ -1,4 +1,28 @@ +# frozen_string_literal: true + class TypesDeChamp::EpciTypeDeChamp < TypesDeChamp::TextTypeDeChamp + def champ_value_for_export(champ, path = :value) + case path + when :value + champ_value(champ) + when :code + champ.code + when :departement + champ.departement_code_and_name + end + end + + def champ_value_for_tag(champ, path = :value) + case path + when :value + champ_value(champ) + when :code + champ.code + when :departement + champ.departement_code_and_name + end + end + private def paths diff --git a/app/models/types_de_champ/explication_type_de_champ.rb b/app/models/types_de_champ/explication_type_de_champ.rb index fd27efb55..837c1239f 100644 --- a/app/models/types_de_champ/explication_type_de_champ.rb +++ b/app/models/types_de_champ/explication_type_de_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TypesDeChamp::ExplicationTypeDeChamp < TypesDeChamp::TextTypeDeChamp def tags_for_template = [].freeze end diff --git a/app/models/types_de_champ/expression_reguliere_type_de_champ.rb b/app/models/types_de_champ/expression_reguliere_type_de_champ.rb index 9dcbe14af..f46829581 100644 --- a/app/models/types_de_champ/expression_reguliere_type_de_champ.rb +++ b/app/models/types_de_champ/expression_reguliere_type_de_champ.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class TypesDeChamp::ExpressionReguliereTypeDeChamp < TypesDeChamp::TypeDeChampBase end diff --git a/app/models/types_de_champ/header_section_type_de_champ.rb b/app/models/types_de_champ/header_section_type_de_champ.rb index ba596abbc..5214c18f5 100644 --- a/app/models/types_de_champ/header_section_type_de_champ.rb +++ b/app/models/types_de_champ/header_section_type_de_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TypesDeChamp::HeaderSectionTypeDeChamp < TypesDeChamp::TypeDeChampBase def tags_for_template = [].freeze end diff --git a/app/models/types_de_champ/iban_type_de_champ.rb b/app/models/types_de_champ/iban_type_de_champ.rb index 209e4b899..a5a683f25 100644 --- a/app/models/types_de_champ/iban_type_de_champ.rb +++ b/app/models/types_de_champ/iban_type_de_champ.rb @@ -1,5 +1,11 @@ +# frozen_string_literal: true + class TypesDeChamp::IbanTypeDeChamp < TypesDeChamp::TypeDeChampBase def estimated_fill_duration(revision) FILL_DURATION_MEDIUM end + + def champ_value_for_api(champ, version: 2) + champ_value(champ).gsub(/\s+/, '') + end end diff --git a/app/models/types_de_champ/integer_number_type_de_champ.rb b/app/models/types_de_champ/integer_number_type_de_champ.rb index 0eda2c3d5..5a7670a83 100644 --- a/app/models/types_de_champ/integer_number_type_de_champ.rb +++ b/app/models/types_de_champ/integer_number_type_de_champ.rb @@ -1,2 +1,26 @@ +# frozen_string_literal: true + class TypesDeChamp::IntegerNumberTypeDeChamp < TypesDeChamp::TypeDeChampBase + def champ_value_for_export(champ, path = :value) + champ_formatted_value(champ) + end + + def champ_value_for_api(champ, version: 2) + case version + when 1 + champ_formatted_value(champ) + else + super + end + end + + def champ_default_export_value(path = :value) + 0 + end + + private + + def champ_formatted_value(champ) + champ.value&.to_i + end end diff --git a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb index e5b0b56a3..cecb2699b 100644 --- a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb @@ -1,7 +1,8 @@ +# frozen_string_literal: true + class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBase PRIMARY_PATTERN = /^--(.*)--$/ - delegate :drop_down_list_options, to: :@type_de_champ validate :check_presence_of_primary_options def libelles_for_export @@ -9,11 +10,6 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas [[path[:libelle], path[:path]]] end - def add_blank_option_when_not_mandatory(options) - return options if mandatory - options.unshift('') - end - def primary_options primary_options = unpack_options.map(&:first) if primary_options.present? @@ -30,8 +26,107 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas secondary_options end + def champ_value(champ) + [primary_value(champ), secondary_value(champ)].filter(&:present?).join(' / ') + end + + def champ_value_for_tag(champ, path = :value) + case path + when :primary + primary_value(champ) + when :secondary + secondary_value(champ) + when :value + champ_value(champ) + end + end + + def champ_value_for_export(champ, path = :value) + case path + when :primary + primary_value(champ) + when :secondary + secondary_value(champ) + when :value + "#{primary_value(champ) || ''};#{secondary_value(champ) || ''}" + end + end + + def champ_value_for_api(champ, version: 2) + case version + when 1 + { primary: primary_value(champ), secondary: secondary_value(champ) } + else + super + end + end + + def champ_blank?(champ) + primary_value(champ).blank? && secondary_value(champ).blank? + end + + def champ_blank_or_invalid?(champ) + primary_value(champ).blank? || + (has_secondary_options_for_primary?(champ) && secondary_value(champ).blank?) + end + + def columns(procedure:, displayable: true, prefix: nil) + [ + Columns::LinkedDropDownColumn.new( + procedure_id: procedure.id, + label: libelle_with_prefix(prefix), + stable_id:, + tdc_type: type_champ, + type: :text, + path: :value, + displayable: + ), + Columns::LinkedDropDownColumn.new( + procedure_id: procedure.id, + stable_id:, + tdc_type: type_champ, + label: "#{libelle_with_prefix(prefix)} (Primaire)", + type: :enum, + path: :primary, + displayable: false, + options_for_select: primary_options + ), + Columns::LinkedDropDownColumn.new( + procedure_id: procedure.id, + stable_id:, + tdc_type: type_champ, + label: "#{libelle_with_prefix(prefix)} (Secondaire)", + type: :enum, + path: :secondary, + displayable: false, + options_for_select: secondary_options.values.flatten.uniq.sort + ) + ] + end + private + def add_blank_option_when_not_mandatory(options) + return options if mandatory + options.unshift('') + end + + def primary_value(champ) = unpack_value(champ.value, 0, primary_options) + def secondary_value(champ) = unpack_value(champ.value, 1, secondary_options.values.flatten) + + def unpack_value(value, index, options) + value&.then do + unpacked_value = JSON.parse(_1)[index] + unpacked_value if options.include?(unpacked_value) + rescue + nil + end + end + + def has_secondary_options_for_primary?(champ) + primary_value(champ).present? && secondary_options[primary_value(champ)]&.any?(&:present?) + end + def paths paths = super paths.push({ @@ -50,8 +145,8 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas end def unpack_options - _, *options = drop_down_list_options - chunked = options.slice_before(PRIMARY_PATTERN) + chunked = drop_down_options.slice_before(PRIMARY_PATTERN) + chunked.map do |chunk| primary, *secondary = chunk secondary = add_blank_option_when_not_mandatory(secondary) @@ -60,7 +155,7 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas end def check_presence_of_primary_options - if !PRIMARY_PATTERN.match?(drop_down_list_options.second) + if !PRIMARY_PATTERN.match?(drop_down_options.first) errors.add(libelle.presence || "La liste", "doit commencer par une entrée de menu primaire de la forme --texte--") end end diff --git a/app/models/types_de_champ/mesri_type_de_champ.rb b/app/models/types_de_champ/mesri_type_de_champ.rb index ed616875f..ff0584800 100644 --- a/app/models/types_de_champ/mesri_type_de_champ.rb +++ b/app/models/types_de_champ/mesri_type_de_champ.rb @@ -1,5 +1,9 @@ +# frozen_string_literal: true + class TypesDeChamp::MesriTypeDeChamp < TypesDeChamp::TextTypeDeChamp def estimated_fill_duration(revision) FILL_DURATION_MEDIUM end + + def champ_blank?(champ) = champ.external_id.blank? end diff --git a/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb b/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb index f5028c302..bbca8ee52 100644 --- a/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb @@ -1,2 +1,31 @@ +# frozen_string_literal: true + class TypesDeChamp::MultipleDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBase + def champ_value(champ) + selected_options(champ).join(', ') + end + + def champ_value_for_tag(champ, path = :value) + ChampPresentations::MultipleDropDownListPresentation.new(selected_options(champ)) + end + + def champ_value_for_export(champ, path = :value) + champ_value(champ) + end + + def champ_blank?(champ) = selected_options(champ).blank? + + private + + def selected_options(champ) + return [] if champ.value.blank? + + if champ.is_type?(TypeDeChamp.type_champs.fetch(:drop_down_list)) + [champ.value] + else + JSON.parse(champ.value) + end.filter { drop_down_options.include?(_1) } + rescue JSON::ParserError + [] + end end diff --git a/app/models/types_de_champ/number_type_de_champ.rb b/app/models/types_de_champ/number_type_de_champ.rb index 9ff857f15..df32d878e 100644 --- a/app/models/types_de_champ/number_type_de_champ.rb +++ b/app/models/types_de_champ/number_type_de_champ.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class TypesDeChamp::NumberTypeDeChamp < TypesDeChamp::TypeDeChampBase end diff --git a/app/models/types_de_champ/pays_type_de_champ.rb b/app/models/types_de_champ/pays_type_de_champ.rb index 43a041e76..40d60e1b3 100644 --- a/app/models/types_de_champ/pays_type_de_champ.rb +++ b/app/models/types_de_champ/pays_type_de_champ.rb @@ -1,4 +1,32 @@ +# frozen_string_literal: true + class TypesDeChamp::PaysTypeDeChamp < TypesDeChamp::TextTypeDeChamp + def champ_value(champ) + champ.name + end + + def champ_value_for_export(champ, path = :value) + case path + when :value + champ_value(champ) + when :code + champ.code + end + end + + def champ_value_for_tag(champ, path = :value) + case path + when :value + champ_value(champ) + when :code + champ.code + end + end + + def champ_blank?(champ) + champ.value.blank? && champ.external_id.blank? + end + private def paths diff --git a/app/models/types_de_champ/phone_type_de_champ.rb b/app/models/types_de_champ/phone_type_de_champ.rb index 6cd42ac46..7d95156b3 100644 --- a/app/models/types_de_champ/phone_type_de_champ.rb +++ b/app/models/types_de_champ/phone_type_de_champ.rb @@ -1,2 +1,34 @@ +# frozen_string_literal: true + class TypesDeChamp::PhoneTypeDeChamp < TypesDeChamp::TextTypeDeChamp + # We want to allow: + # * international (e164) phone numbers + # * “french format” (ten digits with a leading 0) + # * DROM numbers + # + # However, we need to special-case some ten-digit numbers, + # because the ARCEP assigns some blocks of "O6 XX XX XX XX" numbers to DROM operators. + # Guadeloupe | GP | +590 | 0690XXXXXX, 0691XXXXXX + # Guyane | GF | +594 | 0694XXXXXX + # Martinique | MQ | +596 | 0696XXXXXX, 0697XXXXXX + # Réunion | RE | +262 | 0692XXXXXX, 0693XXXXXX + # Mayotte | YT | +262 | 0692XXXXXX, 0693XXXXXX + # Nouvelle-Calédonie | NC | +687 | + # Polynésie française | PF | +689 | 40XXXXXX, 45XXXXXX, 87XXXXXX, 88XXXXXX, 89XXXXXX + # + # Cf: Plan national de numérotation téléphonique, + # https://www.arcep.fr/uploads/tx_gsavis/05-1085.pdf “Numéros mobiles à 10 chiffres”, page 6 + # + # See issue #6996. + DEFAULT_COUNTRY_CODES = [:FR, :GP, :GF, :MQ, :RE, :YT, :NC, :PF].freeze + + def champ_value(champ) + if Phonelib.valid_for_countries?(champ.value, DEFAULT_COUNTRY_CODES) + Phonelib.parse_for_countries(champ.value, DEFAULT_COUNTRY_CODES).full_national + else + # When he phone number is possible for the default countries, but not strictly valid, + # `full_national` could mess up the formatting. In this case just return the original. + champ.value + end + end end diff --git a/app/models/types_de_champ/piece_justificative_type_de_champ.rb b/app/models/types_de_champ/piece_justificative_type_de_champ.rb index b10a261b7..3fef517d0 100644 --- a/app/models/types_de_champ/piece_justificative_type_de_champ.rb +++ b/app/models/types_de_champ/piece_justificative_type_de_champ.rb @@ -1,7 +1,41 @@ +# frozen_string_literal: true + class TypesDeChamp::PieceJustificativeTypeDeChamp < TypesDeChamp::TypeDeChampBase def estimated_fill_duration(revision) FILL_DURATION_LONG end def tags_for_template = [].freeze + + def champ_value_for_export(champ, path = :value) + champ.piece_justificative_file.map { _1.filename.to_s }.join(', ') + end + + def champ_value_for_api(champ, version: 2) + return if version == 2 + + # API v1 don't support multiple PJ + attachment = champ.piece_justificative_file.first + return if attachment.nil? + + if attachment.virus_scanner.safe? || attachment.virus_scanner.pending? + attachment.url + end + end + + def champ_blank?(champ) = champ.piece_justificative_file.blank? + + def columns(procedure:, displayable: true, prefix: nil) + [ + Columns::AttachedManyColumn.new( + procedure_id: procedure.id, + stable_id:, + tdc_type: type_champ, + label: libelle_with_prefix(prefix), + type: TypeDeChamp.column_type(type_champ), + displayable: false, + filterable: false + ) + ] + end end diff --git a/app/models/types_de_champ/pole_emploi_type_de_champ.rb b/app/models/types_de_champ/pole_emploi_type_de_champ.rb index 508f72e20..e41113f6a 100644 --- a/app/models/types_de_champ/pole_emploi_type_de_champ.rb +++ b/app/models/types_de_champ/pole_emploi_type_de_champ.rb @@ -1,5 +1,9 @@ +# frozen_string_literal: true + class TypesDeChamp::PoleEmploiTypeDeChamp < TypesDeChamp::TextTypeDeChamp def estimated_fill_duration(revision) FILL_DURATION_MEDIUM end + + def champ_blank?(champ) = champ.external_id.blank? end diff --git a/app/models/types_de_champ/prefill_annuaire_education_type_de_champ.rb b/app/models/types_de_champ/prefill_annuaire_education_type_de_champ.rb deleted file mode 100644 index 7790458d9..000000000 --- a/app/models/types_de_champ/prefill_annuaire_education_type_de_champ.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -class TypesDeChamp::PrefillAnnuaireEducationTypeDeChamp < TypesDeChamp::PrefillTypeDeChamp - def to_assignable_attributes(champ, value) - return nil if value.blank? - - { - id: champ.id, - external_id: value, - value: value - } - end -end diff --git a/app/models/types_de_champ/prefill_commune_type_de_champ.rb b/app/models/types_de_champ/prefill_commune_type_de_champ.rb index 524fade1b..122db27f2 100644 --- a/app/models/types_de_champ/prefill_commune_type_de_champ.rb +++ b/app/models/types_de_champ/prefill_commune_type_de_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TypesDeChamp::PrefillCommuneTypeDeChamp < TypesDeChamp::PrefillTypeDeChamp def all_possible_values [] diff --git a/app/models/types_de_champ/prefill_departement_type_de_champ.rb b/app/models/types_de_champ/prefill_departement_type_de_champ.rb index 4c92cad1d..703db7d4b 100644 --- a/app/models/types_de_champ/prefill_departement_type_de_champ.rb +++ b/app/models/types_de_champ/prefill_departement_type_de_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TypesDeChamp::PrefillDepartementTypeDeChamp < TypesDeChamp::PrefillTypeDeChamp def all_possible_values departements.map { |departement| "#{departement[:code]} (#{departement[:name]})" } diff --git a/app/models/types_de_champ/prefill_drop_down_list_type_de_champ.rb b/app/models/types_de_champ/prefill_drop_down_list_type_de_champ.rb index 6930905e7..65702131a 100644 --- a/app/models/types_de_champ/prefill_drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/prefill_drop_down_list_type_de_champ.rb @@ -1,12 +1,11 @@ +# frozen_string_literal: true + class TypesDeChamp::PrefillDropDownListTypeDeChamp < TypesDeChamp::PrefillTypeDeChamp def all_possible_values if drop_down_other? - drop_down_list_enabled_non_empty_options.insert( - 0, - I18n.t("views.prefill_descriptions.edit.possible_values.drop_down_list_other_html") - ) + [I18n.t("views.prefill_descriptions.edit.possible_values.drop_down_list_other_html")] + drop_down_options else - drop_down_list_enabled_non_empty_options + drop_down_options end end diff --git a/app/models/types_de_champ/prefill_epci_type_de_champ.rb b/app/models/types_de_champ/prefill_epci_type_de_champ.rb index f50c2e35b..8e26c5ad5 100644 --- a/app/models/types_de_champ/prefill_epci_type_de_champ.rb +++ b/app/models/types_de_champ/prefill_epci_type_de_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TypesDeChamp::PrefillEpciTypeDeChamp < TypesDeChamp::PrefillTypeDeChamp def all_possible_values departements.map do |departement| diff --git a/app/models/types_de_champ/prefill_multiple_drop_down_list_type_de_champ.rb b/app/models/types_de_champ/prefill_multiple_drop_down_list_type_de_champ.rb index 17bea90a4..c958204cd 100644 --- a/app/models/types_de_champ/prefill_multiple_drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/prefill_multiple_drop_down_list_type_de_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TypesDeChamp::PrefillMultipleDropDownListTypeDeChamp < TypesDeChamp::PrefillDropDownListTypeDeChamp def example_value return nil if all_possible_values.empty? diff --git a/app/models/types_de_champ/prefill_pays_type_de_champ.rb b/app/models/types_de_champ/prefill_pays_type_de_champ.rb index 9c638c904..1a49c8fc9 100644 --- a/app/models/types_de_champ/prefill_pays_type_de_champ.rb +++ b/app/models/types_de_champ/prefill_pays_type_de_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TypesDeChamp::PrefillPaysTypeDeChamp < TypesDeChamp::PrefillTypeDeChamp def all_possible_values countries.map { |country| "#{country[:code]} (#{country[:name]})" } diff --git a/app/models/types_de_champ/prefill_region_type_de_champ.rb b/app/models/types_de_champ/prefill_region_type_de_champ.rb index ae9d0501a..fb983ef1c 100644 --- a/app/models/types_de_champ/prefill_region_type_de_champ.rb +++ b/app/models/types_de_champ/prefill_region_type_de_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TypesDeChamp::PrefillRegionTypeDeChamp < TypesDeChamp::PrefillTypeDeChamp def all_possible_values regions.map { |region| "#{region[:code]} (#{region[:name]})" } diff --git a/app/models/types_de_champ/prefill_repetition_type_de_champ.rb b/app/models/types_de_champ/prefill_repetition_type_de_champ.rb index 7ff8076ed..5af53c178 100644 --- a/app/models/types_de_champ/prefill_repetition_type_de_champ.rb +++ b/app/models/types_de_champ/prefill_repetition_type_de_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TypesDeChamp::PrefillRepetitionTypeDeChamp < TypesDeChamp::PrefillTypeDeChamp include ActionView::Helpers::UrlHelper include ApplicationHelper @@ -54,14 +56,16 @@ class TypesDeChamp::PrefillRepetitionTypeDeChamp < TypesDeChamp::PrefillTypeDeCh def to_assignable_attributes return unless repetition.is_a?(Hash) - row = champ.rows[index] || champ.add_row(champ.dossier_revision) + row_id = champ.row_ids[index] || champ.add_row(updated_by: nil) repetition.map do |key, value| next unless key.is_a?(String) && key.starts_with?("champ_") - subchamp = row.find { |champ| champ.stable_id == Champ.stable_id_from_typed_id(key) } - next unless subchamp + stable_id = Champ.stable_id_from_typed_id(key) + type_de_champ = revision.types_de_champ.find { _1.stable_id == stable_id } + next unless type_de_champ + subchamp = champ.dossier.champ_for_update(type_de_champ, row_id, updated_by: nil) TypesDeChamp::PrefillTypeDeChamp.build(subchamp.type_de_champ, revision).to_assignable_attributes(subchamp, value) end.compact end diff --git a/app/models/types_de_champ/prefill_type_de_champ.rb b/app/models/types_de_champ/prefill_type_de_champ.rb index 52b3a7315..1917f7b55 100644 --- a/app/models/types_de_champ/prefill_type_de_champ.rb +++ b/app/models/types_de_champ/prefill_type_de_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TypesDeChamp::PrefillTypeDeChamp < SimpleDelegator include ActionView::Helpers::UrlHelper include ApplicationHelper @@ -29,8 +31,6 @@ class TypesDeChamp::PrefillTypeDeChamp < SimpleDelegator TypesDeChamp::PrefillAddressTypeDeChamp.new(type_de_champ, revision) when TypeDeChamp.type_champs.fetch(:epci) TypesDeChamp::PrefillEpciTypeDeChamp.new(type_de_champ, revision) - when TypeDeChamp.type_champs.fetch(:annuaire_education) - TypesDeChamp::PrefillAnnuaireEducationTypeDeChamp.new(type_de_champ, revision) else new(type_de_champ, revision) end @@ -72,7 +72,7 @@ class TypesDeChamp::PrefillTypeDeChamp < SimpleDelegator link_to( I18n.t("views.prefill_descriptions.edit.possible_values.link.text"), - Rails.application.routes.url_helpers.prefill_type_de_champ_path(revision.procedure_path, self), + Rails.application.routes.url_helpers.prefill_type_de_champ_path(@revision.procedure_path, self), title: new_tab_suffix(I18n.t("views.prefill_descriptions.edit.possible_values.link.title")), **external_link_attributes ) diff --git a/app/models/types_de_champ/region_type_de_champ.rb b/app/models/types_de_champ/region_type_de_champ.rb index 015614aa9..ad31c7710 100644 --- a/app/models/types_de_champ/region_type_de_champ.rb +++ b/app/models/types_de_champ/region_type_de_champ.rb @@ -1,8 +1,32 @@ +# frozen_string_literal: true + class TypesDeChamp::RegionTypeDeChamp < TypesDeChamp::TextTypeDeChamp def filter_to_human(filter_value) APIGeoService.region_name(filter_value).presence || filter_value end + def champ_value(champ) + champ.name + end + + def champ_value_for_export(champ, path = :value) + case path + when :value + champ_value(champ) + when :code + champ.code + end + end + + def champ_value_for_tag(champ, path = :value) + case path + when :value + champ_value(champ) + when :code + champ.code + end + end + private def paths diff --git a/app/models/types_de_champ/repetition_type_de_champ.rb b/app/models/types_de_champ/repetition_type_de_champ.rb index b6184011d..7470162fd 100644 --- a/app/models/types_de_champ/repetition_type_de_champ.rb +++ b/app/models/types_de_champ/repetition_type_de_champ.rb @@ -1,4 +1,11 @@ +# frozen_string_literal: true + class TypesDeChamp::RepetitionTypeDeChamp < TypesDeChamp::TypeDeChampBase + def champ_value_for_tag(champ, path = :value) + return nil if path != :value + ChampPresentations::RepetitionPresentation.new(libelle, champ.dossier.project_rows_for(@type_de_champ)) + end + def estimated_fill_duration(revision) estimated_rows_in_repetition = 2.5 @@ -17,4 +24,14 @@ class TypesDeChamp::RepetitionTypeDeChamp < TypesDeChamp::TypeDeChampBase # /\*?[] are invalid Excel worksheet characters ActiveStorage::Filename.new(str.delete('[]*?')).sanitized end + + def columns(procedure:, displayable: nil, prefix: nil) + prefix = prefix.present? ? "(#{prefix} #{libelle})" : libelle + + procedure + .all_revisions_types_de_champ(parent: @type_de_champ) + .flat_map { _1.columns(procedure:, displayable: false, prefix:) } + end + + def champ_blank?(champ) = champ.dossier.repetition_row_ids(@type_de_champ).blank? end diff --git a/app/models/types_de_champ/rna_type_de_champ.rb b/app/models/types_de_champ/rna_type_de_champ.rb index 01bf96815..e6c2ca915 100644 --- a/app/models/types_de_champ/rna_type_de_champ.rb +++ b/app/models/types_de_champ/rna_type_de_champ.rb @@ -1,5 +1,29 @@ +# frozen_string_literal: true + class TypesDeChamp::RNATypeDeChamp < TypesDeChamp::TypeDeChampBase + include AddressableColumnConcern + def estimated_fill_duration(revision) FILL_DURATION_MEDIUM end + + def champ_value_for_export(champ, path = :value) + champ.identifier + end + + def columns(procedure:, displayable: true, prefix: nil) + super + .concat(addressable_columns(procedure:, displayable:, prefix:)) + .concat([ + Columns::JSONPathColumn.new( + procedure_id: procedure.id, + stable_id:, + tdc_type: type_champ, + label: "#{libelle_with_prefix(prefix)} – Titre au répertoire national des associations", + type: :text, + jsonpath: '$.title', + displayable: + ) + ]) + end end diff --git a/app/models/types_de_champ/rnf_type_de_champ.rb b/app/models/types_de_champ/rnf_type_de_champ.rb index d25e02a46..e92989af3 100644 --- a/app/models/types_de_champ/rnf_type_de_champ.rb +++ b/app/models/types_de_champ/rnf_type_de_champ.rb @@ -1,4 +1,56 @@ +# frozen_string_literal: true + class TypesDeChamp::RNFTypeDeChamp < TypesDeChamp::TextTypeDeChamp + include AddressableColumnConcern + + def champ_value_for_export(champ, path = :value) + case path + when :value + champ.rnf_id + when :departement + champ.departement_code_and_name + when :code_insee + champ.commune&.fetch(:code) + when :address + champ.full_address + when :nom + champ.title + end + end + + def champ_value_for_tag(champ, path = :value) + case path + when :value + champ.rnf_id + when :departement + champ.departement_code_and_name || '' + when :code_insee + champ.commune&.fetch(:code) || '' + when :address + champ.full_address || '' + when :nom + champ.title || '' + end + end + + def champ_blank?(champ) = champ.external_id.blank? + + def columns(procedure:, displayable: true, prefix: nil) + super + .concat(addressable_columns(procedure:, displayable:, prefix:)) + .concat([ + Columns::JSONPathColumn.new( + procedure_id: procedure.id, + stable_id:, + tdc_type: type_champ, + label: "#{libelle_with_prefix(prefix)} – Titre au répertoire national des fondations ", + type: :text, + jsonpath: '$.title', + displayable: + ) + ]) + end + private def paths diff --git a/app/models/types_de_champ/siret_type_de_champ.rb b/app/models/types_de_champ/siret_type_de_champ.rb index 26b653cf6..5786a071f 100644 --- a/app/models/types_de_champ/siret_type_de_champ.rb +++ b/app/models/types_de_champ/siret_type_de_champ.rb @@ -1,5 +1,15 @@ +# frozen_string_literal: true + class TypesDeChamp::SiretTypeDeChamp < TypesDeChamp::TypeDeChampBase + include AddressableColumnConcern + def estimated_fill_duration(revision) FILL_DURATION_MEDIUM end + + def champ_blank_or_invalid?(champ) = Siret.new(siret: champ.value).invalid? + + def columns(procedure:, displayable: true, prefix: nil) + super.concat(addressable_columns(procedure:, displayable:, prefix:)) + end end diff --git a/app/models/types_de_champ/text_type_de_champ.rb b/app/models/types_de_champ/text_type_de_champ.rb index 437a1ef8a..50b1e2c0d 100644 --- a/app/models/types_de_champ/text_type_de_champ.rb +++ b/app/models/types_de_champ/text_type_de_champ.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + class TypesDeChamp::TextTypeDeChamp < TypesDeChamp::TypeDeChampBase end diff --git a/app/models/types_de_champ/textarea_type_de_champ.rb b/app/models/types_de_champ/textarea_type_de_champ.rb index 3c92afb1e..d16309498 100644 --- a/app/models/types_de_champ/textarea_type_de_champ.rb +++ b/app/models/types_de_champ/textarea_type_de_champ.rb @@ -1,5 +1,11 @@ +# frozen_string_literal: true + class TypesDeChamp::TextareaTypeDeChamp < TypesDeChamp::TextTypeDeChamp def estimated_fill_duration(revision) FILL_DURATION_MEDIUM end + + def champ_value_for_export(champ, path = :value) + ActionView::Base.full_sanitizer.sanitize(champ.value) + end end diff --git a/app/models/types_de_champ/titre_identite_type_de_champ.rb b/app/models/types_de_champ/titre_identite_type_de_champ.rb index 36ff0cd52..ed929dec4 100644 --- a/app/models/types_de_champ/titre_identite_type_de_champ.rb +++ b/app/models/types_de_champ/titre_identite_type_de_champ.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TypesDeChamp::TitreIdentiteTypeDeChamp < TypesDeChamp::TypeDeChampBase FRANCE_CONNECT = 'france_connect' PIECE_JUSTIFICATIVE = 'piece_justificative' @@ -7,4 +9,32 @@ class TypesDeChamp::TitreIdentiteTypeDeChamp < TypesDeChamp::TypeDeChampBase end def tags_for_template = [].freeze + + def champ_value_for_export(champ, path = :value) + champ.piece_justificative_file.attached? ? "présent" : "absent" + end + + def champ_value_for_api(champ, version: 2) + nil + end + + def champ_default_export_value(path = :value) + "absent" + end + + def champ_blank?(champ) = champ.piece_justificative_file.blank? + + def columns(procedure:, displayable: nil, prefix: nil) + [ + Columns::AttachedManyColumn.new( + procedure_id: procedure.id, + stable_id:, + tdc_type: type_champ, + label: libelle_with_prefix(prefix), + type: TypeDeChamp.column_type(type_champ), + displayable: false, + filterable: false + ) + ] + end end diff --git a/app/models/types_de_champ/type_de_champ_base.rb b/app/models/types_de_champ/type_de_champ_base.rb index db0d38d9b..86ab644f5 100644 --- a/app/models/types_de_champ/type_de_champ_base.rb +++ b/app/models/types_de_champ/type_de_champ_base.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + class TypesDeChamp::TypeDeChampBase include ActiveModel::Validations - delegate :description, :libelle, :mandatory, :mandatory?, :stable_id, :fillable?, :public?, to: :@type_de_champ + delegate :description, :libelle, :mandatory, :mandatory?, :stable_id, :fillable?, :public?, :type_champ, :options_for_select, :drop_down_options, :drop_down_other?, to: :@type_de_champ FILL_DURATION_SHORT = 10.seconds FILL_DURATION_MEDIUM = 1.minute @@ -13,12 +15,12 @@ class TypesDeChamp::TypeDeChampBase end def tags_for_template - tdc = @type_de_champ + type_de_champ = @type_de_champ paths.map do |path| path.merge( libelle: TagsSubstitutionConcern::TagsParser.normalize(path[:libelle]), id: path[:path] == :value ? "tdc#{stable_id}" : "tdc#{stable_id}/#{path[:path]}", - lambda: -> (dossier) { dossier.project_champ(tdc, nil).for_tag(path[:path]) } + lambda: -> (dossier) { dossier.champ_value_for_tag(type_de_champ, path[:path]) } ) end end @@ -52,12 +54,71 @@ class TypesDeChamp::TypeDeChampBase filter_value end - def human_to_filter(human_value) - human_value + def champ_value(champ) + champ.value.present? ? champ.value.to_s : champ_default_value + end + + def champ_value_for_api(champ, version: 2) + case version + when 2 + champ_value(champ) + else + champ.value.presence || champ_default_api_value(version) + end + end + + def champ_value_for_export(champ, path = :value) + path == :value ? champ.value.presence : champ_default_export_value(path) + end + + def champ_value_for_tag(champ, path = :value) + path == :value ? champ_value(champ) : nil + end + + def champ_default_value + '' + end + + def champ_default_export_value(path = :value) + nil + end + + def champ_default_api_value(version = 2) + case version + when 2 + '' + else + nil + end + end + + def champ_blank?(champ) = champ.value.blank? + def champ_blank_or_invalid?(champ) = champ_blank?(champ) + + def columns(procedure:, displayable: true, prefix: nil) + if fillable? + [ + Columns::ChampColumn.new( + procedure_id: procedure.id, + stable_id:, + tdc_type: type_champ, + label: libelle_with_prefix(prefix), + type: TypeDeChamp.column_type(type_champ), + displayable:, + options_for_select: + ) + ] + else + [] + end end private + def libelle_with_prefix(prefix) + [prefix, libelle].compact.join(' – ') + end + def paths [ { diff --git a/app/models/types_de_champ/yes_no_type_de_champ.rb b/app/models/types_de_champ/yes_no_type_de_champ.rb index 25dae97bc..1821ce4cb 100644 --- a/app/models/types_de_champ/yes_no_type_de_champ.rb +++ b/app/models/types_de_champ/yes_no_type_de_champ.rb @@ -1,4 +1,6 @@ -class TypesDeChamp::YesNoTypeDeChamp < TypesDeChamp::CheckboxTypeDeChamp +# frozen_string_literal: true + +class TypesDeChamp::YesNoTypeDeChamp < TypesDeChamp::TypeDeChampBase def filter_to_human(filter_value) if filter_value == "true" I18n.t('activerecord.attributes.type_de_champ.type_champs.yes_no_true') @@ -9,14 +11,34 @@ class TypesDeChamp::YesNoTypeDeChamp < TypesDeChamp::CheckboxTypeDeChamp end end - def human_to_filter(human_value) - human_value.downcase! - if human_value == "oui" - "true" - elsif human_value == "non" - "false" + def champ_value(champ) + champ_value_true?(champ) ? 'Oui' : 'Non' + end + + def champ_value_for_export(champ, path = :value) + champ_value_true?(champ) ? 'Oui' : 'Non' + end + + def champ_value_for_api(champ, version: 2) + case version + when 2 + champ_value_true?(champ).to_s else - human_value + super end end + + def champ_default_value + '' + end + + def champ_default_export_value(path = :value) + '' + end + + private + + def champ_value_true?(champ) + champ.value == 'true' + end end diff --git a/app/models/user.rb b/app/models/user.rb index 3a9aebfb2..e6a659e43 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class User < ApplicationRecord include DomainMigratableConcern include EmailSanitizableConcern @@ -42,10 +44,6 @@ class User < ApplicationRecord # plug our custom validation a la devise (same options) https://github.com/heartcombo/devise/blob/main/lib/devise/models/validatable.rb#L30 validates :email, strict_email: true, allow_blank: true, if: :devise_will_save_change_to_email? - def validate_password_complexity? - administrateur? - end - # Override of Devise::Models::Confirmable#send_confirmation_instructions def send_confirmation_instructions unless @raw_confirmation_token @@ -78,10 +76,28 @@ class User < ApplicationRecord owns?(dossier) || invite?(dossier) end - def invite! + def invite_instructeur! UserMailer.invite_instructeur(self, set_reset_password_token).deliver_later end + def invite_tiers!(dossier) + token = SecureRandom.hex(10) + self.update!(confirmation_token: token, confirmation_sent_at: Time.zone.now) + UserMailer.invite_tiers(self, token, dossier).deliver_later + end + + def invite_expert_and_send_avis!(avis) + token = SecureRandom.hex(10) + self.update!(confirmation_token: token, confirmation_sent_at: Time.zone.now) + AvisMailer.avis_invitation_and_confirm_email(self, token, avis).deliver_later + end + + def resend_confirmation_email! + token = SecureRandom.hex(10) + self.update!(confirmation_token: token, confirmation_sent_at: Time.zone.now) + UserMailer.resend_confirmation_email(self, token).deliver_later + end + def invite_gestionnaire!(groupe_gestionnaire) UserMailer.invite_gestionnaire(self, set_reset_password_token, groupe_gestionnaire).deliver_later end @@ -96,10 +112,16 @@ class User < ApplicationRecord AdministrateurMailer.activate_before_expiration(self, reset_password_token).deliver_later end - def self.create_or_promote_to_instructeur(email, password, administrateurs: []) - user = User - .create_with(password: password, confirmed_at: Time.zone.now) - .find_or_create_by(email: email) + def self.create_or_promote_to_instructeur(email, password, administrateurs: [], agent_connect: false) + if agent_connect + user = User + .create_with(password: password, confirmed_at: Time.zone.now, email_verified_at: Time.zone.now) + .find_or_create_by(email: email) + else + user = User + .create_with(password: password, confirmed_at: Time.zone.now) + .find_or_create_by(email: email) + end if user.valid? if user.instructeur.nil? @@ -123,6 +145,17 @@ class User < ApplicationRecord user end + def self.create_or_promote_to_tiers(email, password, dossier = nil) + user = User + .create_with(password: password, confirmed_at: Time.zone.now) + .find_or_create_by(email: email) + + if user.valid? && user.unverified_email? + user.invite_tiers!(dossier) + end + user + end + def self.create_or_promote_to_administrateur(email, password) user = User.create_or_promote_to_instructeur(email, password) @@ -140,10 +173,8 @@ class User < ApplicationRecord .create_with(password: password, confirmed_at: Time.zone.now) .find_or_create_by(email: email) - if user.valid? - if user.expert.nil? - user.create_expert! - end + if user.valid? && user.expert.nil? + user.create_expert! end user @@ -250,12 +281,8 @@ class User < ApplicationRecord end def ask_for_merge(requested_user) - if update(requested_merge_into: requested_user) - UserMailer.ask_for_merge(self, requested_user.email).deliver_later - return true - else - return false - end + update!(requested_merge_into: requested_user, unconfirmed_email: nil) + UserMailer.ask_for_merge(self, requested_user.email).deliver_later end def send_devise_notification(notification, *args) @@ -266,6 +293,8 @@ class User < ApplicationRecord super && blocked_at.nil? end + def unverified_email? = !email_verified_at? + private def does_not_merge_on_self diff --git a/app/models/zone.rb b/app/models/zone.rb index 20829bc81..64f288b63 100644 --- a/app/models/zone.rb +++ b/app/models/zone.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Zone < ApplicationRecord validates :acronym, presence: true, uniqueness: true has_many :labels, -> { order(designated_on: :desc) }, class_name: 'ZoneLabel', inverse_of: :zone diff --git a/app/models/zone_label.rb b/app/models/zone_label.rb index 926488b75..1ddf1f32a 100644 --- a/app/models/zone_label.rb +++ b/app/models/zone_label.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ZoneLabel < ApplicationRecord belongs_to :zone end diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb index dfebff858..e8e816cc3 100644 --- a/app/policies/application_policy.rb +++ b/app/policies/application_policy.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ApplicationPolicy attr_reader :user, :record diff --git a/app/policies/champ_policy.rb b/app/policies/dossier_policy.rb similarity index 64% rename from app/policies/champ_policy.rb rename to app/policies/dossier_policy.rb index 1376abe40..fff3aa1d0 100644 --- a/app/policies/champ_policy.rb +++ b/app/policies/dossier_policy.rb @@ -1,7 +1,9 @@ -class ChampPolicy < ApplicationPolicy - # Scope for WRITING to a champ. +# frozen_string_literal: true + +class DossierPolicy < ApplicationPolicy + # Scope for WRITING to a dossier. # - # (If the need for a scope to READ a champ emerges, we can implement another scope + # (If the need for a scope to READ a dossier emerges, we can implement another scope # in this file, following this example: https://github.com/varvet/pundit/issues/368#issuecomment-196111115) class Scope < ApplicationScope def resolve @@ -11,33 +13,29 @@ class ChampPolicy < ApplicationPolicy # The join must be the same for all elements of the WHERE clause. # - # NB: here we want to do `.left_outer_joins(dossier: [:invites, { :groupe_instructeur: :instructeurs }]))`, + # NB: here we want to do `.left_outer_joins(:invites, { :groupe_instructeur: :instructeurs })`, # but for some reasons ActiveRecord <= 5.2 generates bogus SQL. Hence the manual version of it below. joined_scope = scope - .joins('LEFT OUTER JOIN dossiers ON dossiers.id = champs.dossier_id') .joins('LEFT OUTER JOIN invites ON invites.dossier_id = dossiers.id OR invites.dossier_id = dossiers.editing_fork_origin_id') .joins('LEFT OUTER JOIN groupe_instructeurs ON groupe_instructeurs.id = dossiers.groupe_instructeur_id') .joins('LEFT OUTER JOIN assign_tos ON assign_tos.groupe_instructeur_id = groupe_instructeurs.id') .joins('LEFT OUTER JOIN instructeurs ON instructeurs.id = assign_tos.instructeur_id') # Users can access public champs on their own dossiers. - resolved_scope = joined_scope - .where('dossiers.user_id': user.id, private: false) + resolved_scope = joined_scope.where(user_id: user.id) # Invited users can access public champs on dossiers they are invited to - invite_clause = joined_scope - .where('invites.user_id': user.id, private: false) + invite_clause = joined_scope.where('invites.user_id': user.id) resolved_scope = resolved_scope.or(invite_clause) if instructeur.present? # Additionnaly, instructeurs can access private champs # on dossiers they are allowed to instruct. - instructeur_clause = joined_scope - .where('instructeurs.id': instructeur.id, private: true) + instructeur_clause = joined_scope.where('instructeurs.id': instructeur.id) resolved_scope = resolved_scope.or(instructeur_clause) end - resolved_scope.or(joined_scope.where('dossiers.for_procedure_preview': true)) + resolved_scope.or(joined_scope.where(for_procedure_preview: true)) end end end diff --git a/app/policies/type_de_champ_policy.rb b/app/policies/type_de_champ_policy.rb deleted file mode 100644 index 98bd57ec6..000000000 --- a/app/policies/type_de_champ_policy.rb +++ /dev/null @@ -1,13 +0,0 @@ -class TypeDeChampPolicy < ApplicationPolicy - class Scope < ApplicationScope - def resolve - if administrateur.present? - scope - .joins(procedure: [:administrateurs]) - .where({ administrateurs: { id: administrateur.id } }) - else - scope.none - end - end - end -end diff --git a/app/schemas/rnf.json b/app/schemas/rnf.json index 866821090..b17244751 100644 --- a/app/schemas/rnf.json +++ b/app/schemas/rnf.json @@ -30,7 +30,6 @@ "regionName":{ "type": "string" }, "regionCode":{ "type": "string" } }, - "status": { "type": ["string", "null"] }, "persons": { "type": "array" } } } diff --git a/app/schemas/service-public.json b/app/schemas/service-public.json new file mode 100644 index 000000000..f5ceb71c5 --- /dev/null +++ b/app/schemas/service-public.json @@ -0,0 +1,76 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://demarches-simplifiees.fr/service-public.schema.json", + "title": "Service Public", + "type": "object", + "properties": { + "total_count": { + "type": "integer" + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "plage_ouverture": { "type": ["string", "null"] }, + "site_internet": { "type": ["string", "null"] }, + "copyright": { "type": ["string", "null"] }, + "siren": { "type": ["string", "null"] }, + "ancien_code_pivot": { "type": ["string", "null"] }, + "reseau_social": { "type": ["string", "null"] }, + "texte_reference": { "type": ["string", "null"] }, + "partenaire": { "type": ["string", "null"] }, + "telecopie": { "type": ["string", "null"] }, + "nom": { "type": "string" }, + "siret": { "type": ["string", "null"] }, + "itm_identifiant": { "type": ["string", "null"] }, + "sigle": { "type": ["string", "null"] }, + "affectation_personne": { "type": ["string", "null"] }, + "date_modification": { "type": "string" }, + "adresse_courriel": { "type": ["string", "null"] }, + "service_disponible": { "type": ["string", "null"] }, + "organigramme": { "type": ["string", "null"] }, + "pivot": { "type": ["string", "null"] }, + "partenaire_identifiant": { "type": ["string", "null"] }, + "ancien_identifiant": { "type": ["string", "null"] }, + "id": { "type": "string" }, + "ancien_nom": { "type": ["string", "null"] }, + "commentaire_plage_ouverture": { "type": ["string", "null"] }, + "annuaire": { "type": ["string", "null"] }, + "tchat": { "type": ["string", "null"] }, + "hierarchie": { "type": ["string", "null"] }, + "categorie": { "type": "string" }, + "sve": { "type": ["string", "null"] }, + "telephone_accessible": { "type": ["string", "null"] }, + "application_mobile": { "type": ["string", "null"] }, + "version_type": { "type": "string" }, + "type_repertoire": { "type": ["string", "null"] }, + "telephone": { "type": ["string", "null"] }, + "version_etat_modification": { "type": ["string", "null"] }, + "date_creation": { "type": "string" }, + "partenaire_date_modification": { "type": ["string", "null"] }, + "mission": { "type": ["string", "null"] }, + "formulaire_contact": { "type": ["string", "null"] }, + "version_source": { "type": ["string", "null"] }, + "type_organisme": { "type": ["string", "null"] }, + "code_insee_commune": { "type": ["string", "null"] }, + "statut_de_diffusion": { "type": ["string", "null"] }, + "adresse": { "type": ["string", "null"] }, + "url_service_public": { "type": ["string", "null"] }, + "information_complementaire": { "type": ["string", "null"] }, + "date_diffusion": { "type": ["string", "null"] } + }, + "required": [ + "id", + "nom", + "categorie", + "adresse", + "adresse_courriel", + "telephone", + "plage_ouverture" + ] + } + } + }, + "required": ["total_count", "results"] +} diff --git a/app/serializers/avis_serializer.rb b/app/serializers/avis_serializer.rb index a1212c0a7..94b116da8 100644 --- a/app/serializers/avis_serializer.rb +++ b/app/serializers/avis_serializer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AvisSerializer < ActiveModel::Serializer attributes :answer, :introduction, diff --git a/app/serializers/champ_serializer.rb b/app/serializers/champ_serializer.rb index 003f611a5..4ba762f05 100644 --- a/app/serializers/champ_serializer.rb +++ b/app/serializers/champ_serializer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ChampSerializer < ActiveModel::Serializer include Rails.application.routes.url_helpers @@ -15,7 +17,7 @@ class ChampSerializer < ActiveModel::Serializer when GeoArea object.geometry else - object.for_api + object.type_de_champ.champ_value_for_api(object, version: 1) end end @@ -46,11 +48,7 @@ class ChampSerializer < ActiveModel::Serializer end def rows - object.dossier - .champs_for_revision(scope: object.type_de_champ) - .group_by(&:row_id) - .values - .map.with_index(1) { |champs, index| Row.new(index:, champs:) } + object.rows.map.with_index(1) { |champs, index| Row.new(index:, champs:) } end def include_etablissement? diff --git a/app/serializers/commentaire_serializer.rb b/app/serializers/commentaire_serializer.rb index 3cc79c8a2..96d2b74c5 100644 --- a/app/serializers/commentaire_serializer.rb +++ b/app/serializers/commentaire_serializer.rb @@ -1,7 +1,10 @@ +# frozen_string_literal: true + class CommentaireSerializer < ActiveModel::Serializer attributes :email, :body, :created_at, + :piece_jointe_attachments, :attachment def created_at @@ -9,6 +12,10 @@ class CommentaireSerializer < ActiveModel::Serializer end def attachment - object.file_url + piece_jointe = object.piece_jointe_attachments.first + + if piece_jointe&.virus_scanner&.safe? + Rails.application.routes.url_helpers.url_for(piece_jointe) + end end end diff --git a/app/serializers/dossier_serializer.rb b/app/serializers/dossier_serializer.rb index 81e783bba..33bc5e75c 100644 --- a/app/serializers/dossier_serializer.rb +++ b/app/serializers/dossier_serializer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class DossierSerializer < ActiveModel::Serializer include DossierHelper @@ -30,7 +32,7 @@ class DossierSerializer < ActiveModel::Serializer has_many :champs, serializer: ChampSerializer def champs - champs = object.champs_public.reject { |c| c.type_de_champ.old_pj.present? } + champs = object.project_champs_public.reject { |c| c.type_de_champ.old_pj.present? } if object.expose_legacy_carto_api? champ_carte = champs.find do |champ| @@ -50,16 +52,20 @@ class DossierSerializer < ActiveModel::Serializer champs end + def champs_private + object.project_champs_private + end + def cerfa [] end def pieces_justificatives - object.champs_public.filter { |champ| champ.type_de_champ.old_pj }.map do |champ| + object.project_champs_public.filter { |champ| champ.type_de_champ.old_pj }.map do |champ| { created_at: champ.created_at&.in_time_zone('UTC'), type_de_piece_justificative_id: champ.type_de_champ.old_pj[:stable_id], - content_url: champ.for_api, + content_url: champ.type_de_champ.champ_value_for_api(champ, version: 1), user: champ.dossier.user } end.flatten diff --git a/app/serializers/dossiers_serializer.rb b/app/serializers/dossiers_serializer.rb index d94e1ba6e..80961600f 100644 --- a/app/serializers/dossiers_serializer.rb +++ b/app/serializers/dossiers_serializer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class DossiersSerializer < ActiveModel::Serializer include DossierHelper diff --git a/app/serializers/entreprise_serializer.rb b/app/serializers/entreprise_serializer.rb index 602f9e69b..c4b6a2b46 100644 --- a/app/serializers/entreprise_serializer.rb +++ b/app/serializers/entreprise_serializer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EntrepriseSerializer < ActiveModel::Serializer attributes :siren, :capital_social, diff --git a/app/serializers/etablissement_serializer.rb b/app/serializers/etablissement_serializer.rb index 6cd091859..2e4c96146 100644 --- a/app/serializers/etablissement_serializer.rb +++ b/app/serializers/etablissement_serializer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EtablissementSerializer < ActiveModel::Serializer attributes :siret, :siege_social, diff --git a/app/serializers/geo_area_serializer.rb b/app/serializers/geo_area_serializer.rb index 39fb6cfd3..5620ca29b 100644 --- a/app/serializers/geo_area_serializer.rb +++ b/app/serializers/geo_area_serializer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GeoAreaSerializer < ActiveModel::Serializer attributes :geometry, :source, :geo_reference_id diff --git a/app/serializers/individual_serializer.rb b/app/serializers/individual_serializer.rb index 0ef0f4207..3f85b860a 100644 --- a/app/serializers/individual_serializer.rb +++ b/app/serializers/individual_serializer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class IndividualSerializer < ActiveModel::Serializer attribute :gender, key: :civilite attributes :nom, :prenom diff --git a/app/serializers/logic_serializer.rb b/app/serializers/logic_serializer.rb index 90cec89af..44f21b45b 100644 --- a/app/serializers/logic_serializer.rb +++ b/app/serializers/logic_serializer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class LogicSerializer def self.load(logic) if logic.present? diff --git a/app/serializers/module_api_carto_serializer.rb b/app/serializers/module_api_carto_serializer.rb index bbc19de16..3fde0635d 100644 --- a/app/serializers/module_api_carto_serializer.rb +++ b/app/serializers/module_api_carto_serializer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ModuleAPICartoSerializer < ActiveModel::Serializer attributes :use_api_carto, :cadastre end diff --git a/app/serializers/procedure_serializer.rb b/app/serializers/procedure_serializer.rb index 2659a58a5..556192f0d 100644 --- a/app/serializers/procedure_serializer.rb +++ b/app/serializers/procedure_serializer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ProcedureSerializer < ActiveModel::Serializer include Rails.application.routes.url_helpers diff --git a/app/serializers/row_serializer.rb b/app/serializers/row_serializer.rb index b7aa2affd..d257fc55a 100644 --- a/app/serializers/row_serializer.rb +++ b/app/serializers/row_serializer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class RowSerializer < ActiveModel::Serializer has_many :champs, serializer: ChampSerializer diff --git a/app/serializers/service_serializer.rb b/app/serializers/service_serializer.rb index 12586202c..d8e3b4f26 100644 --- a/app/serializers/service_serializer.rb +++ b/app/serializers/service_serializer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ServiceSerializer < ActiveModel::Serializer attributes :id, :email attribute :nom, key: :name diff --git a/app/serializers/type_de_champ_serializer.rb b/app/serializers/type_de_champ_serializer.rb index bf07af980..d08be2694 100644 --- a/app/serializers/type_de_champ_serializer.rb +++ b/app/serializers/type_de_champ_serializer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TypeDeChampSerializer < ActiveModel::Serializer attributes :id, :libelle, diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index 4665c89d7..99d9b6126 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class UserSerializer < ActiveModel::Serializer attributes :email end diff --git a/app/services/administrateur_deletion_service.rb b/app/services/administrateur_deletion_service.rb index 75d2ee458..f13b0b291 100644 --- a/app/services/administrateur_deletion_service.rb +++ b/app/services/administrateur_deletion_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AdministrateurDeletionService include Dry::Monads[:result] diff --git a/app/services/agent_connect_service.rb b/app/services/agent_connect_service.rb index b0d9de66f..6b2dc6ba1 100644 --- a/app/services/agent_connect_service.rb +++ b/app/services/agent_connect_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AgentConnectService include OpenIDConnect @@ -12,10 +14,12 @@ class AgentConnectService nonce = SecureRandom.hex(16) uri = client.authorization_uri( - scope: [:openid, :email, :given_name, :usual_name, :organizational_unit, :belonging_population, :siret], + scope: [:openid, :email, :given_name, :usual_name, :organizational_unit, :belonging_population, :siret, :idp_id], state:, nonce:, - acr_values: 'eidas1' + acr_values: 'eidas1', + claims: { id_token: { amr: { essential: true } } }.to_json, + prompt: :login ) [uri, state, nonce] @@ -30,7 +34,15 @@ class AgentConnectService id_token = ResponseObject::IdToken.decode(access_token.id_token, conf[:jwks]) id_token.verify!(conf.merge(nonce: nonce)) - [access_token.userinfo!.raw_attributes, access_token.id_token] + amr = id_token.amr.present? ? JSON.parse(id_token.amr) : [] + + [access_token.userinfo!.raw_attributes, access_token.id_token, amr] + end + + def self.logout_url(id_token, host_with_port:) + app_logout = Rails.application.routes.url_helpers.logout_url(host: host_with_port) + h = { id_token_hint: id_token, post_logout_redirect_uri: app_logout } + "#{AGENT_CONNECT[:end_session_endpoint]}?#{h.to_query}" end private diff --git a/app/services/annuaire_service_public_service.rb b/app/services/annuaire_service_public_service.rb new file mode 100644 index 000000000..7c5728fd8 --- /dev/null +++ b/app/services/annuaire_service_public_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class AnnuaireServicePublicService + include Dry::Monads[:result] + + def call(siret:) + result = API::Client.new.call(url: url(siret), schema:, timeout: 1.second) + + case result + in Success(body:) + result = body[:results].first + + if result.present? + Success( + result.slice(:nom, :adresse, :adresse_courriel).merge( + telephone: maybe_json_parse(result[:telephone]), + plage_ouverture: maybe_json_parse(result[:plage_ouverture]), + adresse: maybe_json_parse(result[:adresse]) + ) + ) + else + Failure(API::Client::Error[:not_found, 404, false, "No result found for this SIRET."]) + end + in Failure(code:, reason:) if code.in?(401..403) + Sentry.capture_message("#{self.class.name}: #{reason} code: #{code}", extra: { siret: }) + Failure(API::Client::Error[:unauthorized, code, false, reason]) + in Failure(type: :schema, code:, reason:) + reason.errors[0].first + Sentry.capture_exception(reason, extra: { siret:, code: }) + + Failure(API::Client::Error[:schema, code, false, reason]) + else + result + end + end + + private + + def schema + JSONSchemer.schema(Rails.root.join('app/schemas/service-public.json')) + end + + def url(siret) + "https://api-lannuaire.service-public.fr/api/explore/v2.1/catalog/datasets/api-lannuaire-administration/records?where=siret:#{siret}" + end + + def maybe_json_parse(value) + return nil if value.blank? + + JSON.parse(value) + end +end diff --git a/app/services/api_bretagne_service.rb b/app/services/api_bretagne_service.rb index 1eb319c24..765788dfa 100644 --- a/app/services/api_bretagne_service.rb +++ b/app/services/api_bretagne_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIBretagneService include Dry::Monads[:result] HOST = 'https://api.databretagne.fr' diff --git a/app/services/api_entreprise_service.rb b/app/services/api_entreprise_service.rb index 188f6d23d..f73ccbcb0 100644 --- a/app/services/api_entreprise_service.rb +++ b/app/services/api_entreprise_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIEntrepriseService class << self # create etablissement with EtablissementAdapter @@ -21,6 +23,10 @@ class APIEntrepriseService etablissement = dossier_or_champ.build_etablissement(etablissement_params) etablissement.save! + if dossier_or_champ.is_a?(Champ) + dossier_or_champ.update!(value_json: APIGeoService.parse_etablissement_address(etablissement)) + end + perform_later_fetch_jobs(etablissement, procedure_id, user_id) etablissement @@ -43,15 +49,25 @@ class APIEntrepriseService return nil if etablissement_params.empty? etablissement.update!(etablissement_params) + + if etablissement.champ.present? + etablissement.champ.update!(value_json: APIGeoService.parse_etablissement_address(etablissement)) + end + + etablissement end def perform_later_fetch_jobs(etablissement, procedure_id, user_id, wait: nil) - [ + jobs = [ APIEntreprise::EntrepriseJob, APIEntreprise::ExtraitKbisJob, APIEntreprise::TvaJob, APIEntreprise::AssociationJob, APIEntreprise::ExercicesJob, APIEntreprise::EffectifsJob, APIEntreprise::EffectifsAnnuelsJob, APIEntreprise::AttestationSocialeJob, APIEntreprise::BilansBdfJob - ].each do |job| + ] + if etablissement.as_degraded_mode? + jobs << APIEntreprise::EtablissementJob + end + jobs.each do |job| job.set(wait:).perform_later(etablissement.id, procedure_id) end @@ -67,6 +83,13 @@ class APIEntrepriseService api_up?("https://entreprise.api.gouv.fr/ping/djepva/api-association") end + def service_unavailable_error?(error, target:) + return false if !error.try(:network_error?) + return true if target == :insee && !APIEntrepriseService.api_insee_up? + return true if target == :djepva && !APIEntrepriseService.api_djepva_up? + error.is_a?(APIEntreprise::API::Error::ServiceUnavailable) + end + private def api_up?(url) diff --git a/app/services/api_geo_service.rb b/app/services/api_geo_service.rb index f1ed8c228..19931cc19 100644 --- a/app/services/api_geo_service.rb +++ b/app/services/api_geo_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIGeoService class << self def countries(locale: I18n.locale) @@ -25,6 +27,8 @@ class APIGeoService get_from_api_geo(:regions).sort_by { I18n.transliterate(_1[:name]) } end + def region_options = regions.map { [_1[:name], _1[:code]] } + def region_name(code) regions.find { _1[:code] == code }&.dig(:name) end @@ -40,13 +44,21 @@ class APIGeoService end def departements - [{ code: '99', name: 'Etranger' }] + get_from_api_geo(:departements).sort_by { _1[:code] } + ([{ code: '99', name: 'Etranger' }] + get_from_api_geo(:departements)).sort_by { _1[:code] } + end + + def departement_options + departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } end def departement_name(code) departements.find { _1[:code] == code }&.dig(:name) end + def departement_name_by_postal_code(postal_code) + APIGeoService.departement_name(postal_code[0..2]) || APIGeoService.departement_name(postal_code[0..1]) + end + def departement_code(name) return if name.nil? departements.find { _1[:name] == name }&.dig(:code) @@ -78,6 +90,14 @@ class APIGeoService communes(departement_code).find { _1[:code] == code }&.dig(:name) end + def commune_by_name_or_postal_code(query) + if postal_code?(query) + fetch_by_postal_code(query) + else + fetch_by_name(query) + end + end + def commune_code(departement_code, name) communes(departement_code).find { _1[:name] == name }&.dig(:code) end @@ -122,14 +142,154 @@ class APIGeoService }.merge(territory) end + def parse_rna_address(address) + postal_code = address[:code_postal] + city_name_fallback = address[:commune] + city_code = address[:code_insee] + department_code, region_code = if postal_code.present? && city_code.present? + commune = communes_by_postal_code(postal_code).find { _1[:code] == city_code } + if commune.present? + [commune[:departement_code], commune[:region_code]] + else + [] + end + end + + department_name = departement_name(department_code) + { + street_number: address[:numero_voie], + street_name: address[:libelle_voie], + street_address: address[:libelle_voie].present? ? [address[:numero_voie], address[:type_voie], address[:libelle_voie]].compact.join(' ') : nil, + postal_code: postal_code.presence || '', + city_name: safely_normalize_city_name(department_code, city_code, city_name_fallback), + city_code: city_code.presence || '', + departement_code: department_code, + department_code:, + departement_name: department_name, + department_name:, + region_code:, + region_name: region_name(region_code) + } + end + + def parse_rnf_address(address) + postal_code = address[:postalCode] + city_name_fallback = address[:cityName] + city_code = address[:cityCode] + department_code, region_code = if postal_code.present? && city_code.present? + commune = communes_by_postal_code(postal_code).find { _1[:code] == city_code } + if commune.present? + [commune[:departement_code], commune[:region_code]] + else + [] + end + end + department_name = departement_name(department_code) + + { + street_number: address[:streetNumber], + street_name: address[:streetName], + street_address: address[:streetAddress], + postal_code: postal_code.presence || '', + city_name: safely_normalize_city_name(department_code, city_code, city_name_fallback), + city_code: city_code.presence || '', + departement_code: department_code, + department_code:, + departement_name: department_name, + department_name:, + region_code:, + region_name: region_name(region_code) + } + end + + def parse_etablissement_address(etablissement) + postal_code = etablissement.code_postal + city_name_fallback = etablissement.localite.presence || '' + city_code = etablissement.code_insee_localite + department_code, region_code = if postal_code.present? && city_code.present? + commune = communes_by_postal_code(postal_code).find { _1[:code] == city_code } + if commune.present? + [commune[:departement_code], commune[:region_code]] + else + [] + end + end + + department_name = departement_name(department_code) + + { + street_number: etablissement.numero_voie, + street_name: etablissement.nom_voie, + street_address: etablissement.nom_voie.present? ? [etablissement.numero_voie, etablissement.type_voie, etablissement.nom_voie].compact.join(' ') : nil, + postal_code: postal_code.presence || '', + city_name: safely_normalize_city_name(department_code, city_code, city_name_fallback), + city_code: city_code.presence || '', + departement_code: department_code, + department_code:, + departement_name: department_name, + department_name:, + region_code:, + region_name: region_name(region_code) + } + end + def safely_normalize_city_name(department_code, city_code, fallback) return fallback if department_code.blank? || city_code.blank? commune_name(department_code, city_code) || fallback end + def format_commune_response(results, with_combined_code) + results.reject(&method(:code_metropole?)).flat_map do |result| + item = { + name: result[:nom].tr("'", '’'), + code: result[:code], + epci_code: result[:codeEpci], + departement_code: result[:codeDepartement] + }.compact + + if result[:codesPostaux].present? + result[:codesPostaux].map { item.merge(postal_code: _1) } + else + [item] + end.map do |item| + if with_combined_code.present? + { + label: "#{item[:name]} (#{item[:postal_code]})", + value: "#{item[:code]}-#{item[:postal_code]}" + } + else + { + label: "#{item[:name]} (#{item[:postal_code]})", + value: item[:code], + data: item[:postal_code] + } + end + end + end + end + + def inline_service_public_address(address_data) + return nil if address_data.blank? + + components = [ + address_data['numero_voie'], + address_data['complement1'], + address_data['complement2'], + address_data['service_distribution'], + address_data['code_postal'], + address_data['nom_commune'] + ].compact_blank + + components.join(' ') + end + private + def code_metropole?(result) + result[:code].in?(['75056', '13055', '69123']) + end + def communes_by_postal_code_map Rails.cache.fetch('api_geo_communes', expires_in: 1.day, version: 3) do departements @@ -164,6 +324,28 @@ class APIGeoService private + def fetch_by_name(name) + Typhoeus.get("#{API_GEO_URL}/communes", params: { + type: 'commune-actuelle,arrondissement-municipal', + nom: name, + boost: 'population', + limit: 100 + }, timeout: 3) + end + + def fetch_by_postal_code(postal_code) + Typhoeus.get("#{API_GEO_URL}/communes", params: { + type: 'commune-actuelle,arrondissement-municipal', + codePostal: postal_code, + boost: 'population', + limit: 50 + }, timeout: 3) + end + + def postal_code?(string) + string.match?(/\A[-+]?\d+\z/) ? true : false + end + def ban_address_schema JSONSchemer.schema(Rails.root.join('app/schemas/adresse-ban.json')) end diff --git a/app/services/api_recherche_entreprises_service.rb b/app/services/api_recherche_entreprises_service.rb index f40287907..fe1cf177e 100644 --- a/app/services/api_recherche_entreprises_service.rb +++ b/app/services/api_recherche_entreprises_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class APIRechercheEntreprisesService include Dry::Monads[:result] diff --git a/app/services/archive_uploader.rb b/app/services/archive_uploader.rb index a1ad9023d..673c36b31 100644 --- a/app/services/archive_uploader.rb +++ b/app/services/archive_uploader.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ArchiveUploader # see: https://docs.ovh.com/fr/storage/pcs/capabilities-and-limitations/#max_file_size-5368709122-5gb # officialy it's 5Gb. but let's avoid to reach the exact spot of the limit diff --git a/app/services/auto_rotate_service.rb b/app/services/auto_rotate_service.rb new file mode 100644 index 000000000..66e389e1b --- /dev/null +++ b/app/services/auto_rotate_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class AutoRotateService + def process(file, output) + auto_rotate_image(file, output) + end + + private + + def auto_rotate_image(file, output) + image = MiniMagick::Image.new(file.to_path) + + return nil if !image.valid? + + case image["%[orientation]"] + when 'LeftBottom' + rotate_image(file, output, 90) + when 'BottomRight' + rotate_image(file, output, 180) + when 'RightTop' + rotate_image(file, output, 270) + else + nil + end + end + + def rotate_image(file, output, degree) + MiniMagick::Tool::Convert.new do |convert| + convert << file.to_path + convert.rotate(degree) + convert.auto_orient + convert << output.to_path + end + output + end +end diff --git a/app/services/bill_signature_service.rb b/app/services/bill_signature_service.rb index 7badb3399..663310dcf 100644 --- a/app/services/bill_signature_service.rb +++ b/app/services/bill_signature_service.rb @@ -1,12 +1,6 @@ -class BillSignatureService - def self.grouped_unsigned_operation_until(date) - date = date.in_time_zone - unsigned_operations = DossierOperationLog - .where(bill_signature: nil) - .where('executed_at < ?', date) - unsigned_operations.group_by { |e| e.executed_at.to_date } - end +# frozen_string_literal: true +class BillSignatureService def self.sign_operations(operations, day) return unless Certigna::API.enabled? bill = BillSignature.build_with_operations(operations, day) diff --git a/app/services/browser_support.rb b/app/services/browser_support.rb index 399c2bb6d..482264151 100644 --- a/app/services/browser_support.rb +++ b/app/services/browser_support.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class BrowserSupport def self.supported?(browser) [ diff --git a/app/services/clamav_service.rb b/app/services/clamav_service.rb index 5b7032a55..19ac22139 100644 --- a/app/services/clamav_service.rb +++ b/app/services/clamav_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ClamavService def self.safe_file?(file_path) return true if !Rails.configuration.x.clamav.enabled diff --git a/app/services/clone_pieces_justificatives_service.rb b/app/services/clone_pieces_justificatives_service.rb index d014b1c1c..18375d318 100644 --- a/app/services/clone_pieces_justificatives_service.rb +++ b/app/services/clone_pieces_justificatives_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ClonePiecesJustificativesService def self.clone_attachments(original, kopy) case original diff --git a/app/services/cojo_service.rb b/app/services/cojo_service.rb index 1c95ffc07..93c5c813d 100644 --- a/app/services/cojo_service.rb +++ b/app/services/cojo_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class COJOService include Dry::Monads[:result] diff --git a/app/services/commentaire_service.rb b/app/services/commentaire_service.rb index 5fb2e9821..a69772927 100644 --- a/app/services/commentaire_service.rb +++ b/app/services/commentaire_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CommentaireService def self.create(sender, dossier, params) save(dossier, prepare_params(sender, params)) diff --git a/app/services/demarches_publiques_export_service.rb b/app/services/demarches_publiques_export_service.rb index d210bc2c0..3a735b031 100644 --- a/app/services/demarches_publiques_export_service.rb +++ b/app/services/demarches_publiques_export_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class DemarchesPubliquesExportService attr_reader :gzip_filename diff --git a/app/services/dossier_filter_service.rb b/app/services/dossier_filter_service.rb new file mode 100644 index 000000000..ce32fefc2 --- /dev/null +++ b/app/services/dossier_filter_service.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +class DossierFilterService + TYPE_DE_CHAMP = 'type_de_champ' + + def self.filtered_sorted_ids(dossiers, statut, filters, sorted_column, instructeur, count: nil) + dossiers_by_statut = dossiers.by_statut(statut, instructeur) + dossiers_sorted_ids = self.sorted_ids(dossiers_by_statut, sorted_column, instructeur, count || dossiers_by_statut.size) + + if filters.present? + dossiers_sorted_ids.intersection(filtered_ids(dossiers_by_statut, filters)) + else + dossiers_sorted_ids + end + end + + private + + def self.sorted_ids(dossiers, sorted_column, instructeur, count) + table = sorted_column.column.table + column = sorted_column.column.column + order = sorted_column.order + + case table + when 'notifications' + dossiers_id_with_notification = dossiers.merge(instructeur.followed_dossiers).with_notifications.ids + if order == 'desc' + dossiers_id_with_notification + + (dossiers.order('dossiers.updated_at desc').ids - dossiers_id_with_notification) + else + (dossiers.order('dossiers.updated_at asc').ids - dossiers_id_with_notification) + + dossiers_id_with_notification + end + when TYPE_DE_CHAMP + stable_id = sorted_column.column.stable_id + ids = dossiers + .with_type_de_champ(stable_id) + .order("champs.value #{order}") + .pluck(:id) + if ids.size != count + rest = dossiers.where.not(id: ids).order(id: order).pluck(:id) + order == 'asc' ? ids + rest : rest + ids + else + ids + end + when 'followers_instructeurs' + assert_supported_column(table, column) + # LEFT OUTER JOIN allows to keep dossiers without assigned instructeurs yet + dossiers + .includes(:followers_instructeurs) + .joins('LEFT OUTER JOIN users instructeurs_users ON instructeurs_users.id = instructeurs.user_id') + .order("instructeurs_users.email #{order}") + .pluck(:id) + .uniq + when 'avis' + dossiers.includes(table) + .order("#{sanitized_column(table, column)} #{order}") + .pluck(:id) + .uniq + when 'dossier_labels' + dossiers.includes(:labels) + .order("labels.name #{order}") + .pluck(:id) + .uniq + when 'self', 'user', 'individual', 'etablissement', 'groupe_instructeur' + (table == 'self' ? dossiers : dossiers.includes(table)) + .order("#{sanitized_column(table, column)} #{order}") + .pluck(:id) + end + end + + def self.filtered_ids(dossiers, filters) + filters + .group_by { |filter| filter.column.then { [_1.table, _1.column] } } + .map do |(table, column), filters_for_column| + values = filters_for_column.map(&:filter) + filtered_column = filters_for_column.first.column + + if filtered_column.respond_to?(:filtered_ids) + filtered_column.filtered_ids(dossiers, values) + else + case table + when 'self' + if filtered_column.type == :date || filtered_column.type == :datetime + dates = values + .filter_map { |v| Time.zone.parse(v).beginning_of_day rescue nil } + + dossiers.filter_by_datetimes(column, dates) + elsif filtered_column.column == "state" && values.include?("pending_correction") + dossiers.joins(:corrections).where(corrections: DossierCorrection.pending) + elsif filtered_column.column == "state" && values.include?("en_construction") + dossiers.where("dossiers.#{column} IN (?)", values).includes(:corrections).where.not(corrections: DossierCorrection.pending) + else + dossiers.where("dossiers.#{column} IN (?)", values) + end + when 'etablissement' + if column == 'entreprise_date_creation' + dates = values + .filter_map { |v| v.to_date rescue nil } + + dossiers + .includes(table) + .where(table.pluralize => { column => dates }) + else + dossiers + .includes(table) + .filter_ilike(table, column, values) + end + when 'followers_instructeurs' + assert_supported_column(table, column) + dossiers + .includes(:followers_instructeurs) + .joins('INNER JOIN users instructeurs_users ON instructeurs_users.id = instructeurs.user_id') + .filter_ilike('instructeurs_users', :email, values) # ilike OK, user may want to search by *@domain + when 'user', 'individual' # user_columns: [email], individual_columns: ['nom', 'prenom', 'gender'] + dossiers + .includes(table) + .filter_ilike(table, column, values) # ilike or where column == 'value' are both valid, we opted for ilike + when 'dossier_labels' + assert_supported_column(table, column) + dossiers + .joins(:dossier_labels) + .where(dossier_labels: { label_id: values }) + when 'groupe_instructeur' + assert_supported_column(table, column) + + dossiers + .joins(:groupe_instructeur) + .where(groupe_instructeur_id: values) + end.pluck(:id) + end + end.reduce(:&) + end + + def self.sanitized_column(association, column) + table = if association == 'self' + Dossier.table_name + elsif (association_reflection = Dossier.reflect_on_association(association)) + association_reflection.klass.table_name + else + # Allow filtering on a joined table alias (which doesn’t exist + # in the ActiveRecord domain). + association + end + + [table, column] + .map { |name| ActiveRecord::Base.connection.quote_column_name(name) } + .join('.') + end + + def self.assert_supported_column(table, column) + if table == 'followers_instructeurs' && column != 'email' + raise ArgumentError, 'Table `followers_instructeurs` only supports the `email` column.' + end + if table == 'groupe_instructeur' && (column != 'label' && column != 'id') + raise ArgumentError, 'Table `groupe_instructeur` only supports the `label` or `id` column.' + end + end +end diff --git a/app/services/dossier_projection_service.rb b/app/services/dossier_projection_service.rb index 8727f07a5..3c3f2a438 100644 --- a/app/services/dossier_projection_service.rb +++ b/app/services/dossier_projection_service.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class DossierProjectionService - class DossierProjection < Struct.new(:dossier_id, :state, :archived, :hidden_by_user_at, :hidden_by_administration_at, :for_tiers, :prenom, :nom, :batch_operation_id, :sva_svr_decision_on, :corrections, :columns) do + class DossierProjection < Struct.new(:dossier_id, :state, :archived, :hidden_by_user_at, :hidden_by_administration_at, :hidden_by_reason, :for_tiers, :prenom, :nom, :batch_operation_id, :sva_svr_decision_on, :corrections, :columns) do def pending_correction? return false if corrections.blank? @@ -25,6 +27,7 @@ class DossierProjectionService TABLE = 'table' COLUMN = 'column' + STABLE_ID = 'stable_id' # Returns [DossierProjection(dossier, columns)] ordered by dossiers_ids # and the columns orderd by fields. @@ -38,34 +41,48 @@ class DossierProjectionService # Those hashes are needed because: # - the order of the intermediary query results are unknown # - some values can be missing (if a revision added or removed them) - def self.project(dossiers_ids, fields) + def self.project(dossiers_ids, columns) + fields = columns.map do |c| + if c.is_a?(Columns::ChampColumn) + { TABLE => c.table, STABLE_ID => c.stable_id, original_column: c } + else + { TABLE => c.table, COLUMN => c.column } + end + end + champ_value = champ_value_formatter(dossiers_ids, fields) + state_field = { TABLE => 'self', COLUMN => 'state' } archived_field = { TABLE => 'self', COLUMN => 'archived' } batch_operation_field = { TABLE => 'self', COLUMN => 'batch_operation_id' } hidden_by_user_at_field = { TABLE => 'self', COLUMN => 'hidden_by_user_at' } hidden_by_administration_at_field = { TABLE => 'self', COLUMN => 'hidden_by_administration_at' } + hidden_by_reason_field = { TABLE => 'self', COLUMN => 'hidden_by_reason' } for_tiers_field = { TABLE => 'self', COLUMN => 'for_tiers' } individual_first_name = { TABLE => 'individual', COLUMN => 'prenom' } individual_last_name = { TABLE => 'individual', COLUMN => 'nom' } sva_svr_decision_on_field = { TABLE => 'self', COLUMN => 'sva_svr_decision_on' } dossier_corrections = { TABLE => 'dossier_corrections', COLUMN => 'resolved_at' } - ([state_field, archived_field, sva_svr_decision_on_field, hidden_by_user_at_field, hidden_by_administration_at_field, for_tiers_field, individual_first_name, individual_last_name, batch_operation_field, dossier_corrections] + fields) # the view needs state and archived dossier attributes + + ([state_field, archived_field, sva_svr_decision_on_field, hidden_by_user_at_field, hidden_by_administration_at_field, hidden_by_reason_field, for_tiers_field, individual_first_name, individual_last_name, batch_operation_field, dossier_corrections] + fields) .each { |f| f[:id_value_h] = {} } .group_by { |f| f[TABLE] } # one query per table .each do |table, fields| case table - when 'type_de_champ', 'type_de_champ_private' + when 'type_de_champ' Champ - .includes(:type_de_champ) .where( - types_de_champ: { stable_id: fields.map { |f| f[COLUMN] } }, + stable_id: fields.map { |f| f[STABLE_ID] }, dossier_id: dossiers_ids ) - .select(:dossier_id, :value, :type_de_champ_id, 'types_de_champ.stable_id', :type, :external_id, :data, :value_json) # we cannot pluck :value, as we need the champ.to_s method + .select(:dossier_id, :value, :stable_id, :type, :external_id, :data, :value_json) # we cannot pluck :value, as we need the champ.to_s method .group_by(&:stable_id) # the champs are redispatched to their respective fields .map do |stable_id, champs| - field = fields.find { |f| f[COLUMN] == stable_id.to_s } - field[:id_value_h] = champs.to_h { |c| [c.dossier_id, c.to_s] } + fields + .filter { |f| f[STABLE_ID] == stable_id } + .each do |field| + column = field[:original_column] + field[:id_value_h] = champs.to_h { [_1.dossier_id, column.is_a?(Columns::JSONPathColumn) ? column.value(_1) : champ_value.(_1)] } + end end when 'self' Dossier @@ -73,10 +90,11 @@ class DossierProjectionService .pluck(:id, *fields.map { |f| f[COLUMN].to_sym }) .each do |id, *columns| fields.zip(columns).each do |field, value| - if [state_field, archived_field, hidden_by_user_at_field, hidden_by_administration_at_field, for_tiers_field, batch_operation_field, sva_svr_decision_on_field].include?(field) - field[:id_value_h][id] = value + # SVA must remain a date: in other column we compute remaining delay with it + field[:id_value_h][id] = if value.respond_to?(:strftime) && field != sva_svr_decision_on_field + I18n.l(value.to_date) else - field[:id_value_h][id] = value&.strftime('%d/%m/%Y') # other fields are datetime + value end end end @@ -116,6 +134,18 @@ class DossierProjectionService fields[0][:id_value_h] = id_value_h + when 'dossier_labels' + columns = fields.map { _1[COLUMN].to_sym } + + id_value_h = + DossierLabel + .includes(:label) + .where(dossier_id: dossiers_ids) + .pluck('dossier_id, labels.name, labels.color') + .group_by { |dossier_id, _| dossier_id } + + fields[0][:id_value_h] = id_value_h.transform_values { |v| { value: v, type: :label } } + when 'procedure' Dossier .joins(:procedure) @@ -150,6 +180,7 @@ class DossierProjectionService archived_field[:id_value_h][dossier_id], hidden_by_user_at_field[:id_value_h][dossier_id], hidden_by_administration_at_field[:id_value_h][dossier_id], + hidden_by_reason_field[:id_value_h][dossier_id], for_tiers_field[:id_value_h][dossier_id], individual_first_name[:id_value_h][dossier_id], individual_last_name[:id_value_h][dossier_id], @@ -160,4 +191,24 @@ class DossierProjectionService ) end end + + class << self + private + + def champ_value_formatter(dossiers_ids, fields) + stable_ids = fields.filter { _1[TABLE].in?(['type_de_champ']) }.map { _1[STABLE_ID] } + revision_ids_by_dossier_ids = Dossier.where(id: dossiers_ids).pluck(:id, :revision_id).to_h + stable_ids_and_types_de_champ_by_revision_ids = ProcedureRevisionTypeDeChamp.includes(:type_de_champ) + .where(revision_id: revision_ids_by_dossier_ids.values.uniq, type_de_champ: { stable_id: stable_ids }) + .map { [_1.revision_id, _1.type_de_champ] } + .group_by(&:first) + .transform_values { _1.map { |_, type_de_champ| [type_de_champ.stable_id, type_de_champ] }.to_h } + stable_ids_and_types_de_champ_by_dossier_ids = revision_ids_by_dossier_ids.transform_values { stable_ids_and_types_de_champ_by_revision_ids[_1] }.compact + -> (champ) { + type_de_champ = stable_ids_and_types_de_champ_by_dossier_ids + .fetch(champ.dossier_id, {})[champ.stable_id] + type_de_champ&.champ_value(champ) + } + end + end end diff --git a/app/services/dossier_search_service.rb b/app/services/dossier_search_service.rb index 7afdad5f6..dc046ed92 100644 --- a/app/services/dossier_search_service.rb +++ b/app/services/dossier_search_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class DossierSearchService def self.matching_dossiers(dossiers, search_terms, with_annotations = false) if dossiers.nil? diff --git a/app/services/downloadable_file_service.rb b/app/services/downloadable_file_service.rb index 97909578b..3a8519600 100644 --- a/app/services/downloadable_file_service.rb +++ b/app/services/downloadable_file_service.rb @@ -1,9 +1,12 @@ +# frozen_string_literal: true + class DownloadableFileService ARCHIVE_CREATION_DIR = ENV.fetch('ARCHIVE_CREATION_DIR') { '/tmp' } + EXPORT_DIRNAME = 'export' def self.download_and_zip(procedure, attachments, filename, &block) Dir.mktmpdir(nil, ARCHIVE_CREATION_DIR) do |tmp_dir| - export_dir = File.join(tmp_dir, filename) + export_dir = File.join(tmp_dir, EXPORT_DIRNAME) zip_path = File.join(ARCHIVE_CREATION_DIR, "#{filename}.zip") begin @@ -15,7 +18,7 @@ class DownloadableFileService Dir.chdir(tmp_dir) do File.delete(zip_path) if File.exist?(zip_path) - system 'zip', '-0', '-r', zip_path, filename + system 'zip', '-0', '-r', zip_path, EXPORT_DIRNAME end yield(zip_path) ensure diff --git a/app/services/email_delivering_interceptor.rb b/app/services/email_delivering_interceptor.rb index e4a7648d6..b864a200a 100644 --- a/app/services/email_delivering_interceptor.rb +++ b/app/services/email_delivering_interceptor.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EmailDeliveringInterceptor def self.delivering_email(message) EmailEvent.create_from_message!(message, status: "pending") diff --git a/app/services/email_delivery_observer.rb b/app/services/email_delivery_observer.rb index c297dcf85..a80fab94d 100644 --- a/app/services/email_delivery_observer.rb +++ b/app/services/email_delivery_observer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EmailDeliveryObserver def self.delivered_email(message) EmailEvent.create_from_message!(message, status: "dispatched") diff --git a/app/services/encryption_service.rb b/app/services/encryption_service.rb index 448ddcc7a..3d0dbb92e 100644 --- a/app/services/encryption_service.rb +++ b/app/services/encryption_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EncryptionService def initialize len = ActiveSupport::MessageEncryptor.key_len @@ -5,6 +7,10 @@ class EncryptionService password = Rails.application.secrets.secret_key_base key = ActiveSupport::KeyGenerator.new(password).generate_key(salt, len) @encryptor = ActiveSupport::MessageEncryptor.new(key) + + # Remove after all encrypted attributes have been rotated. + legacy_key = ActiveSupport::KeyGenerator.new(password, hash_digest_class: OpenSSL::Digest::SHA1).generate_key(salt, len) + @encryptor.rotate legacy_key end def encrypt(value) diff --git a/app/services/expired.rb b/app/services/expired.rb index b6f4a0210..11d79c76d 100644 --- a/app/services/expired.rb +++ b/app/services/expired.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Expired # User is considered inactive after two years of idleness regarding # when he does not have a dossier en instruction @@ -23,7 +25,7 @@ module Expired when 'Cron::ExpiredPrefilledDossiersDeletionJob' "every day at 3 am" when 'Cron::ExpiredDossiersTermineDeletionJob' - "every day at 7 am" + "every day at 1 am" when 'Cron::ExpiredDossiersBrouillonDeletionJob' "every day at 10 pm" when 'Cron::ExpiredUsersDeletionJob' diff --git a/app/services/expired/dossiers_deletion_service.rb b/app/services/expired/dossiers_deletion_service.rb index 9d4bfddb7..62e5d8a76 100644 --- a/app/services/expired/dossiers_deletion_service.rb +++ b/app/services/expired/dossiers_deletion_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Expired::DossiersDeletionService < Expired::MailRateLimiter def process_expired_dossiers_brouillon send_brouillon_expiration_notices @@ -93,27 +95,26 @@ class Expired::DossiersDeletionService < Expired::MailRateLimiter administration_notifications = group_by_fonctionnaire_email(dossiers_to_remove) .map { |(email, dossiers)| [email, dossiers.map(&:id)] } - deleted_dossier_ids = [] + hidden_dossier_ids = [] dossiers_to_remove.find_each do |dossier| - if dossier.expired_keep_track_and_destroy! - deleted_dossier_ids << dossier.id - end + dossier.hide_and_keep_track!(:automatic, :expired) + hidden_dossier_ids << dossier.id end user_notifications.each do |(email, dossier_ids)| - dossier_ids = dossier_ids.intersection(deleted_dossier_ids) + dossier_ids = dossier_ids.intersection(hidden_dossier_ids) if dossier_ids.present? mail = DossierMailer.notify_automatic_deletion_to_user( - DeletedDossier.where(dossier_id: dossier_ids).to_a, + Dossier.where(id: dossier_ids).to_a, email ) send_with_delay(mail) end end administration_notifications.each do |(email, dossier_ids)| - dossier_ids = dossier_ids.intersection(deleted_dossier_ids) + dossier_ids = dossier_ids.intersection(hidden_dossier_ids) if dossier_ids.present? mail = DossierMailer.notify_automatic_deletion_to_administration( - DeletedDossier.where(dossier_id: dossier_ids).to_a, + Dossier.where(id: dossier_ids).to_a, email ) send_with_delay(mail) diff --git a/app/services/expired/mail_rate_limiter.rb b/app/services/expired/mail_rate_limiter.rb index fba9f625b..e91d6f918 100644 --- a/app/services/expired/mail_rate_limiter.rb +++ b/app/services/expired/mail_rate_limiter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Expired::MailRateLimiter attr_reader :delay, :current_window diff --git a/app/services/expired/users_deletion_service.rb b/app/services/expired/users_deletion_service.rb index f8e448422..24a994420 100644 --- a/app/services/expired/users_deletion_service.rb +++ b/app/services/expired/users_deletion_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Expired::UsersDeletionService < Expired::MailRateLimiter def process_expired # we are working on two dataset because we apply two incompatible join on the same query diff --git a/app/services/exported_column_formatter.rb b/app/services/exported_column_formatter.rb new file mode 100644 index 000000000..824b26d29 --- /dev/null +++ b/app/services/exported_column_formatter.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class ExportedColumnFormatter + def self.format(column:, champ_or_dossier:, format:) + return if champ_or_dossier.nil? + + raw_value = column.value(champ_or_dossier) + + case column.type + when :boolean + format_boolean(column:, raw_value:, format:) + when :attachements + format_attachments(column:, raw_value:) + when :enum + format_enum(column:, raw_value:) + when :enums + format_enums(column:, raw_values: raw_value) + else + raw_value + end + end + + private + + def self.format_boolean(column:, raw_value:, format:) + if format == :ods + raw_value ? 1 : 0 + else + raw_value + end + end + + def self.format_attachments(column:, raw_value:) + case column.tdc_type + when TypeDeChamp.type_champs[:titre_identite] + raw_value.present? ? 'présent' : 'absent' + when TypeDeChamp.type_champs[:piece_justificative] + raw_value.map { _1.blob.filename }.join(", ") + end + end + + def self.format_enums(column:, raw_values:) + raw_values.map { format_enum(column:, raw_value: _1) }.join(', ') + end + + def self.format_enum(column:, raw_value:) + # options for select store ["trad", :enum_value] + selected_option = column.options_for_select.find { _1[1].to_s == raw_value } + + selected_option ? selected_option.first : raw_value + end +end diff --git a/app/services/falsify_opendata_service.rb b/app/services/falsify_opendata_service.rb index e1afca40b..cc1e3c7d7 100644 --- a/app/services/falsify_opendata_service.rb +++ b/app/services/falsify_opendata_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class FalsifyOpendataService def self.call(lines) errors = [] diff --git a/app/services/faqs_loader_service.rb b/app/services/faqs_loader_service.rb new file mode 100644 index 000000000..be2434d83 --- /dev/null +++ b/app/services/faqs_loader_service.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +class FAQsLoaderService + PATH = Rails.root.join('doc', 'faqs').freeze + ORDER = ['usager', 'instructeur', 'administrateur'].freeze + + attr_reader :substitutions + + def initialize(substitutions) + @substitutions = substitutions + + @faqs_by_path ||= Rails.cache.fetch(["faqs_data", ApplicationVersion.current, substitutions], expires_in: 1.day) do + load_faqs + end + end + + def find(path) + Rails.cache.fetch(["faq", path, ApplicationVersion.current, substitutions], expires_in: 1.day) do + file_path = @faqs_by_path.fetch(path).fetch(:file_path) + + parse_with_substitutions(file_path) + end + end + + def faqs_for_category(category) + @faqs_by_path.values + .filter { |faq| faq[:category] == category } + .group_by { |faq| faq[:subcategory] } + end + + def all + @faqs_by_path.values + .group_by { |faq| faq.fetch(:category) } + .sort_by { |category, _| ORDER.index(category) || ORDER.size } + .to_h + .transform_values do |faqs| + faqs.group_by { |faq| faq.fetch(:subcategory) } + end + end + + private + + def load_faqs + Dir.glob("#{PATH}/**/*.md").each_with_object({}) do |file_path, faqs_by_path| + parsed = parse_with_substitutions(file_path) + front_matter = parsed.front_matter.symbolize_keys + + faq_data = front_matter.slice(:slug, :title, :category, :subcategory, :locale, :keywords).merge(file_path: file_path) + + path = front_matter.fetch(:category) + '/' + front_matter.fetch(:slug) + faqs_by_path[path] = faq_data + end + end + + # Substitute all string before front matter parser so metadata are also substituted. + # using standard ruby formatting, ie => `%{my_var} % { my_var: 'value' }` + # We have to escape % chars not used for substitutions, ie. not preceeded by { + def parse_with_substitutions(file_path) + substituted_content = File.read(file_path).gsub(/%(?!{)/, '%%') % substitutions + + FrontMatterParser::Parser.new(:md).call(substituted_content) + end +end diff --git a/app/services/france_connect_service.rb b/app/services/france_connect_service.rb index 31b2491c4..5da4be924 100644 --- a/app/services/france_connect_service.rb +++ b/app/services/france_connect_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class FranceConnectService def self.enabled? ENV.fetch("FRANCE_CONNECT_ENABLED", "enabled") == "enabled" diff --git a/app/services/generate_open_data_csv_service.rb b/app/services/generate_open_data_csv_service.rb index 2511d1fed..6f111c87f 100644 --- a/app/services/generate_open_data_csv_service.rb +++ b/app/services/generate_open_data_csv_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GenerateOpenDataCsvService def self.save_csv_to_tmp(file_name, data) f = Tempfile.create(["#{file_name}_#{date_last_month}", '.csv'], 'tmp') diff --git a/app/services/geojson_service.rb b/app/services/geojson_service.rb index 47721ac8f..49132b154 100644 --- a/app/services/geojson_service.rb +++ b/app/services/geojson_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GeojsonService def self.valid?(json) schemer = JSONSchemer.schema(Rails.root.join('app/schemas/geojson.json')) diff --git a/app/services/instructeurs_import_service.rb b/app/services/instructeurs_import_service.rb index 2a98f7ee9..cf2c95cf3 100644 --- a/app/services/instructeurs_import_service.rb +++ b/app/services/instructeurs_import_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class InstructeursImportService def self.import_groupes(procedure, groupes_emails) groupes_emails, error_groupe_emails = groupes_emails.partition { _1['groupe'].present? } diff --git a/app/services/ip_service.rb b/app/services/ip_service.rb index 8338698cc..327de4e37 100644 --- a/app/services/ip_service.rb +++ b/app/services/ip_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class IPService class << self def ip_trusted?(ip) diff --git a/app/services/mail_template_presenter_service.rb b/app/services/mail_template_presenter_service.rb index b0e9db3bb..82ff6bff0 100644 --- a/app/services/mail_template_presenter_service.rb +++ b/app/services/mail_template_presenter_service.rb @@ -1,10 +1,12 @@ +# frozen_string_literal: true + class MailTemplatePresenterService include ActionView::Helpers::SanitizeHelper include ActionView::Helpers::TextHelper def self.create_commentaire_for_state(dossier, state) if dossier.procedure.accuse_lecture? && Dossier::TERMINE.include?(state) - CommentaireService.create!(CONTACT_EMAIL, dossier, body: I18n.t('layouts.mailers.accuse_lecture.commentaire_html', service: dossier.procedure.service.nom)) + CommentaireService.create!(CONTACT_EMAIL, dossier, body: I18n.t('layouts.mailers.accuse_lecture.commentaire_html', service: dossier.procedure.service&.nom)) else service = new(dossier, state) body = ["

    [#{service.safe_subject}]

    ", service.safe_body].join('') diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 0c17c884e..1976855e6 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class NotificationService class << self SPREAD_DURATION = 2.hours diff --git a/app/services/pieces_justificatives_service.rb b/app/services/pieces_justificatives_service.rb index 6c5648597..cdd79e99c 100644 --- a/app/services/pieces_justificatives_service.rb +++ b/app/services/pieces_justificatives_service.rb @@ -1,45 +1,40 @@ +# frozen_string_literal: true + class PiecesJustificativesService - def initialize(user_profile:) + def initialize(user_profile:, export_template:) @user_profile = user_profile + @export_template = export_template end def liste_documents(dossiers) - bill_ids = [] + docs = pjs_for_champs(dossiers) + + pjs_for_commentaires(dossiers) + + pjs_for_dossier(dossiers) + + pjs_for_avis(dossiers) - docs = dossiers.in_batches.flat_map do |batch| - pjs = pjs_for_champs(batch) + - pjs_for_commentaires(batch) + - pjs_for_dossier(batch) + - pjs_for_avis(batch) + # we do not export bills no more with the new export system + # the bills have never been properly understood by the users + # their export is now deprecated + if liste_documents_allows?(:with_bills) && @export_template.nil? + # some bills are shared among operations + # so first, all the bill_ids are fetched + operation_logs, some_bill_ids = operation_logs_and_signature_ids(dossiers) - if liste_documents_allows?(:with_bills) - # some bills are shared among operations - # so first, all the bill_ids are fetched - operation_logs, some_bill_ids = operation_logs_and_signature_ids(batch) + docs += operation_logs - pjs += operation_logs - bill_ids += some_bill_ids - end - - pjs - end - - if liste_documents_allows?(:with_bills) # then the bills are retrieved without duplication - docs += signatures(bill_ids.uniq) + docs += signatures(some_bill_ids.uniq) end - docs + docs.filter { |_attachment, path| path.present? } end - def generate_dossiers_export(dossiers) + def generate_dossiers_export(dossiers) # TODO: renommer generate_dossier_export sans s return [] if dossiers.empty? pdfs = [] procedure = dossiers.first.procedure - dossiers = dossiers.includes(:individual, :traitement, :etablissement, user: :france_connect_informations, avis: :expert, commentaires: [:instructeur, :expert]) - dossiers = DossierPreloader.new(dossiers).in_batches dossiers.each do |dossier| dossier.association(:procedure).target = procedure @@ -49,16 +44,19 @@ class PiecesJustificativesService acls: acl_for_dossier_export(procedure), dossier: dossier }) - a = ActiveStorage::FakeAttachment.new( file: StringIO.new(pdf), - filename: "export-#{dossier.id}.pdf", + filename: ActiveStorage::Filename.new("export-#{dossier.id}.pdf"), name: 'pdf_export_for_instructeur', id: dossier.id, created_at: dossier.updated_at ) - pdfs << ActiveStorage::DownloadableFile.pj_and_path(dossier.id, a) + if @export_template + pdfs << [a, @export_template.attachment_path(dossier, a)] + else + pdfs << ActiveStorage::DownloadableFile.pj_and_path(dossier.id, a) + end end pdfs @@ -137,31 +135,27 @@ class PiecesJustificativesService end def pjs_for_champs(dossiers) - champs = Champ - .joins(:piece_justificative_file_attachments) - .where(type: "Champs::PieceJustificativeChamp", dossier: dossiers) + champs = liste_documents_allows?(:with_champs_private) ? dossiers.flat_map(&:filled_champs) : dossiers.flat_map(&:filled_champs_public) + champs = champs.filter { _1.piece_justificative? && _1.is_type?(_1.type_de_champ.type_champ) } - if !liste_documents_allows?(:with_champs_private) - champs = champs.where(private: false) - end + champs_id_row_index = compute_champ_id_row_index(champs) - champ_id_dossier_id = champs - .pluck(:id, :dossier_id) - .to_h + champs.flat_map do |champ| + champ.piece_justificative_file_attachments.filter { |a| safe_attachment(a) }.map.with_index do |attachment, index| + row_index = champs_id_row_index[champ.id] - ActiveStorage::Attachment - .includes(:blob) - .where(record_type: "Champ", record_id: champ_id_dossier_id.keys) - .filter { |a| safe_attachment(a) } - .map do |a| - dossier_id = champ_id_dossier_id[a.record_id] - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + if @export_template + [attachment, @export_template.attachment_path(champ.dossier, attachment, index:, row_index:, champ:)] + else + ActiveStorage::DownloadableFile.pj_and_path(champ.dossier_id, attachment) + end end + end end def pjs_for_commentaires(dossiers) commentaire_id_dossier_id = Commentaire - .joins(:piece_jointe_attachment) + .joins(:piece_jointe_attachments) .where(dossier: dossiers) .pluck(:id, :dossier_id) .to_h @@ -172,7 +166,12 @@ class PiecesJustificativesService .filter { |a| safe_attachment(a) } .map do |a| dossier_id = commentaire_id_dossier_id[a.record_id] - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + if @export_template + dossier = dossiers.find { _1.id == dossier_id } + [a, @export_template.attachment_path(dossier, a)] + else + ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + end end end @@ -193,7 +192,12 @@ class PiecesJustificativesService .where(record_type: "Etablissement", record_id: etablissement_id_dossier_id.keys) .map do |a| dossier_id = etablissement_id_dossier_id[a.record_id] - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + if @export_template + dossier = dossiers.find { _1.id == dossier_id } + [a, @export_template.attachment_path(dossier, a)] + else + ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + end end end @@ -204,7 +208,12 @@ class PiecesJustificativesService .filter { |a| safe_attachment(a) } .map do |a| dossier_id = a.record_id - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + if @export_template + dossier = dossiers.find { _1.id == dossier_id } + [a, @export_template.attachment_path(dossier, a)] + else + ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + end end end @@ -220,7 +229,12 @@ class PiecesJustificativesService .where(record_type: "Attestation", record_id: attestation_id_dossier_id.keys) .map do |a| dossier_id = attestation_id_dossier_id[a.record_id] - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + if @export_template + dossier = dossiers.find { _1.id == dossier_id } + [a, @export_template.attachment_path(dossier, a)] + else + ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + end end end @@ -244,7 +258,12 @@ class PiecesJustificativesService .filter { |a| safe_attachment(a) } .map do |a| dossier_id = avis_ids_dossier_id[a.record_id] - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + if @export_template + dossier = dossiers.find { _1.id == dossier_id } + [a, @export_template.attachment_path(dossier, a)] + else + ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + end end end @@ -300,4 +319,26 @@ class PiecesJustificativesService .blob .virus_scan_result == ActiveStorage::VirusScanner::SAFE end + + # given + # repet_0 (stable_id: r0) + # # row_0 + # # # pj_champ_0 (stable_id: 0) + # # row_1 + # # # pj_champ_1 (stable_id: 0) + # repet_1 (stable_id: r1) + # # row_0 + # # # pj_champ_2 (stable_id: 1) + # # # pj_champ_3 (stable_id: 2) + # # row_1 + # # # pj_champ_4 (stable_id: 1) + # # # pj_champ_5 (stable_id: 2) + # it returns { pj_0.id => 0, pj_1.id => 1, pj_2.id => 0, pj_3.id => 0, pj_4.id => 1, pj_5.id => 1 } + def compute_champ_id_row_index(champs) + champs.filter(&:child?).group_by(&:dossier_id).values.each_with_object({}) do |children_for_dossier, hash| + children_for_dossier.group_by(&:stable_id).values.each do |champs_for_stable_id| + champs_for_stable_id.sort_by(&:row_id).each.with_index { |c, index| hash[c.id] = index } + end + end + end end diff --git a/app/services/procedure_archive_service.rb b/app/services/procedure_archive_service.rb index 3ad452ebf..34b73f215 100644 --- a/app/services/procedure_archive_service.rb +++ b/app/services/procedure_archive_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'tempfile' class ProcedureArchiveService diff --git a/app/services/procedure_export_service.rb b/app/services/procedure_export_service.rb index 75eab89e8..181352d5c 100644 --- a/app/services/procedure_export_service.rb +++ b/app/services/procedure_export_service.rb @@ -1,10 +1,13 @@ +# frozen_string_literal: true + class ProcedureExportService attr_reader :procedure, :dossiers - def initialize(procedure, dossiers, user_profile) + def initialize(procedure, dossiers, user_profile, export_template) @procedure = procedure @dossiers = dossiers @user_profile = user_profile + @export_template = export_template end def to_csv @@ -15,7 +18,7 @@ class ProcedureExportService def to_xlsx @dossiers = @dossiers.downloadable_sorted_batch - tables = [:dossiers, :etablissements, :avis] + champs_repetables_options + tables = [:dossiers, :etablissements, :avis] + champs_repetables_options(format: :xlsx) # We recursively build multi page spreadsheet io = tables.reduce(nil) do |package, table| @@ -26,7 +29,7 @@ class ProcedureExportService def to_ods @dossiers = @dossiers.downloadable_sorted_batch - tables = [:dossiers, :etablissements, :avis] + champs_repetables_options + tables = [:dossiers, :etablissements, :avis] + champs_repetables_options(format: :ods) # We recursively build multi page spreadsheet io = StringIO.new(tables.reduce(nil) do |spreadsheet, table| @@ -36,7 +39,7 @@ class ProcedureExportService end def to_zip - attachments = ActiveStorage::DownloadableFile.create_list_from_dossiers(dossiers:, user_profile: @user_profile) + attachments = ActiveStorage::DownloadableFile.create_list_from_dossiers(dossiers:, user_profile: @user_profile, export_template: @export_template) DownloadableFileService.download_and_zip(procedure, attachments, base_filename) do |zip_filepath| ArchiveUploader.new(procedure: procedure, filename: filename(:zip), filepath: zip_filepath).blob @@ -44,7 +47,9 @@ class ProcedureExportService end def to_geo_json - io = StringIO.new(dossiers.to_feature_collection.to_json) + champs_carte = dossiers.flat_map { _1.filled_champs.filter(&:carte?) } + features = GeoArea.where(champ_id: champs_carte).map(&:to_feature) + io = StringIO.new({ type: 'FeatureCollection', features: }.to_json) create_blob(io, :json) end @@ -89,32 +94,28 @@ class ProcedureExportService end def etablissements - @etablissements ||= dossiers.flat_map do |dossier| - dossier.champs.filter { _1.is_a?(Champs::SiretChamp) } - end.filter_map(&:etablissement) + dossiers.filter_map(&:etablissement) + @etablissements ||= dossiers + .flat_map { _1.filled_champs.filter(&:siret?) } + .filter_map(&:etablissement) + dossiers.filter_map(&:etablissement) end def avis @avis ||= dossiers.flat_map(&:avis) end - def champs_repetables_options - champs_by_stable_id = dossiers - .flat_map { _1.champs.filter(&:repetition?) } - .group_by(&:stable_id) - + def champs_repetables_options(format:) procedure - .types_de_champ_for_procedure_presentation + .all_revisions_types_de_champ .repetition .filter_map do |type_de_champ_repetition| - types_de_champ = procedure.types_de_champ_for_procedure_presentation(type_de_champ_repetition).to_a - rows = champs_by_stable_id.fetch(type_de_champ_repetition.stable_id, []).flat_map(&:rows_for_export) + types_de_champ = procedure.all_revisions_types_de_champ(parent: type_de_champ_repetition).to_a + rows = dossiers.flat_map { _1.repetition_rows_for_export(type_de_champ_repetition) } if types_de_champ.present? && rows.present? { sheet_name: type_de_champ_repetition.libelle_for_export, instances: rows, - spreadsheet_columns: Proc.new { |instance| instance.spreadsheet_columns(types_de_champ) } + spreadsheet_columns: Proc.new { |instance| instance.spreadsheet_columns(types_de_champ, export_template: @export_template, format:) } } end end @@ -148,10 +149,10 @@ class ProcedureExportService end def spreadsheet_columns(format) - types_de_champ = procedure.types_de_champ_for_procedure_presentation.not_repetition.to_a + types_de_champ = procedure.types_de_champ_for_procedure_export.to_a Proc.new do |instance| - instance.send(:"spreadsheet_columns_#{format}", types_de_champ: types_de_champ) + instance.send(:"spreadsheet_columns_#{format}", types_de_champ: types_de_champ, export_template: @export_template) end end end diff --git a/app/services/recovery_service.rb b/app/services/recovery_service.rb index 9efa4c6e5..57e9674f5 100644 --- a/app/services/recovery_service.rb +++ b/app/services/recovery_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class RecoveryService def self.recoverable_procedures(previous_user:, siret:) return [] if previous_user.nil? diff --git a/app/services/rnf_service.rb b/app/services/rnf_service.rb index c58e41719..b3663f75a 100644 --- a/app/services/rnf_service.rb +++ b/app/services/rnf_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class RNFService include Dry::Monads[:result] diff --git a/app/services/serializer_service.rb b/app/services/serializer_service.rb index 6f97841c1..8c25f6b76 100644 --- a/app/services/serializer_service.rb +++ b/app/services/serializer_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class SerializerService def self.dossier(dossier) Sentry.with_scope do |scope| @@ -164,6 +166,7 @@ class SerializerService demandeur { ...PersonnePhysiqueFragment ...PersonneMoraleFragment + ...PersonneMoraleIncompleteFragment } motivation motivationAttachment { @@ -309,6 +312,10 @@ class SerializerService } } + fragment PersonneMoraleIncompleteFragment on PersonneMoraleIncomplete { + siret + } + fragment AddressFragment on Address { label type diff --git a/app/services/staging_auth_service.rb b/app/services/staging_auth_service.rb index 085ec497c..9235d73a3 100644 --- a/app/services/staging_auth_service.rb +++ b/app/services/staging_auth_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class StagingAuthService def self.authenticate(username, password) if enabled? diff --git a/app/services/sva_svr_decision_date_calculator_service.rb b/app/services/sva_svr_decision_date_calculator_service.rb index c17fd8347..ec8c9e829 100644 --- a/app/services/sva_svr_decision_date_calculator_service.rb +++ b/app/services/sva_svr_decision_date_calculator_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class SVASVRDecisionDateCalculatorService attr_reader :dossier, :procedure, :unit, :period, :resume_method @@ -57,7 +59,7 @@ class SVASVRDecisionDateCalculatorService end def latest_correction_date - correction_date dossier.corrections.max_by(&:resolved_at) + correction_date dossier.corrections.max_by { _1.resolved_at || Time.current } end def calculate_correction_delay(start_date) diff --git a/app/services/tiptap_service.rb b/app/services/tiptap_service.rb index ce372d39f..c63ee7f0a 100644 --- a/app/services/tiptap_service.rb +++ b/app/services/tiptap_service.rb @@ -1,12 +1,8 @@ +# frozen_string_literal: true + class TiptapService - def to_html(node, substitutions = {}) - return '' if node.nil? - - children(node[:content], substitutions, 0) - end - # NOTE: node must be deep symbolized keys - def used_tags_and_libelle_for(node, tags = Set.new) + def self.used_tags_and_libelle_for(node, tags = Set.new) case node in type: 'mention', attrs: { id:, label: }, **rest tags << [id, label] @@ -19,12 +15,45 @@ class TiptapService tags end + def to_html(node, substitutions = {}) + return '' if node.nil? + + children(node[:content], substitutions, 0).gsub('

    ', '') + end + + def to_texts_and_tags(node, substitutions = {}) + return '' if node.nil? + + children_texts_and_tags(node[:content], substitutions) + end + private def initialize @body_started = false end + def children_texts_and_tags(content, substitutions) + content.map { node_to_texts_and_tags(_1, substitutions) }.join + end + + def node_to_texts_and_tags(node, substitutions) + case node + in type: 'paragraph', content: + children_texts_and_tags(content, substitutions) + in type: 'paragraph' # empty paragraph + '' + in type: 'text', text: + text.strip + in type: 'mention', attrs: { id:, label: } + if substitutions.present? + substitutions.fetch(id) { "--#{id}--" } + else + "#{label}" + end + end + end + def children(content, substitutions, level) content.map { node_to_html(_1, substitutions, level) }.join end @@ -50,10 +79,16 @@ class TiptapService "#{children(content, substitutions, level + 1)}" in type: 'bulletList', content: "
      #{children(content, substitutions, level + 1)}
    " - in type: 'orderedList', content: - "
      #{children(content, substitutions, level + 1)}
    " + in type: 'orderedList', content:, **rest + "#{children(content, substitutions, level + 1)}" in type: 'listItem', content: "
  • #{children(content, substitutions, level + 1)}
  • " + in type: 'descriptionList', content: + "
    #{children(content, substitutions, level + 1)}
    " + in type: 'descriptionTerm', content:, **rest + "#{children(content, substitutions, level + 1)}" + in type: 'descriptionDetails', content: + "
    #{children(content, substitutions, level + 1)}
    " in type: 'text', text:, **rest if rest[:marks].present? apply_marks(text, rest[:marks]) @@ -61,7 +96,12 @@ class TiptapService text end in type: 'mention', attrs: { id: }, **rest - text = substitutions.fetch(id) { "--#{id}--" } + text_or_presentation = substitutions.fetch(id) { "--#{id}--" } + text = if text_or_presentation.respond_to?(:to_tiptap_node) + handle_presentation_node(text_or_presentation, substitutions, level + 1) + else + text_or_presentation + end if rest[:marks].present? apply_marks(text, rest[:marks]) @@ -73,6 +113,16 @@ class TiptapService end end + def handle_presentation_node(presentation, substitutions, level) + node = presentation.to_tiptap_node + content = node_to_html(node, substitutions, level) + if presentation.block_level? + "

    #{content}

    " + else + content + end + end + def text_align(attrs) if attrs.present? && attrs[:textAlign].present? " style=\"text-align: #{attrs[:textAlign]}\"" @@ -81,6 +131,12 @@ class TiptapService end end + def class_list(attrs) + if attrs.present? && attrs[:class].present? + " class=\"#{attrs[:class]}\"" + end + end + def apply_marks(text, marks) marks.reduce(text) do |text, mark| case mark diff --git a/app/services/uninterlace_service.rb b/app/services/uninterlace_service.rb new file mode 100644 index 000000000..72b51b63e --- /dev/null +++ b/app/services/uninterlace_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class UninterlaceService + def process(file) + uninterlace_png(file) + end + + private + + def uninterlace_png(uploaded_file) + if interlaced?(uploaded_file.to_path) + chunky_img = ChunkyPNG::Image.from_io(uploaded_file.to_io) + chunky_img.save(uploaded_file.to_path, interlace: false) + uploaded_file.reopen(uploaded_file.to_path, 'rb') + end + uploaded_file + end + + def interlaced?(png_path) + return false if png_path.blank? + begin + png = MiniMagick::Image.open(png_path) + rescue MiniMagick::Invalid + return false + end + png.data["interlace"] != "None" + end +end diff --git a/app/services/watermark_service.rb b/app/services/watermark_service.rb index a3412ccb1..671881a05 100644 --- a/app/services/watermark_service.rb +++ b/app/services/watermark_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class WatermarkService POINTSIZE = 20 KERNING = 1.2 @@ -7,7 +9,7 @@ class WatermarkService attr_reader :text attr_reader :text_length - def initialize(text = Current.application_name) + def initialize(text = APPLICATION_NAME) @text = " #{text} " # give more space around each occurence @text_length = @text.length end diff --git a/app/services/weasyprint_service.rb b/app/services/weasyprint_service.rb new file mode 100644 index 000000000..d0ab2944b --- /dev/null +++ b/app/services/weasyprint_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class WeasyprintService + def self.generate_pdf(html, options = {}) + headers = { + 'Content-Type' => 'application/json', + 'X-Request-Id' => Current.request_id + } + + body = { + html:, + upstream_context: options + }.to_json + + response = Typhoeus.post(WEASYPRINT_URL, headers:, body:) + + if response.success? + response.body + else + raise StandardError, "PDF Generation failed: #{response.code} #{response.status_message}" + end + end +end diff --git a/app/services/zxcvbn_service.rb b/app/services/zxcvbn_service.rb index 1930b66fe..1b35e3055 100644 --- a/app/services/zxcvbn_service.rb +++ b/app/services/zxcvbn_service.rb @@ -1,51 +1,18 @@ +# frozen_string_literal: true + class ZxcvbnService @tester_mutex = Mutex.new - class << self - # Returns an Zxcvbn instance cached between classes instances and between threads. - # - # The tester weights ~20 Mo, and we'd like to save some memory – so rather - # that storing it in a per-thread accessor, we prefer to use a mutex - # to cache it between threads. - def tester - @tester_mutex.synchronize do - @tester ||= build_tester - end - end - - private - - # Returns a fully initializer tester from the on-disk dictionary. - # - # This is slow: loading and parsing the dictionary may take around 1s. - def build_tester - dictionaries = YAML.safe_load(Rails.root.join("config", "initializers", "zxcvbn_dictionnaries.yaml").read) - - tester = Zxcvbn::Tester.new - tester.add_word_lists(dictionaries) - tester + # Returns an Zxcvbn instance cached between classes instances and between threads. + # + # The tester weights ~20 Mo, and we'd like to save some memory – so rather + # that storing it in a per-thread accessor, we prefer to use a mutex + # to cache it between threads. + def self.tester + @tester_mutex.synchronize do + @tester ||= Zxcvbn::Tester.new end end - def initialize(password) - @password = password - end - - def complexity - wxcvbn = compute_zxcvbn - score = wxcvbn.score - length = @password.blank? ? 0 : @password.length - vulnerabilities = wxcvbn.match_sequence.map { |m| m.matched_word.nil? ? m.token : m.matched_word }.filter { |s| s.length > 2 }.join(', ') - [score, vulnerabilities, length] - end - - def score - compute_zxcvbn.score - end - - private - - def compute_zxcvbn - self.class.tester.test(@password) - end + def self.complexity(password)= tester.test(password.to_s).score end diff --git a/app/tasks/maintenance/backfill_bulk_messages_with_procedure_id_task.rb b/app/tasks/maintenance/backfill_bulk_messages_with_procedure_id_task.rb index 952c36b09..b7fb63d16 100644 --- a/app/tasks/maintenance/backfill_bulk_messages_with_procedure_id_task.rb +++ b/app/tasks/maintenance/backfill_bulk_messages_with_procedure_id_task.rb @@ -2,6 +2,9 @@ module Maintenance class BackfillBulkMessagesWithProcedureIdTask < MaintenanceTasks::Task + # Périmètre: envoi d’un email groupé aux usagers ayant dossiers en brouillon. + # Change la manière dont ces messages sont liés aux démarches. + # 2024-03-12-01 PR #10071 def collection BulkMessage .where(procedure: nil) diff --git a/app/tasks/maintenance/backfill_city_name_task.rb b/app/tasks/maintenance/backfill_city_name_task.rb index e22aa403e..636a65e76 100644 --- a/app/tasks/maintenance/backfill_city_name_task.rb +++ b/app/tasks/maintenance/backfill_city_name_task.rb @@ -2,6 +2,9 @@ module Maintenance class BackfillCityNameTask < MaintenanceTasks::Task + # corrige des données du champ adresse suite à un bug + # introduit pendant quelques jours début mars + # 2024-04-09-02 PR #10290 attribute :champ_ids, :string validates :champ_ids, presence: true diff --git a/app/tasks/maintenance/backfill_cloned_champs_private_piece_justificatives_task.rb b/app/tasks/maintenance/backfill_cloned_champs_private_piece_justificatives_task.rb new file mode 100644 index 000000000..a042c8db9 --- /dev/null +++ b/app/tasks/maintenance/backfill_cloned_champs_private_piece_justificatives_task.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Maintenance + class BackfillClonedChampsPrivatePieceJustificativesTask < MaintenanceTasks::Task + # Supprime les PJ d’annotations privées + # qui étaient conservées par erreur lorsqu’un dossier était cloné + # 2024-05-27-01 PR #10435 + def collection + Dossier.en_brouillon.where.not(parent_dossier_id: nil) + end + + def process(cloned_dossier) + cloned_dossier.project_champs_private + .filter { checkable_pj?(_1, cloned_dossier) } + .map do |cloned_champ| + parent_champ = cloned_dossier.parent_dossier + .project_champs_private + .find { _1.stable_id == cloned_champ.stable_id } + + next if !parent_champ + + parent_blob_ids = parent_champ.piece_justificative_file.map(&:blob_id) + cloned_blob_ids = cloned_champ.piece_justificative_file.map(&:blob_id) + + if parent_blob_ids.sort == cloned_blob_ids.sort + cloned_champ.piece_justificative_file.detach + end + end + end + + def checkable_pj?(champ, dossier) + return false if champ.type != "Champs::PieceJustificativeChamp" + return false if !champ.piece_justificative_file.attached? + true + end + end +end diff --git a/app/tasks/maintenance/backfill_closing_reason_in_closed_procedures_task.rb b/app/tasks/maintenance/backfill_closing_reason_in_closed_procedures_task.rb index 49981e6ed..88525d1f9 100644 --- a/app/tasks/maintenance/backfill_closing_reason_in_closed_procedures_task.rb +++ b/app/tasks/maintenance/backfill_closing_reason_in_closed_procedures_task.rb @@ -2,6 +2,9 @@ module Maintenance class BackfillClosingReasonInClosedProceduresTask < MaintenanceTasks::Task + # Remet les messages de cloture d'une démarche proprement (sinon affichage KO). + # Suite de UpdateClosingReasonIfNoReplacedByIdTask + # 2024-05-27-01 PR #9930 def collection Procedure .with_discarded diff --git a/app/tasks/maintenance/backfill_commune_code_from_name_task.rb b/app/tasks/maintenance/backfill_commune_code_from_name_task.rb index d5092a16d..b2285a602 100644 --- a/app/tasks/maintenance/backfill_commune_code_from_name_task.rb +++ b/app/tasks/maintenance/backfill_commune_code_from_name_task.rb @@ -2,11 +2,15 @@ module Maintenance class BackfillCommuneCodeFromNameTask < MaintenanceTasks::Task - attribute :champ_ids, :string - validates :champ_ids, presence: true + # corrige structure champs commune pour une démarche donnée. Suite à un bug ? + # 2024-05-31-01 PR #10469 + + attribute :procedure_id, :string + validates :procedure_id, presence: true def collection - Champ.where(id: champ_ids.split(',').map(&:strip).map(&:to_i)) + procedure = Procedure.find(procedure_id.strip.to_i) + Champs::CommuneChamp.where(dossier_id: procedure.dossiers.not_brouillon) end def process(champ) @@ -14,11 +18,11 @@ module Maintenance return if champ.external_id.present? return if champ.value.blank? - data = champ.data - return if data.blank? - return if data['code_departement'].blank? + value_json = champ.value_json + return if value_json.blank? + return if value_json['code_departement'].blank? - external_id = APIGeoService.commune_code(data['code_departement'], champ.value) + external_id = APIGeoService.commune_code(value_json['code_departement'], champ.value) if external_id.present? champ.update(external_id:) diff --git a/app/tasks/maintenance/backfill_departement_services_task.rb b/app/tasks/maintenance/backfill_departement_services_task.rb index cb75cee97..e2da6bc11 100644 --- a/app/tasks/maintenance/backfill_departement_services_task.rb +++ b/app/tasks/maintenance/backfill_departement_services_task.rb @@ -2,6 +2,9 @@ module Maintenance class BackfillDepartementServicesTask < MaintenanceTasks::Task + # Fait le lien service – département pour permettre + # le filtrage des démarches par département + # 2023-10-30-01 PR #9647 def collection Service.where.not(etablissement_infos: nil) end diff --git a/app/tasks/maintenance/backfill_depose_at_on_deleted_dossiers_task.rb b/app/tasks/maintenance/backfill_depose_at_on_deleted_dossiers_task.rb index 2f8eb6178..389eddef3 100644 --- a/app/tasks/maintenance/backfill_depose_at_on_deleted_dossiers_task.rb +++ b/app/tasks/maintenance/backfill_depose_at_on_deleted_dossiers_task.rb @@ -2,6 +2,8 @@ module Maintenance class BackfillDeposeAtOnDeletedDossiersTask < MaintenanceTasks::Task + # Améliore les stats à propos des dates de dépôts pour les dossiers supprimés + # 2024-04-05-01 PR #10259 def collection DeletedDossier.where(depose_at: nil) end diff --git a/app/tasks/maintenance/backfill_effectif_annuel_annee_task.rb b/app/tasks/maintenance/backfill_effectif_annuel_annee_task.rb index ce52d05f9..65d6d4a1e 100644 --- a/app/tasks/maintenance/backfill_effectif_annuel_annee_task.rb +++ b/app/tasks/maintenance/backfill_effectif_annuel_annee_task.rb @@ -2,6 +2,8 @@ module Maintenance class BackfillEffectifAnnuelAnneeTask < MaintenanceTasks::Task + # API entreprise: rattrape les informations d'effectif + # 2024-05-27-01 PR #10053 def collection Etablissement.where.not(entreprise_effectif_annuel: nil).where(entreprise_effectif_annuel_annee: nil) end diff --git a/app/tasks/maintenance/backfill_invalid_dossiers_for_tiers_task.rb b/app/tasks/maintenance/backfill_invalid_dossiers_for_tiers_task.rb new file mode 100644 index 000000000..1af3f814a --- /dev/null +++ b/app/tasks/maintenance/backfill_invalid_dossiers_for_tiers_task.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Maintenance + class BackfillInvalidDossiersForTiersTask < MaintenanceTasks::Task + # Corrige les dossiers declarés pour un tiers mais sans avoir renseigné les infos du tiers + # 2024-05-22-01 + def collection + Dossier.where(for_tiers: true).where(mandataire_first_name: nil) + end + + def process(element) + element.update_column(:for_tiers, false) + end + end +end diff --git a/app/tasks/maintenance/backfill_labels_for_procedures_task.rb b/app/tasks/maintenance/backfill_labels_for_procedures_task.rb new file mode 100644 index 000000000..b207454f1 --- /dev/null +++ b/app/tasks/maintenance/backfill_labels_for_procedures_task.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Maintenance + class BackfillLabelsForProceduresTask < MaintenanceTasks::Task + # Cette tâche permet de créer un jeu de labels génériques pour les anciennes procédures + # Plus d'informations sur l'implémentation des labels ici : https://github.com/demarches-simplifiees/demarches-simplifiees.fr/issues/9787 + # 2024-10-15 + + include RunnableOnDeployConcern + + run_on_first_deploy + + def collection + Procedure + .includes(:labels) + .where(labels: { id: nil }) + end + + def process(procedure) + Label::GENERIC_LABELS.each do |label| + Label.create(name: label[:name], color: label[:color], procedure_id: procedure.id) + end + end + end +end diff --git a/app/tasks/maintenance/clean_header_section_options_task.rb b/app/tasks/maintenance/clean_header_section_options_task.rb new file mode 100644 index 000000000..7bcd250bb --- /dev/null +++ b/app/tasks/maintenance/clean_header_section_options_task.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Maintenance + class CleanHeaderSectionOptionsTask < MaintenanceTasks::Task + # In the rest of PR 10713 + # TypeDeChamp options may contain options which are not consistent with the + # type_champ (e.g. a ‘header_section’ TypeDeChamp which has a + # drop_down_options key/value in its options). + # The aim here is to clean up the options so that only those wich are useful + # for the type_champ in question. + + def collection + TypeDeChamp + .where(type_champ: 'header_section') + .where.not(options: {}) + .where.not("(SELECT COUNT(*) FROM jsonb_each_text(options)) = 1 AND options ? 'header_section_level'") + end + + def process(tdc) + tdc.update(options: tdc.options.slice(:header_section_level)) + end + end +end diff --git a/app/tasks/maintenance/clean_invalid_procedure_presentation_task.rb b/app/tasks/maintenance/clean_invalid_procedure_presentation_task.rb new file mode 100644 index 000000000..669c6d3bf --- /dev/null +++ b/app/tasks/maintenance/clean_invalid_procedure_presentation_task.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Maintenance + # PR: 10774 + # why: postgres does not support integer greater than FilteredColumn::PG_INTEGER_MAX_VALUE) + # it occures when user copypaste the dossier id twice (like missed copy paste,paste) + # once this huge integer is saved on procedure presentation, page with this filter can't be loaded + # when: run this migration when it appears in your maintenance tasks list, this file fix the data and we added some validations too + class CleanInvalidProcedurePresentationTask < MaintenanceTasks::Task + def collection + ProcedurePresentation.all + end + + def process(element) + element.filters = element.filters.transform_values do |filters_by_status| + filters_by_status.reject do |filter| + filter.is_a?(Hash) && + filter['column'] == 'id' && + (filter['value']&.to_i&. >= FilteredColumn::PG_INTEGER_MAX_VALUE) + end + end + element.save + end + + def count + # Optionally, define the number of rows that will be iterated over + # This is used to track the task's progress + end + end +end diff --git a/app/tasks/maintenance/concerns/runnable_on_deploy_concern.rb b/app/tasks/maintenance/concerns/runnable_on_deploy_concern.rb new file mode 100644 index 000000000..f324cef29 --- /dev/null +++ b/app/tasks/maintenance/concerns/runnable_on_deploy_concern.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Maintenance + module RunnableOnDeployConcern + extend ActiveSupport::Concern + + class_methods do + def run_on_first_deploy + @run_on_first_deploy = true + end + + def run_on_deploy? + return false unless @run_on_first_deploy + + task = MaintenanceTasks::TaskDataShow.new(name) + + return false if task.completed_runs.not_errored.any? + return false if task.active_runs.any? + + true + end + end + end +end diff --git a/app/tasks/maintenance/concerns/statements_helpers_concern.rb b/app/tasks/maintenance/concerns/statements_helpers_concern.rb new file mode 100644 index 000000000..658c07b1b --- /dev/null +++ b/app/tasks/maintenance/concerns/statements_helpers_concern.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Maintenance + module StatementsHelpersConcern + extend ActiveSupport::Concern + + included do + # Execute block in transaction with a local statement timeout. + # A value of 0 disable the timeout. + # + # Example: + # def collection + # with_statement_timeout("5min") do + # Dossier.all + # end + # end + def with_statement_timeout(timeout) + ApplicationRecord.transaction do + ApplicationRecord.connection.execute("SET LOCAL statement_timeout = '#{timeout}'") + yield + end + end + end + end +end diff --git a/app/tasks/maintenance/copy_super_admin_otp_secret_to_rails7_encrypted_attr_task.rb b/app/tasks/maintenance/copy_super_admin_otp_secret_to_rails7_encrypted_attr_task.rb new file mode 100644 index 000000000..396aa946d --- /dev/null +++ b/app/tasks/maintenance/copy_super_admin_otp_secret_to_rails7_encrypted_attr_task.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Maintenance + class CopySuperAdminOtpSecretToRails7EncryptedAttrTask < MaintenanceTasks::Task + # Cette tâche finalise la mise à niveau vers devies-two-factor 5 + # qui utilise les encrypted attributes de Rails 7. + # Elle copie les secrets OTP des super admins vers la nouvelle colonne + # avant une suppression plus tard des anciennes colonnes. + # Plus d'informations : https://github.com/devise-two-factor/devise-two-factor/blob/main/UPGRADING.md + # Introduit 2024-08-29, https://github.com/demarches-simplifiees/demarches-simplifiees.fr/pull/10722 + def collection + SuperAdmin.all + end + + def process(super_admin) + # From https://github.com/devise-two-factor/devise-two-factor/blob/main/UPGRADING.md + otp_secret = super_admin.otp_secret # read from otp_secret column, fall back to legacy columns if new column is empty + # This is NOOP when otp_secret column has already the same value + super_admin.update!(otp_secret: otp_secret) + end + + def count + SuperAdmin.count + end + end +end diff --git a/app/tasks/maintenance/create_previews_for_pj_of_latest_dossiers_task.rb b/app/tasks/maintenance/create_previews_for_pj_of_latest_dossiers_task.rb new file mode 100644 index 000000000..37e1d2b7b --- /dev/null +++ b/app/tasks/maintenance/create_previews_for_pj_of_latest_dossiers_task.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Maintenance + class CreatePreviewsForPjOfLatestDossiersTask < MaintenanceTasks::Task + # Génère les vignettes de PJ existantes pour les dossiers déposés entre 2 dates (facultatif) + # Elles sont affichées dans le nouvel onglet "Pièces jointes" des instructeurs. + # 2024-07-11-01 + attribute :start_text, :string + validates :start_text, presence: true + + attribute :end_text, :string + validates :end_text, presence: true + + def collection + start_date = DateTime.parse(start_text) + end_date = DateTime.parse(end_text) + + Dossier + .state_en_construction_ou_instruction + .where(depose_at: start_date..end_date) + end + + def process(dossier) + champ_ids = Champ + .where(dossier_id: dossier) + .where(type: ["Champs::PieceJustificativeChamp", 'Champs::TitreIdentiteChamp']) + .ids + + attachments = ActiveStorage::Attachment + .where(record_id: champ_ids) + + attachments.each do |attachment| + next if !(attachment.previewable? && attachment.representation_required?) + attachment.preview(resize_to_limit: [400, 400]).processed unless attachment.preview(resize_to_limit: [400, 400]).image.attached? + rescue MiniMagick::Error, ActiveStorage::Error + end + end + end +end diff --git a/app/tasks/maintenance/create_previews_for_pjs_from_messagerie_task.rb b/app/tasks/maintenance/create_previews_for_pjs_from_messagerie_task.rb new file mode 100644 index 000000000..b75c530b9 --- /dev/null +++ b/app/tasks/maintenance/create_previews_for_pjs_from_messagerie_task.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Maintenance + class CreatePreviewsForPjsFromMessagerieTask < MaintenanceTasks::Task + attribute :start_text, :string + validates :start_text, presence: true + + attribute :end_text, :string + validates :end_text, presence: true + + def collection + start_date = DateTime.parse(start_text) + end_date = DateTime.parse(end_text) + + Dossier + .state_en_construction_ou_instruction + .where(depose_at: start_date..end_date) + end + + def process(dossier) + commentaire_ids = Commentaire + .where(dossier_id: dossier) + .pluck(:id) + + attachments = ActiveStorage::Attachment + .where(record_id: commentaire_ids) + + attachments.each do |attachment| + next if !(attachment.previewable? && attachment.representation_required?) + attachment.preview(resize_to_limit: [400, 400]).processed unless attachment.preview(resize_to_limit: [400, 400]).image.attached? + rescue MiniMagick::Error, ActiveStorage::Error + end + end + end +end diff --git a/app/tasks/maintenance/create_procedure_tags_task.rb b/app/tasks/maintenance/create_procedure_tags_task.rb new file mode 100644 index 000000000..7b8c7c1ab --- /dev/null +++ b/app/tasks/maintenance/create_procedure_tags_task.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +# this task is used to create the procedure_tags and backfill the procedures that have the tag in their tags array + +module Maintenance + class CreateProcedureTagsTask < MaintenanceTasks::Task + include RunnableOnDeployConcern + include StatementsHelpersConcern + run_on_first_deploy + + def collection + [ + "Aap", + "Accompagnement", + "Action sociale", + "Adeli", + "Affectation", + "Agrément", + "Agriculture", + "agroécologie", + "Aide aux entreprises", + "Aide financière", + "Appel à manifestation d'intérêt", + "AMI", + "Animaux", + "Appel à projets", + "Association", + "Auto-école", + "Autorisation", + "Autorisation d'exercer", + "Bilan", + "Biodiversité", + "Candidature", + "Cerfa", + "Chasse", + "Cinéma", + "Cmg", + "Collectivé territoriale", + "Collège", + "Convention", + "Covid", + "Culture", + "Dérogation", + "Diplôme", + "Drone", + "DSDEN", + "Eau", + "Ecoles", + "Education", + "Elections", + "Energie", + "Enseignant", + "ENT", + "Environnement", + "Étrangers", + "Formation", + "FPRNM", + "Funéraire", + "Handicap", + "Hygiène", + "Industrie", + "innovation", + "Inscription", + "Logement", + "Lycée", + "Manifestation", + "Médicament", + "Micro-crèche", + "MODELE DS", + "Numérique", + "Permis", + "Pompiers", + "Préfecture", + "Professionels de santé", + "Recrutement", + "Rh", + "Santé", + "Scolaire", + "SDIS", + "Sécurité", + "Sécurité routière", + "Sécurité sociale", + "Séjour", + "Service civique", + "Subvention", + "Supérieur", + "Taxi", + "Télétravail", + "Tirs", + "Transition écologique", + "Transport", + "Travail", + "Université", + "Urbanisme" + ] + end + + def process(tag) + procedure_tag = ProcedureTag.find_or_create_by(name: tag) + + Procedure.where("? ILIKE ANY(tags)", tag).find_each(batch_size: 500) do |procedure| + procedure.procedure_tags << procedure_tag unless procedure.procedure_tags.include?(procedure_tag) + end + end + + def count + collection.size + end + end +end diff --git a/app/tasks/maintenance/create_variants_for_pj_of_latest_dossiers_task.rb b/app/tasks/maintenance/create_variants_for_pj_of_latest_dossiers_task.rb new file mode 100644 index 000000000..2fa1bd2a4 --- /dev/null +++ b/app/tasks/maintenance/create_variants_for_pj_of_latest_dossiers_task.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Maintenance + class CreateVariantsForPjOfLatestDossiersTask < MaintenanceTasks::Task + # Génère les vignettes de fichiers PDF pour les dossiers déposés entre 2 dates (facultatif) + # Elles sont affichées dans le nouvel onglet "Pièces jointes" des instructeurs. + # 2024-07-11-01 + attribute :start_text, :string + validates :start_text, presence: true + + attribute :end_text, :string + validates :end_text, presence: true + + def collection + start_date = DateTime.parse(start_text) + end_date = DateTime.parse(end_text) + + Dossier + .state_en_construction_ou_instruction + .where(depose_at: start_date..end_date) + end + + def process(dossier) + champ_ids = Champ + .where(dossier_id: dossier) + .where(type: ["Champs::PieceJustificativeChamp", 'Champs::TitreIdentiteChamp']) + .ids + + attachments = ActiveStorage::Attachment + .where(record_id: champ_ids) + + attachments.each do |attachment| + next if !(attachment.variable? && attachment.representation_required?) + attachment.variant(resize_to_limit: [400, 400]).processed if attachment.variant(resize_to_limit: [400, 400]).key.nil? + if attachment.blob.content_type.in?(RARE_IMAGE_TYPES) && attachment.variant(resize_to_limit: [2000, 2000]).key.nil? + attachment.variant(resize_to_limit: [2000, 2000]).processed + end + rescue MiniMagick::Error, ActiveStorage::Error + end + end + end +end diff --git a/app/tasks/maintenance/create_variants_for_pjs_from_messagerie__task.rb b/app/tasks/maintenance/create_variants_for_pjs_from_messagerie__task.rb new file mode 100644 index 000000000..fa1eb30d2 --- /dev/null +++ b/app/tasks/maintenance/create_variants_for_pjs_from_messagerie__task.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Maintenance + class CreateVariantsForPjsFromMessagerieTask < MaintenanceTasks::Task + attribute :start_text, :string + validates :start_text, presence: true + + attribute :end_text, :string + validates :end_text, presence: true + + def collection + start_date = DateTime.parse(start_text) + end_date = DateTime.parse(end_text) + + Dossier + .state_en_construction_ou_instruction + .where(depose_at: start_date..end_date) + end + + def process(dossier) + commentaire_ids = Commentaire + .where(dossier_id: dossier) + .pluck(:id) + + attachments = ActiveStorage::Attachment + .where(record_id: commentaire_ids) + + attachments.each do |attachment| + next if !(attachment.variable? && attachment.representation_required?) + attachment.variant(resize_to_limit: [400, 400]).processed if attachment.variant(resize_to_limit: [400, 400]).key.nil? + if attachment.blob.content_type.in?(RARE_IMAGE_TYPES) && attachment.variant(resize_to_limit: [2000, 2000]).key.nil? + attachment.variant(resize_to_limit: [2000, 2000]).processed + end + rescue MiniMagick::Error, ActiveStorage::Error + end + end + end +end diff --git a/app/tasks/maintenance/delete_draft_revision_type_de_champs_task.rb b/app/tasks/maintenance/delete_draft_revision_type_de_champs_task.rb index 95f577403..cc08a519d 100644 --- a/app/tasks/maintenance/delete_draft_revision_type_de_champs_task.rb +++ b/app/tasks/maintenance/delete_draft_revision_type_de_champs_task.rb @@ -2,10 +2,10 @@ module Maintenance class DeleteDraftRevisionTypeDeChampsTask < MaintenanceTasks::Task - csv_collection - + # Modifie le form d’une démarche à partir d’un CSV (dev spécifique Fonds Verts). # See UpdateDraftRevisionTypeDeChampsTask for more information - # Just add delete_flag with "true" to effectively remove the type de champ from the draft. + # Just add delete_flag with "true" in CSV to effectively remove the type de champ from the draft. + csv_collection def process(row) return unless row["delete_flag"] == "true" diff --git a/app/tasks/maintenance/destroy_incomplete_bulk_messages_task.rb b/app/tasks/maintenance/destroy_incomplete_bulk_messages_task.rb index 32d1bef43..f0c96cbc8 100644 --- a/app/tasks/maintenance/destroy_incomplete_bulk_messages_task.rb +++ b/app/tasks/maintenance/destroy_incomplete_bulk_messages_task.rb @@ -2,6 +2,10 @@ module Maintenance class DestroyIncompleteBulkMessagesTask < MaintenanceTasks::Task + # Périmètre: envoi d’un email groupé aux usagers ayant dossiers en brouillon. + # Change la manière dont ces messages sont liés aux démarches. + # Suite de BackfillBulkMessagesWithProcedureIdTask + # 2024-03-12-01 PR #10071 def collection BulkMessage.where(procedure: nil).where.missing(:groupe_instructeurs) end diff --git a/app/tasks/maintenance/destroy_procedure_without_administrateur_and_without_dossier_task.rb b/app/tasks/maintenance/destroy_procedure_without_administrateur_and_without_dossier_task.rb index 70860af82..29768d104 100644 --- a/app/tasks/maintenance/destroy_procedure_without_administrateur_and_without_dossier_task.rb +++ b/app/tasks/maintenance/destroy_procedure_without_administrateur_and_without_dossier_task.rb @@ -2,6 +2,8 @@ module Maintenance class DestroyProcedureWithoutAdministrateurAndWithoutDossierTask < MaintenanceTasks::Task + # suppression de procédures closes sans admin et sans dossier + # 2024-03-18-01 PR #10125 def collection Procedure.with_discarded.where.missing(:administrateurs, :dossiers) end diff --git a/app/tasks/maintenance/disable_remaining_invalid_mon_avis_task.rb b/app/tasks/maintenance/disable_remaining_invalid_mon_avis_task.rb index 71a78696d..b0bef2c77 100644 --- a/app/tasks/maintenance/disable_remaining_invalid_mon_avis_task.rb +++ b/app/tasks/maintenance/disable_remaining_invalid_mon_avis_task.rb @@ -2,6 +2,8 @@ module Maintenance class DisableRemainingInvalidMonAvisTask < MaintenanceTasks::Task + # Supprime les codes d’intégration « mon avis » invalides + # 2024-03-18-01 PR #10120 def collection # rubocop:disable DS/Unscoped Procedure.unscoped.where.not(monavis_embed: nil) diff --git a/app/tasks/maintenance/fix_champs_commune_having_value_but_not_external_id_task.rb b/app/tasks/maintenance/fix_champs_commune_having_value_but_not_external_id_task.rb new file mode 100644 index 000000000..3520075ac --- /dev/null +++ b/app/tasks/maintenance/fix_champs_commune_having_value_but_not_external_id_task.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Maintenance + # some of our Champs::CommuneChamp had been corrupted, ie: missing external_id + # this tasks fix this issue + class FixChampsCommuneHavingValueButNotExternalIdTask < MaintenanceTasks::Task + DEFAULT_INSTRUCTEUR_EMAIL = ENV.fetch('DEFAULT_INSTRUCTEUR_EMAIL') { CONTACT_EMAIL } + + def collection + Champs::CommuneChamp.select(:id, :value, :external_id) + end + + def process(champ) + return if !(champ.value.present? && champ.external_id.blank?) + champ.reload + return if !fixable?(champ) + + response = APIGeoService.commune_by_name_or_postal_code(champ.value) + if !response.success? + notify("Strange case of existing commune not requestable", champ) + else + results = JSON.parse(response.body, symbolize_names: true) + formated_results = APIGeoService.format_commune_response(results, true) + case formated_results.size + when 1 + champ.code = formated_results.first[:value] + champ.save! + else # otherwise, we can't find the expected departement + if champ.dossier.en_construction? + champ.code_departement = nil + champ.code_postal = nil + champ.external_id = nil + champ.value = nil + champ.save(validate: false) + + ask_user_correction(champ) + end + end + end + end + + def count + # osf, count is not an option + end + + private + + def ask_user_correction(champ) + dossier = champ.dossier + + commentaire = CommentaireService.build(current_instructeur, dossier, { body: "Suite à un problème technique, Veuillez re-remplir le champs : #{champ.libelle}" }) + dossier.flag_as_pending_correction!(commentaire, :incomplete) + end + + def current_instructeur + user = User.find_by(email: DEFAULT_INSTRUCTEUR_EMAIL) + user ||= User.create(email: DEFAULT_INSTRUCTEUR_EMAIL, + password: Random.srand, + confirmed_at: Time.zone.now, + email_verified_at: Time.zone.now) + instructeur = user.instructeur + instructeur ||= user.create_instructeur! + + instructeur + end + + def fixable?(champ) + champ.dossier.en_instruction? || champ.dossier.en_construction? + end + + def notify(message, champ) = Sentry.capture_message(message, extra: { champ: }) + end +end diff --git a/app/tasks/maintenance/fix_decimal_number_with_spaces_task.rb b/app/tasks/maintenance/fix_decimal_number_with_spaces_task.rb new file mode 100644 index 000000000..499093a4b --- /dev/null +++ b/app/tasks/maintenance/fix_decimal_number_with_spaces_task.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Maintenance + class FixDecimalNumberWithSpacesTask < MaintenanceTasks::Task + # normalise les champs nombres en y supprimant les éventuels espaces + # 2024-07-01-01 PR #10554 + + ANY_SPACES = /[[:space:]]/ + def collection + Champs::DecimalNumberChamp.where.not(value: nil) + end + + def process(element) + if element.value.present? && ANY_SPACES.match?(element.value) + element.update_column(:value, element.value.gsub(ANY_SPACES, '')) + end + end + + def count + # not really interested in counting because it raises PG Statement timeout + end + end +end diff --git a/app/tasks/maintenance/fix_duree_conservation_greater_than_max_duree_conservation_task.rb b/app/tasks/maintenance/fix_duree_conservation_greater_than_max_duree_conservation_task.rb index 4d3618dbf..adcea8687 100644 --- a/app/tasks/maintenance/fix_duree_conservation_greater_than_max_duree_conservation_task.rb +++ b/app/tasks/maintenance/fix_duree_conservation_greater_than_max_duree_conservation_task.rb @@ -2,6 +2,10 @@ module Maintenance class FixDureeConservationGreaterThanMaxDureeConservationTask < MaintenanceTasks::Task + # Corrige la durée de conservation des dossiers : + # pour toutes les démarches dont la durée de conservation est supérieure + # à celle de l'instance, on prend la durée max de DS (12 mois) + # 2024-05-27-01 PR #10107 def collection Procedure.where('duree_conservation_dossiers_dans_ds > max_duree_conservation_dossiers_dans_ds') end diff --git a/app/tasks/maintenance/fix_missing_champs_task.rb b/app/tasks/maintenance/fix_missing_champs_task.rb deleted file mode 100644 index c7c92eb1a..000000000 --- a/app/tasks/maintenance/fix_missing_champs_task.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -# bundle exec maintenance_tasks perform Maintenance::FixMissingChampsTask --arguments procedure_ids:id1,id2,id3 -module Maintenance - class FixMissingChampsTask < MaintenanceTasks::Task - attribute :procedure_ids, array: true, default: [] - - def collection - Dossier.joins(:procedure).where(procedure: { id: procedure_ids }).in_batches - end - - def process(dossiers) - # rubocop:disable Rails/FindEach - DossierPreloader.new(dossiers).all.each do |dossier| - # rubocop:enable Rails/FindEach - maybe_fixable = [dossier, dossier.editing_forks.first].compact.any? { _1.champs.size < _1.revision.types_de_champ.size } - if maybe_fixable - DataFixer::DossierChampsMissing.new(dossier:).fix - end - end - end - end -end diff --git a/app/tasks/maintenance/fix_open_procedures_with_closing_reason_task.rb b/app/tasks/maintenance/fix_open_procedures_with_closing_reason_task.rb index daf619542..711e454ff 100644 --- a/app/tasks/maintenance/fix_open_procedures_with_closing_reason_task.rb +++ b/app/tasks/maintenance/fix_open_procedures_with_closing_reason_task.rb @@ -2,6 +2,8 @@ module Maintenance class FixOpenProceduresWithClosingReasonTask < MaintenanceTasks::Task + # Corrige des démarches avec un motif de fermerture alors qu’elles ont été publiées + # 2024-05-27-01 PR #10181 def collection Procedure .with_discarded diff --git a/app/tasks/maintenance/helpscout_delete_old_conversations_task.rb b/app/tasks/maintenance/helpscout_delete_old_conversations_task.rb new file mode 100644 index 000000000..a8e37a874 --- /dev/null +++ b/app/tasks/maintenance/helpscout_delete_old_conversations_task.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Maintenance + class HelpscoutDeleteOldConversationsTask < MaintenanceTasks::Task + # Delete Helpscout conversations not modified in the last 2 years, given a status. + # In order to delete all conversations, this task must be invoked 4 times + # for the 4 status: active, closed, spam, pending. + # Respects the Helpscount API rate limit (200 calls per minute). + + attribute :status, :string # active, closed, spam, or pending + validates :status, presence: true + + MODIFIED_BEFORE = 2.years.freeze + + throttle_on(backoff: 1.minute) do + limit = Rails.cache.read(Helpscout::API::RATELIMIT_KEY) + limit.present? && limit.to_i <= 26 # check is made before each process but not before listing each page. External activity can affect the rate limit. + end + + def count + _conversations, pagination = api.list_old_conversations(status, modified_before) + + pagination[:totalElements] + end + + # Because conversations are deleted progressively, + # ignore cursor and always pick the first page + def enumerator_builder(cursor:) + Enumerator.new do |yielder| + loop do + conversations, pagination = api.list_old_conversations(status, modified_before) + conversations.each do |conversation| + yielder.yield(conversation[:id], nil) # don't care about cursor parameter + end + + # "number" is the current page (always 1 in our case) + # iterate until there are no remaining pages + break if pagination[:totalPages] == 0 || pagination[:totalPages] == pagination[:number] + end + end + end + + def process(conversation_id) + @api.delete_conversation(conversation_id) + end + + private + + def api + @api ||= Helpscout::API.new + end + + def modified_before + MODIFIED_BEFORE.ago.utc.beginning_of_day + end + end +end diff --git a/app/tasks/maintenance/helpscout_delete_old_customers_task.rb b/app/tasks/maintenance/helpscout_delete_old_customers_task.rb new file mode 100644 index 000000000..32e241435 --- /dev/null +++ b/app/tasks/maintenance/helpscout_delete_old_customers_task.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Maintenance + class HelpscoutDeleteOldCustomersTask < MaintenanceTasks::Task + # Delete Helpscout customers not seen in the last 2 years + # with any conversations, and any data related with GPDR compliance. + # Respects the Helpscout API rate limit (200 calls per minute). + + MODIFIED_BEFORE = 2.years.freeze + + throttle_on(backoff: 1.minute) do + limit = Rails.cache.read(Helpscout::API::RATELIMIT_KEY) + limit.present? && limit.to_i <= 26 # check is made before each process but not before listing each page. External activity can affect the rate limit. + end + + def count + _customers, pagination = api.list_old_customers(modified_before) + + pagination[:totalElements] + end + + # Because customers are deleted progressively, + # ignore cursor and always pick the first page + def enumerator_builder(cursor:) + Enumerator.new do |yielder| + loop do + customers, pagination = api.list_old_customers(modified_before) + customers.each do |customer| + yielder.yield(customer[:id], nil) # don't care about cursor parameter + end + + # "number" is the current page (always 1 in our case) + # iterate until there are no remaining pages + break if pagination[:totalPages] == 0 || pagination[:totalPages] == pagination[:number] + end + end + end + + def process(customer_id) + api.delete_customer(customer_id) + rescue Helpscout::API::RateLimitError # despite throttle and counter, race conditions sometimes lead to rate limit hit + sleep 1.minute + retry + end + + private + + def api + @api ||= Helpscout::API.new + end + + def modified_before + MODIFIED_BEFORE.ago.utc.beginning_of_day + end + end +end diff --git a/app/tasks/maintenance/hotfix_former_procedure_presentation_naming_task.rb b/app/tasks/maintenance/hotfix_former_procedure_presentation_naming_task.rb new file mode 100644 index 000000000..3d3a6f9af --- /dev/null +++ b/app/tasks/maintenance/hotfix_former_procedure_presentation_naming_task.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Maintenance + # a previous commit : https://github.com/demarches-simplifiees/demarches-simplifiees.fr/pull/10625/commits/305b8c13c75a711a85521d0b19659293d8d92805 + # the previous brokes a naming convention on ProcedurePresentation.filters|displayed_fields|sort + # this commit + # it adjusts live data to fit new convention and avoid validation error + class HotfixFormerProcedurePresentationNamingTask < MaintenanceTasks::Task + def collection + ProcedurePresentation.all + end + + def process(element) + element.displayed_fields = element.displayed_fields.map do |displayed_field| + if displayed_field['table'] == 'type_de_champ_private' + displayed_field['table'] = 'type_de_champ' + end + displayed_field + end + element.filters.map do |status, filters_by_status| + element.filters[status] = filters_by_status.map do |filter_by_status| + if filter_by_status['table'] == 'type_de_champ_private' + filter_by_status['table'] = 'type_de_champ' + end + filter_by_status + end + end + if element.sort['table'] == 'type_de_champ_private' + element.sort['table'] = 'type_de_champ' + end + element.save! + rescue ActiveRecord::RecordInvalid + # do nothing, former invalid ProcedurePresentation still exist + # cf: La validation a échoué : Le champ « Displayed fields » etablissement.entreprise_siren n’est pas une colonne permise + end + end +end diff --git a/app/tasks/maintenance/move_dol_to_cold_storage_task.rb b/app/tasks/maintenance/move_dol_to_cold_storage_task.rb index 8f039d94c..f4ab280b3 100644 --- a/app/tasks/maintenance/move_dol_to_cold_storage_task.rb +++ b/app/tasks/maintenance/move_dol_to_cold_storage_task.rb @@ -2,6 +2,10 @@ module Maintenance class MoveDolToColdStorageTask < MaintenanceTasks::Task + # Opération de rattrapage suite à un cron qui ne fonctionnait plus. + # Permet de déplacer toutes les traces fonctionnelles (DossierOperationLog) + # vers le stockage object plutot que de les conserver en BDD + # 2024-04-15-01 attribute :start_text, :string validates :start_text, presence: true diff --git a/app/tasks/maintenance/normalize_rna_values_task.rb b/app/tasks/maintenance/normalize_rna_values_task.rb new file mode 100644 index 000000000..3837d7bf7 --- /dev/null +++ b/app/tasks/maintenance/normalize_rna_values_task.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Maintenance + class NormalizeRNAValuesTask < MaintenanceTasks::Task + def collection + Champs::RNAChamp.where.not(value: nil) + end + + def process(element) + if /\s/.match?(element.value) + element.update_column(:value, element.value.gsub(/\s+/, '')) + end + end + + def count + # to costly + end + end +end diff --git a/app/tasks/maintenance/phishing_alert_task.rb b/app/tasks/maintenance/phishing_alert_task.rb new file mode 100644 index 000000000..0f0efa535 --- /dev/null +++ b/app/tasks/maintenance/phishing_alert_task.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Maintenance + class PhishingAlertTask < MaintenanceTasks::Task + csv_collection + + def process(row) + email = row["Identity"].delete('"') + user = User.find_by(email: email) + + # if the user has been updated less than a minute ago + # we guess that the user has already been processed + # in another row of the csv + return if user.nil? || 1.minute.ago < user.updated_at + + user.update(password: SecureRandom.hex) + + PhishingAlertMailer.notify(user).deliver_later + end + end +end diff --git a/app/tasks/maintenance/populate_rna_json_value_task.rb b/app/tasks/maintenance/populate_rna_json_value_task.rb new file mode 100644 index 000000000..e366d3aee --- /dev/null +++ b/app/tasks/maintenance/populate_rna_json_value_task.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Dans le cadre de la story pour pouvoir rechercher un dossier en fonction des valeurs des champs branchées sur une API, voici une première pièce qui cible les champs RNA/RNF/SIRET (notamment les adresses pour de la recherche). Cette PR intègre : +# la normalisation des adresses des champs RNA/RNF/SIRET +# le fait de stocker ces données normalisées dans le champs.value_json (un jsonb) +# le backfill les anciens champs RNA/RNF/SIRET +module Maintenance + class PopulateRNAJSONValueTask < MaintenanceTasks::Task + def collection + Champs::RNAChamp.where.not(value: nil) + end + + def process(champ) + return if champ&.dossier&.procedure&.id.blank? + data = APIEntreprise::RNAAdapter.new(champ.value, champ&.dossier&.procedure&.id).to_params + return if data.blank? + champ.update_with_external_data!(data:) + rescue URI::InvalidURIError + # some Champs::RNAChamp contain spaces which raise this error + rescue ActiveRecord::RecordNotFound + # some Champs::RNAChamp procedure had been soft deleted + end + + def count + # not really interested in counting because it raises PG Statement timeout + end + end +end diff --git a/app/tasks/maintenance/populate_rnf_json_value_task.rb b/app/tasks/maintenance/populate_rnf_json_value_task.rb new file mode 100644 index 000000000..baaca634d --- /dev/null +++ b/app/tasks/maintenance/populate_rnf_json_value_task.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# Dans le cadre de la story pour pouvoir rechercher un dossier en fonction des valeurs des champs branchées sur une API, voici une première pièce qui cible les champs RNA/RNF/SIRET (notamment les adresses pour de la recherche). Cette PR intègre : +# la normalisation des adresses des champs RNA/RNF/SIRET +# le fait de stocker ces données normalisées dans le champs.value_json (un jsonb) +# le backfill les anciens champs RNA/RNF/SIRET +module Maintenance + class PopulateRNFJSONValueTask < MaintenanceTasks::Task + include Dry::Monads[:result] + + def collection + Champs::RNFChamp.where("external_id != null and data != null") # had been found + # Collection to be iterated over + # Must be Active Record Relation or Array + end + + def process(champ) + result = champ.fetch_external_data + case result + in Success(data) + begin + champ.update_with_external_data!(data:) + rescue ActiveRecord::RecordInvalid + # some champ might have dossier nil + end + else # fondation was removed, but we kept API data in data:, use it to restore stuff + + champ.update_with_external_data!(data: champ.data.with_indifferent_access) + end + end + + def count + # not really interested in counting because it raises PG Statement timeout + end + end +end diff --git a/app/tasks/maintenance/populate_siret_value_json_task.rb b/app/tasks/maintenance/populate_siret_value_json_task.rb new file mode 100644 index 000000000..3b43ebb73 --- /dev/null +++ b/app/tasks/maintenance/populate_siret_value_json_task.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# Dans le cadre de la story pour pouvoir rechercher un dossier en fonction des valeurs des champs branchées sur une API, voici une première pièce qui cible les champs RNA/RNF/SIRET (notamment les adresses pour de la recherche). Cette PR intègre : +# la normalisation des adresses des champs RNA/RNF/SIRET +# le fait de stocker ces données normalisées dans le champs.value_json (un jsonb) +# le backfill les anciens champs RNA/RNF/SIRET +module Maintenance + class PopulateSiretValueJSONTask < MaintenanceTasks::Task + def collection + Champs::SiretChamp.where.not(value: nil) + end + + def process(champ) + return if champ.etablissement.blank? + champ.update!(value_json: APIGeoService.parse_etablissement_address(champ.etablissement)) + rescue ActiveRecord::RecordInvalid + # noop, just a champ without dossier + end + + def count + # not really interested in counting because it raises PG Statement timeout + end + end +end diff --git a/app/tasks/maintenance/prefill_individual_email_verified_at_task.rb b/app/tasks/maintenance/prefill_individual_email_verified_at_task.rb new file mode 100644 index 000000000..fa6e045d2 --- /dev/null +++ b/app/tasks/maintenance/prefill_individual_email_verified_at_task.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# We are going to confirm the various email addresses of the users in the system. +# Individual model (mandant) needs their email_verified_at attribute to be set in order to receive emails. +# This task sets the email_verified_at attribute to the current time for all the individual to be backward compatible +# See https://github.com/demarches-simplifiees/demarches-simplifiees.fr/issues/10450 +module Maintenance + class PrefillIndividualEmailVerifiedAtTask < MaintenanceTasks::Task + def collection + Individual.in_batches + end + + def process(batch_of_individuals) + batch_of_individuals.update_all(email_verified_at: Time.zone.now) + end + end +end diff --git a/app/tasks/maintenance/prefill_user_email_verified_at_task.rb b/app/tasks/maintenance/prefill_user_email_verified_at_task.rb new file mode 100644 index 000000000..ad3ba6f5b --- /dev/null +++ b/app/tasks/maintenance/prefill_user_email_verified_at_task.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# We are going to confirm the various email addresses of the users in the system. +# User model needs their email_verified_at attribute to be set in order to receive emails. +# This task sets the email_verified_at attribute to the current time for all users to be backward compatible +# See https://github.com/demarches-simplifiees/demarches-simplifiees.fr/issues/10450 +module Maintenance + class PrefillUserEmailVerifiedAtTask < MaintenanceTasks::Task + def collection + User.in_batches + end + + def process(batch_of_users) + batch_of_users.update_all(email_verified_at: Time.zone.now) + end + end +end diff --git a/app/tasks/maintenance/recompute_blob_checksum_task.rb b/app/tasks/maintenance/recompute_blob_checksum_task.rb new file mode 100644 index 000000000..474c535b9 --- /dev/null +++ b/app/tasks/maintenance/recompute_blob_checksum_task.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Maintenance + class RecomputeBlobChecksumTask < MaintenanceTasks::Task + # Avant février 2024, les filigranes ont corrompu les hash des fichiers. + # Régulièrement, des dossiers en brouillon étaient déposés avec ce problème + # (on retrouve les fichiers corrompu dans l'onglet retry de sidekiq). + # Cette tache recalcule les hashes. + # 2024-05-27-01 + attribute :blob_ids, :string + validates :blob_ids, presence: true + + def collection + ids = blob_ids.split(',').map(&:strip).map(&:to_i) + ActiveStorage::Blob.where(id: ids) + end + + def process(blob) + blob.upload(StringIO.new(blob.download), identify: false) + blob.save! + end + + def count + # Optionally, define the number of rows that will be iterated over + # This is used to track the task's progress + end + end +end diff --git a/app/tasks/maintenance/remove_non_unique_champs_task.rb b/app/tasks/maintenance/remove_non_unique_champs_task.rb new file mode 100644 index 000000000..0d9bf1d64 --- /dev/null +++ b/app/tasks/maintenance/remove_non_unique_champs_task.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Maintenance + class RemoveNonUniqueChampsTask < MaintenanceTasks::Task + attribute :stable_ids, :string + validates :stable_ids, presence: true + + def collection + champs = Champ.where(stable_id: stable_ids.split(',').map(&:strip).map(&:to_i)) + champs + .group_by { [_1.dossier_id, _1.stream, _1.stable_id, _1.row_id] } + .values + .filter { _1.size > 1 } + end + + def process(champs) + champs_to_remove = champs.sort_by(&:updated_at)[0...-1] + champs_to_remove.each do |champ| + champ.update_column(:stream, 'bad_data') + end + end + end +end diff --git a/app/tasks/maintenance/remove_piece_justificative_file_not_visible_task.rb b/app/tasks/maintenance/remove_piece_justificative_file_not_visible_task.rb new file mode 100644 index 000000000..473b5698c --- /dev/null +++ b/app/tasks/maintenance/remove_piece_justificative_file_not_visible_task.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Maintenance + class RemovePieceJustificativeFileNotVisibleTask < MaintenanceTasks::Task + attribute :procedure_id, :string + validates :procedure_id, presence: true + + def collection + procedure = Procedure.with_discarded.find(procedure_id.strip.to_i) + procedure.dossiers.state_not_brouillon + end + + def process(dossier) + dossier.remove_piece_justificative_file_not_visible! + end + end +end diff --git a/app/tasks/maintenance/resolve_pending_correction_for_dossier_with_invalid_commune_external_id_task.rb b/app/tasks/maintenance/resolve_pending_correction_for_dossier_with_invalid_commune_external_id_task.rb new file mode 100644 index 000000000..badfa4c43 --- /dev/null +++ b/app/tasks/maintenance/resolve_pending_correction_for_dossier_with_invalid_commune_external_id_task.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Maintenance + # this maintenance task should be run due to app/tasks/maintenance/fix_champs_commune_having_value_but_not_external_id_task.rb + # if you have not ran app/tasks/maintenance/fix_champs_commune_having_value_but_not_external_id_task.rb, this task is not required + class ResolvePendingCorrectionForDossierWithInvalidCommuneExternalIdTask < MaintenanceTasks::Task + DEFAULT_INSTRUCTEUR_EMAIL = ENV.fetch('DEFAULT_INSTRUCTEUR_EMAIL') { CONTACT_EMAIL } + + no_collection + + def process + DossierCorrection.joins(:commentaire) + .where(commentaire: { instructeur_id: current_instructeur.id }) + .where(resolved_at: nil) + .find_each do |dossier_correction| + penultimate_traitement, last_traitement = *dossier_correction.dossier.traitements.last(2) + dossier_correction.resolve! + + next if penultimate_traitement.nil? || last_traitement.nil? + + if last_traitement_by_us?(last_traitement) && last_transition_to_en_construction?(last_traitement, penultimate_traitement) + dossier_correction.dossier.passer_en_instruction(instructeur: current_instructeur) if dossier_correction.dossier.validate(:champs_public_value) + end + end + end + + def current_instructeur + @current_instructeur = User.find_by(email: DEFAULT_INSTRUCTEUR_EMAIL).instructeur + end + + def current_instructeur_id + current_instructeur.id + end + + def current_instructeur_email + current_instructeur.email + end + + def last_traitement_by_us?(traitement) + traitement&.instructeur_email == DEFAULT_INSTRUCTEUR_EMAIL + end + + def last_transition_to_en_construction?(last_traitement, penultimate_traitement) + last_traitement.state == "en_construction" && penultimate_traitement.state == 'en_instruction' + end + end +end diff --git a/app/tasks/maintenance/rotate_api_particulier_token_encryption_task.rb b/app/tasks/maintenance/rotate_api_particulier_token_encryption_task.rb new file mode 100644 index 000000000..a47c6e132 --- /dev/null +++ b/app/tasks/maintenance/rotate_api_particulier_token_encryption_task.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Maintenance + class RotateAPIParticulierTokenEncryptionTask < MaintenanceTasks::Task + def collection + # rubocop:disable DS/Unscoped + Procedure.unscoped.where.not(encrypted_api_particulier_token: nil) + # rubocop:enable DS/Unscoped + end + + def process(procedure) + decrypted_token = procedure.api_particulier_token + + procedure.api_particulier_token = decrypted_token + procedure.save!(validate: false) + end + + def count + collection.count + end + end +end diff --git a/app/tasks/maintenance/samsung_browser_is_supported_task.rb b/app/tasks/maintenance/samsung_browser_is_supported_task.rb index 988e3c010..d9ebfbb3d 100644 --- a/app/tasks/maintenance/samsung_browser_is_supported_task.rb +++ b/app/tasks/maintenance/samsung_browser_is_supported_task.rb @@ -2,6 +2,9 @@ module Maintenance class SamsungBrowserIsSupportedTask < MaintenanceTasks::Task + # Corrige une donnée si le navigateur utilisé + # dans l’historique des Traitements des dossiers + # 2024-02-21-01 def collection Traitement.where(browser_name: 'Samsung Browser', browser_version: 12..) end diff --git a/app/tasks/maintenance/spread_dossier_deletion_task.rb b/app/tasks/maintenance/spread_dossier_deletion_task.rb index 2e2f3c6a4..9c94bd6cf 100644 --- a/app/tasks/maintenance/spread_dossier_deletion_task.rb +++ b/app/tasks/maintenance/spread_dossier_deletion_task.rb @@ -2,6 +2,8 @@ module Maintenance class SpreadDossierDeletionTask < MaintenanceTasks::Task + # Contourne un égorgement de suppression de millions de dossiers qui aurait eu lieu le même jour + # 2024-05-27-01 PR #10062 ERROR_OCCURED_AT = Date.new(2024, 2, 14) ERROR_OCCURED_RANGE = ERROR_OCCURED_AT.at_midnight..(ERROR_OCCURED_AT + 1.day) SPREAD_DURATION_IN_DAYS = 150 diff --git a/app/tasks/maintenance/t20241009_noop_attempt_run_on_deploy_task.rb b/app/tasks/maintenance/t20241009_noop_attempt_run_on_deploy_task.rb new file mode 100644 index 000000000..36c5ae2e2 --- /dev/null +++ b/app/tasks/maintenance/t20241009_noop_attempt_run_on_deploy_task.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Maintenance + class T20241009NoopAttemptRunOnDeployTask < MaintenanceTasks::Task + # Documentation: cette tâche ne fait rien mais sert à vérifier + # qu'elle sera bien exécutée sur le déploiement suivant + # pour remplacer after party. + + include RunnableOnDeployConcern + include StatementsHelpersConcern + + # Uncomment only if this task MUST run imperatively on its first deployment. + # If possible, leave commented for manual execution later. + run_on_first_deploy + + def collection + 1.upto(10).to_a + end + + def process(element) + # NOOP + end + + def count + 10 + end + end +end diff --git a/app/tasks/maintenance/t20241018fix_follows_with_nil_pieces_jointes_seen_at_task.rb b/app/tasks/maintenance/t20241018fix_follows_with_nil_pieces_jointes_seen_at_task.rb new file mode 100644 index 000000000..96c62ad53 --- /dev/null +++ b/app/tasks/maintenance/t20241018fix_follows_with_nil_pieces_jointes_seen_at_task.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Maintenance + class T20241018fixFollowsWithNilPiecesJointesSeenAtTask < MaintenanceTasks::Task + # Normalement tous les follows auraient du être mis à jour lors de la migration db/migrate/20240911064340_backfill_follows_with_pieces_jointes_seen_at.rb + # Mais, sur l'instance de DS, 57 follows créés lorsque la migration a tourné ont gardé une valeur nulle pour pieces_jointes_seen_at. On les met à jour ici. + + include RunnableOnDeployConcern + include StatementsHelpersConcern + + # Uncomment only if this task MUST run imperatively on its first deployment. + # If possible, leave commented for manual execution later. + # run_on_first_deploy + + def collection + # Collection to be iterated over + # Must be Active Record Relation or Array + Follow.where(pieces_jointes_seen_at: nil) + end + + def process(element) + # The work to be done in a single iteration of the task. + # This should be idempotent, as the same element may be processed more + # than once if the task is interrupted and resumed. + element.update_columns(pieces_jointes_seen_at: Time.zone.now) + end + end +end diff --git a/app/tasks/maintenance/update_api_entreprise_token_expires_at_task.rb b/app/tasks/maintenance/update_api_entreprise_token_expires_at_task.rb new file mode 100644 index 000000000..206a270ec --- /dev/null +++ b/app/tasks/maintenance/update_api_entreprise_token_expires_at_task.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Maintenance + class UpdateAPIEntrepriseTokenExpiresAtTask < MaintenanceTasks::Task + def collection + Procedure.with_discarded.where.not(api_entreprise_token: nil) + end + + def process(procedure) + procedure.set_api_entreprise_token_expires_at + procedure.save! + end + end +end diff --git a/app/tasks/maintenance/update_closing_reason_if_no_replaced_by_id_task.rb b/app/tasks/maintenance/update_closing_reason_if_no_replaced_by_id_task.rb index daf013ca0..99e715dd3 100644 --- a/app/tasks/maintenance/update_closing_reason_if_no_replaced_by_id_task.rb +++ b/app/tasks/maintenance/update_closing_reason_if_no_replaced_by_id_task.rb @@ -2,6 +2,9 @@ module Maintenance class UpdateClosingReasonIfNoReplacedByIdTask < MaintenanceTasks::Task + # Remet les messages de cloture d'une démarche proprement (sinon affichage KO). + # Avant BackfillClosingReasonInClosedProceduresTask + # 2024-03-21-01 PR #10158 def collection Procedure .with_discarded diff --git a/app/tasks/maintenance/update_conditions_based_on_commune_or_epci_champ_task.rb b/app/tasks/maintenance/update_conditions_based_on_commune_or_epci_champ_task.rb index c8f2afab5..bf67eb3b4 100644 --- a/app/tasks/maintenance/update_conditions_based_on_commune_or_epci_champ_task.rb +++ b/app/tasks/maintenance/update_conditions_based_on_commune_or_epci_champ_task.rb @@ -2,6 +2,10 @@ module Maintenance class UpdateConditionsBasedOnCommuneOrEpciChampTask < MaintenanceTasks::Task + # Met à jour les conditions et règles de routage + # pour les champs communes et ECPI suite à l'ajout de nouveaux opérateurs + # Voir aussi UpdateRoutingRulesBasedOnCommuneOrEpciChampTask + # 2023-12-20-01 PR #9850 include Logic def collection diff --git a/app/tasks/maintenance/update_draft_revision_type_de_champs_task.rb b/app/tasks/maintenance/update_draft_revision_type_de_champs_task.rb index af03e79ec..60b39e65b 100644 --- a/app/tasks/maintenance/update_draft_revision_type_de_champs_task.rb +++ b/app/tasks/maintenance/update_draft_revision_type_de_champs_task.rb @@ -2,6 +2,8 @@ module Maintenance class UpdateDraftRevisionTypeDeChampsTask < MaintenanceTasks::Task + # Modifie le form d’une démarche à partir d’un CSV (dev pour les Fonds Verts) + csv_collection # CSV structure: diff --git a/app/tasks/maintenance/update_routing_rules_based_on_commune_or_epci_champ_task.rb b/app/tasks/maintenance/update_routing_rules_based_on_commune_or_epci_champ_task.rb index afdda78e9..22f1ad2e4 100644 --- a/app/tasks/maintenance/update_routing_rules_based_on_commune_or_epci_champ_task.rb +++ b/app/tasks/maintenance/update_routing_rules_based_on_commune_or_epci_champ_task.rb @@ -2,6 +2,10 @@ module Maintenance class UpdateRoutingRulesBasedOnCommuneOrEpciChampTask < MaintenanceTasks::Task + # Ces 2 tâches mettent à jour les conditions et règles de routage + # pour les champs communes et ECPI suite à l'ajout de nouveaux opérateurs + # Voir aussi UpdateConditionsBasedOnCommuneOrEpciChampTask + # 2023-12-20-01 PR #9850 include Logic def collection diff --git a/app/tasks/maintenance/update_service_etablissement_infos_task.rb b/app/tasks/maintenance/update_service_etablissement_infos_task.rb index 8dcf511bd..a8fdfe15c 100644 --- a/app/tasks/maintenance/update_service_etablissement_infos_task.rb +++ b/app/tasks/maintenance/update_service_etablissement_infos_task.rb @@ -2,6 +2,9 @@ module Maintenance class UpdateServiceEtablissementInfosTask < MaintenanceTasks::Task + # Géocode les services à partir des établissements + # 2024-05-27-01 PR #10106 + # No more 20 geocoding by 10 seconds window THROTTLE_LIMIT = 20 THROTTLE_PERIOD = 10.seconds diff --git a/app/tasks/maintenance/update_zones_task.rb b/app/tasks/maintenance/update_zones_task.rb index c985f946a..203b00adf 100644 --- a/app/tasks/maintenance/update_zones_task.rb +++ b/app/tasks/maintenance/update_zones_task.rb @@ -2,6 +2,8 @@ module Maintenance class UpdateZonesTask < MaintenanceTasks::Task + # Synchronise les zones en base à partir du fichier de config zones.yml + # 2024-05-27-01 PR #10077 def collection config = Psych.safe_load(Rails.root.join("config", "zones.yml").read) config['ministeres'] diff --git a/app/types/column_type.rb b/app/types/column_type.rb new file mode 100644 index 000000000..b54e49f9c --- /dev/null +++ b/app/types/column_type.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class ColumnType < ActiveRecord::Type::Value + # value can come from: + # setter: column (Column), + # form_input: column.id == { procedure_id:, column_id: }.to_json (String), + # from db: { procedure_id:, column_id: } (Hash) + def cast(value) + case value + in NilClass + nil + in Column + value + # from form + in String => id + h_id = JSON.parse(id, symbolize_names: true) + Column.find(h_id) + # from db + in Hash => h_id + Column.find(h_id) + end + end + + # db -> ruby + def deserialize(value) = cast(value) + + # ruby -> db + def serialize(value) + case value + in NilClass + nil + in Column + JSON.generate(value.h_id) + else + raise ArgumentError, "Invalid value for Column serialization: #{value}" + end + end +end diff --git a/app/types/export_item_type.rb b/app/types/export_item_type.rb new file mode 100644 index 000000000..04c37c6ef --- /dev/null +++ b/app/types/export_item_type.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class ExportItemType < ActiveRecord::Type::Value + # form_input, or setter -> type + def cast(value) + value = value.deep_symbolize_keys if value.respond_to?(:deep_symbolize_keys) + + case value + in ExportItem + value + in NilClass # default value + nil + # from db + in { template: Hash, enabled: TrueClass | FalseClass } => h + + ExportItem.new(**h.slice(:template, :enabled, :stable_id)) + # from form + in { template: String } => h + + template = JSON.parse(h[:template]).deep_symbolize_keys + enabled = h[:enabled] == 'true' + stable_id = h[:stable_id]&.to_i + ExportItem.new(template:, enabled:, stable_id:) + end + end + + # db -> ruby + def deserialize(value) = cast(value&.then { JSON.parse(_1) }) + + # ruby -> db + def serialize(value) + case value + in NilClass + nil + in ExportItem + JSON.generate({ + template: value.template, + enabled: value.enabled, + stable_id: value.stable_id + }.compact) + else + raise ArgumentError, "Invalid value for ExportItem serialization: #{value}" + end + end +end diff --git a/app/types/exported_column_type.rb b/app/types/exported_column_type.rb new file mode 100644 index 000000000..dddd35532 --- /dev/null +++ b/app/types/exported_column_type.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class ExportedColumnType < ActiveRecord::Type::Value + # form_input or setter -> type + def cast(value) + value = value.deep_symbolize_keys if value.respond_to?(:deep_symbolize_keys) + + case value + in ExportedColumn + value + in NilClass # default value + nil + # from db + in { id: String|Hash, libelle: String } => h + ExportedColumn.new(column: ColumnType.new.cast(h[:id]), libelle: h[:libelle]) + # from form + in String + h = JSON.parse(value).deep_symbolize_keys + ExportedColumn.new(column: ColumnType.new.cast(h[:id]), libelle: h[:libelle]) + end + end + + # db -> ruby + def deserialize(value) = cast(value&.then { JSON.parse(_1) }) + + # ruby -> db + def serialize(value) + case value + in NilClass + nil + in ExportedColumn + JSON.generate({ + id: value.column.h_id, + libelle: value.libelle + }) + else + raise ArgumentError, "Invalid value for ExportedColumn serialization: #{value}" + end + end +end diff --git a/app/types/filtered_column_type.rb b/app/types/filtered_column_type.rb new file mode 100644 index 000000000..ca097e4d5 --- /dev/null +++ b/app/types/filtered_column_type.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class FilteredColumnType < ActiveRecord::Type::Value + # form_input or setter -> type + def cast(value) + value = value.deep_symbolize_keys if value.respond_to?(:deep_symbolize_keys) + + case value + in FilteredColumn + value + in NilClass # default value + nil + # from form (id is a string) or from db (id is a hash) + in { id: String|Hash, filter: String } => h + FilteredColumn.new(column: ColumnType.new.cast(h[:id]), filter: h[:filter]) + end + end + + # db -> ruby + def deserialize(value) = cast(value&.then { JSON.parse(_1) }) + + # ruby -> db + def serialize(value) + case value + in NilClass + nil + in FilteredColumn + JSON.generate({ + id: value.column.h_id, + filter: value.filter + }) + else + raise ArgumentError, "Invalid value for FilteredColumn serialization: #{value}" + end + end +end diff --git a/app/types/sorted_column_type.rb b/app/types/sorted_column_type.rb new file mode 100644 index 000000000..b9daae095 --- /dev/null +++ b/app/types/sorted_column_type.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class SortedColumnType < ActiveRecord::Type::Value + # form_input or setter -> type + def cast(value) + value = value.deep_symbolize_keys if value.respond_to?(:deep_symbolize_keys) + + case value + in SortedColumn + value + in NilClass # default value + nil + # from form (id is a string) or from db (id is a hash) + in { order: 'asc'|'desc', id: String|Hash } => h + SortedColumn.new(column: ColumnType.new.cast(h[:id]), order: h[:order]) + end + end + + # db -> ruby + def deserialize(value) = cast(value&.then { JSON.parse(_1) }) + + # ruby -> db + def serialize(value) + case value + in NilClass + nil + in SortedColumn + JSON.generate({ + id: value.column.h_id, + order: value.order + }) + else + raise ArgumentError, "Invalid value for SortedColumn serialization: #{value}" + end + end +end diff --git a/app/validators/export_template_validator.rb b/app/validators/export_template_validator.rb new file mode 100644 index 000000000..51099b714 --- /dev/null +++ b/app/validators/export_template_validator.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +class ExportTemplateValidator < ActiveModel::Validator + def validate(export_template) + return if !export_template.template_zip? + + validate_all_templates(export_template) + + return if export_template.errors.any? # no need to continue if the templates are invalid + + validate_dossier_folder(export_template) + validate_export_pdf(export_template) + validate_pjs(export_template) + + validate_different_templates(export_template) + end + + private + + def validate_all_templates(export_template) + [export_template.dossier_folder, export_template.export_pdf, *export_template.pjs].each(&:template_string) + + rescue StandardError + export_template.errors.add(:base, :invalid_template) + end + + def validate_dossier_folder(export_template) + if !mentions(export_template.dossier_folder.template).include?('dossier_number') + export_template.errors.add(:dossier_folder, :dossier_number_required) + end + end + + def mentions(template) + TiptapService.used_tags_and_libelle_for(template).map(&:first) + end + + def validate_export_pdf(export_template) + return if !export_template.export_pdf.enabled? + + if export_template.export_pdf.template_string.empty? + export_template.errors.add(:export_pdf, :blank) + end + end + + def validate_pjs(export_template) + libelle_by_stable_ids = pj_libelle_by_stable_id(export_template) + + export_template.pjs.filter(&:enabled?).each do |pj| + if pj.template_string.empty? + libelle = libelle_by_stable_ids[pj.stable_id] + export_template.errors.add(libelle, I18n.t(:blank, scope: 'errors.messages')) + end + end + end + + def validate_different_templates(export_template) + templates = [export_template.export_pdf, *export_template.pjs] + .filter(&:enabled?) + .map(&:template_string) + + return if templates.uniq.size == templates.size + + export_template.errors.add(:base, :different_templates) + end + + def pj_libelle_by_stable_id(export_template) + export_template.procedure.exportables_pieces_jointes + .pluck(:stable_id, :libelle).to_h + end +end diff --git a/app/validators/expression_reguliere_validator.rb b/app/validators/expression_reguliere_validator.rb index 08a44fdc4..52cb153f5 100644 --- a/app/validators/expression_reguliere_validator.rb +++ b/app/validators/expression_reguliere_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ExpressionReguliereValidator < ActiveModel::Validator TIMEOUT = 1.second.freeze diff --git a/app/validators/geo_json_validator.rb b/app/validators/geo_json_validator.rb index ed55e8326..9fdd9be98 100644 --- a/app/validators/geo_json_validator.rb +++ b/app/validators/geo_json_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GeoJSONValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) if options[:allow_nil] == false && value.nil? diff --git a/app/validators/iban_validator.rb b/app/validators/iban_validator.rb index 5c343005e..377d74347 100644 --- a/app/validators/iban_validator.rb +++ b/app/validators/iban_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'iban-tools' class IbanValidator < ActiveModel::Validator diff --git a/app/validators/jwt_token_validator.rb b/app/validators/jwt_token_validator.rb index 031a9d1ab..9dcfc8b66 100644 --- a/app/validators/jwt_token_validator.rb +++ b/app/validators/jwt_token_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class JwtTokenValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) begin diff --git a/app/validators/mon_avis_embed_validator.rb b/app/validators/mon_avis_embed_validator.rb index 276552163..b01ac2238 100644 --- a/app/validators/mon_avis_embed_validator.rb +++ b/app/validators/mon_avis_embed_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # We need to ensure the embed code is not any random string in order to avoid injections class MonAvisEmbedValidator < ActiveModel::Validator class MonAvisEmbedError < StandardError; end diff --git a/app/validators/password_complexity_validator.rb b/app/validators/password_complexity_validator.rb index a915a8575..504578985 100644 --- a/app/validators/password_complexity_validator.rb +++ b/app/validators/password_complexity_validator.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + class PasswordComplexityValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) - if value.present? && ZxcvbnService.new(value).score < PASSWORD_COMPLEXITY_FOR_ADMIN + if value.present? && ZxcvbnService.complexity(value) < PASSWORD_COMPLEXITY_FOR_ADMIN record.errors.add(attribute, :not_strong) end end diff --git a/app/validators/siret_format_validator.rb b/app/validators/siret_format_validator.rb index 964dd9e4a..eaf415711 100644 --- a/app/validators/siret_format_validator.rb +++ b/app/validators/siret_format_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class SiretFormatValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) if !format_is_valid(value) diff --git a/app/validators/strict_email_validator.rb b/app/validators/strict_email_validator.rb index 7549aa23d..01a126ffc 100644 --- a/app/validators/strict_email_validator.rb +++ b/app/validators/strict_email_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class StrictEmailValidator < ActiveModel::EachValidator # default devise email is : /\A[^@\s]+@[^@\s]+\z/ # saying that it's quite permissive diff --git a/app/validators/tags_validator.rb b/app/validators/tags_validator.rb index 7d981936e..d3cdf2c0e 100644 --- a/app/validators/tags_validator.rb +++ b/app/validators/tags_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TagsValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) procedure = record.procedure diff --git a/app/validators/types_de_champ/condition_validator.rb b/app/validators/types_de_champ/condition_validator.rb new file mode 100644 index 000000000..2de95838c --- /dev/null +++ b/app/validators/types_de_champ/condition_validator.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class TypesDeChamp::ConditionValidator < ActiveModel::EachValidator + # condition are valid when + # tdc.condition.left is present in upper tdcs + # in case of types_de_champ_private, we should include types_de_champ_publics too + def validate_each(procedure, collection, tdcs) + return if tdcs.empty? + + tdcs = tdcs_with_children(procedure, tdcs) + tdcs.each_with_index do |tdc, tdc_index| + next unless tdc.condition? + + upper_tdcs = [] + if collection == :draft_types_de_champ_private # in case of private tdc validation, we must include public tdcs + upper_tdcs += tdcs_with_children(procedure, procedure.draft_types_de_champ_public) + end + upper_tdcs += tdcs.take(tdc_index) # we take all upper_tdcs of current tdcs + + errors = tdc.condition.errors(upper_tdcs) + next if errors.blank? + + procedure.errors.add( + collection, + procedure.errors.generate_message(collection, :invalid_condition, { value: tdc.libelle }), + type_de_champ: tdc + ) + end + end + + # find children in repetitions + def tdcs_with_children(procedure, tdcs) + tdcs.to_a + .flat_map { _1.repetition? ? procedure.draft_revision.children_of(_1) : _1 } + end +end diff --git a/app/validators/types_de_champ/expression_reguliere_validator.rb b/app/validators/types_de_champ/expression_reguliere_validator.rb new file mode 100644 index 000000000..775379bf6 --- /dev/null +++ b/app/validators/types_de_champ/expression_reguliere_validator.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class TypesDeChamp::ExpressionReguliereValidator < ActiveModel::EachValidator + def validate_each(procedure, attribute, types_de_champ) + types_de_champ.to_a + .flat_map { _1.repetition? ? procedure.draft_revision.children_of(_1) : _1 } + .each do |tdc| + if tdc.expression_reguliere? && tdc.invalid_regexp? + procedure.errors.add( + attribute, + procedure.errors.generate_message(attribute, :expression_reguliere_invalid, { value: tdc.libelle }), + type_de_champ: tdc + ) + end + end + end +end diff --git a/app/validators/types_de_champ/header_section_consistency_validator.rb b/app/validators/types_de_champ/header_section_consistency_validator.rb new file mode 100644 index 000000000..58c69d0f6 --- /dev/null +++ b/app/validators/types_de_champ/header_section_consistency_validator.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class TypesDeChamp::HeaderSectionConsistencyValidator < ActiveModel::EachValidator + def validate_each(procedure, attribute, types_de_champ) + public_tdcs = types_de_champ.to_a + + root_tdcs_errors = errors_for_header_sections_order(procedure, attribute, public_tdcs) + repetition_tdcs_errors = public_tdcs + .filter_map { _1.repetition? ? procedure.draft_revision.children_of(_1) : nil } + .map { errors_for_header_sections_order(procedure, attribute, _1) } + + repetition_tdcs_errors + root_tdcs_errors + end + + private + + def errors_for_header_sections_order(procedure, attribute, types_de_champ) + types_de_champ + .map.with_index + .filter_map { |tdc, i| tdc.header_section? ? [tdc, i] : nil } + .map { |tdc, i| [tdc, tdc.check_coherent_header_level(types_de_champ.take(i))] } + .filter { |_tdc, errors| errors.present? } + .each do |tdc, message| + procedure.errors.add( + attribute, + procedure.errors.generate_message(attribute, :inconsistent_header_section, { value: tdc.libelle, custom_message: message }), + type_de_champ: tdc + ) + end + end +end diff --git a/app/validators/types_de_champ/no_empty_block_validator.rb b/app/validators/types_de_champ/no_empty_block_validator.rb index e1ea17739..6cd39a2e4 100644 --- a/app/validators/types_de_champ/no_empty_block_validator.rb +++ b/app/validators/types_de_champ/no_empty_block_validator.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + class TypesDeChamp::NoEmptyBlockValidator < ActiveModel::EachValidator def validate_each(procedure, attribute, types_de_champ) - types_de_champ.filter(&:block?).each do |repetition| + types_de_champ.filter(&:repetition?).each do |repetition| validate_block_not_empty(procedure, attribute, repetition) end end @@ -11,7 +13,8 @@ class TypesDeChamp::NoEmptyBlockValidator < ActiveModel::EachValidator if procedure.draft_revision.children_of(parent).empty? procedure.errors.add( attribute, - procedure.errors.generate_message(attribute, :empty_repetition, { value: parent.libelle }) + procedure.errors.generate_message(attribute, :empty_repetition, { value: parent.libelle }), + type_de_champ: parent ) end end diff --git a/app/validators/types_de_champ/no_empty_drop_down_validator.rb b/app/validators/types_de_champ/no_empty_drop_down_validator.rb index bd8fa21dd..d1028bce3 100644 --- a/app/validators/types_de_champ/no_empty_drop_down_validator.rb +++ b/app/validators/types_de_champ/no_empty_drop_down_validator.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + class TypesDeChamp::NoEmptyDropDownValidator < ActiveModel::EachValidator def validate_each(procedure, attribute, types_de_champ) - types_de_champ.filter(&:drop_down_list?).each do |drop_down| + types_de_champ.filter(&:any_drop_down_list?).each do |drop_down| validate_drop_down_not_empty(procedure, attribute, drop_down) end end @@ -8,10 +10,11 @@ class TypesDeChamp::NoEmptyDropDownValidator < ActiveModel::EachValidator private def validate_drop_down_not_empty(procedure, attribute, drop_down) - if drop_down.drop_down_list_enabled_non_empty_options.empty? + if drop_down.drop_down_options.empty? procedure.errors.add( attribute, - procedure.errors.generate_message(attribute, :empty_drop_down, { value: drop_down.libelle }) + procedure.errors.generate_message(attribute, :empty_drop_down, { value: drop_down.libelle }), + type_de_champ: drop_down ) end end diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb index fb2f4df02..0eed00103 100644 --- a/app/validators/url_validator.rb +++ b/app/validators/url_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'active_model' require 'active_support/i18n' require 'public_suffix' diff --git a/app/views/active_storage/blobs/_blob.html.erb b/app/views/active_storage/blobs/_blob.html.erb index 49ba357dd..8e95058a4 100644 --- a/app/views/active_storage/blobs/_blob.html.erb +++ b/app/views/active_storage/blobs/_blob.html.erb @@ -1,14 +1,19 @@ -

    attachment--<%= blob.filename.extension %>"> - <% if blob.representable? %> - <%= image_tag blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ]) %> - <% end %> - -
    - <% if caption = blob.try(:caption) %> - <%= caption %> +<% if blob.representable? %> + <% representation = blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ]) %> + <% if representation.image&.attached? || representation.key.present? %> +
    + <%= image_tag representation %> <% else %> - <%= blob.filename %> - <%= number_to_human_size blob.byte_size %> +
    + <%= image_tag blob %> <% end %> -
    -
    + +
    + <% if caption = blob.try(:caption) %> + <%= caption %> + <% else %> + <%= blob.filename %> + <%= number_to_human_size blob.byte_size %> + <% end %> +
    +<% end %> diff --git a/app/views/administrateurs/_autosave_notice.html.haml b/app/views/administrateurs/_autosave_notice.html.haml deleted file mode 100644 index 989e3970d..000000000 --- a/app/views/administrateurs/_autosave_notice.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -- success = local_assigns.fetch(:success, true) -#autosave-notice.fr-badge.fr-badge--sm{ class: class_names("fr-badge--success" => success, "fr-badge--error" => !success) }= success ? t(".form_saved") : t(".form_error") diff --git a/app/views/administrateurs/_breadcrumbs.html.haml b/app/views/administrateurs/_breadcrumbs.html.haml index 95c4295ac..6c64f233e 100644 --- a/app/views/administrateurs/_breadcrumbs.html.haml +++ b/app/views/administrateurs/_breadcrumbs.html.haml @@ -28,6 +28,11 @@ - elsif @procedure.locked? = link_to commencer_url(@procedure.path), commencer_url(@procedure.path), class: "fr-link" .flex.fr-mt-1w + + - if @procedure.api_entreprise_token_expired_or_expires_soon? + %span.fr-badge.fr-badge--error.fr-mr-1w + = t('to_modify', scope: [:layouts, :breadcrumb]) + %span.fr-badge.fr-badge--success.fr-mr-1w = t('published', scope: [:layouts, :breadcrumb]) = t('since', scope: [:layouts, :breadcrumb], number: @procedure.id, date: l(@procedure.published_at.to_date)) @@ -35,7 +40,6 @@ - else %p.fr-mb-1w = t('more_info_on_test', scope: [:layouts, :breadcrumb]) - = link_to t('go_to_FAQ', scope: [:layouts, :breadcrumb]), t("url_FAQ", scope: [:layouts, :breadcrumb]), title: new_tab_suffix(t('go_to_FAQ', scope: [:layouts, :breadcrumb])), **external_link_attributes .flex %span.fr-badge.fr-badge--new.fr-mr-1w = t('draft', scope: [:layouts, :breadcrumb]) diff --git a/app/views/administrateurs/_main_navigation.html.haml b/app/views/administrateurs/_main_navigation.html.haml index 4c6010d04..f775ff34a 100644 --- a/app/views/administrateurs/_main_navigation.html.haml +++ b/app/views/administrateurs/_main_navigation.html.haml @@ -1,4 +1,4 @@ -%nav#header-navigation.fr-nav{ role: 'navigation', 'aria-label': 'Menu principal administrateur' } +#header-navigation.fr-nav %ul.fr-nav__list %li.fr-nav__item= link_to 'Mes démarches', admin_procedures_path, class:'fr-nav__link', 'aria-current': current_page?(controller: 'administrateurs/procedures', action: :index) ? 'true' : nil - if Rails.application.config.ds_zonage_enabled diff --git a/app/views/administrateurs/activate/new.html.haml b/app/views/administrateurs/activate/new.html.haml index b826ddffe..0dca181ed 100644 --- a/app/views/administrateurs/activate/new.html.haml +++ b/app/views/administrateurs/activate/new.html.haml @@ -18,9 +18,9 @@ .fr-fieldset__element = render Dsfr::InputComponent.new(form: f, attribute: :password, input_type: :password_field, - opts: { autofocus: 'true', autocomplete: 'new-password', data: { controller: 'turbo-input', turbo_input_url_value: show_password_complexity_path }}) + opts: { autofocus: 'true', autocomplete: 'new-password', data: { controller: 'turbo-input', turbo_input_url_value: show_password_complexity_path }, aria: {describedby: 'password_hint'}}) #password_complexity = render PasswordComplexityComponent.new - = f.submit t('.continue'), id: 'submit-password', class: "fr-btn fr-btn--lg fr-mt-2w", data: { disable_with: t('views.users.passwords.edit.submit_loading') } + = f.submit t('.continue'), id: 'submit-password', disabled: :disabled, class: "fr-btn fr-btn--lg fr-mt-2w", data: { disable_with: t('views.users.passwords.edit.submit_loading') } diff --git a/app/views/administrateurs/api_tokens/edit.html.haml b/app/views/administrateurs/api_tokens/edit.html.haml index 6efa775f5..1d4635e43 100644 --- a/app/views/administrateurs/api_tokens/edit.html.haml +++ b/app/views/administrateurs/api_tokens/edit.html.haml @@ -6,41 +6,76 @@ ["Jeton d’API : #{@api_token.name}"]] } .fr-container.fr-mt-2w - %h1 Modification du jeton d'API « #{@api_token.name} » - = form_with url: admin_api_token_path(@api_token), method: :patch, html: { class: 'fr-mt-2w' } do |f| - .fr-input-group - = f.label :name, class: 'fr-label' do - = t('name', scope: [:administrateurs, :api_tokens, :nom]) - %span.fr-hint-text= t('name-hint', scope: [:administrateurs, :api_tokens, :nom]) - = f.text_field :name, - class: 'fr-input width-33', - autocomplete: 'off', - autocapitalize: 'off', - autocorrect: 'off', - spellcheck: false, - required: true, - value: @api_token.name + %turbo-frame#tokenUpdate + %h1 Modification du jeton d'API « #{@api_token.name} » - .fr-input-group.fr-mb-4w{ - class: class_names('fr-input-group--error': @invalid_network) } - = f.label :name, class: 'fr-label' do - = @api_token.eternal? ? "Entrez au moins 1 réseau autorisé" : "Entrez les adresses ip autorisées" - %span.fr-hint-text adresses réseaux séparées par des espaces. ex: 176.31.79.200 192.168.33.0/24 2001:41d0:304:400::52f/128 - = f.text_field :networks, - class: class_names('fr-input': true, 'fr-input--error': @invalid_network), - autocomplete: 'off', - autocapitalize: 'off', - autocorrect: 'off', - spellcheck: false, - required: @api_token.eternal?, - value: @api_token.authorized_networks_for_ui.gsub(/,/, ' ') + = form_with url: admin_api_token_path(@api_token), method: :patch, html: { class: 'fr-mt-2w' } do |f| + .fr-input-group + = f.label :name, class: 'fr-label' do + = t('name', scope: [:administrateurs, :api_tokens, :nom]) + %span.fr-hint-text= t('name-hint', scope: [:administrateurs, :api_tokens, :nom]) + .flex + = f.text_field :name, + class: 'fr-input width-33', + autocomplete: 'off', + autocapitalize: 'off', + autocorrect: 'off', + spellcheck: false, + required: true, + value: @api_token.name - - if @invalid_network - %p.fr-error-text vous devez entrer des adresses ipv4 ou ipv6 valides + %button.fr-btn.fr-btn--secondary.fr-ml-1w Renommer - %ul.fr-btns-group.fr-btns-group--inline - %li - = f.button 'Modifier', type: :submit, class: "fr-btn fr-btn--primary" - %li - = link_to 'Revenir', profil_path, class: "fr-btn fr-btn--secondary" + = form_with url: admin_api_token_path(@api_token), method: :patch, html: { class: 'fr-mt-2w' } do |f| + .fr-input-group.fr-mb-4w{ + class: class_names('fr-input-group--error': @invalid_network_message.present?) } + = f.label :name, class: 'fr-label' do + = @api_token.eternal? ? "Entrez au moins 1 réseau autorisé" : "Entrez les adresses ip autorisées" + %span.fr-hint-text adresses réseaux séparées par des espaces. ex: 176.31.79.200 192.168.33.0/24 2001:41d0:304:400::52f/128 + .flex + = f.text_field :networks, + class: class_names('fr-input': true, 'fr-input--error': @invalid_network_message.present?), + autocomplete: 'off', + autocapitalize: 'off', + autocorrect: 'off', + spellcheck: false, + value: @api_token.authorized_networks_for_ui.gsub(/,/, ' ') + + %button.fr-btn.fr-btn--secondary.fr-ml-1w Modifier + + - if @invalid_network_message.present? + %p.fr-error-text= @invalid_network_message + + = form_with url: admin_api_token_path(@api_token), method: :patch, html: { class: 'fr-mt-2w' } do |f| + .fr-mb-4w + - if @api_token.full_access? + %p Votre jeton d'API a accès à toutes vos démarches. + = hidden_field_tag :procedure_to_add, '[]' + %button.fr-btn.fr-btn--secondary.fr-btn--sm Restreindre l'accès à certaines les démarches + - else + .fr-select-group + %label.fr-label{ for: 'procedure_to_add' } Ajouter des démarches autorisées + .flex + = f.select :value, + options_for_select(@libelle_id_procedures), + { include_blank: true }, + { class: 'fr-select width-33', + name: 'procedure_to_add'} + + %button.fr-btn.fr-btn--secondary.fr-ml-1w Ajouter + + %ul.fr-mb-4w + - @api_token.procedures.each do |procedure| + %li{ id: dom_id(procedure, :authorized) } + = procedure.libelle + = button_to 'Supprimer', + remove_procedure_admin_api_token_path(@api_token, procedure_id: procedure.id), + class: 'fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-btn--icon-left fr-icon-delete-line', + form_class: 'inline', + method: :delete, + form: { data: { turbo: 'true' } } + + %ul.fr-btns-group.fr-btns-group--inline + %li + = link_to 'Revenir', profil_path, class: "fr-btn fr-btn--secondary" diff --git a/app/views/administrateurs/archives/index.html.haml b/app/views/administrateurs/archives/index.html.haml index b50059d6c..86cc6465e 100644 --- a/app/views/administrateurs/archives/index.html.haml +++ b/app/views/administrateurs/archives/index.html.haml @@ -4,11 +4,12 @@ ['Export et Archives']] } -.container - %h1.mb-2 +.container.flex + %h1.mb-2.mr-2 Archives -# index not renderable as administrateur flagged as manager, so render it anyway - = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_url: method(:download_admin_procedure_exports_path)) + = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_url: method(:download_admin_procedure_exports_path), show_export_template_tab: false) +.container = render Dossiers::ExportLinkComponent.new(procedure: @procedure, exports: @exports, export_url: method(:download_admin_procedure_exports_path)) = render partial: "shared/archives/notice" diff --git a/app/views/administrateurs/attestation_template_v2s/_fixed_footer.html.haml b/app/views/administrateurs/attestation_template_v2s/_fixed_footer.html.haml new file mode 100644 index 000000000..e66ef2949 --- /dev/null +++ b/app/views/administrateurs/attestation_template_v2s/_fixed_footer.html.haml @@ -0,0 +1,9 @@ +.fr-container + .fr-grid-row.fr-grid-row--middle.fr-pb-3v + .fr-col-12.fr-col-md-4 + = link_to admin_procedure_path(id: procedure), class: 'fr-link' do + %span.fr-icon-arrow-left-line.fr-icon--sm + Revenir à l’écran de gestion + + .fr-col-12.fr-col-md-8.text-right + %span#autosave-notice diff --git a/app/views/administrateurs/attestation_template_v2s/_sticky_header.html.haml b/app/views/administrateurs/attestation_template_v2s/_sticky_header.html.haml new file mode 100644 index 000000000..d209f5d4d --- /dev/null +++ b/app/views/administrateurs/attestation_template_v2s/_sticky_header.html.haml @@ -0,0 +1,21 @@ +.sticky-header.sticky-header-warning + .fr-container + %p.flex.justify-between.align-center.fr-text-default--warning + %span + = dsfr_icon("fr-icon-warning-fill fr-mr-1v") + - if @procedure.attestation_templates.many? + Les modifications effectuées ne seront appliquées qu’à la prochaine publication. + - else + L’attestation ne sera délivrée qu’après sa publication. + + %span.no-wrap + - if @procedure.attestation_templates.many? + = link_to reset_admin_procedure_attestation_template_v2_path(@procedure), class: "fr-btn fr-btn--secondary fr-ml-2w", method: :post do + Réinitialiser les modifications + + %button.fr-btn.fr-ml-2w{ form: "attestation-template", name: field_name(:attestation_template, :state), value: "published", + data: { 'disable-with': "Publication en cours…", controller: 'autosave-submit' } } + - if @procedure.attestation_templates.many? + Publier les modifications + - else + Publier diff --git a/app/views/administrateurs/attestation_template_v2s/edit.html.haml b/app/views/administrateurs/attestation_template_v2s/edit.html.haml index 308dca5e4..cd97011c6 100644 --- a/app/views/administrateurs/attestation_template_v2s/edit.html.haml +++ b/app/views/administrateurs/attestation_template_v2s/edit.html.haml @@ -4,7 +4,8 @@ ['Attestation']] } = render NestedForms::FormOwnerComponent.new -= form_for @attestation_template, url: admin_procedure_attestation_template_v2_path(@procedure), html: { multipart: true }, += form_for @attestation_template, url: admin_procedure_attestation_template_v2_path(@procedure), + html: { multipart: true , id: "attestation-template" }, data: { turbo: 'true', controller: 'autosubmit attestation', autosubmit_debounce_delay_value: 1000, @@ -12,18 +13,16 @@ attestation_logo_attachment_free_label_value: AttestationTemplate.human_attribute_name(:logo) } do |f| #attestation-edit.fr-container.fr-my-4w{ data: { controller: 'tiptap', tiptap_insert_after_tag_value: ' ' } } - .fr-mb-6w - = render Dsfr::AlertComponent.new(state: :info, title: "Nouvel éditeur d’attestation", heading_level: 'h3') do |c| - - c.with_body do - Cette page permet la mise en forme de l’attestation avec un nouvel éditeur plus flexible - tout en respectant la charte de l’état. Essayez-la et donnez-nous votre avis - en nous envoyant un email à #{mail_to(Current.contact_email, subject: "Feedback attestation v2")}. - %br - %strong Les attestations délivrées suivent encore l’ancien format : - l’activation des attestations basées sur ce format sera bientôt disponible. - %br + - if @procedure.attestation_templates.v1.published.any? + .fr-mb-6w + = render Dsfr::AlertComponent.new(state: :info, title: "Nouvel éditeur d’attestation", heading_level: 'h3') do |c| + - c.with_body do + %p Cette page présente un nouvel éditeur d'attestations, plus flexible et conforme à la charte de l'État. + %p + %strong Pour modifier l’attestation existante (actuellement délivrée aux usagers), + = link_to("cliquez ici", edit_admin_procedure_attestation_template_path(@procedure)) + "." + %p Pour générer une attestation à la charte de l‘État, créez-la ci-dessous puis publiez-la: elle remplacera alors l’attestation actuelle. - = link_to("Suivez ce lien pour revenir aux attestations actuellement délivrées", edit_admin_procedure_attestation_template_path(@procedure)) .fr-grid-row.fr-grid-row--gutters .fr-col-12.fr-col-lg-7 @@ -34,13 +33,23 @@ L’attestation est émise au moment où un dossier est accepté, elle est jointe à l’email d’accusé d’acceptation. Elle est également disponible au téléchargement depuis l’espace personnel de l’usager. + .fr-fieldset__element + = render Dsfr::CalloutComponent.new(title: "Activation de la délivrance de l’attestation", theme: :neutral) do |c| + - c.with_html_body do + .fr-toggle.fr-toggle--label-left + = f.check_box :activated, class: "fr-toggle__input", id: dom_id(@attestation_template, :activated) + %label.fr-toggle__label{ for: dom_id(@attestation_template, :activated), + data: { fr_checked_label: "Activée", fr_unchecked_label: "Désactivée" } } + Activer cette option permet la délivrance automatique de l’attestation dès l’acceptation du dossier. + Désactiver cette option arrête immédiatement l’émission de nouvelles attestations. + .fr-fieldset__element %h2.fr-h4 En-tête .fr-fieldset__element - .fr-toggle - = f.check_box :official_layout, class: "fr-toggle-input", id: dom_id(@attestation_template, :official_layout), data: { "attestation-target": "layoutToggle"} - %label.fr-toggle__label{ for: dom_id(@attestation_template, :official_layout), data: { fr_checked_label: "Activé", fr_unchecked_label: "Désactivé" } } + .fr-toggle.fr-toggle--label-left + = f.check_box :official_layout, class: "fr-toggle__input", id: dom_id(@attestation_template, :official_layout), data: { "attestation-target": "layoutToggle"} + %label.fr-toggle__label{ for: dom_id(@attestation_template, :official_layout), data: { fr_checked_label: "Oui", fr_unchecked_label: "Non" } } Je souhaite générer une attestation à la charte de l’état (logo avec Marianne) .fr-fieldset__element{ class: class_names("hidden" => !@attestation_template.official_layout?), data: { "attestation-target": 'logoMarianneLabelFieldset'} } @@ -48,7 +57,7 @@ - c.with_hint { "Exemple: Ministère de la Mer. 5 lignes maximum" } .fr-fieldset__element{ data: { attestation_target: 'logoAttachmentFieldset' } } - %label.fr-label{ for: field_id(@attestation_template, :logo) } + %label.fr-label{ for: dom_id(@attestation_template, :logo) } - if @attestation_template.official_layout? = AttestationTemplate.human_attribute_name(:logo_additional) - else @@ -77,10 +86,10 @@ %button.fr-btn.fr-btn--secondary.fr-btn--sm{ type: 'button', title: label, class: icon == :hidden ? "hidden" : "fr-icon-#{icon}", data: { action: 'click->tiptap#menuButton', tiptap_target: 'button', tiptap_action: action } } = label - #editor.editor{ data: { tiptap_target: 'editor' }, aria: { describedby: dom_id(f.object, "json-body-messages")} } + #editor.tiptap-editor{ data: { tiptap_target: 'editor' }, aria: { describedby: "attestation-template-json-body-messages"} } = f.hidden_field :tiptap_body, data: { tiptap_target: 'input' } - .fr-error-text{ id: dom_id(f.object, "json-body-messages"), class: class_names("hidden" => !f.object.errors.include?(:json_body)) } + .fr-error-text{ id: "attestation-template-json-body-messages", class: class_names("hidden" => !f.object.errors.include?(:json_body)) } - if f.object.errors.include?(:json_body) = render partial: "shared/errors_list", locals: { object: f.object, attribute: :json_body } @@ -96,7 +105,7 @@ %h2.fr-h4 Pied de page .fr-fieldset__element - %label.fr-label{ for: field_id(@attestation_template, :signature) } Tampon ou signature + %label.fr-label{ for: dom_id(@attestation_template, :signature) } Tampon ou signature %span.fr-hint-text Dimensions conseillées : au minimum 500px de largeur ou de hauteur. @@ -108,30 +117,21 @@ - c.with_hint { "Exemple: 20 avenue de Ségur, 75007 Paris" } #preview-column.fr-col-12.fr-col-lg-5.fr-background-alt--blue-france - .sticky--top.fr-px-1w + .sticky--top.fr-px-1w{ data: { controller: "sticky-top" } } .flex.justify-between.align-center %h2.fr-h4 Aperçu %p= link_to 'Prévisualiser en taille réelle', admin_procedure_attestation_template_v2_path(@procedure, format: :pdf), class: 'fr-link', target: '_blank', rel: 'noopener' %iframe.attestation-preview{ title: "Aperçu", src: admin_procedure_attestation_template_v2_path(@procedure, format: :pdf), data: { attestation_target: 'preview' } } %p.fr-hint-text L’aperçu est mis à jour automatiquement après chaque modification. - Pour générer un aperçu fidèle avec tous les champs et les dates, créez-vous un dossier et acceptez-le : l’aperçu l’utilisera. + Pour générer un aperçu fidèle avec champs et dates, + = link_to("créez-vous un dossier", new_dossier_path(procedure_id: @procedure, brouillon: true), **external_link_attributes) + et acceptez-le : l’aperçu l’utilisera. + + - if @procedure.feature_enabled?(:attestation_v2) && @attestation_template.draft? + - content_for(:sticky_header) do + = render partial: "sticky_header" .padded-fixed-footer - .fixed-footer - .fr-container - .fr-grid-row - .fr-col-12.fr-col-md-7 - %ul.fr-btns-group.fr-btns-group--inline-md - %li - = link_to admin_procedure_path(id: @procedure), class: 'fr-btn fr-btn--secondary' do - %span.fr-icon-arrow-go-back-line.fr-icon--sm.fr-mr-1v - Revenir à la démarche - - .fr-col-12.fr-col-md-5 - -# .fr-toggle - -# = f.check_box :activated, class: "fr-toggle-input", disabled: true, id: dom_id(@attestation_template, :activated) - -# %label.fr-toggle__label{ for: dom_id(@attestation_template, :activated), data: { fr_checked_label: "Attestation activée", fr_unchecked_label: "Attestation désactivée" } } - .text-right - %span#autosave-notice - %p.fr-hint-text L’activation de cette attestation sera bientôt disponible. + .fixed-footer#fixed_footer + = render partial: "fixed_footer", locals: { procedure: @procedure } diff --git a/app/views/administrateurs/attestation_template_v2s/show.html.haml b/app/views/administrateurs/attestation_template_v2s/show.html.haml index 06c87773f..e27889003 100644 --- a/app/views/administrateurs/attestation_template_v2s/show.html.haml +++ b/app/views/administrateurs/attestation_template_v2s/show.html.haml @@ -30,12 +30,13 @@ - if @attestation_template.label_direction.present? = simple_format @attestation_template.label_direction, class: "direction" + - if @attestation_template.footer.present? + %footer + = simple_format @attestation_template.footer + .main = sanitize(@body, attributes: %w[class style], tags: Rails.configuration.action_view.sanitized_allowed_tags + %w[header]) - if @attestation_template.signature.present? .signature = image_tag(@attestation_template.signature_url) - - - if @attestation_template.footer.present? - = simple_format @attestation_template.footer, class: "footer" diff --git a/app/views/administrateurs/attestation_template_v2s/update.turbo_stream.haml b/app/views/administrateurs/attestation_template_v2s/update.turbo_stream.haml index 67ef140a1..ce033ac46 100644 --- a/app/views/administrateurs/attestation_template_v2s/update.turbo_stream.haml +++ b/app/views/administrateurs/attestation_template_v2s/update.turbo_stream.haml @@ -1,5 +1,8 @@ +- if @attestation_template.draft? + = turbo_stream.update "sticky-header", render(partial: "sticky_header") + = turbo_stream.show 'autosave-notice' -= turbo_stream.replace 'autosave-notice', render(partial: 'administrateurs/autosave_notice', locals: { success: !@attestation_template.changed? }) += turbo_stream.replace('autosave-notice', render(AutosaveNoticeComponent.new(success: !@attestation_template.changed?, label_scope: :attestation))) = turbo_stream.hide 'autosave-notice', delay: 15000 - if @attestation_template.logo_blob&.previously_new_record? @@ -10,7 +13,7 @@ = turbo_stream.update dom_id(@attestation_template, :signature_attachment) do = render(Attachment::EditComponent.new(attached_file: @attestation_template.signature, direct_upload: false)) -- body_id = dom_id(@attestation_template, "json-body-messages") +- body_id = "attestation-template-json-body-messages" - if @attestation_template.errors.include?(:json_body) = turbo_stream.update body_id do = render partial: "shared/errors_list", locals: { object: @attestation_template, attribute: :json_body } diff --git a/app/views/administrateurs/attestation_templates/show.pdf.prawn b/app/views/administrateurs/attestation_templates/show.pdf.prawn index 3b2b89f2e..c71482690 100644 --- a/app/views/administrateurs/attestation_templates/show.pdf.prawn +++ b/app/views/administrateurs/attestation_templates/show.pdf.prawn @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'prawn/measurement_extensions' #----- A4 page size diff --git a/app/views/administrateurs/conditions/_update.turbo_stream.haml b/app/views/administrateurs/conditions/_update.turbo_stream.haml index 3fba14ec8..028fb8c0e 100644 --- a/app/views/administrateurs/conditions/_update.turbo_stream.haml +++ b/app/views/administrateurs/conditions/_update.turbo_stream.haml @@ -4,7 +4,7 @@ ['Configuration des champs']], preview: @procedure.draft_revision.valid? }) -= turbo_stream.replace 'errors-summary', render(TypesDeChampEditor::ErrorsSummary.new(revision: @procedure.draft_revision)) += turbo_stream.replace 'errors-summary', render(Procedure::ErrorsSummary.new(procedure: @procedure, validation_context: @tdc.public? ? :types_de_champ_public_editor : :types_de_champ_private_editor)) - rendered = render @condition_component diff --git a/app/views/administrateurs/dossier_submitted_messages/edit.html.haml b/app/views/administrateurs/dossier_submitted_messages/edit.html.haml index 08ce1b7cd..dc289c291 100644 --- a/app/views/administrateurs/dossier_submitted_messages/edit.html.haml +++ b/app/views/administrateurs/dossier_submitted_messages/edit.html.haml @@ -3,31 +3,31 @@ = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_path], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], - ['Fin de dépot']] } + ['Fin de dépôt']] } -.procedure-form - .procedure-form__columns.container - = form_for @dossier_submitted_message, - url: url_for({ controller: 'administrateurs/dossier_submitted_messages', action: :update, id: @procedure.id }), - html: { class: 'form procedure-form__column--form fr-background-alt--blue-france' } do |f| += form_for @dossier_submitted_message, + url: url_for({ controller: 'administrateurs/dossier_submitted_messages', action: :update, id: @procedure.id }), + html: { class: 'form' } do |f| - %h1.page-title - Fin du dépot - %p.notice - L'utilisateur se verra afficher ce message une fois le dossier envoyé + .procedure-form + .procedure-form__columns.container + .procedure-form__column--form.fr-background-alt--blue-france.fr-pt-5w + %h1.fr-h2 + Fin de dépôt + %p.notice + L'utilisateur se verra afficher ce message une fois le dossier envoyé - = render partial: 'administrateurs/dossier_submitted_messages/informations', locals: { f: f } + = render partial: 'administrateurs/dossier_submitted_messages/informations', locals: { f: f } - .procedure-form__actions - .actions-left - = f.submit 'Enregistrer', class: 'fr-btn send' - .procedure-form__column--preview - %h3 - .procedure-form__preview-title - Aperçu - .notice - Cet aperçu est mis à jour après chaque sauvegarde. + .procedure-form__column--preview + %h3 + .procedure-form__preview-title + Aperçu + .notice + Cet aperçu est mis à jour après chaque sauvegarde. - .procedure-preview - = render partial: 'users/dossiers/merci', locals: { procedure: @procedure, dossier: nil} + .procedure-preview + = render partial: 'users/dossiers/merci', locals: { procedure: @procedure, dossier: nil} + + = render Procedure::FixedFooterComponent.new(procedure: @procedure, form: f) diff --git a/app/views/administrateurs/experts_procedures/index.html.haml b/app/views/administrateurs/experts_procedures/index.html.haml index 886aa061f..865d1cb67 100644 --- a/app/views/administrateurs/experts_procedures/index.html.haml +++ b/app/views/administrateurs/experts_procedures/index.html.haml @@ -1,109 +1,120 @@ = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_path], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], - ['Liste des experts']] } + ['Avis externes']] } -.container - %h1.page-title.mt-2= t('.titles.main', libelle: @procedure.libelle) +.fr-container + %h1.fr-h2 + Avis externes - .container.groupe-instructeur + = render Dsfr::CalloutComponent.new(title: nil) do |c| + - c.with_body do + Pendant l'instruction d'un dossier, les instructeurs peuvent demander leur avis à un ou plusieurs experts. + %p + = link_to('Comment gérer les avis externes', t('.experts_doc.url'), + title: t('.experts_doc.title'), + **external_link_attributes) - .card - .card-title= t('.titles.allow_invite_experts') - %p= t('.descriptions.allow_invite_experts') + %ul.fr-toggle__list + %li = form_for @procedure, method: :put, url: allow_expert_review_admin_procedure_path(@procedure), - html: { class: 'form procedure-form__column--form no-background' } do |f| - %label.toggle-switch{ data: { controller: 'autosubmit' } } - = f.check_box :allow_expert_review, class: 'toggle-switch-checkbox' - %span.toggle-switch-control.round - %span.toggle-switch-label.on - %span.toggle-switch-label.off + data: { controller: 'autosubmit', turbo: 'true' } do |f| + + = render Dsfr::ToggleComponent.new(form: f, + target: :allow_expert_review, + title: t('.titles.allow_invite_experts'), + hint: t('.descriptions.allow_invite_experts'), + disabled: false, + extra_class_names: 'fr-toggle--border-bottom') - if @procedure.allow_expert_review? - .card - .card-title= t('.titles.manage_procedure_experts') - %p= t('.descriptions.manage_procedure_experts') + %li + = form_for @procedure, + method: :put, + url: allow_expert_messaging_admin_procedure_path(@procedure), + data: { controller: 'autosubmit', turbo: 'true' } do |f| + + = render Dsfr::ToggleComponent.new(form: f, + target: :allow_expert_messaging, + title: t('.titles.allow_expert_messaging'), + hint: t('.descriptions.allow_expert_messaging'), + disabled: false, + extra_class_names: 'fr-toggle--border-bottom') + + %li = form_for @procedure, method: :put, url: experts_require_administrateur_invitation_admin_procedure_path(@procedure), - html: { class: 'form procedure-form__column--form no-background' } do |f| - %label.toggle-switch{ data: { controller: 'autosubmit' } } - = f.check_box :experts_require_administrateur_invitation, class: 'toggle-switch-checkbox' - %span.toggle-switch-control.round - %span.toggle-switch-label.on - %span.toggle-switch-label.off + data: { controller: 'autosubmit', turbo: 'true' } do |f| - .card - .card-title= t('.titles.allow_expert_messaging') - %p= t('.descriptions.allow_expert_messaging') - = form_for @procedure, - method: :put, - url: allow_expert_messaging_admin_procedure_path(@procedure), - html: { class: 'form procedure-form__column--form no-background' } do |f| - %label.toggle-switch{ data: { controller: 'autosubmit' } } - = f.check_box :allow_expert_messaging, class: 'toggle-switch-checkbox' - %span.toggle-switch-control.round - %span.toggle-switch-label.on - %span.toggle-switch-label.off + = render Dsfr::ToggleComponent.new(form: f, + target: :experts_require_administrateur_invitation, + title: t('.titles.manage_procedure_experts'), + hint: t('.descriptions.manage_procedure_experts'), + disabled: false) - - if @procedure.experts_require_administrateur_invitation? - .card - .card-title Affecter des experts à la démarche - = form_for :experts_procedure, - url: admin_procedure_experts_path(@procedure), - html: { class: 'form' } do |f| - .instructeur-wrapper - %p Pendant l'instruction d’un dossier, les instructeurs peuvent demander leur avis à un ou plusieurs experts. - %p#experts-emails Entrez les adresses email des experts que vous souhaitez affecter à cette démarche - = hidden_field_tag :emails, nil - = react_component("ComboMultiple", - options: [], - selected: [], disabled: [], - group: '.instructeur-wrapper', - name: 'emails', - label: 'Emails', - describedby: 'experts-emails', - acceptNewValues: true) + - if @procedure.experts_require_administrateur_invitation? + .card + = render Procedure::InvitationWithTypoComponent.new(maybe_typos: @maybe_typos, url: admin_procedure_experts_path(@procedure), title: "Avant d'ajouter l'email à la liste d'expert prédéfinie, veuillez confirmer" ) + = form_for :experts_procedure, + url: admin_procedure_experts_path(@procedure), + html: { class: 'form' } do |f| + + .instructeur-wrapper + %p#experts-emails Entrez les adresses emails des experts que vous souhaitez ajouter à la liste prédéfinie + %react-fragment + = render ReactComponent.new "ComboBox/MultiComboBox", + id: 'emails', + name: 'emails[]', + allows_custom_value: true, + 'aria-label': 'Emails', + 'aria-describedby': 'experts-emails' + + = f.submit 'Ajouter à la liste', class: 'fr-btn' - = f.submit 'Affecter à la démarche', class: 'button primary send' - if @experts_procedure.present? - %table.table.mt-2 - %thead - %tr - %th Liste des experts - %th Nombre d’avis - - if @procedure.experts_require_administrateur_invitation - %th Notifier des décisions sur les dossiers - %tbody - - @experts_procedure.each do |expert_procedure| + .fr-table.fr-table--no-caption.fr-table--layout-fixed.fr-mt-3w + %table + %thead %tr - %td - = dsfr_icon('fr-icon-user-fill') - = expert_procedure.expert.email - %td.text-center - = expert_procedure.avis.count + %th Liste des experts + %th Nombre d’avis - if @procedure.experts_require_administrateur_invitation + %th Notifier des décisions sur les dossiers + - if @procedure.experts_require_administrateur_invitation + %th Action + %tbody + - @experts_procedure.each do |expert_procedure| + %tr + %td + = dsfr_icon('fr-icon-user-fill') + = expert_procedure.expert.email %td.text-center - = form_for expert_procedure, - url: admin_procedure_expert_path(id: expert_procedure), - method: :put, - data: { turbo: true }, - html: { class: 'form procedure-form__column--form no-background' } do |f| - %label.toggle-switch{ data: { controller: 'autosubmit' } } - = f.check_box :allow_decision_access, class: 'toggle-switch-checkbox' - %span.toggle-switch-control.round - %span.toggle-switch-label.on - %span.toggle-switch-label.off - - if @procedure.experts_require_administrateur_invitation - %td.actions= button_to 'retirer', - admin_procedure_expert_path(id: expert_procedure, procedure: @procedure), - method: :delete, - data: { confirm: "Êtes-vous sûr de vouloir révoquer l'expert « #{expert_procedure.expert.email} » de la démarche #{expert_procedure.procedure.libelle} ? Les instructeurs ne pourront plus lui demander d’avis" }, - class: 'button' + = expert_procedure.avis.count + - if @procedure.experts_require_administrateur_invitation + %td.text-center + = form_for expert_procedure, + url: admin_procedure_expert_path(id: expert_procedure), + method: :put, + data: { turbo: true }, + html: { class: 'form procedure-form__column--form no-background' } do |f| + %label.toggle-switch{ data: { controller: 'autosubmit' } } + = f.check_box :allow_decision_access, class: 'toggle-switch-checkbox' + %span.toggle-switch-control.round + %span.toggle-switch-label.on + %span.toggle-switch-label.off + - if @procedure.experts_require_administrateur_invitation + %td.actions= button_to 'retirer', + admin_procedure_expert_path(id: expert_procedure, procedure: @procedure), + method: :delete, + data: { confirm: "Êtes-vous sûr de vouloir révoquer l'expert « #{expert_procedure.expert.email} » de la démarche #{expert_procedure.procedure.libelle} ? Les instructeurs ne pourront plus lui demander d’avis" }, + class: 'fr-btn fr-btn--secondary' - else .blank-tab %h2.empty-text Aucun expert invité pour le moment. %p.empty-text-details Les instructeurs de cette démarche n’ont pas encore fait appel aux experts. + += render Procedure::FixedFooterComponent.new(procedure: @procedure) diff --git a/app/views/administrateurs/groupe_instructeurs/_custom_routing_modal.html.haml b/app/views/administrateurs/groupe_instructeurs/_custom_routing_modal.html.haml new file mode 100644 index 000000000..4f14875e0 --- /dev/null +++ b/app/views/administrateurs/groupe_instructeurs/_custom_routing_modal.html.haml @@ -0,0 +1,20 @@ +%dialog{ aria: { labelledby: "fr-modal-title-modal-1" }, role: "dialog", id: "routing-mode-modal", class: "fr-modal fr-modal--opened" } + .fr-container.fr-container--fluid.fr-container-md + .fr-grid-row.fr-grid-row--center + .fr-col-12.fr-col-md-8.fr-col-lg-6 + .fr-modal__body + .fr-modal__header + %button.fr-btn.fr-btn--close{ title: "Fermer la fenêtre modale", aria: { controls: "routing-mode-modal" } } Fermer + .fr-modal__content + %h1#fr-modal-title-modal-1.fr-modal__title + %span.fr-icon-arrow-right-line.fr-icon--lg + Configuration manuelle du routage + .fr-alert.fr-alert--success + %h2.fr-alert__title + Deux groupes par défaut ont été créés + %p + Vous devez maintenant les renommer et leur attribuer des règles de routage à partir du ou des champs « routables » de votre formulaire, soit des champs de type : + %ul + - TypeDeChamp.humanized_conditionable_types_by_category.each do |category| + %li + = category.join(', ') diff --git a/app/views/administrateurs/groupe_instructeurs/_import_export.html.haml b/app/views/administrateurs/groupe_instructeurs/_import_export.html.haml deleted file mode 100644 index 1286c969b..000000000 --- a/app/views/administrateurs/groupe_instructeurs/_import_export.html.haml +++ /dev/null @@ -1,28 +0,0 @@ -- key = procedure.groupe_instructeurs.one? ? 'instructeurs' : 'groupes' -%section.fr-accordion.fr-mb-3w - %h3.fr-accordion__title - %button.fr-accordion__btn{ "aria-controls" => "accordion-106", "aria-expanded" => "false" } - = t(".csv_import.#{key}.title") - .fr-collapse#accordion-106 - - csv_max_size = Administrateurs::GroupeInstructeursController::CSV_MAX_SIZE - - if procedure.publiee_or_close? - %p.notice - = t(".csv_import.#{key}.notice_1_html", csv_max_size: number_to_human_size(csv_max_size)) - %p.notice - = t(".csv_import.#{key}.notice_2") - = form_tag import_admin_procedure_groupe_instructeurs_path(procedure), method: :post, multipart: true, class: "mt-4 form flex justify-between align-center" do - = file_field_tag :csv_file, required: true, accept: 'text/csv', size: "1" - = submit_tag t('.csv_import.import_file'), class: 'fr-btn fr-btn--secondary', data: { disable_with: "Envoi...", confirm: t('.csv_import.import_file_alert') } - - else - %p.mt-4.form.font-weight-bold.mb-2.text-lg - = t(".csv_import.#{key}.title") - %p.notice - = t('.csv_import.import_file_procedure_not_published') - - if procedure.groupe_instructeurs.many? - .flex.justify-between.align-center.mt-4 - %div - = t(".existing_groupe", count: procedure.groupe_instructeurs.count) - = button_to "Exporter au format CSV", - export_groupe_instructeurs_admin_procedure_groupe_instructeurs_path(procedure, format: :csv), - method: :get, - class: 'fr-btn fr-btn--secondary' diff --git a/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml b/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml index 3e8dd7643..968c6b938 100644 --- a/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml +++ b/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml @@ -1,42 +1,46 @@ .card - .card-title Affectation des instructeurs + = render Procedure::InvitationWithTypoComponent.new(maybe_typos: @maybe_typos, url: add_instructeur_admin_procedure_groupe_instructeur_path(@procedure, groupe_instructeur.id), title: "Avant d'ajouter l'email, veuillez confirmer" ) + .card-title= t('.instructeur_assignation') = form_for :instructeur, url: { action: :add_instructeur, id: groupe_instructeur.id }, html: { class: 'form' } do |f| .instructeur-wrapper - if !procedure.routing_enabled? - %p Entrez les adresses email des instructeurs que vous souhaitez affecter à cette démarche + %p= t('.instructeur_emails') + %p.fr-hint-text= t('.copy_paste_hint') - if disabled_as_super_admin = f.select :emails, available_instructeur_emails, {}, disabled: disabled_as_super_admin, id: 'instructeur_emails' - else - = hidden_field_tag :emails, nil - = react_component("ComboMultiple", - options: available_instructeur_emails, selected: [], disabled: [], - group: '.instructeur-wrapper', - id: 'instructeur_emails', - name: 'emails', - label: 'Emails', - acceptNewValues: true) + %react-fragment + = render ReactComponent.new 'ComboBox/MultiComboBox', items: available_instructeur_emails, id: 'instructeur_emails', name: 'emails[]', allows_custom_value: true, 'aria-label': 'Emails' - = f.submit 'Affecter', class: 'fr-btn', disabled: disabled_as_super_admin + = f.submit t('.assign'), class: 'fr-btn fr-btn--tertiary', disabled: disabled_as_super_admin - %table.fr-table.fr-mt-2w.width-100 + %hr.fr-mt-4w + + .flex.justify-between.align-baseline + .card-title= t('.assigned_instructeur', count: instructeurs.count) + = button_to export_groupe_instructeurs_admin_procedure_groupe_instructeurs_path(procedure, format: :csv), method: :get, class: 'fr-btn fr-btn--tertiary fr-btn--icon-left fr-icon-download-line' do + Exporter la liste (.CSV) + + %table.fr-table.fr-table--bordered.width-100 %thead %tr - %th{ colspan: 2 }= t('.assigned_instructeur', count: instructeurs.count) + %th= t('.title') + %th.text-right= t('.actions') %tbody - instructeurs.each do |instructeur| %tr %td - = dsfr_icon('fr-icon-user-fill') + = dsfr_icon('fr-icon-user-line') #{instructeur.email} - confirmation_message = procedure.routing_enabled? ? "Êtes-vous sûr de vouloir retirer l’instructeur « #{instructeur.email} » du groupe « #{groupe_instructeur.label} » ?" : "Êtes-vous sûr de vouloir retirer l’instructeur « #{instructeur.email} » de la démarche ?" - %td.actions= button_to 'Retirer', + %td.actions= button_to t('.remove'), { action: :remove_instructeur, id: groupe_instructeur.id }, { method: :delete, data: { confirm: confirmation_message }, params: { instructeur: { id: instructeur.id }}, - class: 'fr-btn fr-btn--secondary' } + class: 'fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-subtract-line' } = paginate instructeurs, views_prefix: 'shared' diff --git a/app/views/administrateurs/groupe_instructeurs/_simple_routing_modal.html.haml b/app/views/administrateurs/groupe_instructeurs/_simple_routing_modal.html.haml new file mode 100644 index 000000000..ff817b750 --- /dev/null +++ b/app/views/administrateurs/groupe_instructeurs/_simple_routing_modal.html.haml @@ -0,0 +1,14 @@ +%dialog{ aria: { labelledby: "fr-modal-title-modal-1" }, role: "dialog", id: "routing-mode-modal", class: "fr-modal fr-modal--opened" } + .fr-container.fr-container--fluid.fr-container-md + .fr-grid-row.fr-grid-row--center + .fr-col-12.fr-col-md-8.fr-col-lg-6 + .fr-modal__body + .fr-modal__header + %button.fr-btn.fr-btn--close{ title: "Fermer la fenêtre modale", aria: { controls: "routing-mode-modal" } } Fermer + .fr-modal__content + %h1#fr-modal-title-modal-1.fr-modal__title + %span.fr-icon-arrow-right-line.fr-icon--lg + Configuration automatique du routage + .fr-alert.fr-alert--success + %h2.fr-alert__title + Les groupes instructeurs ont été créés à partir du champ « #{procedure.routing_champs.first} » diff --git a/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml b/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml index 854ef9c18..de4deae6a 100644 --- a/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml +++ b/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml @@ -2,26 +2,53 @@ locals: { steps: [[t('.procedures'), admin_procedures_path], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], ['Groupes', admin_procedure_groupe_instructeurs_path(@procedure)], - ['Routage à partir d’un champ']] } + ['Configuration automatique du routage']] } -= render Procedure::InstructeursMenuComponent.new(procedure: @procedure) do - - content_for(:title, 'Routage') - %h1 Routage à partir d’un champ - = form_for :create_simple_routing, - method: :post, - data: { controller: 'enable-submit-if-checked' }, - url: create_simple_routing_admin_procedure_groupe_instructeurs_path(@procedure) do |f| +.container + .fr-grid-row + .fr-col.fr-col-12.fr-col-md-3 + .fr-container + %ul.fr-btns-group.fr-btns-group--inline-md.fr-ml-0 + %li + = link_to options_admin_procedure_groupe_instructeurs_path, class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w fr-mr-2w' do + Revenir aux options - %div{ data: { 'action': "click->enable-submit-if-checked#click" } } - .notice - Sélectionner le champ à partir duquel créer des groupes d’instructeurs - - buttons_content = @procedure.active_revision.routable_types_de_champ.map { |tdc| { label: tdc.libelle, value: tdc.stable_id } } - = render Dsfr::RadioButtonListComponent.new(form: f, - target: :stable_id, - buttons: buttons_content) + .fr-col + - content_for(:title, 'Routage') + %h1 Configuration du routage + %h2 Configuration automatique + .fr-alert.fr-alert--info.fr-mb-3w{ aria: { hidden: true } } + %p + Vous trouverez ci-dessous une liste de champs de votre formulaire à partir desquels configurer le routage de façon automatique. Les groupes d’instructeurs seront créés à partir des valeurs possibles du champ. + Seuls les champs suivants sont ouverts à ce mode de configuration : + %ul + - TypeDeChamp.humanized_simple_routable_types_by_category.each do |category| + %li + = category.join(', ') - %ul.fr-btns-group.fr-btns-group--inline-sm - %li - = link_to 'Retour', options_admin_procedure_groupe_instructeurs_path(@procedure, state: :choix), class: 'fr-btn fr-btn--secondary' - %li - %button.fr-btn{ disabled: true, data: { disable_with: 'Création des groupes…', 'enable-submit-if-checked-target': 'submit' } } Créer les groupes + %p + Si besoin, vous pourrez ensuite affiner votre configuration de façon manuelle, également à partir des champs suivants : + + %ul + - TypeDeChamp.humanized_custom_routable_types_by_category.each do |category| + %li + = category.join(', ') + + = form_for :create_simple_routing, + method: :post, + data: { controller: 'enable-submit-if-checked' }, + url: create_simple_routing_admin_procedure_groupe_instructeurs_path(@procedure) do |f| + + .card.fr-pb-0{ data: { 'action': "click->enable-submit-if-checked#click" } } + .notice + Sélectionner le champ à partir duquel créer des groupes d’instructeurs + - buttons_content = @procedure.active_revision.simple_routable_types_de_champ.map { |tdc| { label: tdc.libelle, value: tdc.stable_id, hint: "[#{I18n.t(tdc.type_champ, scope: 'activerecord.attributes.type_de_champ.type_champs')}]", tooltip: tdc.drop_down_options.join(", ")} } + = render Dsfr::RadioButtonListComponent.new(form: f, + target: :stable_id, + buttons: buttons_content) + + %ul.fr-btns-group.fr-btns-group--inline-sm + %li + = link_to 'Annuler', options_admin_procedure_groupe_instructeurs_path(@procedure, state: :choix), class: 'fr-btn fr-btn--secondary' + %li + %button.fr-btn{ disabled: true, data: { disable_with: 'Création des groupes…', 'enable-submit-if-checked-target': 'submit' } } Créer les groupes diff --git a/app/views/administrateurs/ineligibilite_rules/_update.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/_update.turbo_stream.haml new file mode 100644 index 000000000..a0ace0eca --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/_update.turbo_stream.haml @@ -0,0 +1,7 @@ +- rendered = render @ineligibilite_rules_component + +- if rendered.present? + = turbo_stream.replace dom_id(@procedure.draft_revision, :ineligibilite_rules) do + - rendered +- else + = turbo_stream.remove dom_id(@procedure.draft_revision, :ineligibilite_rules) diff --git a/app/views/administrateurs/ineligibilite_rules/add_row.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/add_row.turbo_stream.haml new file mode 100644 index 000000000..8f9900e50 --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/add_row.turbo_stream.haml @@ -0,0 +1 @@ += render partial: 'update' diff --git a/app/views/administrateurs/ineligibilite_rules/change_targeted_champ.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/change_targeted_champ.turbo_stream.haml new file mode 100644 index 000000000..8f9900e50 --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/change_targeted_champ.turbo_stream.haml @@ -0,0 +1 @@ += render partial: 'update' diff --git a/app/views/administrateurs/ineligibilite_rules/delete_row.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/delete_row.turbo_stream.haml new file mode 100644 index 000000000..8f9900e50 --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/delete_row.turbo_stream.haml @@ -0,0 +1 @@ += render partial: 'update' diff --git a/app/views/administrateurs/ineligibilite_rules/destroy.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/destroy.turbo_stream.haml new file mode 100644 index 000000000..8f9900e50 --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/destroy.turbo_stream.haml @@ -0,0 +1 @@ += render partial: 'update' diff --git a/app/views/administrateurs/ineligibilite_rules/edit.html.haml b/app/views/administrateurs/ineligibilite_rules/edit.html.haml new file mode 100644 index 000000000..6eb91d12f --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/edit.html.haml @@ -0,0 +1,28 @@ += render partial: 'administrateurs/breadcrumbs', + locals: { steps: [['Démarches', admin_procedures_path], + [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], + ['Inéligibilité des dossiers']] } + + +.fr-container + .fr-grid-row + .fr-col-12.fr-col-offset-md-2.fr-col-md-8 + %h1.fr-h1 Inéligibilité des dossiers + + = render Dsfr::AlertComponent.new(title: nil, size: :sm, state: :info, heading_level: 'h2', extra_class_names: 'fr-my-2w') do |c| + - c.with_body do + %p + Les dossiers répondant à vos conditions d’inéligibilité ne pourront pas être déposés. Plus d’informations sur l’inéligibilité des dossiers dans la + = link_to('doc', ELIGIBILITE_URL, title: "Document sur l’inéligibilité des dossiers", **external_link_attributes) + + - if !@procedure.draft_revision.conditionable_types_de_champ.present? + %p.fr-mt-2w.fr-mb-2w + Pour configurer l’inéligibilité des dossiers, votre formulaire doit comporter au moins un champ supportant les conditions d’inéligibilité. Il vous faut donc ajouter au moins un des champs suivant à votre formulaire : + %ul + - Logic::ChampValue::MANAGED_TYPE_DE_CHAMP.values.each do + %li= "« #{t(_1, scope: [:activerecord, :attributes, :type_de_champ, :type_champs])} »" + %p.fr-mt-2w + = link_to 'Ajouter un champ supportant les conditions d’inéligibilité', champs_admin_procedure_path(@procedure), class: 'fr-link fr-icon-arrow-right-line fr-link--icon-right' + = render Procedure::FixedFooterComponent.new(procedure: @procedure) + - else + = render Conditions::IneligibiliteRulesComponent.new(draft_revision: @procedure.draft_revision) diff --git a/app/views/administrateurs/ineligibilite_rules/update.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/update.turbo_stream.haml new file mode 100644 index 000000000..8f9900e50 --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/update.turbo_stream.haml @@ -0,0 +1 @@ += render partial: 'update' diff --git a/app/views/administrateurs/jeton_particulier/show.html.haml b/app/views/administrateurs/jeton_particulier/show.html.haml index 7527313eb..f0ded6bdb 100644 --- a/app/views/administrateurs/jeton_particulier/show.html.haml +++ b/app/views/administrateurs/jeton_particulier/show.html.haml @@ -2,14 +2,13 @@ locals: { steps: [['Démarches', admin_procedures_path], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], [Procedure.human_attribute_name(:jeton_api_particulier), admin_procedure_api_particulier_path(@procedure)], - ['Jeton']] } + ['Jeton Particulier']] } -.container - %h1.page-title - = t('.configure_token') +.fr-container + %h1.fr-h2 + Jeton Particulier -.container - %h1 +.fr-container = form_with model: @procedure, url: admin_procedure_api_particulier_jeton_path, local: true do |f| = render Dsfr::AlertComponent.new(state: :info, size: :sm, extra_class_names: 'fr-mb-2w') do |c| - c.with_body do diff --git a/app/views/administrateurs/labels/_form.html.haml b/app/views/administrateurs/labels/_form.html.haml new file mode 100644 index 000000000..6742b1d35 --- /dev/null +++ b/app/views/administrateurs/labels/_form.html.haml @@ -0,0 +1,15 @@ += form_with model: label, url: [:admin, @procedure, @label], local: true do |f| + = render Dsfr::InputComponent.new(form: f, attribute: :name, input_type: :text_field, opts: { maxlength: Label::NAME_MAX_LENGTH}) + + %fieldset.fr-fieldset + %legend.fr-fieldset__legend.fr-fieldset__legend--regular + = t('activerecord.attributes.label.color') + = asterisk + + - @colors_collection.each do |color| + .fr-fieldset__element.fr-fieldset__element--inline + .fr-radio-group + = f.radio_button :color, color, checked: (label.color == color) + = f.label :color, t("activerecord.attributes.label/color.#{color}"), value: color, class: "fr-label fr-tag fr-tag--sm fr-tag--#{Label.class_name(color)}" + + = render Procedure::FixedFooterComponent.new(procedure: @procedure, form: f) diff --git a/app/views/administrateurs/labels/edit.html.haml b/app/views/administrateurs/labels/edit.html.haml new file mode 100644 index 000000000..8f3784ca4 --- /dev/null +++ b/app/views/administrateurs/labels/edit.html.haml @@ -0,0 +1,20 @@ +- content_for :title, "Modifier le label" + += render partial: 'administrateurs/breadcrumbs', + locals: { steps: [['Démarches', admin_procedures_path], + [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], + ['gestion des labels', [:admin, @procedure, :labels]], + ['Modifier le label']] } + + +.fr-container + .fr-mb-3w + = link_to "Liste de tous les labels", + [:admin, @procedure, :labels], + class: "fr-link fr-icon-arrow-left-line fr-link--icon-left" + + %h1.fr-h2 + Modifier le label + + = render partial: 'form', + locals: { label: @label, procedure_id: @procedure.id } diff --git a/app/views/administrateurs/labels/index.html.haml b/app/views/administrateurs/labels/index.html.haml new file mode 100644 index 000000000..f0dc512f5 --- /dev/null +++ b/app/views/administrateurs/labels/index.html.haml @@ -0,0 +1,43 @@ +- content_for :title, "Labels" + += render partial: 'administrateurs/breadcrumbs', + locals: { steps: [['Démarches', admin_procedures_path], + [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], + ['Labels']] } + +.fr-container + %h1.fr-h2 Labels + + = link_to "Nouveau label", + [:new, :admin, @procedure, :label], + class: "fr-btn fr-btn--primary fr-btn--icon-left fr-icon-add-circle-line mb-3" + + - if @procedure.labels.present? + .fr-table.fr-table--layout-fixed.fr-table--bordered + %table + %caption Liste des labels + %thead + %tr + %th{ scope: "col" } + Nom + %th.change{ scope: "col" } + Actions + + %tbody + - @labels.each do |label| + %tr + %td + = tag_label(label.name, label.color) + %td.change + + = link_to 'Modifier', + [:edit, :admin, @procedure, label], + class: 'fr-btn fr-btn--sm fr-btn--secondary fr-btn--icon-left fr-icon-pencil-line' + + = link_to 'Supprimer', + [:admin, @procedure, label], + method: :delete, + data: { confirm: "Confirmez vous la suppression de #{label.name}" }, + class: 'fr-btn fr-btn--sm fr-btn--secondary fr-btn--icon-left fr-icon-delete-line fr-ml-1w' + += render Procedure::FixedFooterComponent.new(procedure: @procedure) diff --git a/app/views/administrateurs/labels/new.html.haml b/app/views/administrateurs/labels/new.html.haml new file mode 100644 index 000000000..fc4713cd3 --- /dev/null +++ b/app/views/administrateurs/labels/new.html.haml @@ -0,0 +1,20 @@ +- content_for :title, "Nouveau label" + += render partial: 'administrateurs/breadcrumbs', + locals: { steps: [['Démarches', admin_procedures_path], + [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], + ['gestion des labels', [:admin, @procedure, :labels]], + ['Nouveau label']] } + + +.fr-container + .fr-mb-3w + = link_to "Liste de tous les labels", + [:admin, @procedure, :labels], + class: "fr-link fr-icon-arrow-left-line fr-link--icon-left" + + %h1.fr-h2 + Créer un nouveau label + + = render partial: 'form', + locals: { label: @label, procedure_id: @procedure.id } diff --git a/app/views/administrateurs/mail_templates/index.html.haml b/app/views/administrateurs/mail_templates/index.html.haml index 07345a1fa..9fd744726 100644 --- a/app/views/administrateurs/mail_templates/index.html.haml +++ b/app/views/administrateurs/mail_templates/index.html.haml @@ -3,8 +3,19 @@ ["#{@procedure.libelle.truncate_words(10)}", admin_procedure_path(@procedure)], ["Configuration des emails"]] } -.container - .fr-grid-row.fr-grid-row--gutters.fr-py-5w +.fr-container + .fr-grid-row.fr-grid-row--gutters + .fr-col-12 + %h1.fr-h2 Configuration des emails + - if @procedure.accuse_lecture? + = render Dsfr::AlertComponent.new(state: :info, size: :sm) do |c| + - c.with_body do + %p + L'accusé de lecture est activé sur cette démarche. Dans ce contexte, les emails « d’acceptation », « de rejet » et de « classement sans suite », ne sont pas modifiables afin de s'assurer que la décision finale reste masquée pour l'usager. + - @mail_templates.each do |mail_template| .fr-col-md-6.fr-col-12 = render Procedure::EmailTemplateCardComponent.new(email_template: mail_template) + + += render Procedure::FixedFooterComponent.new(procedure: @procedure) diff --git a/app/views/administrateurs/procedure_administrateurs/index.html.haml b/app/views/administrateurs/procedure_administrateurs/index.html.haml index 8ad25f4e1..3e9bf37aa 100644 --- a/app/views/administrateurs/procedure_administrateurs/index.html.haml +++ b/app/views/administrateurs/procedure_administrateurs/index.html.haml @@ -4,10 +4,14 @@ ['Administrateurs']], preview: false } .fr-container - %h1 Gérer les administrateurs de « #{@procedure.libelle} » + %h1.fr-h2 Administrateurs - .fr-table.fr-table--bordered + .fr-mb-4w + = render 'add_admin_form', procedure: @procedure, disabled_as_super_admin: administrateur_as_manager? + + .fr-table.fr-table--bordered.fr-table--layout-fixed %table + %caption Liste des administrateurs %thead %th= 'Adresse email' %th= 'Enregistré le' @@ -16,5 +20,4 @@ %tbody#administrateurs = render(Procedure::ProcedureAdministrateurs::AdministrateurComponent.with_collection(@procedure.administrateurs.order('users.email'), procedure: @procedure)) - .fr-mt-4w - = render 'add_admin_form', procedure: @procedure, disabled_as_super_admin: administrateur_as_manager? += render Procedure::FixedFooterComponent.new(procedure: @procedure) diff --git a/app/views/administrateurs/procedures/_api_entreprise_token_expiration_alert.html.haml b/app/views/administrateurs/procedures/_api_entreprise_token_expiration_alert.html.haml new file mode 100644 index 000000000..d37e14142 --- /dev/null +++ b/app/views/administrateurs/procedures/_api_entreprise_token_expiration_alert.html.haml @@ -0,0 +1,14 @@ +- if procedure.api_entreprise_token_expires_at.present? + - if procedure.api_entreprise_token_expires_at < Time.zone.now + = render Dsfr::AlertComponent.new(state: :error, size: :sm, extra_class_names: 'fr-mb-2w') do |c| + - c.with_body do + %p + Votre jeton API Entreprise est expiré. + Merci de le renouveler. + - elsif procedure.api_entreprise_token_expired_or_expires_soon? + = render Dsfr::AlertComponent.new(state: :warning, size: :sm, extra_class_names: 'fr-mb-2w') do |c| + - c.with_body do + %p + Votre jeton API Entreprise expirera le + = procedure.api_entreprise_token_expires_at.strftime('%d/%m/%Y à %H:%M.') + Merci de le renouveler avant cette date. diff --git a/app/views/administrateurs/procedures/_champs_summary.html.haml b/app/views/administrateurs/procedures/_champs_summary.html.haml deleted file mode 100644 index 2db332926..000000000 --- a/app/views/administrateurs/procedures/_champs_summary.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -#summary{ class: @procedure.header_sections.present? ? 'fr-col-12 fr-col-md-3' : '' } - - if @procedure.header_sections.present? - %nav.fr-sidemenu.sticky.fr-hidden.fr-unhidden-md{ "aria-labelledby" => "fr-summary-title", role: "navigation" } - %ul.fr-sidemenu__list - - @procedure.header_sections.each do |header| - %li.fr-sidemenu__item - - level = header.type_de_champ.header_section_level_value.to_i - - if level == 1 - %a.fr-sidemenu__link{ href: "##{dom_id(header, :type_de_champ_editor)}" }= header.libelle - - else - %a.fr-sidemenu__link{ class: level >= 3 ? 'custom-link-grey': '', href: "##{dom_id(header, :type_de_champ_editor)}" }= "-- #{header.libelle}" diff --git a/app/views/administrateurs/procedures/_detail.html.haml b/app/views/administrateurs/procedures/_detail.html.haml index b2e89c4b7..99b8b1410 100644 --- a/app/views/administrateurs/procedures/_detail.html.haml +++ b/app/views/administrateurs/procedures/_detail.html.haml @@ -5,15 +5,23 @@ - params = show_detail ? {} : { show_detail: true } = button_to detail_admin_procedure_path(procedure["id"]), method: :post, params:, title:, class: [icon, "fr-icon--sm fr-mr-1w fr-mb-1w fr-text-action-high--blue-france fr-btn fr-btn--tertiary-no-outline" ] do = title - %td - if procedure.template - %p.fr-badge.fr-badge--info.fr-badge--sm= "Modèle DS" - %br + %p.fr-badge.fr-badge--info.fr-badge--sm= "Modèle" + %abbr{ title: APPLICATION_NAME }= acronymize(APPLICATION_NAME) = procedure.libelle %td= procedure.id %td= procedure.estimated_dossiers_count - %td= procedure.administrateurs.count + %td + - if procedure.respond_to?(:parsed_latest_zone_labels) + - procedure.parsed_latest_zone_labels.uniq.each do |zone_label| + %span.mb-2= zone_label + .mb-2 + - else + - procedure.zones.uniq.each do |zone| + %span= zone.current_label + .mb-1 + %td= t procedure.aasm_state, scope: 'activerecord.attributes.procedure.aasm_state' %td= l(procedure.published_at, format: :message_date_without_time) if procedure.published_at %td @@ -21,16 +29,10 @@ = link_to('Cloner', admin_procedure_clone_path(procedure.id, from_new_from_existing: true), 'data-method' => :put, class: 'fr-btn fr-btn--tertiary fr-btn--sm') - - - if show_detail %tr.procedure{ id: "procedure_detail_#{procedure.id}" } - %td.fr-highlight--beige-gris-galet{ colspan: '8' } + %td.fr-highlight--green-emeraude{ colspan: '8' } .fr-container - .fr-grid-row - .fr-col-6 - - procedure.zones.uniq.each do |zone| - = zone.label_at(procedure.published_or_created_at) - .fr-col-6 - - procedure.administrateurs.uniq.each do |admin| - = admin.email + .fr-col-6 + - procedure.administrateurs.uniq.each do |admin| + = admin.email diff --git a/app/views/administrateurs/procedures/_informations.html.haml b/app/views/administrateurs/procedures/_informations.html.haml index 4fc3224b8..3fbb1b39e 100644 --- a/app/views/administrateurs/procedures/_informations.html.haml +++ b/app/views/administrateurs/procedures/_informations.html.haml @@ -16,7 +16,7 @@ .fr-fieldset__element .fr-input-group - = f.label :logo, 'Ajouter un logo de la démarche', class: 'fr-label' + = f.label :logo, 'Ajouter un logo de la démarche', class: 'fr-label', for: dom_id(@procedure, :logo) = render Attachment::EditComponent.new(attached_file: @procedure.logo, view_as: :link) .fr-fieldset__element .fr-input-group @@ -55,7 +55,7 @@ .fr-fieldset__element .fr-input-group - = f.label :deliberation, 'Cadre juridique - texte à importer', class: 'fr-label' + = f.label :deliberation, 'Cadre juridique - texte à importer', class: 'fr-label', for: dom_id(@procedure, :deliberation) = render Attachment::EditComponent.new(attached_file: @procedure.deliberation, view_as: :download) %fieldset.fr-fieldset @@ -80,7 +80,7 @@ .fr-fieldset__element .fr-input-group - = f.label :notice, 'Notice explicative de la démarche', class: 'fr-label' + = f.label :notice, 'Notice explicative de la démarche', class: 'fr-label', for: dom_id(@procedure, :notice) %p.fr-hint-text Une notice explicative est un document que vous avez élaboré, destiné à guider l’usager dans sa démarche. Le bouton pour télécharger cette notice apparaît en haut du formulaire pour l’usager. %br @@ -101,7 +101,7 @@ Ma démarche s’adresse à un particulier %span.fr-hint-text En choisissant cette option, l’usager devra renseigner son nom et prénom avant d’accéder au formulaire .fr-radio-rich__img - %svg.fr-artwork{ aria_hidden: "true", viewBox: "0 0 80 80", width: "80px", height: "80px" } + %svg.fr-artwork{ "aria-hidden": "true", viewBox: "0 0 80 80", width: "80px", height: "80px" } %use.fr-artwork-decorative{ href: image_path("pictograms/digital/avatar.svg#artwork-decorative") } %use.fr-artwork-minor{ href: image_path("pictograms/digital/avatar.svg#artwork-minor") } %use.fr-artwork-major{ href: image_path("pictograms/digital/avatar.svg#artwork-major") } @@ -114,25 +114,26 @@ %span.fr-hint-text En choisissant cette option, l’usager devra renseigner son n° SIRET.
    Grâce à l’API Entreprise, les informations sur la personne morale (raison sociale, adresse du siège, etc.) seront automatiquement renseignées. .fr-radio-rich__img - %svg.fr-artwork{ aria_hidden: "true", viewBox: "0 0 80 80", width: "80px", height: "80px" } + %svg.fr-artwork{ "aria-hidden": "true", viewBox: "0 0 80 80", width: "80px", height: "80px" } %use.fr-artwork-decorative{ href: image_path("pictograms/buildings/school.svg#artwork-decorative") } %use.fr-artwork-minor{ href: image_path("pictograms/buildings/school.svg#artwork-minor") } %use.fr-artwork-major{ href: image_path("pictograms/buildings/school.svg#artwork-major") } .fr-fieldset__element - = f.label :tags, 'Associez les tags à la démarche', class: 'fr-label' - %p.fr-hint-text Les tags sont des mots ou des expressions que vous attribuez aux démarches pour décrire leur contenu et pour les retrouver. Les tags sont partagés avec la communauté, ce qui vous permet de voir les tags attribués aux démarches créées par les autres administrateurs. - = hidden_field_tag 'procedure[tags]', JSON.generate(@procedure.tags) - = react_component("ComboMultiple", - id: "procedure_tags_combo", - options: Procedure.tags, - selected: @procedure.tags, - disabled: [], - label: 'Tags', - group: '.procedure_tags_combo', - name: 'tags', - describedby: 'procedure-tags', - acceptNewValues: true) + = f.label :tags, 'Associez des thématiques à la démarche', class: 'fr-label' + %p.fr-hint-text + Par des mots ou des expressions que vous attribuez aux démarches pour décrire leur contenu et pour les retrouver. + Les thèmes sont partagées avec la communauté, ce qui vous permet de voir les thèmes attribués aux démarches créées par les autres administrateurs. + %react-fragment + = render ReactComponent.new "ComboBox/MultiComboBox", + id: "procedure_tags_combo", + items: ProcedureTag.order(:name).pluck(:name), + selected_keys: @procedure.procedure_tags.pluck(:name), + name: 'procedure[procedure_tag_names][]', + value_separator: ',|;', + allows_custom_value: false, + 'aria-label': 'Tags', + 'aria-describedby': 'procedure-tags' %details.procedure-form__options-details %summary.procedure-form__options-summary diff --git a/app/views/administrateurs/procedures/_monavis.html.haml b/app/views/administrateurs/procedures/_monavis.html.haml index 499f22d0e..62ac9d711 100644 --- a/app/views/administrateurs/procedures/_monavis.html.haml +++ b/app/views/administrateurs/procedures/_monavis.html.haml @@ -14,5 +14,5 @@ Une fois en possession du code généré sur le site MonAvis, vous pouvez le coller dans le champ ci-dessous : .fr-input-group - = f.label :monavis_embed, "Mon avis", class: "fr-label" + = f.label :monavis_embed, "Code généré sur le site MonAvis", class: "fr-label" = f.text_area :monavis_embed, rows: '6', placeholder: '
    Je donne mon avis', class: 'fr-input' diff --git a/app/views/administrateurs/procedures/_procedures_list.html.haml b/app/views/administrateurs/procedures/_procedures_list.html.haml index ccd0e4afc..99eecd912 100644 --- a/app/views/administrateurs/procedures/_procedures_list.html.haml +++ b/app/views/administrateurs/procedures/_procedures_list.html.haml @@ -1,4 +1,4 @@ -.fr-h6 +%h2.fr-h6 = page_entries_info procedures - procedures.each do |procedure| @@ -10,7 +10,7 @@ = image_tag procedure.logo, alt: procedure.libelle, class: 'logo' %div - .card-title + %h3.card-title = link_to procedure.libelle, admin_procedure_path(procedure) = link_to commencer_url(procedure.path), commencer_url(procedure.path), class: 'fr-link fr-mb-1w' @@ -45,20 +45,24 @@ %div = dsfr_icon('fr-icon-team-fill') - if procedure.routing_enabled? - %span.fr-badge= procedure.groupe_instructeurs.count + %span.fr-badge= procedure.groupe_instructeurs_count - else - %span.fr-badge= procedure.instructeurs.count + %span.fr-badge= procedure.instructeurs_count = dsfr_icon('fr-icon-file-text-fill fr-ml-1w') %span.fr-badge= procedure.dossiers.state_not_brouillon.visible_by_administration.count .text-right %p.fr-mb-0.width-max-content N° #{number_with_html_delimiter(procedure.id)} + - if procedure.close? || procedure.depubliee? %span.fr-badge.fr-badge--sm.fr-badge--warning = t('closed', scope: [:layouts, :breadcrumb]) - elsif procedure.publiee? + - if procedure.api_entreprise_token_expired_or_expires_soon? + %span.fr-badge.fr-badge--sm.fr-badge--error + = t('to_modify', scope: [:layouts, :breadcrumb]) %span.fr-badge.fr-badge--sm.fr-badge--success = t('published', scope: [:layouts, :breadcrumb]) @@ -98,7 +102,7 @@ .dropdown-description %h4= t('administrateurs.dropdown_actions.to_close') - - if procedure.can_be_deleted_by_administrateur? && !procedure.discarded? + - if procedure.can_be_deleted_by_administrateur? && !procedure.discarded? && !procedure.publiee? - menu.with_item do = link_to admin_procedure_path(procedure), role: 'menuitem', method: :delete, data: { confirm: "Voulez-vous vraiment supprimer la démarche ? \nToute suppression est définitive et s'appliquera aux éventuels autres administrateurs de cette démarche !" } do = dsfr_icon('fr-icon-delete-line') diff --git a/app/views/administrateurs/procedures/_publication_form.html.haml b/app/views/administrateurs/procedures/_publication_form.html.haml index 94ff52d42..284206699 100644 --- a/app/views/administrateurs/procedures/_publication_form.html.haml +++ b/app/views/administrateurs/procedures/_publication_form.html.haml @@ -2,13 +2,13 @@ url: admin_procedure_publish_path(procedure_id: procedure.id), method: :put, html: { class: 'form' } do |f| - = render Procedure::PublicationWarningComponent.new(procedure: procedure) + = render Procedure::ErrorsSummary.new(procedure: @procedure, validation_context: :publication) .mt-2 - if procedure.draft_changed? %p.mb-2= t('.draft_changed_procedure_alert') = render Dsfr::AlertComponent.new(state: :info, size: :sm, extra_class_names: 'fr-mb-2w') do |c| - c.with_body do - = render Procedure::RevisionChangesComponent.new changes: procedure.revision_changes, previous_revision: procedure.published_revision + = render Procedure::RevisionChangesComponent.new new_revision: procedure.draft_revision, previous_revision: procedure.published_revision - if procedure.close? = render partial: 'publication_form_inputs', locals: { procedure: procedure, closed_procedures: @closed_procedures, form: f } - elsif @procedure.brouillon? && @procedure.missing_steps.empty? @@ -16,7 +16,6 @@ - c.with_body do %p = t('.faq_test_alert') - = link_to t('.faq_test_alert_link'), t('.faq_test_alert_link_url'), **external_link_attributes = render partial: 'publication_form_inputs', locals: { procedure: procedure, closed_procedures: @closed_procedures, form: f } = render Dsfr::CalloutComponent.new(title: t('.dpd_title'), heading_level: 'h2') do |c| - c.with_body do diff --git a/app/views/administrateurs/procedures/_unpublished_changes_sticky_header.html.haml b/app/views/administrateurs/procedures/_unpublished_changes_sticky_header.html.haml new file mode 100644 index 000000000..af2ec32b4 --- /dev/null +++ b/app/views/administrateurs/procedures/_unpublished_changes_sticky_header.html.haml @@ -0,0 +1,10 @@ +.sticky-header.sticky-header-warning + .fr-container + %p.flex.justify-between.align-center.fr-text-default--warning + %span + = dsfr_icon("fr-icon-warning-fill fr-mr-1v") + = t('.intro_html').html_safe + %span.no-wrap + = link_to t('.see_changes'), admin_procedure_path(procedure), class: 'fr-btn fr-btn--secondary fr-mr-2w' + = link_to_if procedure.draft_revision.valid? && procedure.valid?(:publication), t('.publish_changes'), admin_procedure_publish_revision_path(procedure), class: 'fr-btn', method: :put, data: { disable_with: "Publication...", confirm: 'Êtes-vous sûr de vouloir publier les modifications ?' } do + %button.fr-btn{ disabled: "disabled" }= t('.publish_changes') diff --git a/app/views/administrateurs/procedures/accuse_lecture.html.haml b/app/views/administrateurs/procedures/accuse_lecture.html.haml index 13e998dec..90be126cc 100644 --- a/app/views/administrateurs/procedures/accuse_lecture.html.haml +++ b/app/views/administrateurs/procedures/accuse_lecture.html.haml @@ -6,7 +6,7 @@ .fr-container .fr-grid-row .fr-col-12.fr-col-offset-md-2.fr-col-md-8 - %h1.page-title + %h1.fr-h2 Accusé de lecture = render Dsfr::CalloutComponent.new(title: nil) do |c| @@ -29,15 +29,8 @@ = render Dsfr::ToggleComponent.new(form: f, target: :accuse_lecture, - title: "Accusé de lecture de la démarche", + title: "Accusé de lecture de la décision par l’usager", hint: "L’accusé de lecture est à activer uniquement pour les démarches avec voies de recours car il complexifie l’accès à la décision finale pour les usagers", opt: {"checked" => @procedure.accuse_lecture}) -.padded-fixed-footer - .fixed-footer - .fr-container - .fr-grid-row - .fr-col-12.fr-col-offset-md-2.fr-col-md-8 - %ul.fr-btns-group.fr-btns-group--inline-md - %li - = link_to 'Enregistrer et revenir à la page de suivi', admin_procedure_path(id: @procedure), class: 'fr-btn' += render Procedure::FixedFooterComponent.new(procedure: @procedure, extra_class_names: 'fr-col-offset-md-2 fr-col-md-8' ) diff --git a/app/views/administrateurs/procedures/all.html.haml b/app/views/administrateurs/procedures/all.html.haml index 43069b757..224e59fc6 100644 --- a/app/views/administrateurs/procedures/all.html.haml +++ b/app/views/administrateurs/procedures/all.html.haml @@ -57,8 +57,8 @@ %th{ scope: 'col' } %th{ scope: 'col' } Démarche %th{ scope: 'col' } № - %th{ scope: 'col' } Dossiers - %th{ scope: 'col' } Administrateurs + %th{ scope: 'col' } Nombre de dossiers + %th{ scope: 'col' } Zones %th{ scope: 'col' } Statut %th{ scope: 'col' } Date %th{ scope: 'col' } Action diff --git a/app/views/administrateurs/procedures/annotations.html.haml b/app/views/administrateurs/procedures/annotations.html.haml index 05d198cf6..a8eb33b08 100644 --- a/app/views/administrateurs/procedures/annotations.html.haml +++ b/app/views/administrateurs/procedures/annotations.html.haml @@ -1,21 +1,26 @@ = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_back_path(@procedure)], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], - ['Configuration des annotations privées']], preview: true } + ['Annotations privées']], preview: true } .fr-container - %h1 Configuration des annotations privées + .flex.justify-between.align-center.fr-mb-3w + %h1.fr-h2 Annotations privées + - if @procedure.revised? + = link_to "Voir l'historique des modifications des annotations", modifications_admin_procedure_path(@procedure), class: 'fr-link' + = render NestedForms::FormOwnerComponent.new .fr-grid-row + = render TypesDeChampEditor::HeaderSectionsSummaryComponent.new(procedure: @procedure, is_private: true) .fr-col = render TypesDeChampEditor::EditorComponent.new(revision: @procedure.draft_revision, is_annotation: true) .padded-fixed-footer .fixed-footer .fr-container - %ul.fr-btns-group.fr-btns-group--inline-md + %ul.fr-btns-group.fr-btns-group--inline-md.fr-ml-0 %li - = link_to t('continue_annotations', scope: [:layouts, :breadcrumb]), admin_procedure_path(@procedure), title: t('continue_annotations', scope: [:layouts, :breadcrumb]), class: 'fr-btn' + = link_to "Revenir à l’écran de gestion", admin_procedure_path(@procedure), title: t('continue_annotations', scope: [:layouts, :breadcrumb]), class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w fr-mr-2w' - if @procedure.draft_revision.revision_types_de_champ_private.count > 0 %li - = link_to t('preview_annotations', scope: [:layouts, :breadcrumb]), apercu_admin_procedure_path(@procedure, params: {tab: 'annotations-privees'}), target: "_blank", rel: "noopener", class: 'fr-btn fr-btn--secondary' + = link_to t('preview_annotations', scope: [:layouts, :breadcrumb]), apercu_admin_procedure_path(@procedure, params: {tab: 'annotations-privees'}), target: "_blank", rel: "noopener", class: 'fr-link fr-mb-2w' diff --git a/app/views/administrateurs/procedures/champs.html.haml b/app/views/administrateurs/procedures/champs.html.haml index d927556a1..7fa005e67 100644 --- a/app/views/administrateurs/procedures/champs.html.haml +++ b/app/views/administrateurs/procedures/champs.html.haml @@ -1,26 +1,35 @@ + +- if @procedure.draft_changed? + - content_for(:sticky_header) do + = render partial: 'administrateurs/procedures/unpublished_changes_sticky_header', locals: { procedure: @procedure } = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_back_path(@procedure)], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], - ['Configuration des champs']], preview: @procedure.draft_revision.valid? } + ['Champs du formulaire']], preview: @procedure.draft_revision.valid? } .fr-container - %h1 Configuration des champs + .flex.justify-between.align-center.fr-mb-3w + %h1.fr-h2 Champs du formulaire + - if @procedure.revised? + = link_to "Voir l'historique des modifications du formulaire", modifications_admin_procedure_path(@procedure), class: 'fr-link' + = render NestedForms::FormOwnerComponent.new .fr-grid-row - = render partial: 'champs_summary' + = render TypesDeChampEditor::HeaderSectionsSummaryComponent.new(procedure: @procedure, is_private: false) .fr-col - = render TypesDeChampEditor::EditorComponent.new(revision: @procedure.draft_revision) + = render TypesDeChampEditor::EditorComponent.new(revision: @procedure.draft_revision, is_annotation: false) .padded-fixed-footer .fixed-footer .fr-container .flex - %ul.fr-btns-group.fr-btns-group--inline-md + %ul.fr-btns-group.fr-btns-group--inline-md.fr-ml-0 %li - = link_to t('continue', scope: [:layouts, :breadcrumb]), admin_procedure_path(@procedure), title: t('continue_title', scope: [:layouts, :breadcrumb]), class: 'fr-btn' + = link_to admin_procedure_path(id: @procedure), class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w fr-mr-2w' do + Revenir à l’écran de gestion - if @procedure.draft_revision.revision_types_de_champ_public.count > 0 %li - = link_to t('preview', scope: [:layouts, :breadcrumb]), apercu_admin_procedure_path(@procedure), target: "_blank", rel: "noopener", class: 'fr-btn fr-btn--secondary' + = link_to t('preview', scope: [:layouts, :breadcrumb]), apercu_admin_procedure_path(@procedure), target: "_blank", rel: "noopener", class: 'fr-link fr-mb-2w' .fr-ml-auto #autosave-notice.hidden = render TypesDeChampEditor::EstimatedFillDurationComponent.new(revision: @procedure.draft_revision, is_annotation: false) diff --git a/app/views/administrateurs/procedures/edit.html.haml b/app/views/administrateurs/procedures/edit.html.haml index 137932e3a..95164e8e6 100644 --- a/app/views/administrateurs/procedures/edit.html.haml +++ b/app/views/administrateurs/procedures/edit.html.haml @@ -3,7 +3,7 @@ = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_back_path(@procedure)], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], - ['Description']] } + ['Présentation']] } = render NestedForms::FormOwnerComponent.new = form_for @procedure, @@ -12,17 +12,8 @@ .fr-container .fr-grid-row .fr-col-12.fr-col-offset-md-2.fr-col-md-8 - %h1.fr-h2 Description + %h1.fr-h2 Présentation = render partial: 'administrateurs/procedures/informations', locals: { f: f } - .padded-fixed-footer - .fixed-footer - .fr-container - .fr-grid-row - .fr-col-12.fr-col-offset-md-2.fr-col-md-8 - %ul.fr-btns-group.fr-btns-group--inline-md - %li - = f.button 'Enregistrer', class: 'fr-btn' - %li - = link_to 'Annuler', admin_procedure_path(id: @procedure), class: 'fr-btn fr-btn--secondary', data: { confirm: 'Êtes-vous sûr de vouloir annuler les modifications effectuées ?'} + = render Procedure::FixedFooterComponent.new(procedure: @procedure, form: f, extra_class_names: 'fr-col-offset-md-2 fr-col-md-8') diff --git a/app/views/administrateurs/procedures/index.html.haml b/app/views/administrateurs/procedures/index.html.haml index 60d2441da..ba2311111 100644 --- a/app/views/administrateurs/procedures/index.html.haml +++ b/app/views/administrateurs/procedures/index.html.haml @@ -1,12 +1,14 @@ .sub-header - .procedure-admin-listing-container - = link_to "Nouvelle Démarche", new_from_existing_admin_procedures_path, id: 'new-procedure', class: 'fr-btn' + .flex.fr-container + %h1.fr-h3 Mes démarches + .procedure-admin-listing-container.fr-mt-1v + = link_to "Nouvelle Démarche", new_from_existing_admin_procedures_path, id: 'new-procedure', class: 'fr-btn' .fr-container %nav.fr-tabs{ role: 'navigation', 'aria-label': t('views.users.dossiers.secondary_menu') } %ul.fr-tabs__list{ role: 'tablist' } = tab_item(t('pluralize.published', count: @procedures_publiees_count), admin_procedures_path(statut: 'publiees'), active: @statut == 'publiees', badge: number_with_html_delimiter(@procedures_publiees_count)) - = tab_item('En test', admin_procedures_path(statut: 'brouillons'), active: @statut == 'brouillons', badge: number_with_html_delimiter(@procedures_draft_count)) + = tab_item('en test', admin_procedures_path(statut: 'brouillons'), active: @statut == 'brouillons', badge: number_with_html_delimiter(@procedures_draft_count)) = tab_item(t('pluralize.closed', count: @procedures_closed_count), admin_procedures_path(statut: 'archivees'), active: @statut == 'archivees', badge: number_with_html_delimiter(@procedures_closed_count)) = tab_item(t('pluralize.deleted', count: @procedures_deleted_count), admin_procedures_path(statut: 'supprimees'), active: @statut === 'supprimees', badge: number_with_html_delimiter(@procedures_deleted_count)) diff --git a/app/views/administrateurs/procedures/jeton.html.haml b/app/views/administrateurs/procedures/jeton.html.haml index 0062ac47b..8172136d5 100644 --- a/app/views/administrateurs/procedures/jeton.html.haml +++ b/app/views/administrateurs/procedures/jeton.html.haml @@ -1,26 +1,29 @@ = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_back_path(@procedure)], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], - ['Jeton']] } + ['Jeton API Entreprise']] } -.container - %h1.page-title - Configurer le jeton API Entreprise +.fr-container + %h1.fr-h2 Jeton API Entreprise -.container - %h1 - = form_with model: @procedure, url: url_for({ controller: 'administrateurs/procedures', action: :update_jeton }) do |f| += form_with model: @procedure, url: url_for({ controller: 'administrateurs/procedures', action: :update_jeton }) do |f| + .fr-container = render Dsfr::AlertComponent.new(state: :info, size: :sm, extra_class_names: 'fr-mb-2w') do |c| - c.with_body do %p Démarches Simplifiées utilise = link_to 'API Entreprise', "https://entreprise.api.gouv.fr/" qui permet de récupérer les informations administratives des entreprises et des associations. - Si votre démarche nécessite des autorisations spécifiques que Démarches Simplifiées n’a pas par défaut, merci de renseigner ici le jeton - = link_to 'API Entreprise', "https://api.gouv.fr/les-api/api-entreprise/demande-acces" + Si votre démarche nécessite des autorisations spécifiques que Démarches Simplifiées n’a pas par défaut, merci de renseigner ci-dessous + %strong le jeton API Entreprise propre à votre démarche. + %p + Si besoin, vous pouvez demander une habilitation API Entreprise en cliquant sur le lien suivant : + = link_to "https://api.gouv.fr/les-api/api-entreprise/demande-acces.", "https://api.gouv.fr/les-api/api-entreprise/demande-acces" - .fr-input-group - = f.label :api_entreprise_token, "Jeton", class: 'fr-label' - = f.password_field :api_entreprise_token, value: @procedure.read_attribute(:api_entreprise_token), class: 'fr-input' - = f.button 'Enregistrer', class: 'fr-btn' + + = render partial: 'administrateurs/procedures/api_entreprise_token_expiration_alert', locals: { procedure: @procedure } + + = render Dsfr::InputComponent.new(form: f, attribute: :api_entreprise_token, input_type: :password_field, required: false, opts: { value: @procedure.read_attribute(:api_entreprise_token)}) + + = render Procedure::FixedFooterComponent.new(procedure: @procedure, form: f) diff --git a/app/views/administrateurs/procedures/modifications.html.haml b/app/views/administrateurs/procedures/modifications.html.haml index a3775ada7..73b8673bd 100644 --- a/app/views/administrateurs/procedures/modifications.html.haml +++ b/app/views/administrateurs/procedures/modifications.html.haml @@ -1,16 +1,18 @@ = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_back_path(@procedure)], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], - ['Modifications']] } -.container - %h1.page-title + ['Champs du formulaire', champs_admin_procedure_path(@procedure)], + ['Historique des modifications du formulaire']] } +.fr-container + .fr-mb-3w + = link_to "Champs du formulaire", champs_admin_procedure_path(@procedure), class: "fr-link fr-icon-arrow-left-line fr-link--icon-left" + %h1.fr-h2 Historique des modifications du formulaire -.container +.fr-container - previous_revision = nil - @procedure.revisions.each do |revision| - if previous_revision.present? && !revision.draft? - - changes = previous_revision.compare(revision) - dossiers = revision.dossiers.visible_by_administration - dossiers_en_construction_count = dossiers.state_en_construction.count - dossiers_en_instruction_count = dossiers.state_en_instruction.count @@ -28,5 +30,7 @@ %p= t('.dossiers_en_construction', count: dossiers_en_construction_count) - elsif !dossiers_en_instruction_count.zero? %p= t('.dossiers_en_instruction', count: dossiers_en_instruction_count) - = render Procedure::RevisionChangesComponent.new changes:, previous_revision: + = render Procedure::RevisionChangesComponent.new new_revision: revision, previous_revision: - previous_revision = revision + += render Procedure::FixedFooterComponent.new(procedure: @procedure) diff --git a/app/views/administrateurs/procedures/monavis.html.haml b/app/views/administrateurs/procedures/monavis.html.haml index 2cbc3cce3..ee5e98fe9 100644 --- a/app/views/administrateurs/procedures/monavis.html.haml +++ b/app/views/administrateurs/procedures/monavis.html.haml @@ -3,12 +3,12 @@ [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], ['MonAvis']] } -.container - %h1.page-title - Insérer un lien vers « MonAvis » +.fr-container + %h1.fr-h2 + Bouton « MonAvis » -.container - %h1 - = form_for @procedure, url: url_for({ controller: 'administrateurs/procedures', action: :update_monavis }), html: { class: 'form', multipart: true } do |f| += form_for @procedure, url: url_for({ controller: 'administrateurs/procedures', action: :update_monavis }), html: { class: 'form', multipart: true } do |f| + .fr-container = render partial: 'monavis', locals: { f: f } - = f.button 'Enregistrer', class: 'fr-btn' + + = render Procedure::FixedFooterComponent.new(procedure: @procedure, form: f) diff --git a/app/views/administrateurs/procedures/new_from_existing.html.haml b/app/views/administrateurs/procedures/new_from_existing.html.haml index 76ccd4178..228004b1d 100644 --- a/app/views/administrateurs/procedures/new_from_existing.html.haml +++ b/app/views/administrateurs/procedures/new_from_existing.html.haml @@ -1,35 +1,34 @@ .container - if current_administrateur.procedures.brouillons.count == 0 - .card.feedback - .card-title - Bienvenue, - %br - vous allez pouvoir créer une première démarche de test. - Celle-ci sera visible uniquement par vous et ne sera publiée nulle part, alors pas de crainte à avoir. - %br - %br - Besoin d’aide ? - %br - > Vous pouvez - = link_to "visionner cette vidéo", - "https://vimeo.com/261478872", - target: "_blank" - %br - > Vous pouvez lire notre - = link_to "documentation en ligne", - ADMINISTRATEUR_TUTORIAL_URL, - target: "_blank" - - %br - > Vous pouvez enfin - = link_to "prendre un rendez-vous téléphonique avec nous", - CALENDLY_URL, - target: "_blank" - - :javascript - document.addEventListener("DOMContentLoaded", function() { - $crisp.push(["do", "trigger:run", ["admin-signup"]]); - }); + = render Dsfr::CalloutComponent.new(title: nil, icon: "fr-icon-information-line", extra_class_names: 'fr-my-4w') do |c| + - c.with_html_body do + %p + Bienvenue, + %br + vous allez pouvoir créer une première démarche de test. + Celle-ci sera visible uniquement par vous et ne sera publiée nulle part, alors pas de crainte à avoir. + %br + %br + Besoin d’aide ? + %br + > Vous pouvez + = link_to "visionner cette vidéo", + "https://vimeo.com/261478872", + target: "_blank" + %br + > Vous pouvez lire notre + = link_to "documentation en ligne", + ADMINISTRATEUR_TUTORIAL_URL, + target: "_blank" + %br + > Vous pouvez enfin + = link_to "prendre un rendez-vous téléphonique avec nous", + CALENDLY_URL, + target: "_blank" + :javascript + document.addEventListener("DOMContentLoaded", function() { + $crisp.push(["do", "trigger:run", ["admin-signup"]]); + }); .form diff --git a/app/views/administrateurs/procedures/publication.html.haml b/app/views/administrateurs/procedures/publication.html.haml index 95513ddbf..9248dc2b6 100644 --- a/app/views/administrateurs/procedures/publication.html.haml +++ b/app/views/administrateurs/procedures/publication.html.haml @@ -46,6 +46,8 @@ %li= link_to("des instructeurs", admin_procedure_groupe_instructeur_path(@procedure, @procedure.defaut_groupe_instructeur)) - if @procedure.service.nil? %li= link_to("un service", admin_services_path(procedure_id: @procedure)) + - if @procedure.service_siret_test? + %li= link_to("un service avec un SIRET valide", admin_services_path(procedure_id: @procedure)) = link_to t('.back_to_procedure'), admin_procedure_path(id: @procedure), class: 'fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-arrow-go-back-line fr-mt-2w' - else diff --git a/app/views/administrateurs/procedures/show.html.haml b/app/views/administrateurs/procedures/show.html.haml index 16ef212bf..877f4ef98 100644 --- a/app/views/administrateurs/procedures/show.html.haml +++ b/app/views/administrateurs/procedures/show.html.haml @@ -5,7 +5,7 @@ .fr-container.procedure-admin-container %ul.fr-btns-group.fr-btns-group--inline-sm.fr-btns-group--icon-left - - if @procedure.draft_revision.valid? + - if @procedure.validate(:publication) - if !@procedure.brouillon? = link_to 'Télécharger', admin_procedure_archives_path(@procedure), class: 'fr-btn fr-btn--tertiary fr-btn--icon-left fr-icon-download-line', id: "archive-procedure" @@ -27,15 +27,20 @@ = link_to 'Clore', admin_procedure_close_path(procedure_id: @procedure.id), class: 'fr-btn fr-btn--tertiary fr-btn--icon-left fr-icon-calendar-close-fill', id: "close-procedure-link" .fr-container - = render TypesDeChampEditor::ErrorsSummary.new(revision: @procedure.draft_revision) + - if @procedure.api_entreprise_token_expired_or_expires_soon? + = render Dsfr::AlertComponent.new(state: :error, title: t(:technical_issues, scope: [:administrateurs, :procedures]), extra_class_names: 'fr-mb-2w') do |c| + - c.with_body do + %ul.fr-mb-0 + %li + Le + = link_to "Jeton API Entreprise", jeton_admin_procedure_path(@procedure), class: 'error-anchor' + est expiré ou va expirer prochainement -- if @procedure.draft_changed? - .fr-container + - if @procedure.draft_changed? = render Dsfr::CalloutComponent.new(title: t(:has_changes, scope: [:administrateurs, :revision_changes]), icon: "fr-fi-information-line") do |c| - c.with_body do - = render Procedure::RevisionChangesComponent.new changes: @procedure.revision_changes, previous_revision: @procedure.published_revision - - = render Procedure::PublicationWarningComponent.new(procedure: @procedure) + = render Procedure::ErrorsSummary.new(procedure: @procedure, validation_context: :publication) + = render Procedure::RevisionChangesComponent.new new_revision: @procedure.draft_revision, previous_revision: @procedure.published_revision - c.with_bottom do %ul.fr-mt-2w.fr-btns-group.fr-btns-group--inline @@ -44,6 +49,9 @@ - else %li= button_to 'Publier les modifications', admin_procedure_publication_path(@procedure), class: 'fr-btn', id: 'publish-procedure-link', data: { disable_with: "Publication..." }, disabled: !@procedure.draft_revision.valid? || @procedure.errors.present?, method: :get %li= button_to "Réinitialiser les modifications", admin_procedure_reset_draft_path(@procedure), class: 'fr-btn fr-btn--secondary fr-mr-2w', data: { confirm: 'Êtes-vous sûr de vouloir réinitialiser les modifications ?' }, method: :put + - else + = render Procedure::ErrorsSummary.new(procedure: @procedure, validation_context: :publication) + - if !@procedure.procedure_expires_when_termine_enabled? = render partial: 'administrateurs/procedures/suggest_expires_when_termine', locals: { procedure: @procedure } @@ -66,18 +74,18 @@ = "Un email a été envoyé pour informer les usagers le #{ l(@procedure.closed_at.to_date) }" .fr-container - %h2= "Gestion de la démarche № #{@procedure.id}" + %h2= "Gestion de la démarche № #{number_with_html_delimiter(@procedure.id)}" %h3.fr-h6 Indispensable avant publication .fr-grid-row.fr-grid-row--gutters.fr-mb-5w = render Procedure::Card::PresentationComponent.new(procedure: @procedure) = render Procedure::Card::ZonesComponent.new(procedure: @procedure) if Rails.application.config.ds_zonage_enabled = render Procedure::Card::ChampsComponent.new(procedure: @procedure) + = render Procedure::Card::IneligibiliteDossierComponent.new(procedure: @procedure) = render Procedure::Card::ServiceComponent.new(procedure: @procedure, administrateur: current_administrateur) = render Procedure::Card::AdministrateursComponent.new(procedure: @procedure) = render Procedure::Card::InstructeursComponent.new(procedure: @procedure) - = render Procedure::Card::ModificationsComponent.new(procedure: @procedure) - %h3.fr-h6 Pour aller plus loin + %h3.fr-h6 Autres paramètres .fr-grid-row.fr-grid-row--gutters.fr-mb-5w = render Procedure::Card::AttestationComponent.new(procedure: @procedure) = render Procedure::Card::ExpertsComponent.new(procedure: @procedure) @@ -85,8 +93,9 @@ = render Procedure::Card::AnnotationsComponent.new(procedure: @procedure) = render Procedure::Card::APIEntrepriseComponent.new(procedure: @procedure) = render Procedure::Card::APIParticulierComponent.new(procedure: @procedure) - = render Procedure::Card::SVASVRComponent.new(procedure: @procedure) if @procedure.sva_svr_enabled? || @procedure.feature_enabled?(:sva) + = render Procedure::Card::SVASVRComponent.new(procedure: @procedure) = render Procedure::Card::MonAvisComponent.new(procedure: @procedure) = render Procedure::Card::DossierSubmittedMessageComponent.new(procedure: @procedure) = render Procedure::Card::ChorusComponent.new(procedure: @procedure) = render Procedure::Card::AccuseLectureComponent.new(procedure: @procedure) + = render Procedure::Card::LabelsComponent.new(procedure: @procedure) diff --git a/app/views/administrateurs/procedures/zones.html.haml b/app/views/administrateurs/procedures/zones.html.haml index e8b6e91eb..f30e5c737 100644 --- a/app/views/administrateurs/procedures/zones.html.haml +++ b/app/views/administrateurs/procedures/zones.html.haml @@ -4,12 +4,12 @@ locals: { steps: [['Démarches', admin_procedures_back_path(@procedure)], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], ['Description']] } -.container - = form_for @procedure, - url: url_for({ controller: 'administrateurs/procedures', action: :update, id: @procedure.id }), - html: { multipart: true } do |f| += form_for @procedure, + url: url_for({ controller: 'administrateurs/procedures', action: :update, id: @procedure.id }), + html: { multipart: true } do |f| - %h1.page-title Zones + .fr-container + %h1.fr-h2 Zones - if Rails.application.config.ds_zonage_enabled %fieldset.fr-fieldset{ aria: { labelledby: "zones-legend"} } @@ -25,7 +25,4 @@ = b.check_box = b.label class: "fr-label" - .procedure-form__actions.sticky--bottom - .actions-right - = link_to 'Annuler', admin_procedure_path(id: @procedure), class: 'fr-btn fr-btn--tertiary fr-mr-2w', data: { confirm: 'Êtes-vous sûr de vouloir annuler les modifications effectuées ?'} - = f.button 'Enregistrer', class: 'fr-btn fr-btn--primary' + = render Procedure::FixedFooterComponent.new(procedure: @procedure, form: f) diff --git a/app/views/administrateurs/services/_form.html.haml b/app/views/administrateurs/services/_form.html.haml index c3afc1522..8a463a3d7 100644 --- a/app/views/administrateurs/services/_form.html.haml +++ b/app/views/administrateurs/services/_form.html.haml @@ -1,4 +1,26 @@ -= form_with model: [ :admin, service], local: true do |f| += form_with model: [:admin, service], id: "service_form" do |f| + + = render Dsfr::InputComponent.new(form: f, attribute: :siret, input_type: :text_field, + opts: { placeholder: "14 chiffres, sans espace", + onblur: token_list("Turbo.visit('#{prefill_admin_services_path(procedure_id: procedure.id)}?siret=' + this.value)" => service.new_record?) }) do |c| + - if service.etablissement_infos.blank? && local_assigns[:prefilled].nil? + - c.with_hint do + = "Indiquez le numéro de SIRET de l’organisme dont ce service dépend. Rechercher le SIRET sur " + = link_to("annuaire-entreprises.data.gouv.fr", annuaire_link, **external_link_attributes) + - if service.new_record? + %br + = "Nous préremplirons les informations de contact à partir de l’Annuaire Service Public correspondant." + + .fr-mb-2w + - if local_assigns[:prefilled] == :success + %p.fr-info-text Génial ! La plupart des informations du service ont été préremplies ci-dessous. Vérifiez-les et complétez-les le cas échéant. + - elsif local_assigns[:prefilled] == :partial + %p.fr-info-text + Nous avons prérempli certaines informations correspondant à ce SIRET. Complétez les autres manuellement. + - elsif local_assigns[:prefilled] == :failure + %p.fr-error-text + Une erreur a empêché le préremplissage des informations. + Vérifiez que le numéro de SIRET est correct et complétez les informations manuellement le cas échéant. = render Dsfr::InputComponent.new(form: f, attribute: :nom, input_type: :text_field) @@ -7,13 +29,9 @@ .fr-input-group = f.label :type_organisme, class: "fr-label" do Type d’organisme + = render EditableChamp::AsteriskMandatoryComponent.new - = f.select :type_organisme, Service.type_organismes.keys.map { |key| [ I18n.t("type_organisme.#{key}"), key] }, {}, class: 'fr-select' - - = render Dsfr::InputComponent.new(form: f, attribute: :siret, input_type: :text_field, opts: { placeholder: "14 chiffres, sans espace" }) do |c| - - c.with_hint do - = "Indiquez le numéro de SIRET de l’organisme dont ce service dépend. Rechercher le SIRET sur " - = link_to("annuaire-entreprises.data.gouv.fr", annuaire_link, **external_link_attributes) + = f.select :type_organisme, Service.type_organismes.keys.map { |key| [ I18n.t("type_organisme.#{key}"), key] }, { include_blank: true }, { class: "fr-select" , required: true } = render Dsfr::CalloutComponent.new(title: "Informations de contact") do |c| - c.with_body do @@ -31,14 +49,6 @@ = render Dsfr::InputComponent.new(form: f, attribute: :horaires, input_type: :text_area) = render Dsfr::InputComponent.new(form: f, attribute: :adresse, input_type: :text_area) - - if procedure_id.present? - = hidden_field_tag :procedure_id, procedure_id - - .padded-fixed-footer - .fixed-footer - .fr-container - %ul.fr-btns-group.fr-btns-group--inline-md - %li - = f.submit "Enregistrer", class: "fr-btn" - %li - = link_to "Annuler et revenir à la page de suivi", admin_procedure_path(id: @procedure.id), class: "fr-btn fr-btn--secondary" + - if local_assigns[:procedure].present? + = hidden_field_tag :procedure_id, procedure.id + = render Procedure::FixedFooterComponent.new(procedure: procedure, form: f) diff --git a/app/views/administrateurs/services/edit.html.haml b/app/views/administrateurs/services/edit.html.haml index 186294bfc..3cd11f6fc 100644 --- a/app/views/administrateurs/services/edit.html.haml +++ b/app/views/administrateurs/services/edit.html.haml @@ -5,21 +5,22 @@ ['Modifier le service']] } -.container +.fr-container + .flex.justify-between.align-center.fr-mb-3w + = link_to "Liste de tous les services", admin_services_path(procedure_id: @procedure.id), class: "fr-link fr-icon-arrow-left-line fr-link--icon-left" + = link_to "+ Nouveau service", new_admin_service_path(procedure_id: @procedure.id), class: "fr-btn" + + %h1.fr-h2 + Modifier le service + - other_services = @service.procedures.reject {|procedure| procedure.id == @procedure.id } - if other_services.count > 1 - = render Dsfr::AlertComponent.new(state: :warning, title: "Modifier ce service impactera la ou les démarches qui sont rattachée/s") do |c| + = render Dsfr::AlertComponent.new(state: :warning, title: "Modifier ce service impactera la ou les démarches qui sont rattachée/s", extra_class_names: 'fr-mb-3w') do |c| - c.with_body do %ul - other_services.each do |proc| %li= "#{proc.libelle} (N° #{proc.id})" %p.mt-3 Si vous souhaitez modifier uniquement les informations pour ce service, créez un nouveau service puis associez-le à la démarche - %p.mt-3 - = link_to "+ Nouveau service", new_admin_service_path(procedure_id: @procedure.id), class: "fr-btn" - - - %h1.mt-2 Modifier le service - = render partial: 'form', - locals: { service: @service, procedure_id: @procedure.id } + locals: { service: @service, procedure: @procedure } diff --git a/app/views/administrateurs/services/index.html.haml b/app/views/administrateurs/services/index.html.haml index b0fb75517..b0532b69b 100644 --- a/app/views/administrateurs/services/index.html.haml +++ b/app/views/administrateurs/services/index.html.haml @@ -1,34 +1,44 @@ = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_path], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], - ['Choix du service']] } + ['Service']] } -#services-index.container - %h1.fr-h1 Liste des Services - %h2.fr-h4 La démarche “#{@procedure.libelle}” peut être affectée aux services dans la liste ci-dessous +#services-index.fr-container + %h1.fr-h2 Service - %table.fr-table.width-100.mt-3 - %thead - %tr - %th{ scope: "col" } - Nom - %th.change{ scope: "col" } - = link_to "Nouveau service", new_admin_service_path(procedure_id: @procedure.id), class: "fr-btn fr-btn--secondary" + = link_to "Nouveau service", new_admin_service_path(procedure_id: @procedure.id), class: "fr-btn fr-btn--primary fr-btn--icon-left fr-icon-add-circle-line mb-3" - %tbody - - @services.each do |service| + .fr-table.fr-table--layout-fixed + %table + %caption Liste des services pouvant être affectés à la démarche + %thead %tr - %td - = service.nom - %td.change - - if @procedure.service == service - %strong.mr-2 (Assigné) - - else - = button_to "Assigner", add_to_procedure_admin_services_path(procedure: { id: @procedure.id, service_id: service.id, }), method: :patch, class: 'link mr-2', form_class: 'inline' - = link_to('Modifier', edit_admin_service_path(service, procedure_id: @procedure.id), class: 'link my-2') - - if @procedure.service != service - = link_to 'Supprimer', - admin_service_path(service, procedure_id: @procedure.id), - method: :delete, - data: { confirm: "Confirmez vous la suppression de #{service.nom}" }, - class: 'btn btn-link ml-2' + %th{ scope: "col" } + Nom + %th.fr-col-4{ scope: "col" } + Actions + + %tbody + - @services.each do |service| + %tr + %td + = service.nom + %td.fr-col-4 + .fr-container.flex.px-0 + .fr-col-4.fr-col--middle + - if @procedure.service == service + %p.fr-badge.fr-badge--success.fr-badge--sm + ASSIGNÉ + - else + = button_to "Assigner", add_to_procedure_admin_services_path(procedure: { id: @procedure.id, service_id: service.id, }), method: :patch, class: 'fr-btn fr-btn--sm fr-btn--secondary fr-btn--icon-left fr-icon-checkbox-circle-line' + .fr-col-4 + = link_to('Modifier', edit_admin_service_path(service, procedure_id: @procedure.id), class: 'fr-btn fr-btn--sm fr-btn--secondary fr-btn--icon-left fr-icon-pencil-line') + .fr-col-4 + = button_to 'Supprimer', + admin_service_path(service, procedure_id: @procedure.id), + method: :delete, + data: { confirm: "Confirmez vous la suppression de #{service.nom}" }, + class: 'fr-btn fr-btn--sm fr-btn--secondary fr-btn--icon-left fr-icon-delete-line', + disabled: (@procedure.service == service) + += render Procedure::FixedFooterComponent.new(procedure: @procedure) diff --git a/app/views/administrateurs/services/new.html.haml b/app/views/administrateurs/services/new.html.haml index 691b864e7..7477ad9b2 100644 --- a/app/views/administrateurs/services/new.html.haml +++ b/app/views/administrateurs/services/new.html.haml @@ -8,4 +8,4 @@ %h1 Nouveau Service = render partial: 'form', - locals: { service: @service, procedure_id: @procedure.id } + locals: { service: @service, procedure: @procedure, prefilled: @prefilled } diff --git a/app/views/administrateurs/sva_svr/edit.html.haml b/app/views/administrateurs/sva_svr/edit.html.haml index 715938671..2c026b42b 100644 --- a/app/views/administrateurs/sva_svr/edit.html.haml +++ b/app/views/administrateurs/sva_svr/edit.html.haml @@ -1,10 +1,10 @@ = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_path], ["#{@procedure.libelle.truncate_words(10)}", admin_procedure_path(@procedure)], - ["Configuration SVA/SVR"]] } + ["Silence Vaut Accord ou Rejet"]] } .fr-container.fr-my-5w - %h1.fr-h1 Règle du Silence Vaut Accord ou Silence Vaut Rejet + %h1.fr-h2 Silence Vaut Accord ou Rejet = render Dsfr::CalloutComponent.new(title: "Fonctionnement du SVA/SVR") do |c| - c.with_body do @@ -40,4 +40,4 @@ = link_to("Liste des démarches encadrées par ce principe", "https://www.service-public.fr/demarches-silence-vaut-accord", class: "fr-link", title: new_tab_suffix("Rechercher les démarches avec SVA sur service-public.fr"), **external_link_attributes) - = render Procedure::SVASVRFormComponent.new(procedure: @procedure, configuration: @configuration) += render Procedure::SVASVRFormComponent.new(procedure: @procedure, configuration: @configuration) diff --git a/app/views/administrateurs/types_de_champ/_insert.turbo_stream.haml b/app/views/administrateurs/types_de_champ/_insert.turbo_stream.haml index 9421b594a..7a246c79a 100644 --- a/app/views/administrateurs/types_de_champ/_insert.turbo_stream.haml +++ b/app/views/administrateurs/types_de_champ/_insert.turbo_stream.haml @@ -10,15 +10,15 @@ locals: { steps: [['Démarches', admin_procedures_path], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], ['Configuration des champs']], - preview: @procedure.draft_revision.valid? }) + preview: @procedure.validate(@coordinate&.private? ? :types_de_champ_private_editor : :types_de_champ_public_editor) }) -= turbo_stream.replace 'errors-summary', render(TypesDeChampEditor::ErrorsSummary.new(revision: @procedure.draft_revision)) += turbo_stream.replace 'errors-summary', render(Procedure::ErrorsSummary.new(procedure: @procedure, validation_context: @coordinate&.private? ? :types_de_champ_private_editor : :types_de_champ_public_editor)) -= turbo_stream.replace 'summary', render(partial: 'administrateurs/procedures/champs_summary') += turbo_stream.replace 'summary', render(TypesDeChampEditor::HeaderSectionsSummaryComponent.new(procedure: @procedure, is_private: @coordinate&.private?)) - unless flash.alert = turbo_stream.show 'autosave-notice' - = turbo_stream.replace 'autosave-notice', render(partial: 'administrateurs/autosave_notice') + = turbo_stream.replace 'autosave-notice', render(AutosaveNoticeComponent.new(success: true, label_scope: :form)) = turbo_stream.hide 'autosave-notice', delay: 30000 - if @destroyed.present? @@ -49,3 +49,10 @@ = turbo_stream.hide dom_id(@created.coordinate.parent, :type_de_champ_add_button) - elsif @destroyed&.child? && @destroyed.parent.empty? = turbo_stream.show dom_id(@destroyed.parent, :type_de_champ_add_button) + +- if @procedure.draft_changed? + = turbo_stream.update "sticky-header" do + = render partial: "administrateurs/procedures/unpublished_changes_sticky_header", locals: { procedure: @procedure } + +- else + = turbo_stream.update "sticky-header", "" diff --git a/app/views/agent_connect/agent/explanation_2fa.html.haml b/app/views/agent_connect/agent/explanation_2fa.html.haml new file mode 100644 index 000000000..ab52b99f1 --- /dev/null +++ b/app/views/agent_connect/agent/explanation_2fa.html.haml @@ -0,0 +1,14 @@ +.fr-container + %h1.fr-h2.fr-mt-4w Une validation en 2 étapes est désormais nécessaire. + + %p.fr-mb-2w + La sécurité de votre compte augmente. Nous vous demandons à présent une validation en 2 étapes pour vous connecter. + + %p.fr-mb-2w + Vous allez devoir configurer votre mode d'authentification sur le site MonComptePro : + + %img{ src: image_url("instructions_moncomptepro.png"), alt: "MonComptePro", loading: 'lazy' } + + + %button.fr-btn.fr-btn--primary.fr-mb-2w + = link_to "Configurer mon appli d'authentification sur MonComptePro", ENV['MON_COMPTE_PRO_2FA_NOT_CONFIGURED_URL'] diff --git a/app/views/agent_connect/agent/index.html.haml b/app/views/agent_connect/agent/index.html.haml index 375ca47ce..d83c40f8c 100644 --- a/app/views/agent_connect/agent/index.html.haml +++ b/app/views/agent_connect/agent/index.html.haml @@ -38,7 +38,7 @@ = t('views.users.sessions.new.for_tiers_alert') .fr-fieldset__element - %p.fr-text--sm= t('utils.mandatory_champs') + %p.fr-text--sm= t('utils.asterisk_html') .fr-fieldset__element = render Dsfr::InputComponent.new(form: f, attribute: :email, input_type: :email_field, opts: { autocomplete: 'email' }) do |c| @@ -55,18 +55,16 @@ = f.check_box :remember_me = f.label :remember_me, t('views.users.sessions.new.remember_me'), class: 'remember-me' - %ul.fr-btns-group - %li= f.submit t('views.users.sessions.new.connection'), class: "fr-btn" + .fr-btns-group= f.submit t('views.users.sessions.new.connection'), class: "fr-btn" %hr %h2.fr-h6= t('.you_are_a_citizen') - %ul.fr-btns-group - %li= link_to t('.citizen_page'), new_user_session_path, class: "fr-btn fr-btn--secondary width-100" + .fr-btns-group= link_to t('.citizen_page'), new_user_session_path, class: "fr-btn fr-btn--secondary" .fr-col-lg.fr-p-6w = render Dsfr::CalloutComponent.new(title: t('.full_deploy_title'), icon: 'fr-icon-information-line') do |c| - c.with_body do = t('.full_deploy_body') %h2.fr-h6= t('.whats_ds', application_name: Current.application_name) - = image_tag "landing/hero/dematerialiser.svg", class: "paperless-logo", alt: "" + = image_tag "landing/hero/dematerialiser.svg", class: "fr-responsive-img", alt: "" diff --git a/app/views/agent_connect/agent/relogin_after_2fa_config.html.haml b/app/views/agent_connect/agent/relogin_after_2fa_config.html.haml new file mode 100644 index 000000000..898f57497 --- /dev/null +++ b/app/views/agent_connect/agent/relogin_after_2fa_config.html.haml @@ -0,0 +1,12 @@ +.fr-container + %h1.fr-h2.fr-mt-4w Poursuivez votre connexion à #{APPLICATION_NAME} + + = render Dsfr::AlertComponent.new(state: :success, extra_class_names: 'fr-mb-4w') do |c| + - c.with_body do + %p Votre application d'authentification a bien été configurée. + + %p.fr-mb-4w + Vous allez maintenant pouvoir vous connecter à nouveau à #{APPLICATION_NAME} en effectuant la validation en 2 étapes avec votre application d'authentification. + + %button.fr-btn.fr-btn--primary.fr-mb-2w + = link_to "Se connecter à #{APPLICATION_NAME} avec #{AgentConnect}", agent_connect_login_path diff --git a/app/views/application/_general_footer_row.html.haml b/app/views/application/_general_footer_row.html.haml index e1fe2d44f..f51f258c2 100644 --- a/app/views/application/_general_footer_row.html.haml +++ b/app/views/application/_general_footer_row.html.haml @@ -8,11 +8,11 @@ %li.fr-footer__bottom-item = link_to t("links.footer.vote_feature.label"), FEATURE_UPVOTE_URL, title: t("links.footer.vote_feature.title"), class: "fr-footer__bottom-link", target: "_blank", rel: "noopener noreferrer" %li.fr-footer__bottom-item - = link_to t("links.footer.accessibilite.label"), ACCESSIBILITE_URL, title: t("links.footer.accessibilite.title"), class: "fr-footer__bottom-link", rel: "noopener noreferrer" + = link_to t("links.footer.accessibilite.label"), ACCESSIBILITE_URL, class: "fr-footer__bottom-link", rel: "noopener noreferrer" %li.fr-footer__bottom-item - = link_to t("links.footer.mentions_legales.label"), MENTIONS_LEGALES_URL, title: t("links.footer.mentions_legales.title"), class: "fr-footer__bottom-link", rel: "noopener noreferrer" + = link_to t("links.footer.mentions_legales.label"), MENTIONS_LEGALES_URL, class: "fr-footer__bottom-link", rel: "noopener noreferrer" %li.fr-footer__bottom-item - = link_to t("links.footer.cookies.label"), suivi_path, title: t("links.footer.cookies.title"), class: "fr-footer__bottom-link" + = link_to t("links.footer.cookies.label"), suivi_path, class: "fr-footer__bottom-link", hreflang: "fr" %li.fr-footer__bottom-item %button.fr-footer__bottom-link.fr-icon-theme-fill.fr-btn--icon-left{ aria: {controls: "fr-theme-modal" }, data: {'fr-opened': "false" } } = t('links.footer.display_params') diff --git a/app/views/attachments/destroy.turbo_stream.haml b/app/views/attachments/destroy.turbo_stream.haml index 48cee0bc3..f17dcaabe 100644 --- a/app/views/attachments/destroy.turbo_stream.haml +++ b/app/views/attachments/destroy.turbo_stream.haml @@ -1,7 +1,9 @@ -= turbo_stream.remove dom_id(@attachment, :persisted_row) - -- if @champ_id - = turbo_stream.show "attachment-multiple-empty-#{@champ_id}" - = turbo_stream.focus_all "#attachment-multiple-empty-#{@champ_id} input" - -= turbo_stream.show_all ".attachment-input-#{@attachment.id}" +- if @champ + = fields_for @champ.input_name, @champ do |form| + = turbo_stream.replace @champ.input_group_id do + = render EditableChamp::EditableChampComponent.new champ: @champ, form: form + = turbo_stream.focus_all "#attachment-multiple-empty-#{@champ.public_id} input" +- else + = turbo_stream.replace dom_id(@attachment, :edit) do + = render Attachment::EditComponent.new(**@attachment_options) + = turbo_stream.focus_all "##{dom_id(@attachment.record, @attachment.name)}" diff --git a/app/views/avis_mailer/avis_invitation.html.haml b/app/views/avis_mailer/avis_invitation.html.haml index e4bb0db96..262029b0d 100644 --- a/app/views/avis_mailer/avis_invitation.html.haml +++ b/app/views/avis_mailer/avis_invitation.html.haml @@ -22,11 +22,7 @@ %p{ style: "padding: 8px; color: #333333; background-color: #EEEEEE; font-size: 14px;" } = @avis.introduction -- if @avis.expert.user.active?.present? - %p - = round_button("Donner votre avis", @url, :primary) -- else - %p - = round_button("Inscrivez-vous pour donner votre avis", @url, :primary) +%p + = round_button("Donner votre avis", @url, :primary) = render partial: "layouts/mailers/signature" diff --git a/app/views/avis_mailer/avis_invitation_and_confirm_email.html.haml b/app/views/avis_mailer/avis_invitation_and_confirm_email.html.haml new file mode 100644 index 000000000..aab81b459 --- /dev/null +++ b/app/views/avis_mailer/avis_invitation_and_confirm_email.html.haml @@ -0,0 +1,33 @@ +- content_for(:title, 'Invitation à donner votre avis') + +- content_for(:footer) do + Merci de ne pas répondre à cet email. Donnez votre avis + = link_to("sur #{Current.application_name}", @url) + ou + = succeed '.' do + = mail_to(@avis.claimant.email, "contactez la personne qui vous a invité") + +%p + Bonjour, + +%p + Vous avez été invité par + %strong= @avis.claimant.email + = "à donner votre avis sur le dossier nº #{@avis.dossier.id} de la démarche :" + %strong= @avis.procedure.libelle + +%p + = "#{@avis.claimant.email} vous a écrit :" + %br +%p{ style: "padding: 8px; color: #333333; background-color: #EEEEEE; font-size: 14px;" } + = @avis.introduction + +- if @avis.expert.user.active? + %p + = round_button('Confirmez votre adresse email pour donner votre avis', users_confirm_email_url(token: @token), :primary) +- else + %p + = round_button("Inscrivez-vous pour donner votre avis", @url, :primary) + + += render partial: "layouts/mailers/signature" diff --git a/app/views/carte/show.html.erb b/app/views/carte/show.html.erb index 04d1aed9b..4847c96d2 100644 --- a/app/views/carte/show.html.erb +++ b/app/views/carte/show.html.erb @@ -249,7 +249,7 @@ <% end %>
    <%= map_form.label :year, class: 'fr-label' %> - <%= map_form.select(:year, (2018..Date.current.year).to_a.reverse, { include_blank: t(:from_beginning, scope: 'activemodel.attributes.map_filter') }, { class: "fr-select" }) %> + <%= map_form.select(:year, MapFilter::YEARS_INTERVAL.to_a.reverse, { include_blank: t(:from_beginning, scope: 'activemodel.attributes.map_filter') }, { class: "fr-select" }) %>
    <%= map_form.submit(name: nil, class: 'hidden', data: { autosubmit_target: 'submitter' } ) %> <% end %> diff --git a/app/views/champs/repetition/remove.turbo_stream.haml b/app/views/champs/repetition/remove.turbo_stream.haml index 5fb0fe0be..d7be234a4 100644 --- a/app/views/champs/repetition/remove.turbo_stream.haml +++ b/app/views/champs/repetition/remove.turbo_stream.haml @@ -1,6 +1,2 @@ -= turbo_stream.remove "safe-row-selector-#{@row_id}" - -- if @champ.rows.size > 0 && @champ.rows.last&.first&.present? - = turbo_stream.focus @champ.rows.last&.first.focusable_input_id -- else - = turbo_stream.focus dom_id(@champ, :create_repetition) += turbo_stream.remove @to_remove += turbo_stream.focus @to_focus diff --git a/app/views/commencer/show.html.haml b/app/views/commencer/show.html.haml index 55b7a35c9..3c5619bfb 100644 --- a/app/views/commencer/show.html.haml +++ b/app/views/commencer/show.html.haml @@ -3,8 +3,8 @@ .commencer.form - if !user_signed_in? = render Dsfr::CalloutComponent.new(title: t(".start_procedure"), heading_level: 'h2') do |c| - - c.with_body do - = render partial: 'shared/france_connect_login', locals: { url: commencer_france_connect_path(path: @procedure.path, prefill_token: @prefilled_dossier&.prefill_token) } + - c.with_html_body do + = render partial: 'shared/france_connect_login', locals: { url: commencer_france_connect_path(path: @procedure.path, prefill_token: @prefilled_dossier&.prefill_token), heading_level: :h3 } %ul.fr-btns-group.fr-btns-group--inline %li = link_to commencer_sign_up_path(path: @procedure.path, prefill_token: @prefilled_dossier&.prefill_token), class: 'fr-btn' do @@ -13,7 +13,11 @@ #{Current.application_name} %li= link_to t('views.shared.account.already_user'), commencer_sign_in_path(path: @procedure.path, prefill_token: @prefilled_dossier&.prefill_token), class: 'fr-btn fr-btn--secondary' + = render ProcedureDraftWarningComponent.new(revision: @revision, current_administrateur:, extra_class_names: "fr-mb-2w") + - else + = render ProcedureDraftWarningComponent.new(revision: @revision, current_administrateur:, extra_class_names: "fr-mb-2w") + - if @prefilled_dossier = render Dsfr::CalloutComponent.new(title: t(".prefilled_draft"), heading_level: 'h2') do |c| - c.with_body do diff --git a/app/views/contact/_form.html.haml b/app/views/contact/_form.html.haml new file mode 100644 index 000000000..c284df566 --- /dev/null +++ b/app/views/contact/_form.html.haml @@ -0,0 +1,60 @@ += form_for form, url: contact_path, method: :post, multipart: true, class: 'fr-form-group', data: {controller: :contact } do |f| + %p.fr-hint-text= t('asterisk_html', scope: [:utils]) + + - if form.require_email? + = render Dsfr::InputComponent.new(form: f, attribute: :email, input_type: :email_field, opts: { autocomplete: 'email' }) do |c| + - c.with_label { ContactForm.human_attribute_name(form.for_admin? ? :email_pro : :email) } + + %fieldset.fr-fieldset{ name: "question_type" } + %legend.fr-fieldset__legend.fr-fieldset__legend--regular + = t('.your_question') + = render EditableChamp::AsteriskMandatoryComponent.new + .fr-fieldset__content + - form.options.each do |(question, question_type, link)| + .fr-radio-group + = f.radio_button :question_type, question_type, required: true, data: {"contact-target": "inputRadio" }, checked: question_type == form.question_type + = f.label "question_type_#{question_type}", { 'aria-controls': link ? "card-#{question_type}" : nil, class: 'fr-label' } do + = question + + - if link.present? + .fr-ml-3w{ id: "card-#{question_type}", + class: class_names('hidden' => question_type != form.question_type), + "aria-hidden": question_type != form.question_type, + data: { "contact-target": "content" } } + = render Dsfr::CalloutComponent.new(title: t('.our_answer')) do |c| + - c.with_html_body do + -# i18n-tasks-use t("contact.index.#{question_type}.answer_html") + = t('answer_html', scope: [:contact, :index, question_type], base_url: Current.application_base_url, "link_#{question_type}": link) + + + - if form.for_admin? + = render Dsfr::InputComponent.new(form: f, attribute: :phone, required: false) + - else + = render Dsfr::InputComponent.new(form: f, attribute: :dossier_id, required: false) + + = render Dsfr::InputComponent.new(form: f, attribute: :subject) + + = render Dsfr::InputComponent.new(form: f, attribute: :text, input_type: :text_area, opts: { rows: 6 }) + + - if !form.for_admin? + .fr-upload-group + = f.label :piece_jointe, class: 'fr-label' do + = t('pj', scope: [:utils]) + %span.fr-hint-text + = t('.notice_upload_group') + + %p.notice.hidden{ data: { 'contact-type-only': ContactForm::TYPE_AMELIORATION } } + = t('.notice_pj_product') + %p.notice.hidden{ data: { 'contact-type-only': ContactForm::TYPE_AUTRE } } + = t('.notice_pj_other') + = f.file_field :piece_jointe, class: 'fr-upload', accept: '.jpg, .jpeg, .png, .pdf' + + - f.object.tags.each_with_index do |tag, index| + = f.hidden_field :tags, name: f.field_name(:tags, multiple: true), id: f.field_id(:tag, index), value: tag + + = f.hidden_field :for_admin + + = invisible_captcha + + .fr-input-group.fr-my-3w + = f.submit t('send_mail', scope: [:utils]), type: :submit, class: 'fr-btn', data: { disable: true } diff --git a/app/views/contact/admin.html.haml b/app/views/contact/admin.html.haml new file mode 100644 index 000000000..1771256fc --- /dev/null +++ b/app/views/contact/admin.html.haml @@ -0,0 +1,12 @@ +- content_for(:title, t('.contact_team')) +- content_for :footer do + = render partial: "root/footer" + +#contact-form + .fr-container + %h1 + = t('.contact_team') + + .fr-highlight= t('.admin_intro_html', contact_path: contact_path) + + = render partial: "form", object: @form diff --git a/app/views/contact/index.html.haml b/app/views/contact/index.html.haml new file mode 100644 index 000000000..0a33c87f1 --- /dev/null +++ b/app/views/contact/index.html.haml @@ -0,0 +1,12 @@ +- content_for(:title, t('.contact')) +- content_for :footer do + = render partial: "root/footer" + +#contact-form + .fr-container + %h1 + = t('.contact') + + .fr-highlight= t('.intro_html') + + = render partial: "form", object: @form diff --git a/app/views/devise/_password_rules.html.haml b/app/views/devise/_password_rules.html.haml deleted file mode 100644 index 2d4083d2f..000000000 --- a/app/views/devise/_password_rules.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -.fr-messages-group{ "aria-live" => "off", id: id } - %p.fr-message= t('views.registrations.new.password_message') - %p.fr-message.fr-message--info= t('views.registrations.new.password_placeholder', min_length: PASSWORD_MIN_LENGTH) diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml index 008f56ce7..4227eaea8 100644 --- a/app/views/devise/passwords/edit.html.haml +++ b/app/views/devise/passwords/edit.html.haml @@ -12,22 +12,21 @@ = f.hidden_field :reset_password_token - %fieldset.fr-mb-0.fr-fieldset{ aria: { labelledby: 'edit-password-legend' } } - %legend.fr-fieldset__legend#edit-password-legend + %fieldset.fr-mb-0.fr-fieldset + %legend.fr-fieldset__legend %h1.fr-h2= I18n.t('views.users.passwords.edit.subtitle') + .fr-fieldset__element + %p.fr-text--sm= t('utils.asterisk_html') + .fr-fieldset__element = render Dsfr::InputComponent.new(form: f, attribute: :password, input_type: :password_field, - opts: { autofocus: 'true', autocomplete: 'new-password', minlength: PASSWORD_MIN_LENGTH, data: { controller: populated_resource.validate_password_complexity? ? 'turbo-input' : false, turbo_input_url_value: show_password_complexity_path }}) do |c| - - c.with_describedby do - - if populated_resource.validate_password_complexity? - %div{ id: c.describedby_id } - #password_complexity - = render PasswordComplexityComponent.new - - else - = render partial: "devise/password_rules", locals: { id: c.describedby_id } + opts: { autofocus: 'true', autocomplete: 'new-password', data: { controller: 'turbo-input', turbo_input_url_value: show_password_complexity_path }, aria: {describedby: 'password_hint'}}) + + #password_complexity + = render PasswordComplexityComponent.new .fr-fieldset__element = render Dsfr::InputComponent.new(form: f, attribute: :password_confirmation, input_type: :password_field, opts: { autocomplete: 'new-password' }) - = f.submit t('views.users.passwords.edit.submit'), id: 'submit-password', class: "fr-btn fr-btn--lg fr-mt-2w", data: { disable_with: t('views.users.passwords.edit.submit_loading') } + = f.submit t('views.users.passwords.edit.submit'), id: 'submit-password', disabled: :disabled, class: "fr-btn fr-btn--lg fr-mt-2w", data: { disable_with: t('views.users.passwords.edit.submit_loading') } diff --git a/app/views/devise/passwords/new.html.haml b/app/views/devise/passwords/new.html.haml index e2f68f417..795162cec 100644 --- a/app/views/devise/passwords/new.html.haml +++ b/app/views/devise/passwords/new.html.haml @@ -9,8 +9,8 @@ = devise_error_messages! = form_for(resource, as: resource_name, url: password_path(resource_name)) do |f| - %fieldset.fr-mb-0.fr-fieldset{ aria: { labelledby: 'new-password-legend' } } - %legend.fr-fieldset__legend#new-password-legend + %fieldset.fr-mb-0.fr-fieldset + %legend.fr-fieldset__legend %h1.fr-h2= t('devise.passwords.new.forgot_your_password') .fr-fieldset__element @@ -19,4 +19,4 @@ .fr-fieldset__element = render Dsfr::InputComponent.new(form: f, attribute: :email, input_type: :email_field, opts: { autocomplete: 'email', autofocus: true }) - = f.submit t('devise.passwords.new.request_new_password'), class: 'fr-btn fr-btn--lg fr-mt-4w' + = f.submit t('devise.passwords.new.request_new_password'), class: 'fr-btn fr-btn--lg fr-mt-4w', 'data-email-input-target': 'next' diff --git a/app/views/dossier_mailer/notify_automatic_deletion_to_administration.html.haml b/app/views/dossier_mailer/notify_automatic_deletion_to_administration.html.haml index 7d2cb3e91..f0a18eb0c 100644 --- a/app/views/dossier_mailer/notify_automatic_deletion_to_administration.html.haml +++ b/app/views/dossier_mailer/notify_automatic_deletion_to_administration.html.haml @@ -3,9 +3,14 @@ %p= t(:hello, scope: [:views, :shared, :greetings]) %p - = t('.header', count: @deleted_dossiers.size) + = t('.header', count: @hidden_dossiers.size) %ul - - @deleted_dossiers.each do |d| - %li n° #{d.dossier_id} (#{d.procedure.libelle}) + - @hidden_dossiers.each do |d| + %li n° #{d.id} (#{d.procedure.libelle}) + +%p + = t('.footer', count: @hidden_dossiers.size) + = link_to("mes dossiers", dossiers_url) + \. = render partial: "layouts/mailers/signature" diff --git a/app/views/dossier_mailer/notify_automatic_deletion_to_user.html.haml b/app/views/dossier_mailer/notify_automatic_deletion_to_user.html.haml index 8d2079e12..0da270439 100644 --- a/app/views/dossier_mailer/notify_automatic_deletion_to_user.html.haml +++ b/app/views/dossier_mailer/notify_automatic_deletion_to_user.html.haml @@ -3,15 +3,14 @@ %p= t(:hello, scope: [:views, :shared, :greetings]) %p - = t('.header', count: @deleted_dossiers.size) + = t('.header', count: @hidden_dossiers.size) %ul - - @deleted_dossiers.each do |d| - %li N° #{d.dossier_id} (#{d.procedure.libelle}) + - @hidden_dossiers.each do |d| + %li N° #{d.id} (#{d.procedure.libelle}) %p - %strong= t('.account_active', count: @deleted_dossiers.size) - -- if @state == Dossier.states.fetch(:en_construction) - %p= t('.footer_en_construction', count: @deleted_dossiers.size, remaining_weeks_before_expiration: distance_of_time_in_words(Expired::REMAINING_WEEKS_BEFORE_EXPIRATION.weeks)) + = t('.footer', count: @hidden_dossiers.size) + = link_to("mes dossiers", dossiers_url) + \. = render partial: "layouts/mailers/signature" diff --git a/app/views/dossier_mailer/notify_brouillon_near_deletion.html.haml b/app/views/dossier_mailer/notify_brouillon_near_deletion.html.haml index 2b358eab6..1c70807a3 100644 --- a/app/views/dossier_mailer/notify_brouillon_near_deletion.html.haml +++ b/app/views/dossier_mailer/notify_brouillon_near_deletion.html.haml @@ -8,6 +8,8 @@ - @dossiers.each do |d| %li= link_to("n° #{d.id} (#{d.procedure.libelle})", dossier_url(d)) -%p= sanitize(t('.footer', count: @dossiers.size)) +%p + = t('.account_active', count: @dossiers.size) + = render partial: "layouts/mailers/signature" diff --git a/app/views/dossier_mailer/notify_deletion_to_administration.html.haml b/app/views/dossier_mailer/notify_deletion_to_administration.html.haml index 55da5cfe9..af217d4a3 100644 --- a/app/views/dossier_mailer/notify_deletion_to_administration.html.haml +++ b/app/views/dossier_mailer/notify_deletion_to_administration.html.haml @@ -3,6 +3,6 @@ %p= t(:hello, scope: [:views, :shared, :greetings]) %p - = t('.body', dossier_id: @deleted_dossier.dossier_id, procedure: @deleted_dossier.procedure.libelle) + = t('.body', dossier_id: @hidden_dossier.id, procedure: @hidden_dossier.procedure.libelle) = render partial: "layouts/mailers/signature" diff --git a/app/views/dossier_mailer/notify_near_deletion_to_administration.html.haml b/app/views/dossier_mailer/notify_near_deletion_to_administration.html.haml index 1bd80d74b..0ad8ed135 100644 --- a/app/views/dossier_mailer/notify_near_deletion_to_administration.html.haml +++ b/app/views/dossier_mailer/notify_near_deletion_to_administration.html.haml @@ -4,18 +4,17 @@ %p - if @state == Dossier.states.fetch(:en_construction) - = t('.header_en_construction', count: @dossiers.size) + = t('.header_en_construction', count: @dossiers.size, remaining_weeks_before_expiration: distance_of_time_in_words(Expired::REMAINING_WEEKS_BEFORE_EXPIRATION.weeks)) - else - = t('.header_termine', count: @dossiers.size) + = t('.header_termine', count: @dossiers.size, remaining_weeks_before_expiration: distance_of_time_in_words(Expired::REMAINING_WEEKS_BEFORE_EXPIRATION.weeks)) + %ul - @dossiers.each do |d| %li #{link_to("N° #{d.id} (#{d.procedure.libelle})", dossier_url(d))}. Retrouvez le dossier au format #{link_to("PDF", instructeur_dossier_url(d.procedure, d, format: :pdf))} -%p - - if @state == Dossier.states.fetch(:en_construction) - = sanitize(t('.footer_en_construction', count: @dossiers.size, remaining_weeks_before_expiration: distance_of_time_in_words(Expired::REMAINING_WEEKS_BEFORE_EXPIRATION.weeks))) - - else - = sanitize(t('.footer_termine', count: @dossiers.size, remaining_weeks_before_expiration: distance_of_time_in_words(Expired::REMAINING_WEEKS_BEFORE_EXPIRATION.weeks))) +- if @state == Dossier.states.fetch(:en_construction) + %p + = sanitize(t('.footer_en_construction')) = render partial: "layouts/mailers/signature" diff --git a/app/views/dossier_mailer/notify_near_deletion_to_user.html.haml b/app/views/dossier_mailer/notify_near_deletion_to_user.html.haml index be5eeaa32..07fadd71d 100644 --- a/app/views/dossier_mailer/notify_near_deletion_to_user.html.haml +++ b/app/views/dossier_mailer/notify_near_deletion_to_user.html.haml @@ -12,13 +12,15 @@ %li #{link_to("N° #{d.id} (#{d.procedure.libelle})", dossier_url(d))} -%p - %strong= t('.account_active', count: @dossiers.size) - %p - if @state == Dossier.states.fetch(:en_construction) = sanitize(t('.footer_en_construction', count: @dossiers.size, remaining_weeks_before_expiration: distance_of_time_in_words(Expired::REMAINING_WEEKS_BEFORE_EXPIRATION.weeks))) - else = sanitize(t('.footer_termine', count: @dossiers.size, dossiers_url: dossiers_url, remaining_weeks_before_expiration: distance_of_time_in_words(Expired::REMAINING_WEEKS_BEFORE_EXPIRATION.weeks))) + = link_to("mes dossiers", dossiers_url) + \. +%p + = t('.account_active', count: @dossiers.size) + = render partial: "layouts/mailers/signature" diff --git a/app/views/dossier_mailer/notify_transfer.html.haml b/app/views/dossier_mailer/notify_transfer.html.haml index fac5aabd2..6ca8e2998 100644 --- a/app/views/dossier_mailer/notify_transfer.html.haml +++ b/app/views/dossier_mailer/notify_transfer.html.haml @@ -10,7 +10,13 @@ = dossier.procedure.libelle %p - = t('.transfer_text') - = link_to t('.transfer_link'), dossiers_url(statut: 'dossiers-transferes') + - if @user.present? + = t('.transfer_text', app_name: Current.application_name) + %br + = link_to t('.transfer_link'), dossiers_url(statut: 'dossiers-transferes') + - else + = t('.no_user_transfer_text') + %br + = link_to t('.no_user_transfer_link', app_name: Current.application_name), new_user_registration_url = render partial: "layouts/mailers/signature" diff --git a/app/views/dossiers/dossier_vide.pdf.prawn b/app/views/dossiers/dossier_vide.pdf.prawn index 266ae50c6..7ff408453 100644 --- a/app/views/dossiers/dossier_vide.pdf.prawn +++ b/app/views/dossiers/dossier_vide.pdf.prawn @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'prawn/measurement_extensions' # Render text in a box that expands vertically, then move the cursor down to the end of the rendered text @@ -161,7 +163,7 @@ def render_single_champ(pdf, revision, type_de_champ) add_libelle(pdf, type_de_champ) add_optionnal_description(pdf, type_de_champ) add_explanation(pdf, 'Cochez la mention applicable, une seule valeur possible') - type_de_champ.drop_down_list_enabled_non_empty_options.each do |option| + type_de_champ.drop_down_options.each do |option| format_with_checkbox(pdf, option) end pdf.text "\n" @@ -169,7 +171,7 @@ def render_single_champ(pdf, revision, type_de_champ) add_libelle(pdf, type_de_champ) add_optionnal_description(pdf, type_de_champ) add_explanation(pdf, 'Cochez la mention applicable, plusieurs valeurs possibles') - type_de_champ.drop_down_list_enabled_non_empty_options.each do |option| + type_de_champ.drop_down_options.each do |option| format_with_checkbox(pdf, option) end pdf.text "\n" diff --git a/app/views/dossiers/show.pdf.prawn b/app/views/dossiers/show.pdf.prawn index 9cbdcdbb2..3bcea71bf 100644 --- a/app/views/dossiers/show.pdf.prawn +++ b/app/views/dossiers/show.pdf.prawn @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'prawn/measurement_extensions' def default_margin diff --git a/app/views/experts/avis/index.html.haml b/app/views/experts/avis/index.html.haml index ca97bcc37..5dda97742 100644 --- a/app/views/experts/avis/index.html.haml +++ b/app/views/experts/avis/index.html.haml @@ -1,8 +1,10 @@ - content_for(:title, "Avis") -.container - %h1.page-title Avis +.sub-header + .fr-container + %h1.fr-h3 Avis +.fr-container %ul.procedure-list.fr-pl-0 - @avis_by_procedure.each do |p, procedure_avis| %li.flex.align-start.fr-my-3w.fr-p-2w{ id: dom_id(p) } diff --git a/app/views/experts/avis/show.html.haml b/app/views/experts/avis/show.html.haml index 3704bedac..7ae1c1e90 100644 --- a/app/views/experts/avis/show.html.haml +++ b/app/views/experts/avis/show.html.haml @@ -2,4 +2,9 @@ = render partial: 'header', locals: { avis: @avis, dossier: @dossier } -= render partial: 'shared/dossiers/demande', locals: { dossier: @dossier, demande_seen_at: nil, profile: 'expert' } +.fr-container + .fr-grid-row.fr-grid-row--center + - summary = ViewableChamp::HeaderSectionsSummaryComponent.new(dossier: @dossier, is_private: false) + = render summary + %div{ class: class_names("fr-col-12", "fr-col-xl-9" => summary.render?, "fr-col-xl-8" => !summary.render?) } + = render partial: "shared/dossiers/demande", locals: { dossier: @dossier, demande_seen_at: nil, profile: 'expert' } diff --git a/app/views/experts/avis/sign_up.html.haml b/app/views/experts/avis/sign_up.html.haml index c4a8f08fe..72d5da9ea 100644 --- a/app/views/experts/avis/sign_up.html.haml +++ b/app/views/experts/avis/sign_up.html.haml @@ -1,20 +1,22 @@ -.two-columns.avis-sign-up - .columns-container - .column.left - %h2.fr-py-5w.text-center= @dossier.procedure.libelle - %p.dossier Dossier nº #{@dossier.id} - .column +.fr-container.fr-my-5w + .fr-grid-row.fr-grid-row--center + .fr-col-lg-6 = form_for(User.new(email: @email), url: sign_up_expert_avis_path(email: @email), method: :post, html: { class: "fr-py-5w" }) do |f| - %h1.fr-h2= t('views.registrations.new.title', name: Current.application_name) + + %h1.fr-h2 + = t('views.registrations.new.title', name: Current.application_name) %fieldset.fr-mb-0.fr-fieldset{ aria: { labelledby: 'create-account-legend' } } .fr-fieldset__element %p.fr-text--sm= t('utils.mandatory_champs') - .fr-fieldset__element= render Dsfr::InputComponent.new(form: f, attribute: :email, input_type: :email_field, opts: { disabled: true, autocomplete: 'email' }) .fr-fieldset__element - = render Dsfr::InputComponent.new(form: f, attribute: :password, input_type: :password_field, opts: { autocomplete: 'new-password', minlength: PASSWORD_MIN_LENGTH }) do |c| - - c.with_describedby do - = render partial: "devise/password_rules", locals: { id: c.describedby_id } + = render Dsfr::InputComponent.new(form: f, attribute: :email, input_type: :email_field, opts: { disabled: true, autocomplete: 'email' }) - %ul.fr-btns-group - %li= f.submit t('views.shared.account.create'), class: "fr-btn" + .fr-fieldset__element + = render Dsfr::InputComponent.new(form: f, attribute: :password, input_type: :password_field, + opts: { autofocus: 'true', autocomplete: 'new-password', data: { controller: 'turbo-input', turbo_input_url_value: show_password_complexity_path }, aria: {describedby: 'password_hint'}}) + + #password_complexity + = render PasswordComplexityComponent.new + + = f.submit t('views.shared.account.create'), id: 'submit-password', disabled: :disabled, class: "fr-btn fr-btn--lg fr-mt-2w", data: { disable_with: t('views.users.passwords.edit.submit_loading') } diff --git a/app/views/faq/_breadcrumb.html.haml b/app/views/faq/_breadcrumb.html.haml new file mode 100644 index 000000000..205b5effb --- /dev/null +++ b/app/views/faq/_breadcrumb.html.haml @@ -0,0 +1,13 @@ +%nav.fr-breadcrumb{ role: "navigation", 'aria-label': t('you_are_here', scope: [:layouts, :breadcrumb]) } + %button.fr-breadcrumb__button{ 'aria-expanded' => "false", 'aria-controls' => "breadcrumb-1" } + = t('show', scope: [:layouts, :breadcrumb]) + .fr-collapse#breadcrumb-1 + %ol.fr-breadcrumb__list + %li= link_to t('root', scope: [:layouts, :breadcrumb]), root_path, class: 'fr-breadcrumb__link' + + %li + %a.fr-breadcrumb__link{ **(defined?(faq_title) ? { href: faq_index_path } : { "aria-current": "page" }) }= t('faq', scope: [:layouts, :breadcrumb]) + + - if defined?(faq_title) + %li + %a.fr-breadcrumb__link{ 'aria-current' => "page" }= faq_title diff --git a/app/views/faq/_sidebar.html.haml b/app/views/faq/_sidebar.html.haml new file mode 100644 index 000000000..d6055490a --- /dev/null +++ b/app/views/faq/_sidebar.html.haml @@ -0,0 +1,20 @@ +%nav.fr-sidemenu.fr-sidemenu--sticky{ role: "navigation", 'aria-labelledby': "fr-sidemenu-title" } + .fr-sidemenu__inner + %button.fr-sidemenu__btn{ 'aria-controls': "fr-sidemenu-wrapper", 'aria-expanded': "false" } + = t(:sidebar_button, scope: [:faq]) + .fr-collapse#fr-sidemenu-wrapper + .fr-sidemenu__title#fr-sidemenu-title + = t(:name, scope: [:faq, :categories, current[:category]]) + %ul.fr-sidemenu__list + - faqs.each_with_index do |(subcategory, faqs), index| + %li{ class: class_names("fr-sidemenu__item", "fr-sidemenu__item--active" => subcategory == current[:subcategory]) } + %button.fr-sidemenu__btn{ aria: { 'expanded': subcategory == current[:subcategory] ? "true" : "false", + 'controls': "fr-sidemenu-item-#{index}", + 'current' => subcategory == current[:subcategory] ? "true" : nil } } + = t(:name, scope: [:faq, :subcategories, subcategory]) + .fr-collapse{ id: "fr-sidemenu-item-#{index}" } + %ul.fr-sidemenu__list + - faqs.each do |faq| + %li{ class: class_names("fr-sidemenu__item", "fr-sidemenu__item--active" => faq[:slug] == current[:slug]) } + = link_to faq[:title], faq_path(category: faq[:category], slug: faq[:slug]), + class: 'fr-sidemenu__link', target: "_self", "aria-current" => current[:slug] == faq[:slug] ? "page" : nil diff --git a/app/views/faq/index.html.haml b/app/views/faq/index.html.haml new file mode 100644 index 000000000..39ccb8821 --- /dev/null +++ b/app/views/faq/index.html.haml @@ -0,0 +1,27 @@ +- content_for(:title, t('.meta_title')) + +.fr-container.fr-my-4w + = render partial: "breadcrumb" + .fr-grid-row + .fr-col-12.fr-col-md-10 + %h1= t('.title', app_name: Current.application_name) + + - @faqs.each do |category, subcategories| + %h2= t(:name, scope: [:faq, :categories, category], raise: true) # i18n-tasks-use t("faq.categories.#{category}.name") + %p= t(:description, scope: [:faq, :categories, category], raise: true) # i18n-tasks-use t("faq.categories.#{category}.description") + + .fr-accordions-group.fr-mb-6w + - subcategories.each_with_index do |(subcategory, faqs), index| + %section.fr-accordion + %h3.fr-accordion__title + %button.fr-accordion__btn{ 'aria-expanded': "false", 'aria-controls': "accordion-#{category}-#{index}" } + = t(:name, scope: [:faq, :subcategories, subcategory], raise: true) # i18n-tasks-use t("faq.subcategories.#{subcategory}.name") + + .fr-collapse{ id: "accordion-#{category}-#{index}" } + - description = t(:description, scope: [:faq, :subcategories, subcategory], default: nil) # i18n-tasks-use t("faq.subcategories.#{subcategory}.description") + - if description + %p= description + + %ul + - faqs.each do |faq| + %li= link_to faq[:title], faq_path(category: faq[:category], slug: faq[:slug]), class: "fr-link" diff --git a/app/views/faq/show.html.haml b/app/views/faq/show.html.haml new file mode 100644 index 000000000..45cfb50fb --- /dev/null +++ b/app/views/faq/show.html.haml @@ -0,0 +1,13 @@ +- content_for(:title, @metadata[:title]) + +.fr-container.fr-my-4w + + .fr-grid-row + .fr-col-12.fr-col-md-4 + = render partial: "sidebar", locals: { faqs: @siblings, current: @metadata } + .fr-col-12.fr-col-md-8 + -# i18n-tasks-use t("faq.categories.#{@metadata[:category]}.short_name") + = render partial: "breadcrumb", locals: { faq_title: "#{t(:short_name, scope: [:faq, :categories, @metadata[:category]])} : #{@metadata[:title]}" } + + .markdown-content + = @renderer.render(@content).html_safe diff --git a/app/views/fields/jwt_field/_show.html.erb b/app/views/fields/jwt_field/_show.html.erb new file mode 100644 index 000000000..6d9dbc907 --- /dev/null +++ b/app/views/fields/jwt_field/_show.html.erb @@ -0,0 +1 @@ +<%= field.to_s %> diff --git a/app/views/france_connect/particulier/_password_confirmation.html.haml b/app/views/france_connect/particulier/_password_confirmation.html.haml index fd48f3619..6a9ce4bbc 100644 --- a/app/views/france_connect/particulier/_password_confirmation.html.haml +++ b/app/views/france_connect/particulier/_password_confirmation.html.haml @@ -1,16 +1,7 @@ -%p - = t('.already_exists', email: email, application_name: Current.application_name) - %br - = t('.fill_in_password') += form_tag france_connect_particulier_merge_using_password_path, data: { turbo: true }, class: 'mt-2 form fconnect-form', id: 'merge_using_password' do + = hidden_field_tag :merge_token, fci.merge_token, id: dom_id(fci, :fusion_merge_token) + .fr-input-group{ class: class_names('fr-input-group--error': wrong_password) } + = label_tag :password, t('views.registrations.new.password_label', min_length: 8), class: 'fr-label' + = password_field_tag :password, nil, autocomplete: 'current-password', class: 'mb-1 fr-input' -= form_tag france_connect_particulier_merge_with_existing_account_path, data: { turbo: true, turbo_force: :server }, class: 'mt-2 form fconnect-form' do - = hidden_field_tag :merge_token, merge_token - = hidden_field_tag :email, email - = label_tag :password, t('views.registrations.new.password_label', min_length: 8) - = password_field_tag :password, nil, autocomplete: 'current-password', id: 'password-for-another-account' - .mb-2 - = t('views.users.sessions.new.reset_password') - = link_to france_connect_particulier_resend_and_renew_merge_confirmation_path(merge_token: merge_token), method: :post do - = t('france_connect.particulier.merge.link_confirm_by_email') - = button_tag t('.back'), type: 'button', class: 'button secondary', onclick: 'DS.showNewAccount(event);' - = submit_tag t('france_connect.particulier.merge.button_merge'), class: 'button primary' + = submit_tag t('france_connect.particulier.merge.button_merge'), class: 'fr-btn' diff --git a/app/views/france_connect/particulier/choose_email.html.haml b/app/views/france_connect/particulier/choose_email.html.haml new file mode 100644 index 000000000..018d51b39 --- /dev/null +++ b/app/views/france_connect/particulier/choose_email.html.haml @@ -0,0 +1,43 @@ +.fr-container.fr-my-5w + .fr-grid-row.fr-col-offset-md-2.fr-col-md-8 + .fr-col-12 + + %h1.text-center.mt-1= t('.choose_email_contact') + + %p= t('.intro_html', email: @fci.email_france_connect) + + %p= t('.use_email_for_notifications') + + %fieldset.fr-fieldset + = form_with url: france_connect_particulier_merge_using_fc_email_path(merge_token: @fci.merge_token), method: :post, data: { controller: 'email-france-connect' } do |f| + = hidden_field_tag :merge_token, @fci.merge_token + + %fieldset.fr-fieldset + %legend.fr-fieldset__legend + .fr-fieldset__element + .fr-radio-group + = f.radio_button :use_france_connect_email, true, id: 'use_france_connect_email_yes', class: 'fr-radio', required: true, data: { action: "email-france-connect#triggerEmailField", email_france_connect_target: "useFranceConnectEmail" } + %label.fr-label.fr-text--wrap{ for: 'use_france_connect_email_yes' } + = t('.keep_fc_email_html', email: h(@fci.email_france_connect)).html_safe + .fr-fieldset__element + .fr-radio-group + = f.radio_button :use_france_connect_email, false, id: 'use_france_connect_email_no', class: 'fr-radio', required: true, data: { action: "email-france-connect#triggerEmailField", email_france_connect_target: "useFranceConnectEmail" } + %label.fr-label.fr-text--wrap{ for: 'use_france_connect_email_no' } + = t('.use_another_email') + + .fr-fieldset__element.fr-fieldset__element--inline.hidden{ aria: { hidden: true }, data: { email_france_connect_target: "emailField", controller: 'email-input', email_input_url_value: show_email_suggestions_path } } + = f.label :email, t('.alternative_email'), class: "fr-label" + %span.fr-hint-text.mb-1= t('activerecord.attributes.user.hints.email') + = f.email_field :email, class: "fr-input" + + .suspect-email.hidden{ data: { "email-input-target": 'ariaRegion'}, aria: { live: 'off' } } + = render Dsfr::AlertComponent.new(title: t('utils.email_suggest.wanna_say'), state: :info, heading_level: :div) do |c| + - c.with_body do + %p{ data: { "email-input-target": 'suggestion'} } exemple@gmail.com  ? + %p + = button_tag type: 'button', class: 'fr-btn fr-btn--sm fr-mr-3w', data: { action: 'click->email-input#accept'} do + = t('utils.yes') + = button_tag type: 'button', class: 'fr-btn fr-btn--sm', data: { action: 'click->email-input#discard'} do + = t('utils.no') + %div + = f.submit t('.confirm'), class: 'fr-btn' diff --git a/app/views/france_connect/particulier/confirmation_sent.html.haml b/app/views/france_connect/particulier/confirmation_sent.html.haml new file mode 100644 index 000000000..98ca08acc --- /dev/null +++ b/app/views/france_connect/particulier/confirmation_sent.html.haml @@ -0,0 +1,12 @@ +.fr-container + .fr-col-12.fr-col-md-6.fr-col-offset-md-3 + %h1.fr-mt-6w.fr-h2.center= t('.confirmation_sent_by_email') + + %p.center= image_tag("user/confirmation-email.svg", alt: '') + + = render Dsfr::AlertComponent.new(title: '', state: :info, heading_level: 'h2', extra_class_names: 'fr-mt-6w fr-mb-3w') do |c| + - c.with_body do + %p= t('.intro_html', email: h(email)).html_safe + %p= t('.click_the_link_in_the_email') + + %p.center= link_to t('.continue'), destination_path, class: 'fr-btn' diff --git a/app/views/france_connect/particulier/merge.html.haml b/app/views/france_connect/particulier/merge.html.haml index 0d7b98f3a..c96f9b53e 100644 --- a/app/views/france_connect/particulier/merge.html.haml +++ b/app/views/france_connect/particulier/merge.html.haml @@ -1,46 +1,42 @@ = content_for :title, "Fusion des comptes FC et #{Current.application_name}" -.container +.fr-container %h1.page-title= t('.title', application_name: Current.application_name) %p= t('.subtitle_html', email: @fci.email_france_connect, application_name: Current.application_name) - .form.mt-2 - %label= t('.label_select_merge_flow', email: @fci.email_france_connect) - %fieldset.radios - %label{ onclick: "DS.showFusion(event);" } - = radio_button_tag :value, true, false, autocomplete: "off", id: 'it-is-mine' - = t('utils.yes') + %fieldset.fr-fieldset{ aria: { labelledby: 'merge-account' } } + %legend.fr-fieldset__legend#merge-account= t('.label_select_merge_flow', email: @fci.email_france_connect) + .fr-fieldset__element.fr-fieldset__element--inline + .fr-radio-group + %input{ type: 'radio', id: 'it-is-mine', name: 'value', value: 'true', autocomplete: "off", onclick: "DS.showFusion(event);" } + %label{ for: 'it-is-mine' }= t('utils.yes') + .fr-fieldset__element.fr-fieldset__element--inline + .fr-radio-group + %input{ type: 'radio', id: 'it-is-not-mine', name: 'value', value: 'false', autocomplete: "off", onclick: "DS.showNewAccount(event);" } + %label{ for: 'it-is-not-mine' }= t('utils.no') - %label{ onclick: "DS.showNewAccount(event);" } - = radio_button_tag :value, false, false, autocomplete: "off", id: 'it-is-not-mine' - = t('utils.no') .fusion.hidden %p= t('.title_fill_in_password') - = form_tag france_connect_particulier_merge_with_existing_account_path, data: { turbo: true }, class: 'mt-2 form fconnect-form' do - = hidden_field_tag :merge_token, @fci.merge_token, id: dom_id(@fci, :fusion_merge_token) - = hidden_field_tag :email, @fci.email_france_connect, id: dom_id(@fci, :fusion_email) - .fr-input-group - = label_tag :password, t('views.registrations.new.password_label', min_length: 8), class: 'fr-label' - = password_field_tag :password, nil, autocomplete: 'current-password', class: 'mb-1 fr-input' - .mb-2 - = t('views.users.sessions.new.reset_password') - = link_to france_connect_particulier_resend_and_renew_merge_confirmation_path(merge_token: @fci.merge_token), method: :post do - = t('.link_confirm_by_email') + = render partial: 'password_confirmation', locals: { fci: @fci, wrong_password: @wrong_password } - = submit_tag t('.button_merge'), class: 'fr-btn' + .mt-2 + = button_to t('.link_confirm_by_email'), + france_connect_particulier_send_email_merge_request_path, + params: { email: @fci.email_france_connect, merge_token: @fci.merge_token }, + class: 'fr-btn fr-btn--secondary' .new-account.hidden %p= t('.title_fill_in_email', application_name: Current.application_name) - = form_tag france_connect_particulier_merge_with_new_account_path, data: { turbo: true }, class: 'mt-2 form' do + = form_tag france_connect_particulier_send_email_merge_request_path, class: 'mt-2 form' do = hidden_field_tag :merge_token, @fci.merge_token, id: dom_id(@fci, :new_account_merge_token) - = label_tag :email, t('views.registrations.new.email_label'), for: dom_id(@fci, :new_account_email) - = email_field_tag :email, "", required: true, id: dom_id(@fci, :new_account_email) - = submit_tag t('.button_use_this_email'), class: 'button primary' + = label_tag :email, t('views.registrations.new.email_label'), for: dom_id(@fci, :new_account_email), class: 'fr-label' + = email_field_tag :email, "", required: true, id: dom_id(@fci, :new_account_email), class: 'mb-1 fr-input' + = submit_tag t('.button_use_this_email'), class: 'fr-btn' #new-account-password-confirmation.hidden diff --git a/app/views/france_connect/particulier/merge_using_password.turbo_stream.haml b/app/views/france_connect/particulier/merge_using_password.turbo_stream.haml new file mode 100644 index 000000000..d3985f5c2 --- /dev/null +++ b/app/views/france_connect/particulier/merge_using_password.turbo_stream.haml @@ -0,0 +1 @@ += turbo_stream.replace('merge_using_password', partial: 'password_confirmation', locals: { fci: @fci, wrong_password: true }) diff --git a/app/views/france_connect/particulier/merge_with_existing_account.turbo_stream.haml b/app/views/france_connect/particulier/merge_with_existing_account.turbo_stream.haml deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/views/france_connect/particulier/merge_with_new_account.turbo_stream.haml b/app/views/france_connect/particulier/merge_with_new_account.turbo_stream.haml deleted file mode 100644 index 7d14ef01a..000000000 --- a/app/views/france_connect/particulier/merge_with_new_account.turbo_stream.haml +++ /dev/null @@ -1,4 +0,0 @@ -= turbo_stream.update 'new-account-password-confirmation', partial: 'password_confirmation', locals: { email: @email, merge_token: @merge_token } -= turbo_stream.hide_all '.fusion' -= turbo_stream.hide_all '.new-account' -= turbo_stream.show 'new-account-password-confirmation' diff --git a/app/views/gestionnaires/activate/new.html.haml b/app/views/gestionnaires/activate/new.html.haml index c020d67bc..5f91c40ea 100644 --- a/app/views/gestionnaires/activate/new.html.haml +++ b/app/views/gestionnaires/activate/new.html.haml @@ -18,9 +18,9 @@ .fr-fieldset__element = render Dsfr::InputComponent.new(form: f, attribute: :password, input_type: :password_field, - opts: { autofocus: 'true', autocomplete: 'new-password', data: { controller: 'turbo-input', turbo_input_url_value: show_password_complexity_path }}) + opts: { autofocus: 'true', autocomplete: 'new-password', data: { controller: 'turbo-input', turbo_input_url_value: show_password_complexity_path }, aria: {describedby: 'password_hint'}}) #password_complexity = render PasswordComplexityComponent.new - = f.submit t('.continue'), id: 'submit-password', class: "fr-btn fr-btn--lg fr-mt-2w", data: { disable_with: t('views.users.passwords.edit.submit_loading') } + = f.submit t('.continue'), id: 'submit-password', disabled: :disabled, class: "fr-btn fr-btn--lg fr-mt-2w", data: { disable_with: t('views.users.passwords.edit.submit_loading') } diff --git a/app/views/gestionnaires/groupe_gestionnaires/_main_navigation.html.haml b/app/views/gestionnaires/groupe_gestionnaires/_main_navigation.html.haml index 842b52017..f239b1ebc 100644 --- a/app/views/gestionnaires/groupe_gestionnaires/_main_navigation.html.haml +++ b/app/views/gestionnaires/groupe_gestionnaires/_main_navigation.html.haml @@ -1,4 +1,4 @@ - content_for(:main_navigation) do - %nav#header-navigation.fr-nav{ role: 'navigation', 'aria-label': 'Menu principal gestionnaire' } + #header-navigation.fr-nav %ul.fr-nav__list %li.fr-nav__item= link_to 'Mes groupes gestionnaires', gestionnaire_groupe_gestionnaires_path, class:'fr-nav__link', 'aria-current': current_page?(controller: 'groupe_gestionnaires', action: :index) ? 'page' : nil diff --git a/app/views/instructeur_mailer/confirm_and_notify_added_instructeur.html.haml b/app/views/instructeur_mailer/confirm_and_notify_added_instructeur.html.haml new file mode 100644 index 000000000..9fd94075e --- /dev/null +++ b/app/views/instructeur_mailer/confirm_and_notify_added_instructeur.html.haml @@ -0,0 +1,24 @@ +%p= t(:hello, scope: [:views, :shared, :greetings]) + +%p + - number_of_groups = @group.procedure.groupe_instructeurs.many? ? 'many_groups' : 'one_group' + = t(".email_body.#{number_of_groups}", groupe: @group.label, email: @current_instructeur_email, procedure: @group.procedure.libelle) + +%p + Votre compte a été créé pour l'adresse email + %strong #{@instructeur.email}. + +%p + Pour l’activer, cliquez sur le lien suivant :  + = link_to(users_activate_url(token: @reset_password_token), users_activate_url(token: @reset_password_token)) + +%p + Lors de vos prochaines connexions sur #{Current.application_name} cliquez sur le bouton « Se connecter » positionné sur le haut de page ou bien sur ce lien :  + = link_to new_user_session_url, new_user_session_url + +%p + Nous vous invitons aussi à consulter notre tutoriel à destination des nouveaux instructeurs : + = link_to(INSTRUCTEUR_TUTORIAL_URL, INSTRUCTEUR_TUTORIAL_URL) + + += render partial: "layouts/mailers/signature" diff --git a/app/views/instructeurs/dossiers/_envoyer_dossier_block.html.haml b/app/views/instructeurs/dossiers/_envoyer_dossier_block.html.haml index b60bf1c32..9c5a6bb78 100644 --- a/app/views/instructeurs/dossiers/_envoyer_dossier_block.html.haml +++ b/app/views/instructeurs/dossiers/_envoyer_dossier_block.html.haml @@ -7,12 +7,7 @@ %p.tab-paragrah.mb-1 Le destinataire suivra automatiquement le dossier = form_for dossier, url: send_to_instructeurs_instructeur_dossier_path(dossier.procedure, dossier), method: :post, html: { class: 'form recipients-form fr-mb-4w' } do |f| - = hidden_field_tag :recipients, nil - = react_component("ComboMultiple", - options: potential_recipients.map{|r| [r.email, r.id]}, - selected: [], disabled: [], - group: '.recipients-form', - name: 'recipients', - label: 'Emails') + %react-fragment + = render ReactComponent.new "ComboBox/MultiComboBox", items: potential_recipients.map { [_1.email, _1.id] }, name: 'recipients[]', 'aria-label': 'Emails' = f.submit "Envoyer", class: "fr-btn fr-mt-2w" diff --git a/app/views/instructeurs/dossiers/_expiration_banner.html.haml b/app/views/instructeurs/dossiers/_expiration_banner.html.haml index 1e5c918cf..5aa622148 100644 --- a/app/views/instructeurs/dossiers/_expiration_banner.html.haml +++ b/app/views/instructeurs/dossiers/_expiration_banner.html.haml @@ -6,8 +6,9 @@ - if dossier.conservation_extension.positive? = t('instructeurs.dossiers.header.banner.expiration_date_extended') - - if dossier.close_to_expiration? - = render Dsfr::CalloutComponent.new(title: t('instructeurs.dossiers.header.banner.title'), theme: :warning) do |c| + - if dossier.close_to_expiration? || dossier.has_expired? + - title = dossier.has_expired? ? 'title_expired' : 'title' + = render Dsfr::CalloutComponent.new(title: t("instructeurs.dossiers.header.banner.#{title}"), theme: :warning) do |c| - c.with_body do - if dossier.brouillon? = t('instructeurs.dossiers.header.banner.states.brouillon') diff --git a/app/views/instructeurs/dossiers/_header.html.haml b/app/views/instructeurs/dossiers/_header.html.haml index 90e146b58..6432f87aa 100644 --- a/app/views/instructeurs/dossiers/_header.html.haml +++ b/app/views/instructeurs/dossiers/_header.html.haml @@ -7,7 +7,7 @@ .sub-header = render partial: 'instructeurs/dossiers/header_top', locals: { dossier: } - = render partial: 'instructeurs/dossiers/header_bottom', locals: { dossier: } + = render partial: 'instructeurs/dossiers/header_bottom', locals: { dossier:, gallery_attachments: } .fr-container .print-header diff --git a/app/views/instructeurs/dossiers/_header_actions.html.haml b/app/views/instructeurs/dossiers/_header_actions.html.haml index f13702df7..69938b91d 100644 --- a/app/views/instructeurs/dossiers/_header_actions.html.haml +++ b/app/views/instructeurs/dossiers/_header_actions.html.haml @@ -8,6 +8,7 @@ dossier_is_followed: current_instructeur&.follow?(dossier), close_to_expiration: dossier.close_to_expiration?, hidden_by_administration: dossier.hidden_by_administration?, + hidden_by_expired: dossier.hidden_by_expired?, has_pending_correction: dossier.pending_correction?, has_blocking_pending_correction: dossier.procedure.feature_enabled?(:blocking_pending_correction) && dossier.pending_correction?, turbo: true, diff --git a/app/views/instructeurs/dossiers/_header_bottom.html.haml b/app/views/instructeurs/dossiers/_header_bottom.html.haml index 57ea40455..b2cba4d8f 100644 --- a/app/views/instructeurs/dossiers/_header_bottom.html.haml +++ b/app/views/instructeurs/dossiers/_header_bottom.html.haml @@ -7,6 +7,11 @@ instructeur_dossier_path(dossier.procedure, dossier), notification: notifications_summary[:demande]) + - if gallery_attachments.present? + = dynamic_tab_item(t('views.instructeurs.dossiers.tab_steps.attachments'), + pieces_jointes_instructeur_dossier_path(dossier.procedure, dossier), + notification: notifications_summary[:pieces_jointes]) + = dynamic_tab_item(t('views.instructeurs.dossiers.tab_steps.private_annotations'), annotations_privees_instructeur_dossier_path(dossier.procedure, dossier), notification: notifications_summary[:annotations_privees]) diff --git a/app/views/instructeurs/dossiers/_header_top.html.haml b/app/views/instructeurs/dossiers/_header_top.html.haml index 765abe36b..7fe1fb2f8 100644 --- a/app/views/instructeurs/dossiers/_header_top.html.haml +++ b/app/views/instructeurs/dossiers/_header_top.html.haml @@ -1,11 +1,11 @@ #header-top.fr-container - .flex.fr-mb-3w + .flex %div %h1.fr-h3.fr-mb-1w = "Dossier nº #{dossier.id}" = link_to dossier.procedure.libelle.truncate_words(10), instructeur_procedure_path(dossier.procedure), title: dossier.procedure.libelle, class: "fr-link" - .fr-mt-2w.badge-group + .fr-mt-2w.fr-badge-group = procedure_badge(dossier.procedure) = status_badge(dossier.state) @@ -16,7 +16,6 @@ = render Instructeurs::SVASVRDecisionBadgeComponent.new(projection_or_dossier: dossier, procedure: dossier.procedure, with_label: true) - .header-actions.fr-ml-auto = render partial: 'instructeurs/dossiers/header_actions', locals: { dossier: } = render partial: 'instructeurs/dossiers/print_and_export_actions', locals: { dossier: } @@ -26,3 +25,30 @@ - if dossier.user_deleted? %p.fr-mb-1w %small L’usager a supprimé son compte. Vous pouvez archiver puis supprimer le dossier. + + - if dossier.procedure.labels.present? + .fr-mb-3w + - if dossier.labels.present? + - dossier.labels.each do |label| + = tag_label(label.name, label.color) + + = render Dropdown::MenuComponent.new(wrapper: :span, button_options: { class: ['fr-btn--sm fr-btn--tertiary-no-outline fr-pl-1v']}, menu_options: { class: ['dropdown-label left-aligned'] }) do |menu| + - if dossier.labels.empty? + - menu.with_button_inner_html do + Ajouter un label + + - menu.with_form do + = form_with(url: dossier_labels_instructeur_dossier_path(dossier_id: dossier.id, procedure_id: dossier.procedure.id), method: :post, class: 'fr-p-3w', data: { controller: 'autosubmit', turbo: 'true' }) do |f| + %fieldset.fr-fieldset.fr-mt-2w.fr-mb-0 + = f.collection_check_boxes :label_id, dossier.procedure.labels, :id, :name, include_hidden: false do |b| + .fr-fieldset__element + .fr-checkbox-group.fr-checkbox-group--sm.fr-mb-1w + = b.check_box(checked: DossierLabel.find_by(dossier_id: dossier.id, label_id: b.value).present? ) + = b.label(class: "fr-label fr-tag fr-tag--sm fr-tag--#{Label.colors.fetch(b.object.color)}") { b.text } + + %hr + %p.fr-text--sm.fr-text-mention--grey.fr-mb-0 + %b Besoin d'autres labels ? + %br + Contactez les + = link_to 'administrateurs de la démarche', administrateurs_instructeur_procedure_path(dossier.procedure), class: 'fr-link fr-link--sm', **external_link_attributes diff --git a/app/views/instructeurs/dossiers/_instruction_button_motivation.html.haml b/app/views/instructeurs/dossiers/_instruction_button_motivation.html.haml index 76b4d576e..56c83c685 100644 --- a/app/views/instructeurs/dossiers/_instruction_button_motivation.html.haml +++ b/app/views/instructeurs/dossiers/_instruction_button_motivation.html.haml @@ -12,7 +12,7 @@ - if unspecified_attestation_champs.present? .warning Attention, les valeurs suivantes n’ont pas été renseignées mais sont nécessaires pour pouvoir envoyer une attestation valide : - - unspecified_annotations_privees, unspecified_champs = unspecified_attestation_champs.partition(&:private) + - unspecified_annotations_privees, unspecified_champs = unspecified_attestation_champs.partition(&:private?) - if unspecified_champs.present? %h4 Champs de la demande diff --git a/app/views/instructeurs/dossiers/annotations_privees.html.haml b/app/views/instructeurs/dossiers/annotations_privees.html.haml index 3212cac3c..f603d753a 100644 --- a/app/views/instructeurs/dossiers/annotations_privees.html.haml +++ b/app/views/instructeurs/dossiers/annotations_privees.html.haml @@ -1,6 +1,11 @@ - content_for(:title, "Annotations privées · Dossier nº #{@dossier.id} (#{@dossier.owner_name})") -= render partial: "header", locals: { dossier: @dossier } += render partial: "header", locals: { dossier: @dossier, gallery_attachments: @gallery_attachments } #dossier-annotations-privees - = render partial: "shared/dossiers/edit_annotations", locals: { dossier: @dossier, seen_at: @annotations_privees_seen_at } + .fr-container + .fr-grid-row.fr-grid-row--center + - summary = ViewableChamp::HeaderSectionsSummaryComponent.new(dossier: @dossier, is_private: true) + = render summary + %div{ class: class_names("fr-col-12", "fr-col-xl-9" => summary.render?, "fr-col-xl-8" => !summary.render?) } + = render partial: "shared/dossiers/edit_annotations", locals: { dossier: @dossier, seen_at: @annotations_privees_seen_at } diff --git a/app/views/instructeurs/dossiers/avis.html.haml b/app/views/instructeurs/dossiers/avis.html.haml index 46e608f9e..172816591 100644 --- a/app/views/instructeurs/dossiers/avis.html.haml +++ b/app/views/instructeurs/dossiers/avis.html.haml @@ -1,6 +1,6 @@ - content_for(:title, "Avis · Dossier nº #{@dossier.id} (#{@dossier.owner_name})") -= render partial: "header", locals: { dossier: @dossier } += render partial: "header", locals: { dossier: @dossier, gallery_attachments: @gallery_attachments } .container .fr-grid-row diff --git a/app/views/instructeurs/dossiers/avis_new.html.haml b/app/views/instructeurs/dossiers/avis_new.html.haml index 143fe618d..6499ad2be 100644 --- a/app/views/instructeurs/dossiers/avis_new.html.haml +++ b/app/views/instructeurs/dossiers/avis_new.html.haml @@ -1,6 +1,6 @@ - content_for(:title, "Avis · Dossier nº #{@dossier.id} (#{@dossier.owner_name})") -= render partial: "header", locals: { dossier: @dossier } += render partial: "header", locals: { dossier: @dossier, gallery_attachments: @gallery_attachments } .container .fr-grid-row diff --git a/app/views/instructeurs/dossiers/messagerie.html.haml b/app/views/instructeurs/dossiers/messagerie.html.haml index f98fbde63..212d521c8 100644 --- a/app/views/instructeurs/dossiers/messagerie.html.haml +++ b/app/views/instructeurs/dossiers/messagerie.html.haml @@ -1,5 +1,5 @@ - content_for(:title, "Messagerie · Dossier nº #{@dossier.id} (#{@dossier.owner_name})") -= render partial: "header", locals: { dossier: @dossier } += render partial: "header", locals: { dossier: @dossier, gallery_attachments: @gallery_attachments } = render partial: "shared/dossiers/messagerie", locals: { dossier: @dossier, connected_user: current_instructeur, messagerie_seen_at: @messagerie_seen_at , new_commentaire: @commentaire, form_url: commentaire_instructeur_dossier_path(@dossier.procedure, @dossier) } diff --git a/app/views/instructeurs/dossiers/personnes_impliquees.html.haml b/app/views/instructeurs/dossiers/personnes_impliquees.html.haml index b90952e91..30d3224ec 100644 --- a/app/views/instructeurs/dossiers/personnes_impliquees.html.haml +++ b/app/views/instructeurs/dossiers/personnes_impliquees.html.haml @@ -1,6 +1,6 @@ - content_for(:title, "Personnes impliquées · Dossier nº #{@dossier.id} (#{@dossier.owner_name})") -= render partial: "header", locals: { dossier: @dossier } += render partial: "header", locals: { dossier: @dossier, gallery_attachments: @gallery_attachments } .personnes-impliquees.container = render partial: 'instructeurs/dossiers/envoyer_dossier_block', locals: { dossier: @dossier, potential_recipients: @potential_recipients } diff --git a/app/views/instructeurs/dossiers/pieces_jointes.html.haml b/app/views/instructeurs/dossiers/pieces_jointes.html.haml new file mode 100644 index 000000000..527b2c65c --- /dev/null +++ b/app/views/instructeurs/dossiers/pieces_jointes.html.haml @@ -0,0 +1,8 @@ +- content_for(:title, "Pièces jointes") + += render partial: "header", locals: { dossier: @dossier, gallery_attachments: @gallery_attachments } + +.fr-container + .gallery.gallery-pieces-jointes{ "data-controller": "lightbox" } + - @gallery_attachments.each do |attachment| + = render Attachment::GalleryItemComponent.new(attachment:, seen_at: @pieces_jointes_seen_at) diff --git a/app/views/instructeurs/dossiers/reaffectation.html.haml b/app/views/instructeurs/dossiers/reaffectation.html.haml index 5b5307592..364b5415a 100644 --- a/app/views/instructeurs/dossiers/reaffectation.html.haml +++ b/app/views/instructeurs/dossiers/reaffectation.html.haml @@ -1,6 +1,6 @@ - content_for(:title, "Réaffectation · Dossier nº #{@dossier.id} (#{@dossier.owner_name})") -= render partial: "header", locals: { dossier: @dossier } += render partial: "header", locals: { dossier: @dossier, gallery_attachments: @gallery_attachments } .container.groupe-instructeur diff --git a/app/views/instructeurs/dossiers/show.html.haml b/app/views/instructeurs/dossiers/show.html.haml index cbb8f7833..8c9ad55e4 100644 --- a/app/views/instructeurs/dossiers/show.html.haml +++ b/app/views/instructeurs/dossiers/show.html.haml @@ -1,6 +1,6 @@ - content_for(:title, "Demande · Dossier nº #{@dossier.id} (#{@dossier.owner_name})") -= render partial: "header", locals: { dossier: @dossier } += render partial: "header", locals: { dossier: @dossier, gallery_attachments: @gallery_attachments } - if @dossier.etablissement&.as_degraded_mode? @@ -14,4 +14,9 @@ %p Les informations sur l'entreprise arriveront d’ici quelques heures. -= render partial: "shared/dossiers/demande", locals: { dossier: @dossier, demande_seen_at: @demande_seen_at, profile: 'instructeur' } +.fr-container + .fr-grid-row.fr-grid-row--center + - summary = ViewableChamp::HeaderSectionsSummaryComponent.new(dossier: @dossier, is_private: false) + = render summary + %div{ class: class_names("fr-col-12", "fr-col-xl-9" => summary.render?, "fr-col-xl-8" => !summary.render?) } + = render partial: "shared/dossiers/demande", locals: { dossier: @dossier, demande_seen_at: @demande_seen_at, profile: 'instructeur' } diff --git a/app/views/instructeurs/export_templates/_checkbox_group.html.haml b/app/views/instructeurs/export_templates/_checkbox_group.html.haml new file mode 100644 index 000000000..80626d857 --- /dev/null +++ b/app/views/instructeurs/export_templates/_checkbox_group.html.haml @@ -0,0 +1,14 @@ +%fieldset.fr-fieldset{ id: "#{title.parameterize}-fieldset", data: { controller: 'checkbox-select-all' } } + %legend.fr-fieldset__legend--regular.fr-fieldset__legend.fr-h5.fr-pb-0 + = title + + .checkbox-group-bordered.fr-mx-1w.fr-mb-2w + .fr-fieldset__element.fr-background-contrast--grey.fr-py-2w.fr-px-4w + .fr-checkbox-group + = check_box_tag "#{title.parameterize}-select-all", "select-all", false, data: { "checkbox-select-all-target": 'checkboxAll' } + = label_tag "#{title.parameterize}-select-all", "Tout sélectionner" + + - all_columns.each do |column| + .fr-fieldset__element.fr-px-4w + .fr-checkbox-group + = render ExportTemplate::CheckboxComponent.new(export_template:, exported_column: ExportedColumn.new(libelle: column.label, column:)) diff --git a/app/views/instructeurs/export_templates/_export_item.html.haml b/app/views/instructeurs/export_templates/_export_item.html.haml new file mode 100644 index 000000000..6ab854adf --- /dev/null +++ b/app/views/instructeurs/export_templates/_export_item.html.haml @@ -0,0 +1,26 @@ +.card.no-list + = hidden_field_tag("#{prefix}[stable_id]", item.stable_id) + + .fr-checkbox-group{ data: { controller: 'hide-target' } } + - id = sanitize_to_id("#{prefix}_#{item.stable_id}_enabled") + = check_box_tag "#{prefix}[enabled]", true, item.enabled?, id:, data: { 'hide-target_target': 'source' } + = label_tag id, libelle, class: 'fr-label' + + %div{ class: class_names('fr-hidden': !item.enabled?), data: { hide_target_target: 'toHide' } } + %div{ data: { controller: 'hide-target tiptap-to-template'} } + .fr-mt-2w{ data: { hide_target_target: 'toHide' } } + %span Nom du fichier : + %span{ data: { 'tiptap-to-template_target': 'output'} }= sanitize(item.template_string) + .fr-mt-2w + %button.fr-btn.fr-btn--tertiary.fr-btn--sm{ type: 'button', data: { 'hide-target_target': 'source' } } Renommer le fichier + + .fr-mt-2w.fr-hidden{ data: { controller: 'tiptap', 'tiptap-attributes-value': { spellcheck: false }.to_json, hide_target_target: 'toHide' } } + %span Renommer le fichier : + .fr-mt-2w.tiptap-editor{ data: { tiptap_target: 'editor' } } + = hidden_field_tag "#{prefix}[template]", item.template_json, data: { tiptap_target: 'input' }, id: nil + + .fr-mt-2w + %span.fr-text--sm Cliquez sur les étiquettes que vous souhaitez intégrer au nom du fichier + .fr-mt-2w= render TagsButtonListComponent.new(tags: { nil => @export_template.pj_tags }) + + = button_tag "Valider", type: 'button', class: 'fr-btn fr-mt-2w', data: { 'tiptap-to-template_target': 'trigger', 'hide-target_target': 'source'} diff --git a/app/views/instructeurs/export_templates/_form.html.haml b/app/views/instructeurs/export_templates/_form.html.haml new file mode 100644 index 000000000..629484f07 --- /dev/null +++ b/app/views/instructeurs/export_templates/_form.html.haml @@ -0,0 +1,90 @@ +- procedure = @export_template.procedure + +#export_template-edit.fr-my-4w + .fr-mb-6w + = render Dsfr::AlertComponent.new(state: :info, title: "Nouvel éditeur de modèle d'export", heading_level: 'h3') do |c| + - c.with_body do + Cette page permet d'éditer un modèle d'export et ainsi personnaliser le contenu des exports (pour l'instant, + uniquement au format zip). Ainsi, vous pouvez notamment normaliser le nom des pièces jointes. + Essayez-le et donnez-nous votre avis + en nous envoyant un email à #{mail_to(CONTACT_EMAIL, subject: "Editeur de modèle d'export")}. + + .fr-grid-row.fr-grid-row--gutters + .fr-col-12.fr-col-md-8.fr-pr-4w + = form_with model: [:instructeur, procedure, export_template], data: { turbo: 'true', controller: 'autosubmit' } do |f| + %input.hidden{ type: 'submit', formaction: preview_instructeur_procedure_export_templates_path, data: { autosubmit_target: 'submitter' }, formnovalidate: 'true', formmethod: 'put' } + + = f.hidden_field :kind, value: 'zip' + + = render Dsfr::InputComponent.new(form: f, attribute: :name, input_type: :text_field) + + .fr-input-group{ class: class_names('fr-hidden': groupe_instructeurs.one?) } + = f.label :groupe_instructeur_id, class: 'fr-label' do + = "#{ExportTemplate.human_attribute_name('groupe_instructeur_id')} #{asterisk}" + %span.fr-hint-text Avec quel groupe instructeur souhaitez-vous partager ce modèle d'export ? + = f.collection_select :groupe_instructeur_id, groupe_instructeurs, :id, :label, {}, class: 'fr-select' + + .fr-input-group{ data: { controller: 'tiptap', 'tiptap-attributes-value': { spellcheck: false }.to_json } } + = f.label '[dossier_folder][template]', class: "fr-label" do + = "#{ExportTemplate.human_attribute_name('dossier_folder')} #{asterisk}" + %span.fr-hint-text Nom du répertoire contenant les différents fichiers à exporter + .tiptap-editor.fr-mt-1w{ data: { tiptap_target: 'editor' } } + = f.hidden_field "[dossier_folder][template]", data: { tiptap_target: 'input' }, value: export_template.dossier_folder.template_json + = f.hidden_field "[dossier_folder][enabled]", value: 'true' + .fr-mt-2w + %span.fr-text--sm Cliquez sur les étiquettes que vous souhaitez intégrer au nom du fichier + .fr-mt-2w= render TagsButtonListComponent.new(tags: { nil => export_template.tags }) + + = render Dsfr::NoticeComponent.new(data_attributes: { class: 'fr-my-4w' }) do |c| + - c.with_title do + Sélectionnez les fichiers que vous souhaitez exporter + + %h3 Dossier au format PDF + = render partial: 'export_item', + locals: { item: export_template.export_pdf, + libelle: ExportTemplate.human_attribute_name(:export_pdf), + prefix: 'export_template[export_pdf]' } + + - if procedure.exportables_pieces_jointes_for_all_versions.any? + %h3 Pièces justificatives + + - procedure.exportables_pieces_jointes.each do |tdc| + - item = export_template.pj(tdc) + = render partial: 'export_item', + locals: { item:, + libelle: tdc.libelle, + prefix: 'export_template[pjs][]'} + + - outdated_tdcs = procedure.outdated_exportables_pieces_jointes + - outdated_stable_ids = outdated_tdcs.map(&:stable_id) + - expanded = export_template.pjs.filter(&:enabled?).any? { _1.stable_id.in?(outdated_stable_ids) } + + - if outdated_tdcs.any? + %section.fr-accordion.fr-mb-3w + %h3.fr-accordion__title + %button.fr-accordion__btn{ "aria-controls" => "accordion-106", "aria-expanded" => expanded.to_s, "type" => "button" } + pièces justificatives uniquement présentes dans les versions précédentes + .fr-collapse#accordion-106 + + - outdated_tdcs.each do |tdc| + - item = export_template.pj(tdc) + = render partial: 'export_item', + locals: { item:, + libelle: tdc.libelle, + prefix: 'export_template[pjs][]'} + + .fixed-footer + .fr-container + %ul.fr-btns-group.fr-btns-group--inline-md + %li= f.button "Enregistrer", class: "fr-btn", data: { turbo: 'false' } + %li= link_to "Annuler", [:exports, :instructeur, procedure], class: "fr-btn fr-btn--secondary" + - if export_template.persisted? + %li + = link_to "Supprimer", + [:instructeur, procedure, export_template], + method: :delete, + data: { confirm: "Voulez-vous vraiment supprimer ce modèle ? Il sera supprimé pour tous les instructeurs du groupe"}, + class: "fr-btn fr-btn--secondary" + + .fr-col-12.fr-col-md-4.fr-background-alt--blue-france + = render partial: 'preview', locals: { export_template: } diff --git a/app/views/instructeurs/export_templates/_form_tabular.html.haml b/app/views/instructeurs/export_templates/_form_tabular.html.haml new file mode 100644 index 000000000..36712f6f0 --- /dev/null +++ b/app/views/instructeurs/export_templates/_form_tabular.html.haml @@ -0,0 +1,57 @@ +#export_template-edit.fr-my-4w + .fr-mb-6w + = render Dsfr::AlertComponent.new(state: :info, title: "Nouvel éditeur de modèle d'export", heading_level: 'h3') do |c| + - c.with_body do + = t('.info_html', mailto: mail_to(CONTACT_EMAIL, subject: 'Editeur de modèle d\'export')) + +.fr-grid-row.fr-grid-row--gutters + .fr-col-12.fr-col-md-8 + = form_with model: [:instructeur, @procedure, export_template], local: true do |f| + + %h2 Paramètres de l'export + = f.hidden_field "[dossier_folder][template]", value: export_template.dossier_folder.template_json + = f.hidden_field "[export_pdf][template]", value: export_template.export_pdf.template_json + + = render Dsfr::InputComponent.new(form: f, attribute: :name, input_type: :text_field) + + - if groupe_instructeurs.many? + .fr-input-group + = f.label :groupe_instructeur_id, class: 'fr-label' do + = f.object.class.human_attribute_name(:groupe_instructeur_id) + = render EditableChamp::AsteriskMandatoryComponent.new + %span.fr-hint-text + Avec quel groupe instructeur souhaitez-vous partager ce modèle d'export ? + = f.collection_select :groupe_instructeur_id, groupe_instructeurs, :id, :label, {}, class: 'fr-select' + - else + = f.hidden_field :groupe_instructeur_id + + %fieldset.fr-fieldset.fr-fieldset--inline + %legend#radio-inline-legend.fr-fieldset__legend.fr-text--regular + Format export + = asterisk + .fr-fieldset__element.fr-fieldset__element--inline + .fr-radio-group + = f.radio_button :kind, "xlsx", id: "xlsx" + %label.fr-label{ for: "xlsx" } xlsx + .fr-radio-group + = f.radio_button :kind, "ods", id: "ods" + %label.fr-label{ for: "ods" } ods + .fr-radio-group + = f.radio_button :kind, "csv", id: "csv" + %label.fr-label{ for: "csv" } csv + + %h2 Contenu de l'export + %p Sélectionnez les colonnes que vous souhaitez voir affichées dans le tableau de votre export. + + = render partial: 'checkbox_group', locals: { title: 'Informations usager', all_columns: @export_template.procedure.usager_columns_for_export, export_template: @export_template } + = render partial: 'checkbox_group', locals: { title: 'Informations dossier', all_columns: @export_template.procedure.dossier_columns_for_export, export_template: @export_template } + = render ExportTemplate::ChampsComponent.new("Formulaire usager", @export_template, @types_de_champ_public) + = render ExportTemplate::ChampsComponent.new("Annotations privées", @export_template, @types_de_champ_private) if @types_de_champ_private.any? + + .fixed-footer + .fr-container + %ul.fr-btns-group.fr-btns-group--inline-md + %li + = link_to "Annuler", instructeur_procedure_path(@procedure), class: "fr-btn fr-btn--secondary" + %li + = f.submit "Enregistrer", class: "fr-btn", data: @export_template.persisted? ? { confirm: t('.warning') } : {} diff --git a/app/views/instructeurs/export_templates/_preview.html.haml b/app/views/instructeurs/export_templates/_preview.html.haml new file mode 100644 index 000000000..0df0548e0 --- /dev/null +++ b/app/views/instructeurs/export_templates/_preview.html.haml @@ -0,0 +1,33 @@ +- procedure = export_template.procedure +- dossier = procedure.dossier_for_preview(current_instructeur) + +#preview.export-template-preview.fr-p-2w.sticky--top + %h2.fr-h4 Aperçu + - if dossier.nil? + %p.fr-text--sm + Pour générer un aperçu fidèle avec tous les champs et les dates, + = link_to 'créez-vous un dossier', commencer_url(procedure.path), target: '_blank' + et acceptez-le : l’aperçu l’utilisera. + + - else + %ul.tree.fr-text--sm + %li + %span.fr-icon-folder-zip-line + #{DownloadableFileService::EXPORT_DIRNAME}/ + %li + %ul + %li + %span.fr-icon-folder-line + #{export_template.dossier_folder.path(dossier)}/ + %ul + - if export_template.export_pdf.enabled? + %li + %span.fr-icon-pdf-2-line + #{export_template.export_pdf.path(dossier)}.pdf + + - procedure.exportables_pieces_jointes.each do |tdc| + - export_pj = export_template.pj(tdc) + - if export_pj.enabled? + %li + %span.fr-icon-file-image-line + #{export_pj.path(dossier)}-1.jpg diff --git a/app/views/instructeurs/export_templates/edit.html.haml b/app/views/instructeurs/export_templates/edit.html.haml new file mode 100644 index 000000000..32a80d8d6 --- /dev/null +++ b/app/views/instructeurs/export_templates/edit.html.haml @@ -0,0 +1,10 @@ += render partial: 'administrateurs/breadcrumbs', + locals: { steps: [[@procedure.libelle.truncate_words(10), instructeur_procedure_path(@procedure)], + [t('.title')]] } +.fr-container + %h1 Mise à jour modèle d'export + + - if @export_template.tabular? + = render partial: 'form_tabular', locals: { export_template: @export_template, groupe_instructeurs: @groupe_instructeurs } + - else + = render partial: 'form', locals: { export_template: @export_template, groupe_instructeurs: @groupe_instructeurs } diff --git a/app/views/instructeurs/export_templates/new.html.haml b/app/views/instructeurs/export_templates/new.html.haml new file mode 100644 index 000000000..358bab190 --- /dev/null +++ b/app/views/instructeurs/export_templates/new.html.haml @@ -0,0 +1,9 @@ += render partial: 'administrateurs/breadcrumbs', + locals: { steps: [[@procedure.libelle.truncate_words(10), instructeur_procedure_path(@procedure)], + [t('.title')]] } +.fr-container + %h1 Nouveau modèle d'export + - if @export_template.tabular? + = render partial: 'form_tabular', locals: { export_template: @export_template, groupe_instructeurs: @groupe_instructeurs } + - else + = render partial: 'form', locals: { export_template: @export_template, groupe_instructeurs: @groupe_instructeurs } diff --git a/app/views/instructeurs/groupe_instructeurs/show.html.haml b/app/views/instructeurs/groupe_instructeurs/show.html.haml index 1197d93bd..ea54dc215 100644 --- a/app/views/instructeurs/groupe_instructeurs/show.html.haml +++ b/app/views/instructeurs/groupe_instructeurs/show.html.haml @@ -21,11 +21,17 @@ Démarche « #{@procedure.libelle} » .card.fr-mt-2w - %h2.fr-h3 Gestion des instructeurs - = form_for(Instructeur.new(user: User.new), url: { action: :add_instructeur }, html: { class: 'form' }) do |f| - %h3.fr-h4 Affecter un nouvel instructeur - = render Dsfr::InputComponent.new(form: f, attribute: :email) - = f.submit 'Affecter', class: 'fr-btn fr-primary' + = render Procedure::InvitationWithTypoComponent.new(maybe_typos: @maybe_typos, url: add_instructeur_instructeur_groupe_path(@procedure, @groupe_instructeur.id), title: "Avant d'ajouter l'email, veuillez confirmer" ) + %h2.fr-h3= t('.title') + + = form_for :instructeur, url: { action: :add_instructeur, id: @groupe_instructeur.id }, html: { class: 'form' } do |f| + .instructeur-wrapper + %p= t('.instructeur_emails') + %p.fr-hint-text= t('.copy_paste_hint') + %react-fragment + = render ReactComponent.new 'ComboBox/MultiComboBox', id: 'instructeur_emails', name: 'emails[]', allows_custom_value: true, 'aria-label': 'Emails' + + = f.submit t('.assign'), class: 'fr-btn fr-btn--tertiary' %table.fr-table.fr-mt-2w.width-100 %thead diff --git a/app/views/instructeurs/passwords/edit.html.haml b/app/views/instructeurs/passwords/edit.html.haml deleted file mode 100644 index 802453c66..000000000 --- a/app/views/instructeurs/passwords/edit.html.haml +++ /dev/null @@ -1,29 +0,0 @@ -= devise_error_messages! - -#form-login - %h2#instructeur_login Changement de mot de passe - - %br - %br - #new-user - = form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| - = f.hidden_field :reset_password_token - %h4 - = f.label 'Nouveau mot de passe' - - .input-group - .input-group-addon - %span.fa.fa-asterisk - = f.password_field :password, autofocus: true, autocomplete: "off", class: 'form-control' - %br - %h4 - = f.label 'Confirmez le nouveau mot de passe' - .input-group - .input-group-addon - %span.fa.fa-asterisk - = f.password_field :password_confirmation, autocomplete: "off", class: 'form-control' - %br - %br - .actions - = f.submit 'Changer le mot de passe', class: 'btn btn-primary' - %br diff --git a/app/views/instructeurs/passwords/new.html.haml b/app/views/instructeurs/passwords/new.html.haml deleted file mode 100644 index aa4533459..000000000 --- a/app/views/instructeurs/passwords/new.html.haml +++ /dev/null @@ -1,21 +0,0 @@ -= devise_error_messages! - -%br -#form-login - %h2#instructeur_login Mot de passe oublié - - %br - %br - #new-user - = form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| - %h4 - = f.label :email - .input-group - .input-group-addon - %span.fa.fa-user - = f.email_field :email, class: 'form-control', placeholder: 'Email' - %br - %br - .actions - = f.submit 'Demander un nouveau mot de passe', class: 'button large expand primary' - %br diff --git a/app/views/instructeurs/procedures/_dossier_actions.html.haml b/app/views/instructeurs/procedures/_dossier_actions.html.haml index fb0b1435d..aeb6748ab 100644 --- a/app/views/instructeurs/procedures/_dossier_actions.html.haml +++ b/app/views/instructeurs/procedures/_dossier_actions.html.haml @@ -1,7 +1,13 @@ -- if hidden_by_administration +- if hidden_by_administration && hidden_by_expired + %li + = button_to repousser_expiration_and_restore_instructeur_dossier_path(procedure_id, dossier_id), method: :post, class: "fr-btn fr-icon-refresh-line" do + = t('views.instructeurs.dossiers.restore_and_extend') + +- elsif hidden_by_administration %li = button_to restore_instructeur_dossier_path(procedure_id, dossier_id), method: :patch, class: "fr-btn fr-icon-refresh-line" do = t('views.instructeurs.dossiers.restore') + - elsif close_to_expiration || Dossier::TERMINE.include?(state) %li - if close_to_expiration diff --git a/app/views/instructeurs/procedures/_dossiers_filter_dropdown.html.haml b/app/views/instructeurs/procedures/_dossiers_filter_dropdown.html.haml index 4bcafb3ed..56c08315b 100644 --- a/app/views/instructeurs/procedures/_dossiers_filter_dropdown.html.haml +++ b/app/views/instructeurs/procedures/_dossiers_filter_dropdown.html.haml @@ -3,4 +3,4 @@ = t('views.instructeurs.dossiers.filters.title') - menu.with_form do - = render Dossiers::InstructeurFilterComponent.new(procedure: procedure, procedure_presentation: @procedure_presentation, statut: statut) + = render Instructeurs::ColumnFilterComponent.new(procedure_presentation:, statut:) diff --git a/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml b/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml deleted file mode 100644 index f3db643a3..000000000 --- a/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -- if current_filters.count > 0 - .fr-mb-2w - - current_filters.group_by { |filter| filter['table'] }.each_with_index do |(table, filters), i| - - if i > 0 - = " et " - - filters.each_with_index do |filter, i| - - if i > 0 - = " ou " - = link_to remove_filter_instructeur_procedure_path(procedure, { statut: statut, field: "#{filter['table']}/#{filter['column']}", value: filter['value'] }), - class: "fr-tag fr-tag--dismiss fr-my-1w", aria: { label: "Retirer le filtre #{filter['column']}" } do - = "#{filter['label'].truncate(50)} : #{procedure_presentation.human_value_for_filter(filter)}" diff --git a/app/views/instructeurs/procedures/_header.html.haml b/app/views/instructeurs/procedures/_header.html.haml index 6714ec668..99ee79e51 100644 --- a/app/views/instructeurs/procedures/_header.html.haml +++ b/app/views/instructeurs/procedures/_header.html.haml @@ -24,9 +24,13 @@ | = link_to t('instructeurs.dossiers.header.banner.administrators_list'), administrateurs_instructeur_procedure_path(procedure), class: 'header-link' | + = link_to t('views.instructeurs.dossiers.show_deleted_dossiers'), deleted_dossiers_instructeur_procedure_path(@procedure), class: "header-link" + | = link_to t('instructeurs.dossiers.header.banner.exports_list'), exports_instructeur_procedure_path(procedure), class: 'header-link' - if @has_export_notification %span.notifications{ 'aria-label': t('instructeurs.dossiers.header.banner.exports_notification_label') } + + #last-export-alert = render partial: "last_export_alert", locals: { export: @last_export, statut: @statut } diff --git a/app/views/instructeurs/procedures/_header_field.html.haml b/app/views/instructeurs/procedures/_header_field.html.haml deleted file mode 100644 index e4a54d673..000000000 --- a/app/views/instructeurs/procedures/_header_field.html.haml +++ /dev/null @@ -1,10 +0,0 @@ -%th{ @procedure_presentation.aria_sort(@procedure_presentation.sort['order'], field), scope: "col", class: classname } - - = link_to update_sort_instructeur_procedure_path(@procedure, table: field['table'], column: field['column'], order: @procedure_presentation.opposite_order_for(field['table'], field['column'])) do - - if @procedure_presentation.sortable?(field) - - if @procedure_presentation.sort['order'] == 'asc' - #{field['label']} ↑ - - else - #{field['label']} ↓ - - else - #{field['label']} diff --git a/app/views/instructeurs/procedures/_list.html.haml b/app/views/instructeurs/procedures/_list.html.haml index 868359274..20604c03a 100644 --- a/app/views/instructeurs/procedures/_list.html.haml +++ b/app/views/instructeurs/procedures/_list.html.haml @@ -1,13 +1,13 @@ %li.flex.align-start.fr-mb-5w .flex - = link_to instructeur_procedure_path(p), class: 'procedure-logo-link' do - .procedure-logo{ style: "background-image: url(#{p.logo_url})" } + .procedure-logo{ style: "background-image: url(#{p.logo_url})" } .procedure-details .flex.clipboard-container - %p.fr-mb-2w + .fr-mb-2w = procedure_badge(p) - = link_to("#{p.libelle} - n°#{p.id}", instructeur_procedure_path(p), class: "fr-link fr-ml-1w") + %h3.font-weight-normal.fr-link.fr-ml-1w + = link_to("#{p.libelle} - n°#{p.id}", instructeur_procedure_path(p)) = render Dsfr::CopyButtonComponent.new(title: t('instructeurs.procedures.index.copy_link_button'), text: commencer_url(p.path)) %ul.procedure-stats.flex @@ -50,12 +50,12 @@ %li %object - = link_to(instructeur_procedure_path(p, statut: 'supprimes_recemment')) do - - dossier_count = dossiers_supprimes_recemment_count_per_procedure[p.id] || 0 + = link_to(instructeur_procedure_path(p, statut: 'supprimes')) do + - dossier_count = dossiers_supprimes_count_per_procedure[p.id] || 0 .stats-number = number_with_html_delimiter(dossier_count) .stats-legend - = t('pluralize.dossiers_supprimes_recemment', count: dossier_count) + = t('pluralize.dossiers_supprimes', count: dossier_count) - if p.procedure_expires_when_termine_enabled %li diff --git a/app/views/instructeurs/procedures/_synthese.html.haml b/app/views/instructeurs/procedures/_synthese.html.haml index aa2760758..fcd0293d0 100644 --- a/app/views/instructeurs/procedures/_synthese.html.haml +++ b/app/views/instructeurs/procedures/_synthese.html.haml @@ -1,6 +1,6 @@ - if procedures.length > 1 .flex.align-center.fr-mb-2w - %h2.fr-text--sm.fr-mb-1w= t('views.instructeurs.dossiers.dossier_synthesis') + %p.font-weight-bold.fr-text--sm.fr-mb-1w= t('views.instructeurs.dossiers.dossier_synthesis') - all_dossiers_counts.each_with_index do |(label, dossier_count)| - if dossier_count != 0 %span.fr-badge.fr-ml-1w.fr-mb-1w= number_with_html_delimiter(dossier_count) + ' ' + label diff --git a/app/views/instructeurs/procedures/_tabs.html.haml b/app/views/instructeurs/procedures/_tabs.html.haml index c9d6a2560..bfaa2760b 100644 --- a/app/views/instructeurs/procedures/_tabs.html.haml +++ b/app/views/instructeurs/procedures/_tabs.html.haml @@ -22,10 +22,10 @@ active: statut == 'tous', badge: number_with_html_delimiter(tous_count)) - = tab_item(t(tab_i18n_key_from_status('supprimes_recemment'), count: supprimes_recemment_count), - instructeur_procedure_path(procedure, statut: 'supprimes_recemment'), - active: statut == 'supprimes_recemment', - badge: number_with_html_delimiter(supprimes_recemment_count)) + = tab_item(t(tab_i18n_key_from_status('supprimes'), count: supprimes_count), + instructeur_procedure_path(procedure, statut: 'supprimes'), + active: statut == 'supprimes', + badge: number_with_html_delimiter(supprimes_count)) - if procedure.procedure_expires_when_termine_enabled = tab_item(t(tab_i18n_key_from_status('expirant'), count: expirant_count), diff --git a/app/views/instructeurs/procedures/deleted_dossiers.html.haml b/app/views/instructeurs/procedures/deleted_dossiers.html.haml index b3b31961f..3b7bb170a 100644 --- a/app/views/instructeurs/procedures/deleted_dossiers.html.haml +++ b/app/views/instructeurs/procedures/deleted_dossiers.html.haml @@ -1,55 +1,11 @@ - content_for(:title, "#{@procedure.libelle}") -#procedure-show - .sub-header - .fr-container.flex += render partial: 'administrateurs/breadcrumbs', + locals: { steps: [[@procedure.libelle.truncate_words(10), instructeur_procedure_path(@procedure)], + ['Historique des dossiers supprimés']] } - .procedure-logo{ style: "background-image: url(#{@procedure.logo_url})", - role: 'img', 'aria-label': "logo de la démarche #{@procedure.libelle}" } +.fr-container + .fr-mb-3w + = link_to "Retour à la démarche", instructeur_procedure_path(@procedure), class: "fr-link fr-icon-arrow-left-line fr-link--icon-left" - = render partial: 'header', locals: { procedure: @procedure, statut: @statut } - - .procedure-actions - - if @can_download_dossiers - = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_url: method(:download_export_instructeur_procedure_path)) - - .fr-container.flex= render partial: "tabs", locals: { procedure: @procedure, - statut: @statut, - a_suivre_count: @a_suivre_count, - suivis_count: @suivis_count, - traites_count: @traites_count, - tous_count: @tous_count, - supprimes_recemment_count: @supprimes_recemment_count, - archives_count: @archives_count, - expirant_count: @expirant_count, - has_en_cours_notifications: @has_en_cours_notifications, - has_termine_notifications: @has_termine_notifications } - - .fr-container - %h1.titre-dossiers Dossiers supprimés - %details - %summary Les dossiers ont été supprimés. Vous ne pouvez plus les récupérer depuis Démarches Simplifiées. - Ceci s'explique pour les raisons suivantes : - %ul - %li L’utilisateur a intentionnellement supprimé son dossier. - %li Le délai de conservation maximal de #{@procedure.duree_conservation_dossiers_dans_ds} mois a expiré. Conformément au règlement RGPD, DS ne peut continuer à les héberger. - - if @deleted_dossiers.any? - = paginate @deleted_dossiers, views_prefix: 'shared' - %table.table.dossiers-table.hoverable - %thead - %tr - %th.number-col N° dossier - %th Raison de suppression - %th Date de suppression - %tbody - - @deleted_dossiers.each do |deleted_dossier| - %tr - %td.number-col - = deleted_dossier.dossier_id - %td - = deletion_reason_badge(deleted_dossier.reason) - %td.deleted-cell - = l(deleted_dossier.deleted_at, format: '%d/%m/%y') - = paginate @deleted_dossiers, views_prefix: 'shared' - - else - Aucun dossier supprimé += render Dossiers::DeletedDossiersComponent.new(deleted_dossiers: @deleted_dossiers) diff --git a/app/views/instructeurs/procedures/download_export.turbo_stream.haml b/app/views/instructeurs/procedures/download_export.turbo_stream.haml index b841c65a0..c6e47b799 100644 --- a/app/views/instructeurs/procedures/download_export.turbo_stream.haml +++ b/app/views/instructeurs/procedures/download_export.turbo_stream.haml @@ -2,10 +2,10 @@ - if @can_download_dossiers - if @statut.nil? = turbo_stream.update_all '.procedure-actions' do - = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_url: method(:download_export_instructeur_procedure_path)) + = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_templates: current_instructeur.export_templates_for(@procedure), export_url: method(:download_export_instructeur_procedure_path)) - else = turbo_stream.update_all '.dossiers-export' do - = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, statut: @statut, count: @dossiers_count, export_url: method(:download_export_instructeur_procedure_path)) + = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_templates: current_instructeur.export_templates_for(@procedure), statut: @statut, count: @dossiers_count, export_url: method(:download_export_instructeur_procedure_path)) = turbo_stream.update "last-export-alert" do = render partial: "last_export_alert", locals: { export: @last_export, statut: @statut } diff --git a/app/views/instructeurs/procedures/exports.html.haml b/app/views/instructeurs/procedures/exports.html.haml index ed2f67fa8..fad0a8022 100644 --- a/app/views/instructeurs/procedures/exports.html.haml +++ b/app/views/instructeurs/procedures/exports.html.haml @@ -6,19 +6,59 @@ [t('.title')]] } .fr-container - %h1= t('.title') - = render Dsfr::CalloutComponent.new(title: nil) do |c| - - c.with_body do - %p= t('.export_description', expiration_time: Export::MAX_DUREE_CONSERVATION_EXPORT.in_hours.to_i) + .fr-tabs.mb-3 + %ul.fr-tabs__list{ role: 'tablist' } + %li{ role: 'presentation' } + %button.fr-tabs__tab.fr-tabs__tab--icon-left{ id: "tabpanel-exports", tabindex: "0", role: "tab", "aria-selected": "true", "aria-controls": "tabpanel-exports-panel" } Liste des exports + %li{ role: 'presentation' } + %button.fr-tabs__tab.fr-tabs__tab--icon-left{ id: "tabpanel-export-templates", tabindex: "-1", role: "tab", "aria-selected": "false", "aria-controls": "tabpanel-export-templates-panel" } Modèles d'export - - if @exports.present? - %div{ data: @exports.any?(&:pending?) ? { controller: "turbo-poll", turbo_poll_url_value: "", turbo_poll_interval_value: 10_000, turbo_poll_max_checks_value: 6 } : {} } - = render Dossiers::ExportLinkComponent.new(procedure: @procedure, exports: @exports, statut: @statut, count: @dossiers_count, class_btn: 'fr-btn--tertiary', export_url: method(:download_export_instructeur_procedure_path)) - - - if @exports.any?{_1.format == Export.formats.fetch(:zip)} - = render Dsfr::AlertComponent.new(title: t('.title_zip'), state: :info, extra_class_names: 'fr-mb-3w') do |c| + .fr-tabs__panel.fr-tabs__panel--selected{ id: "tabpanel-exports-panel", role: "tabpanel", "aria-labelledby": "tabpanel-exports", tabindex: "0" } + = render Dsfr::CalloutComponent.new(title: nil) do |c| - c.with_body do - %p= t('.export_description_zip_html') + %p= t('.export_description', expiration_time: Export::MAX_DUREE_CONSERVATION_EXPORT.in_hours.to_i) - - else - = t('.no_export_html', expiration_time: Export::MAX_DUREE_CONSERVATION_EXPORT.in_hours.to_i ) + - if @exports.present? + %div{ data: @exports.any?(&:pending?) ? { controller: "turbo-poll", turbo_poll_url_value: "", turbo_poll_interval_value: 10_000, turbo_poll_max_checks_value: 6 } : {} } + = render Dossiers::ExportLinkComponent.new(procedure: @procedure, exports: @exports, statut: @statut, count: @dossiers_count, class_btn: 'fr-btn--tertiary', export_url: method(:download_export_instructeur_procedure_path)) + + - if @exports.any?{_1.format == Export.formats.fetch(:zip)} + = render Dsfr::AlertComponent.new(title: t('.title_zip'), state: :info, extra_class_names: 'fr-mb-3w') do |c| + - c.with_body do + %p= t('.export_description_zip_html') + + - else + = t('.no_export_html', expiration_time: Export::MAX_DUREE_CONSERVATION_EXPORT.in_hours.to_i ) + + .fr-tabs__panel.fr-tabs__panel{ id: "tabpanel-export-templates-panel", role: "tabpanel", "aria-labelledby": "tabpanel-export-templates", tabindex: "0" } + = render Dsfr::AlertComponent.new(state: :info) do |c| + - c.with_body do + %p= t('.export_template_list_description_html') + + + .fr-mt-5w + = link_to t('.new_zip_export_template'), new_instructeur_procedure_export_template_path(@procedure, kind: 'zip'), class: "fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-line fr-mr-1w" + = link_to t('.new_tabular_export_template'), new_instructeur_procedure_export_template_path(@procedure, kind: 'tabular'), class: "fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-line" + + .fr-table.fr-table--bordered.fr-table--no-caption.fr-mt-5w + .fr-table__wrapper + .fr-table__container + .fr-table__content + %table + %thead + %tr + = tag.th "Nom du modèle", scope: 'col' + = tag.th "Format", scope: 'col' + = tag.th "Date de création", scope: 'col' + = tag.th "Partagé avec (groupe instructeurs)", scope: 'col' if @procedure.groupe_instructeurs.many? + = tag.th "Actions", scope: 'col' + %tbody + - @export_templates.each do |export_template| + %tr + %td= link_to export_template.name, [:edit, :instructeur, @procedure, export_template] + %td= pretty_kind(export_template.kind) + %td= l(export_template.created_at) + = tag.td export_template.groupe_instructeur.label if @procedure.groupe_instructeurs.many? + %td + = link_to "Modifier", [:edit, :instructeur, @procedure, export_template], class: "fr-btn fr-btn--icon-left fr-icon-edit-line fr-mr-1w" + = link_to "Supprimer", [:instructeur, @procedure, export_template], method: :delete, data: { confirm: "Voulez-vous vraiment supprimer ce modèle ? Il sera supprimé pour tous les instructeurs du groupe"}, class: "fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-delete-line" diff --git a/app/views/instructeurs/procedures/index.html.haml b/app/views/instructeurs/procedures/index.html.haml index a3ff48798..f18d64e94 100644 --- a/app/views/instructeurs/procedures/index.html.haml +++ b/app/views/instructeurs/procedures/index.html.haml @@ -30,7 +30,7 @@ - if collection.present? - .fr-h6 + %h2.fr-h6 = page_entries_info collection %ul.procedure-list.fr-pl-0 = render partial: 'instructeurs/procedures/list', @@ -41,7 +41,7 @@ dossiers_archived_count_per_procedure: @dossiers_archived_count_per_procedure, dossiers_termines_count_per_procedure: @dossiers_termines_count_per_procedure, dossiers_expirant_count_per_procedure: @dossiers_expirant_count_per_procedure, - dossiers_supprimes_recemment_count_per_procedure: @dossiers_supprimes_recemment_count_per_procedure, + dossiers_supprimes_count_per_procedure: @dossiers_supprimes_count_per_procedure, followed_dossiers_count_per_procedure: @followed_dossiers_count_per_procedure, procedure_ids_en_cours_with_notifications: @procedure_ids_en_cours_with_notifications, procedure_ids_termines_with_notifications: @procedure_ids_termines_with_notifications } diff --git a/app/views/instructeurs/procedures/show.html.haml b/app/views/instructeurs/procedures/show.html.haml index a067ef265..00753ecb1 100644 --- a/app/views/instructeurs/procedures/show.html.haml +++ b/app/views/instructeurs/procedures/show.html.haml @@ -11,7 +11,7 @@ .procedure-actions - if @can_download_dossiers - = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_url: method(:download_export_instructeur_procedure_path)) + = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_templates: current_instructeur.export_templates_for(@procedure), export_url: method(:download_export_instructeur_procedure_path)) .fr-container.flex= render partial: "tabs", locals: { procedure: @procedure, statut: @statut, @@ -19,7 +19,7 @@ suivis_count: @counts[:suivis], traites_count: @counts[:traites], tous_count: @counts[:tous], - supprimes_recemment_count: @counts[:supprimes_recemment], + supprimes_count: @counts[:supprimes], archives_count: @counts[:archives], expirant_count: @counts[:expirant], has_en_cours_notifications: @has_en_cours_notifications, @@ -41,9 +41,9 @@ = t('views.instructeurs.dossiers.tab_explainations.tous_with_routing') - else = t('views.instructeurs.dossiers.tab_explainations.tous') - - if @statut == 'supprimes_recemment' + - if @statut == 'supprimes' %p - = t('views.instructeurs.dossiers.tab_explainations.supprimes_recemment').html_safe + = t('views.instructeurs.dossiers.tab_explainations.supprimes').html_safe - if @statut == 'archives' %p = t('views.instructeurs.dossiers.tab_explainations.archives') @@ -61,21 +61,16 @@ %hr .flex.align-center - if @filtered_sorted_paginated_ids.present? || @current_filters.count > 0 - = render partial: "dossiers_filter_dropdown", locals: { procedure: @procedure, statut: @statut} - = render Dossiers::NotifiedToggleComponent.new(procedure: @procedure, procedure_presentation: @procedure_presentation) + = render partial: "dossiers_filter_dropdown", locals: { procedure: @procedure, statut: @statut, procedure_presentation: @procedure_presentation } + = render Dossiers::NotifiedToggleComponent.new(procedure_presentation: @procedure_presentation) if @statut != 'a-suivre' .fr-ml-auto - - - if @statut == 'archives' - = link_to deleted_dossiers_instructeur_procedure_path(@procedure), class: "fr-link fr-icon-delete-line fr-link--icon-left fr-mr-2w" do - = t('views.instructeurs.dossiers.show_deleted_dossiers') - - if @dossiers_count > 0 %span.dossiers-export - = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, statut: @statut, count: @dossiers_count, class_btn: 'fr-btn--tertiary', export_url: method(:download_export_instructeur_procedure_path)) + = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_templates: current_instructeur.export_templates_for(@procedure), statut: @statut, count: @dossiers_count, class_btn: 'fr-btn--tertiary', export_url: method(:download_export_instructeur_procedure_path)) - if @filtered_sorted_paginated_ids.present? || @current_filters.count > 0 - = render partial: "dossiers_filter_tags", locals: { procedure: @procedure, procedure_presentation: @procedure_presentation, current_filters: @current_filters, statut: @statut } + = render Instructeurs::FilterButtonsComponent.new(filters: @current_filters, procedure_presentation: @procedure_presentation, statut: @statut) - batch_operation_component = Dossiers::BatchOperationComponent.new(statut: @statut, procedure: @procedure) @@ -98,8 +93,7 @@ %th.text-center %input{ type: "checkbox", disabled: @disable_checkbox_all, checked: @disable_checkbox_all, data: { action: "batch-operation#onCheckAll" }, id: dom_id(BatchOperation.new, :checkbox_all), aria: { label: t('views.instructeurs.dossiers.select_all') } } - - @procedure_presentation.displayed_fields_for_headers.each do |field| - = render partial: "header_field", locals: { field: field, classname: field['classname'] } + = render Instructeurs::ColumnTableHeaderComponent.new(procedure_presentation: @procedure_presentation) %th.follow-col Actions @@ -109,18 +103,7 @@ - menu.with_button_inner_html do = t('views.instructeurs.dossiers.personalize') - menu.with_form do - = form_tag update_displayed_fields_instructeur_procedure_path(@procedure), method: :patch, class: 'dropdown-form large columns-form' do - = hidden_field_tag :values, nil - = react_component("ComboMultiple", - options: @displayable_fields_for_select, - selected: @displayable_fields_selected, - disabled: [], - label: 'Colonne à afficher', - group: '.columns-form', - name: 'values') - - = submit_tag t('views.instructeurs.dossiers.save'), class: 'fr-btn fr-btn--secondary' - + = render Instructeurs::ColumnPickerComponent.new(procedure: @procedure, procedure_presentation: @procedure_presentation) %tbody = render Dossiers::BatchSelectMoreComponent.new(dossiers_count: @dossiers_count, filtered_sorted_ids: @filtered_sorted_ids) @@ -149,12 +132,13 @@ %td - if p.hidden_by_administration_at.present? %span.cell-link - = column - = "- #{t('views.instructeurs.dossiers.deleted_by_user')}" if p.hidden_by_user_at.present? + = column.is_a?(Hash) ? tags_label(column[:value]) : column + - if p.hidden_by_user_at.present? + = "- #{t("views.instructeurs.dossiers.deleted_reason.#{p.hidden_by_reason}")}" - else %a.cell-link{ href: path } - = column - = "- #{t('views.instructeurs.dossiers.deleted_by_user')}" if p.hidden_by_user_at.present? + = column.is_a?(Hash) ? tags_label(column[:value]) : column + = "- #{t("views.instructeurs.dossiers.deleted_reason.#{p.hidden_by_reason}")}" if p.hidden_by_user_at.present? %td.status-col - status = [status_badge(p.state)] @@ -177,7 +161,8 @@ archived: p.archived, dossier_is_followed: @followed_dossiers_id.include?(p.dossier_id), close_to_expiration: @statut == 'expirant', - hidden_by_administration: @statut == 'supprimes_recemment', + hidden_by_administration: @statut == 'supprimes', + hidden_by_expired: p.hidden_by_reason == 'expired', sva_svr: @procedure.sva_svr_enabled?, has_blocking_pending_correction: @procedure.feature_enabled?(:blocking_pending_correction) && p.pending_correction?, turbo: false, diff --git a/app/views/instructeurs/procedures/update_filter.turbo_stream.haml b/app/views/instructeurs/procedures/update_filter.turbo_stream.haml index a5cd42c91..efc3ed43b 100644 --- a/app/views/instructeurs/procedures/update_filter.turbo_stream.haml +++ b/app/views/instructeurs/procedures/update_filter.turbo_stream.haml @@ -1,2 +1,2 @@ = turbo_stream.replace 'filter-component' do - = render Dossiers::InstructeurFilterComponent.new(procedure: @procedure, procedure_presentation: @procedure_presentation, statut: @statut, field_id: @field) + = render Instructeurs::ColumnFilterComponent.new(procedure_presentation: @procedure_presentation, statut: @statut, column: @column) diff --git a/app/views/invites/_dropdown.html.haml b/app/views/invites/_dropdown.html.haml index 45a00864c..e2da2ffab 100644 --- a/app/views/invites/_dropdown.html.haml +++ b/app/views/invites/_dropdown.html.haml @@ -1,10 +1,11 @@ - invites = dossier.invites.load -= render Dropdown::MenuComponent.new(wrapper: :span, wrapper_options: {class: 'invite-user-action'}, button_options: { class: ['fr-btn--secondary'] }, menu_options: { id: 'invite-content' }) do |menu| += render Dropdown::MenuComponent.new(wrapper: :div, wrapper_options: {class: 'invite-user-action'}, button_options: { class: ['fr-btn--secondary'] }, menu_options: { id: 'invite-content' }) do |menu| + = 'lab' - menu.with_button_inner_html do = dsfr_icon('fr-icon-user-add-fill', :sm, :mr) - if invites.present? = t('views.invites.dropdown.view_invited_people') - %span.badge= invites.size + %span.fr-badge.fr-ml-1v= invites.size - else - if dossier.read_only? = t('views.invites.dropdown.invite_to_view') diff --git a/app/views/invites/_form.html.haml b/app/views/invites/_form.html.haml index 824d1b36b..8f3922f6f 100644 --- a/app/views/invites/_form.html.haml +++ b/app/views/invites/_form.html.haml @@ -4,13 +4,13 @@ %h5.fr-h6= t('views.invites.form.edit_dossier', count: invites.size) - if invites.present? - #invite-list{ morphing ? { tabindex: "-1" } : {} } + #invite-list %ul - - invites.each do |invite| + - invites.each_with_index do |invite, index| %li - = invite.email + %span{ :id => "invite_#{index}" }= invite.email %small{ 'data-turbo': 'true' } - = link_to t('views.invites.form.withdraw_permission'), invite_path(invite), data: { turbo_method: :delete, turbo_confirm: t('views.invites.form.want_to_withdraw_permission', email: invite.email) }, class: "fr-btn fr-btn--sm fr-btn--tertiary-no-outline" + = link_to t('views.invites.form.withdraw_permission'), invite_path(invite), data: { turbo_method: :delete, turbo_confirm: t('views.invites.form.want_to_withdraw_permission', email: invite.email) }, class: "fr-btn fr-btn--sm fr-btn--tertiary-no-outline", id: "link_#{index}", "aria-labelledby": "link_#{index} invite_#{index}" - if dossier.brouillon? %p= t('views.invites.form.submit_dossier_yourself') diff --git a/app/views/layouts/_account_dropdown.haml b/app/views/layouts/_account_dropdown.haml index d46aec7f1..a9279b58f 100644 --- a/app/views/layouts/_account_dropdown.haml +++ b/app/views/layouts/_account_dropdown.haml @@ -1,12 +1,13 @@ -%nav.fr-translate.fr-nav{ role: "navigation", "aria-label"=> t('menu_aria_label', scope: [:layouts]) } +%nav.fr-translate.fr-nav{ role: "navigation", "aria-label"=> t('my_account', scope: [:layouts]) } .fr-nav__item %button.account-btn.fr-translate__btn.fr-btn{ "aria-controls" => "account", "aria-expanded" => "false", :title => t('my_account', scope: [:layouts]) } - %span= current_email + %span.fr-mr-1w= current_email - if dossier.present? && dossier&.france_connected_with_one_identity? %span  via FranceConnect - %span{ class: "fr-badge fr-badge--sm fr-ml-1w #{color_by_role(nav_bar_profile)}" } - = t("layouts.#{nav_bar_profile}") + - if nav_bar_profile != :guest # don't confuse user with unknown profile + %span{ class: "fr-badge fr-badge--sm #{color_by_role(nav_bar_profile)}" } + = t("layouts.#{nav_bar_profile}") #account.fr-collapse.fr-menu %ul.fr-menu__list.max-content - if multiple_devise_profile_connect? diff --git a/app/views/layouts/_display_theme_modal.html.haml b/app/views/layouts/_display_theme_modal.html.haml index 237a5f966..4d44b06b7 100644 --- a/app/views/layouts/_display_theme_modal.html.haml +++ b/app/views/layouts/_display_theme_modal.html.haml @@ -15,7 +15,7 @@ %input#fr-radios-theme-light{ name: "fr-radios-theme", type: "radio", value: "light" }/ %label.fr-label{ for: "fr-radios-theme-light" } Thème clair .fr-radio-rich__img - %svg.fr-artwork{ aria_hidden: "true", viewBox: "0 0 80 80", width: "80px", height: "80px" } + %svg.fr-artwork{ "aria-hidden": "true", viewBox: "0 0 80 80", width: "80px", height: "80px" } %use.fr-artwork-decorative{ href: image_path("pictograms/environment/sun.svg#artwork-decorative") } %use.fr-artwork-minor{ href: image_path("pictograms/environment/sun.svg#artwork-minor") } %use.fr-artwork-major{ href: image_path("pictograms/environment/sun.svg#artwork-major") } diff --git a/app/views/layouts/_flash_messages.html.haml b/app/views/layouts/_flash_messages.html.haml index b0a3e5d72..d2e0eff4c 100644 --- a/app/views/layouts/_flash_messages.html.haml +++ b/app/views/layouts/_flash_messages.html.haml @@ -1,14 +1,13 @@ -#flash_messages{ aria: { live: 'assertive' } } - - if flash.any? - #flash_message.center +#flash_messages{ tabindex: '-1', data: { turbo_force: :server } } + #flash_message.center{ class: defined?(unique_classname) ? unique_classname : '' } + - if flash.any? - flash.each do |key, value| - sticky = defined?(sticky) ? sticky : false - fixed = defined?(fixed) ? fixed : false - - if value.class == Array - .alert{ class: flash_class(key, sticky: sticky, fixed: fixed), role: flash_role(key) } + .alert{ role: flash_role(key), class: flash_class(key, sticky: sticky, fixed: fixed) } + - if value.class == Array - value.each do |message| = sanitize_with_link(message) %br - - elsif value.present? - .alert{ class: flash_class(key, sticky: sticky, fixed: fixed), role: flash_role(key) } + - elsif value.present? = sanitize_with_link(value) diff --git a/app/views/layouts/_header.haml b/app/views/layouts/_header.haml index 4048831e0..5b6488051 100644 --- a/app/views/layouts/_header.haml +++ b/app/views/layouts/_header.haml @@ -1,90 +1,88 @@ --# We can't use &. because the controller may not implement #nav_bar_profile -- nav_bar_profile = controller.try(:nav_bar_profile) || :guest +-# We can't use &. or as helper methods because the controllers from view specs does not implement these methods +- nav_bar_profile = controller.try(:nav_bar_profile) || controller.try(:fallback_nav_bar_profile) || :guest - dossier = controller.try(:dossier_for_help) - procedure = controller.try(:procedure_for_help) - is_instructeur_context = nav_bar_profile == :instructeur && instructeur_signed_in? - is_administrateur_context = nav_bar_profile == :administrateur && administrateur_signed_in? - is_expert_context = nav_bar_profile == :expert && expert_signed_in? - is_user_context = nav_bar_profile == :user -- is_search_enabled = [params[:controller] == 'recherche', is_instructeur_context, is_expert_context, is_user_context && current_user.dossiers.count].any? +- is_search_enabled = [params[:controller] == 'recherche', is_instructeur_context, is_expert_context].any? %header{ class: ["fr-header", content_for?(:notice_info) && "fr-header__with-notice-info"], role: "banner", "data-controller": "dsfr-header" } - .fr-header__body - .fr-container - .fr-header__body-row - .fr-header__brand.fr-enlarge-link - .fr-header__brand-top - .fr-header__logo - %p.fr-logo{ lang: "fr" } - République - = succeed "Française" do - %br/ - .fr-header__navbar - - if is_search_enabled - %button.fr-btn--search.fr-btn{ "aria-controls" => "search-modal", "data-fr-opened" => "false", :title => t('views.users.dossiers.search.search_file') }= t('views.users.dossiers.search.search_file') - %button#navbar-burger-button.fr-btn--menu.fr-btn{ "aria-controls" => "modal-header__menu", "data-fr-opened" => "false", title: "Menu" } Menu - .fr-header__service - - root_profile_link, root_profile_libelle = root_path_info_for_profile(nav_bar_profile) + %nav{ :role => "navigation", "aria-label" => t('layouts.header.main_menu') } + .fr-header__body + .fr-container + .fr-header__body-row + .fr-header__brand.fr-enlarge-link + .fr-header__brand-top + .fr-header__logo + %img{ :src => image_url("dgnum.svg"), alt: '', width: 105, height: 55.6, loading: 'lazy' } + .fr-header__navbar + - if is_search_enabled + %button.fr-btn--search.fr-btn{ "aria-controls" => "search-modal", "data-fr-opened" => "false", :title => t('views.users.dossiers.search.search_file') }= t('views.users.dossiers.search.search_file') + %button#navbar-burger-button.fr-btn--menu.fr-btn{ "aria-controls" => "modal-header__menu", "data-fr-opened" => "false", title: "Menu" } Menu + .fr-header__service + - root_profile_link, root_profile_libelle = root_path_info_for_profile(nav_bar_profile) - = link_to root_profile_link, title: "#{root_profile_libelle} — #{Current.application_name}" do - %span.fr-header__service-title{ lang: "fr" }= Current.application_name + = link_to root_profile_link, title: "#{root_profile_libelle} — #{Current.application_name}" do + %span.fr-header__service-title{ lang: "fr" }= Current.application_name - .fr-header__tools - .fr-header__tools-links.relative + .fr-header__tools + .fr-header__tools-links.relative + + %ul.fr-btns-group.flex.align-center + - if instructeur_signed_in? || user_signed_in? + %li + = render partial: 'layouts/account_dropdown', locals: { nav_bar_profile: nav_bar_profile, dossier: dossier } + - elsif (request.path != new_user_session_path && request.path !=agent_connect_path) + - if request.path == new_user_registration_path + %li.fr-hidden-sm.fr-unhidden-lg.fr-link--sm.fr-mb-2w.fr-mr-1v= t('views.shared.account.already_user_question') + %li= link_to 'Agent', agent_connect_path, class: "fr-btn fr-btn--tertiary fr-icon-government-fill fr-btn--icon-left" + %li= link_to t('views.shared.account.signin'), new_user_session_path, class: "fr-btn fr-btn--tertiary fr-icon-account-circle-fill fr-btn--icon-left" - %ul.fr-btns-group.flex.align-center - - if instructeur_signed_in? || user_signed_in? %li - = render partial: 'layouts/account_dropdown', locals: { nav_bar_profile: nav_bar_profile, dossier: dossier } - - elsif (request.path != new_user_session_path && request.path !=agent_connect_path) - - if request.path == new_user_registration_path - %li.fr-hidden-sm.fr-unhidden-lg.fr-link--sm.fr-mb-2w.fr-mr-1v= t('views.shared.account.already_user_question') - %li= link_to 'Agent', agent_connect_path, class: "fr-btn fr-btn--tertiary fr-icon-government-fill fr-btn--icon-left" - %li= link_to t('views.shared.account.signin'), new_user_session_path, class: "fr-btn fr-btn--tertiary fr-icon-account-circle-fill fr-btn--icon-left" + - if dossier.present? && nav_bar_profile == :user + = render partial: 'shared/help/help_dropdown_dossier', locals: { dossier: dossier } - %li - - if dossier.present? && nav_bar_profile == :user - = render partial: 'shared/help/help_dropdown_dossier', locals: { dossier: dossier } + - elsif procedure.present? && (nav_bar_profile == :user || nav_bar_profile == :guest) + = render partial: 'shared/help/help_dropdown_procedure', locals: { procedure: procedure } - - elsif procedure.present? && (nav_bar_profile == :user || nav_bar_profile == :guest) - = render partial: 'shared/help/help_dropdown_procedure', locals: { procedure: procedure } - - - elsif nav_bar_profile == :instructeur - = render partial: 'shared/help/help_dropdown_instructeur' - - else - // NB: on mobile in order to have links correctly aligned, we need a left icon - = link_to t('help'), t("links.common.faq.url"), class: 'fr-btn dropdown-button', title: new_tab_suffix(t('help')), **external_link_attributes + - elsif nav_bar_profile == :instructeur + = render partial: 'shared/help/help_dropdown_instructeur' + - else + -# NB: on mobile in order to have links correctly aligned, we need a left icon # + = link_to t('help'), t("links.common.faq.url"), class: 'fr-btn' - - if localization_enabled? - %li= render partial: 'layouts/locale_dropdown' + - if localization_enabled? + %li= render partial: 'layouts/locale_dropdown' - - if params[:controller] == 'recherche' - = render partial: 'layouts/search_dossiers_form' + - if is_instructeur_context + = render partial: 'layouts/search_dossiers_form', locals: { context: :instructeur } - - if is_instructeur_context - = render partial: 'layouts/search_dossiers_form' + - elsif is_expert_context + = render partial: 'layouts/search_dossiers_form', locals: { context: :expert } - - if is_expert_context - = render partial: 'layouts/search_dossiers_form' + - elsif params[:controller] == 'recherche' + = render partial: 'layouts/search_dossiers_form' - = render SwitchDomainBannerComponent.new(user: current_user) + = render SwitchDomainBannerComponent.new(user: current_user) - #modal-header__menu.fr-header__menu.fr-modal{ "aria-labelledby": "navbar-burger-button" } - .fr-container - %button.fr-btn--close.fr-btn{ "aria-controls" => "modal-header__menu", title: t('close_modal', scope: [:layouts, :header]) }= t('close_modal', scope: [:layouts, :header]) - .fr-header__menu-links - -# populated by dsfr js + #modal-header__menu.fr-header__menu.fr-modal{ "aria-labelledby": "navbar-burger-button" } + .fr-container + %button.fr-btn--close.fr-btn{ "aria-controls" => "modal-header__menu", title: t('close_modal', scope: [:layouts, :header]) }= t('close_modal', scope: [:layouts, :header]) + .fr-header__menu-links + -# populated by dsfr js - - if content_for?(:main_navigation) - = yield(:main_navigation) - - elsif is_administrateur_context - = render 'administrateurs/main_navigation' - - elsif is_instructeur_context || is_expert_context - = render MainNavigation::InstructeurExpertNavigationComponent.new - - elsif is_user_context - = render 'users/main_navigation' + - if content_for?(:main_navigation) + = yield(:main_navigation) + - elsif is_administrateur_context + = render 'administrateurs/main_navigation' + - elsif is_instructeur_context || is_expert_context + = render MainNavigation::InstructeurExpertNavigationComponent.new + - elsif is_user_context + = render 'users/main_navigation' - = yield(:notice_info) + = yield(:notice_info) diff --git a/app/views/layouts/_locale_dropdown.html.haml b/app/views/layouts/_locale_dropdown.html.haml index 287df6ec3..dd2387016 100644 --- a/app/views/layouts/_locale_dropdown.html.haml +++ b/app/views/layouts/_locale_dropdown.html.haml @@ -1,4 +1,4 @@ -%nav.fr-translate.fr-nav{ :role => "navigation", title: t('.select_locale') } +.fr-translate.fr-nav .fr-nav__item %button.fr-translate__btn.fr-btn{ "aria-controls" => "translate", "aria-expanded" => "false", :title => t('.select_locale') } = I18n.locale.upcase diff --git a/app/views/layouts/_outdated_browser_banner.html.haml b/app/views/layouts/_outdated_browser_banner.html.haml index cbf9e91cd..c3605fd12 100644 --- a/app/views/layouts/_outdated_browser_banner.html.haml +++ b/app/views/layouts/_outdated_browser_banner.html.haml @@ -1,9 +1,7 @@ - if show_outdated_browser_banner? = render Dsfr::AlertComponent.new(state: :warning, title: "Navigateur trop ancien", heading_level: :h2) do |c| - c.with_body do - Votre navigateur internet, #{browser.name} #{browser.version}, est malheureusement trop ancien. Il ne sera plus compatible avec #{APPLICATION_NAME} à partir du  - %strong - 1 juin 2024. + Votre navigateur internet, #{browser.name} #{browser.version}, est malheureusement trop ancien. Il n’est plus compatible avec #{APPLICATION_NAME}. %br Veuillez installer un navigateur plus récent en suivant le lien suivant : %br diff --git a/app/views/layouts/_search_dossiers_form.html.haml b/app/views/layouts/_search_dossiers_form.html.haml index f0aab616e..3ae8dc46d 100644 --- a/app/views/layouts/_search_dossiers_form.html.haml +++ b/app/views/layouts/_search_dossiers_form.html.haml @@ -3,7 +3,8 @@ %button.fr-btn--close.fr-btn{ "aria-controls" => "search-modal", :title => t('close_modal', scope: [:layouts, :header]) }= t('close_modal', scope: [:layouts, :header]) #search-473.fr-search-bar.fr-search-bar--lg = form_tag recherche_index_path, method: :get, :role => "search", class: "flex width-100" do - = label_tag "q", t('views.users.dossiers.search.search_file'), class: 'fr-label' + = hidden_field_tag :context, local_assigns[:context] + = label_tag "q", t('views.users.dossiers.search.search_file'), class: 'sr-only' = text_field_tag "q", "#{@search_terms if @search_terms.present?}", placeholder: t('views.users.dossiers.search.search_file'), class: "fr-input" %button.fr-btn = t('views.users.dossiers.search.simple') diff --git a/app/views/layouts/_skiplinks.html.haml b/app/views/layouts/_skiplinks.html.haml index ccd0f3f18..2e8333d47 100644 --- a/app/views/layouts/_skiplinks.html.haml +++ b/app/views/layouts/_skiplinks.html.haml @@ -1,5 +1,3 @@ .fr-skiplinks %nav.fr-container{ role: "navigation", 'aria-label': t("skiplinks.quick") } - %ul.fr-skiplinks__list - %li - %a.fr-link{ href: "#contenu" }= t('skiplinks.content') + %a.fr-link{ href: "#contenu" }= t('skiplinks.content') diff --git a/app/views/layouts/all.html.haml b/app/views/layouts/all.html.haml index 2b785e13f..c8c95491e 100644 --- a/app/views/layouts/all.html.haml +++ b/app/views/layouts/all.html.haml @@ -1,6 +1,5 @@ - content_for(:main_navigation) do = render 'administrateurs/main_navigation' - - content_for :content do .fr-container %h1.fr-my-4w Toutes les démarches @@ -25,16 +24,31 @@ = link_to all_admin_procedures_path(zone_ids: current_administrateur.zones), { data: { turbo: 'false' } } do %span.fr-icon-arrow-go-back-line Réinitialiser %ul + %li.fr-py-2w.fr-pl-2w{ 'data-controller': "expand" } .fr-mb-1w %button{ 'data-action': 'expand#toggle' } %span.fr-icon-add-line.fr-icon--sm.fr-mr-1w.fr-text-action-high--blue-france{ 'aria-hidden': 'true', 'data-expand-target': 'icon' } - Mes zones - .fr-ml-1w{ 'data-expand-target': 'content' } - = f.collection_check_boxes :zone_ids, @filter.admin_zones, :id, :current_label, include_hidden: false do |b| - .fr-checkbox-group.fr-ml-2w.fr-py-1w - = b.check_box(checked: @filter.zone_filtered?(b.value)) - = b.label(class: 'fr-label') { b.text } + Thématique + .fr-ml-1w.hidden{ 'data-expand-target': 'content' } + %div + = f.search_field :tags, placeholder: 'Choisissez un thème', list: 'tags_list', class: 'fr-input', data: { no_autosubmit: 'input', turbo_force: :server }, multiple: true + %datalist#tags_list + - ProcedureTag.order(:name).each do |tag| + %option{ value: tag.name, data: { id: tag.id } } + - if @filter.tags.present? + - @filter.tags.each do |tag| + = f.hidden_field :tags, value: tag, multiple: true, id: "tag-#{tag.tr(' ', '_')}" + + %li.fr-py-2w.fr-pl-2w{ 'data-controller': "expand" } + .fr-mb-1w + %button{ 'data-action': 'expand#toggle' } + %span.fr-icon-add-line.fr-icon--sm.fr-mr-1w.fr-text-action-high--blue-france{ 'aria-hidden': 'true', 'data-expand-target': 'icon' } + Démarches modèles + .fr-ml-1w.hidden{ 'data-expand-target': 'content' } + .fr-checkbox-group.fr-ml-2w.fr-py-1w + = f.check_box :template, class: 'fr-input' + = f.label :template, 'Modèle DS', class: 'fr-label' %li.fr-py-2w.fr-pl-2w{ 'data-controller': "expand" } .fr-mb-1w %button{ 'data-action': 'expand#toggle' } @@ -45,6 +59,16 @@ .fr-checkbox-group.fr-ml-2w.fr-py-1w = b.check_box(checked: @filter.zone_filtered?(b.value)) = b.label(class: 'fr-label') { b.text } + %li.fr-py-2w.fr-pl-2w{ 'data-controller': "expand" } + .fr-mb-1w + %button{ 'data-action': 'expand#toggle' } + %span.fr-icon-add-line.fr-icon--sm.fr-mr-1w.fr-text-action-high--blue-france{ 'aria-hidden': 'true', 'data-expand-target': 'icon' } + Mes zones + .fr-ml-1w.hidden{ 'data-expand-target': 'content' } + = f.collection_check_boxes :zone_ids, @filter.admin_zones, :id, :current_label, include_hidden: false do |b| + .fr-checkbox-group.fr-ml-2w.fr-py-1w + = b.check_box(checked: @filter.zone_filtered?(b.value)) + = b.label(class: 'fr-label') { b.text } %li.fr-py-2w.fr-pl-2w{ 'data-controller': "expand" } .fr-mb-1w %button{ 'data-action': 'expand#toggle' } @@ -61,10 +85,20 @@ .fr-ml-1w.hidden{ 'data-expand-target': 'content' } %div = f.select :service_departement, - APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] }, + APIGeoService.departement_options, { selected: @filter.service_departement, include_blank: ''}, id: "service_dep_select", class: 'fr-select' + %li.fr-py-2w.fr-pl-2w{ 'data-controller': "expand" } + .fr-mb-1w + %button{ 'data-action': 'expand#toggle' } + %span.fr-icon-add-line.fr-icon--sm.fr-mr-1w.fr-text-action-high--blue-france{ 'aria-hidden': 'true', 'data-expand-target': 'icon' } + Type d'usager + .fr-ml-1w.hidden{ 'data-expand-target': 'content' } + = f.collection_check_boxes :kind_usagers, ['individual', 'personne_morale'], :to_s, :to_s, include_hidden: false do |b| + .fr-checkbox-group.fr-ml-2w.fr-py-1w + = b.check_box(checked: @filter.kind_usager_filtered?(b.value)) + = b.label(class: 'fr-label') { t b.text, scope: 'activerecord.attributes.procedure.kind_usager' } %li.fr-py-2w{ 'data-controller': "expand" } .fr-mb-1w.fr-pl-2w %button{ 'data-action': 'click->expand#toggle' } @@ -86,40 +120,6 @@ = b.check_box(checked: @filter.status_filtered?(b.value)) = b.label(class: 'fr-label') { t b.text, scope: 'activerecord.attributes.procedure.aasm_state' } - %li.fr-py-2w.fr-pl-2w{ 'data-controller': "expand" } - .fr-mb-1w - %button{ 'data-action': 'expand#toggle' } - %span.fr-icon-add-line.fr-icon--sm.fr-mr-1w.fr-text-action-high--blue-france{ 'aria-hidden': 'true', 'data-expand-target': 'icon' } - Type d'usager - .fr-ml-1w.hidden{ 'data-expand-target': 'content' } - = f.collection_check_boxes :kind_usagers, ['individual', 'personne_morale'], :to_s, :to_s, include_hidden: false do |b| - .fr-checkbox-group.fr-ml-2w.fr-py-1w - = b.check_box(checked: @filter.kind_usager_filtered?(b.value)) - = b.label(class: 'fr-label') { t b.text, scope: 'activerecord.attributes.procedure.kind_usager' } - %li.fr-py-2w.fr-pl-2w{ 'data-controller': "expand" } - .fr-mb-1w - %button{ 'data-action': 'expand#toggle' } - %span.fr-icon-add-line.fr-icon--sm.fr-mr-1w.fr-text-action-high--blue-france{ 'aria-hidden': 'true', 'data-expand-target': 'icon' } - Tags - .fr-ml-1w.hidden{ 'data-expand-target': 'content' } - %div - = f.search_field :tags, placeholder: 'Choisissez un tag', list: 'tags_list', class: 'fr-input', data: { no_autosubmit: 'input', turbo_force: :server }, multiple: true - %datalist#tags_list - - Procedure.tags.each do |tag| - %option{ value: tag } - - if @filter.tags.present? - - @filter.tags.each do |tag| - = f.hidden_field :tags, value: tag, multiple: true, id: "tag-#{tag.tr(' ', '_')}" - %li.fr-py-2w.fr-pl-2w{ 'data-controller': "expand" } - .fr-mb-1w - %button{ 'data-action': 'expand#toggle' } - %span.fr-icon-add-line.fr-icon--sm.fr-mr-1w.fr-text-action-high--blue-france{ 'aria-hidden': 'true', 'data-expand-target': 'icon' } - Démarches modèles - .fr-ml-1w.hidden{ 'data-expand-target': 'content' } - .fr-checkbox-group.fr-ml-2w.fr-py-1w - = f.check_box :template, class: 'fr-input' - = f.label :template, 'Modèle DS', class: 'fr-label' - .fr-col-9 = yield(:results) = render template: 'layouts/application' diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index aba53cdd3..6c61cb438 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -9,6 +9,8 @@ %meta{ name: "format-detection", content: "telephone=no,date=no,address=no,email=no,url=no" } = csrf_meta_tags + %script{ defer: true, data: { domain: "demarches.dgnum.eu" }, src: "https://analytics.dgnum.eu/js/script.js" } + %title = content_for?(:title) ? "#{sanitize(yield(:title))} · #{Current.application_name}" : Current.application_name @@ -22,13 +24,6 @@ - if administrateur_signed_in? = vite_javascript_tag 'track-admin' - - if vite_legacy? - = vite_legacy_polyfill_tag - = vite_legacy_javascript_tag 'application' - - if administrateur_signed_in? - = vite_legacy_javascript_tag 'track-admin' - = vite_legacy_fallback_tag - = preload_link_tag(asset_url("Marianne-Regular.woff2")) = preload_link_tag(asset_url("Spectral-Regular.ttf")) @@ -52,6 +47,9 @@ #beta Env Test + #sticky-header.sticky-header-container + = content_for(:sticky_header) + = render partial: "layouts/header" %main#contenu{ role: :main } = render partial: "layouts/flash_messages" @@ -63,7 +61,5 @@ - else = render 'footer' - - if Rails.env.development? - = vite_typescript_tag 'axe-core' = yield :charts_js = render Attachment::ProgressBarComponent.new diff --git a/app/views/layouts/application.turbo_stream.haml b/app/views/layouts/application.turbo_stream.haml index c359b707a..bb1d0ef95 100644 --- a/app/views/layouts/application.turbo_stream.haml +++ b/app/views/layouts/application.turbo_stream.haml @@ -1,7 +1,9 @@ - if flash.any? - = turbo_stream.replace 'flash_messages', partial: 'layouts/flash_messages' + - unique_classname= "u#{SecureRandom.hex}" + = turbo_stream.replace 'flash_messages', partial: 'layouts/flash_messages', locals: { unique_classname: } + = turbo_stream.focus 'flash_messages' = turbo_stream.show 'flash_messages' - = turbo_stream.hide 'flash_messages', delay: 30000 + = turbo_stream.hide_all ".#{unique_classname}", delay: 15000 - flash.clear = yield diff --git a/app/views/layouts/commencer/_no_procedure.html.haml b/app/views/layouts/commencer/_no_procedure.html.haml index 36fb66cae..a3465ac22 100644 --- a/app/views/layouts/commencer/_no_procedure.html.haml +++ b/app/views/layouts/commencer/_no_procedure.html.haml @@ -1,10 +1,4 @@ -.no-procedure - = image_tag "landing/hero/dematerialiser.svg", class: "paperless-logo", alt: "" - .baseline.center - .no-procedure-presentation - %p.simple= t('.line1') - %p= t('.line2') - %p= t('.line3') - %hr - %p.small-simple= t('.are_you_new', app_name: Current.application_name) - = link_to t('views.users.sessions.new.find_procedure'), t("links.common.faq.comment_trouver_ma_demarche_url"), title: new_tab_suffix(t('views.users.sessions.new.find_procedure')), class: "fr-btn fr-btn--secondary", **external_link_attributes +.center + = image_tag "landing/hero/dematerialiser.svg", class: "fr-responsive-img fr-mb-1v", alt: "", "aria-hidden": "true" + %p.fr-m-4w= t('.text') + %hr diff --git a/app/views/layouts/mailers/_jdma.html.haml b/app/views/layouts/mailers/_jdma.html.haml new file mode 100644 index 000000000..4d6ce0af9 --- /dev/null +++ b/app/views/layouts/mailers/_jdma.html.haml @@ -0,0 +1,10 @@ += vertical_margin(50) + +%div{ align: "center" } + %p + %strong Aidez-nous à améliorer ce service ! + %br + Donnez-nous votre avis, cela ne prend que 2 minutes. + != @jdma_html + += vertical_margin(20) diff --git a/app/views/layouts/mailers/layout.html.erb b/app/views/layouts/mailers/layout.html.erb index a7067a244..63b7e1784 100644 --- a/app/views/layouts/mailers/layout.html.erb +++ b/app/views/layouts/mailers/layout.html.erb @@ -98,7 +98,7 @@

    Logo <%= " src="<%= image_url(MAILER_LOGO_SRC) %>" style="max-height:100px; padding:15px 30px 15px 30px; vertical-aligne:middle; display:inline !important; border:0; height:auto; outline:none; text-decoration:none; -ms-interpolation-mode:bicubic;" /> - + <%= Current.application_name %>

    diff --git a/app/views/manager/administrateurs/data_exports.html.erb b/app/views/manager/administrateurs/data_exports.html.erb new file mode 100644 index 000000000..1e3d897cd --- /dev/null +++ b/app/views/manager/administrateurs/data_exports.html.erb @@ -0,0 +1,19 @@ +
    +

    + Export des administrateurs et instructeurs +

    +
    + +
    +

    Pour les invitations aux webinaires

    + + +

    Pour les newsletters

    + +
    diff --git a/app/views/manager/application/_javascript.html.erb b/app/views/manager/application/_javascript.html.erb index 570441489..56f773546 100644 --- a/app/views/manager/application/_javascript.html.erb +++ b/app/views/manager/application/_javascript.html.erb @@ -11,6 +11,8 @@ by providing a `content_for(:javascript)` block. <%= javascript_include_tag js_path %> <% end %> +<%= vite_client_tag %> +<%= vite_react_refresh_tag %> <%= vite_typescript_tag 'manager' %> <%= yield :javascript %> diff --git a/app/views/manager/application/_navigation.html.erb b/app/views/manager/application/_navigation.html.erb index f2358dd28..83dc6da3e 100644 --- a/app/views/manager/application/_navigation.html.erb +++ b/app/views/manager/application/_navigation.html.erb @@ -29,6 +29,7 @@ as defined by the routes in the `admin/` namespace <%= link_to "Features", manager_flipper_path, class: "navigation__link" %> <%= link_to "Annonces", super_admins_release_notes_path, class: "navigation__link" %> <%= link_to "Import data via CSV", manager_import_procedure_tags_path, class: "navigation__link" %> + <%= link_to "Export CSV data", manager_data_exports_path, class: "navigation__link" %> <% if Rails.application.secrets.sendinblue[:enabled] && ENV["SAML_IDP_ENABLED"] == "enabled" %> <%= link_to "Sendinblue", ENV.fetch("SENDINBLUE_LOGIN_URL"), class: "navigation__link", target: '_blank' %> <% end %> diff --git a/app/views/manager/dossiers/transfer_edit.html.erb b/app/views/manager/dossiers/transfer_edit.html.erb index 7fcb26555..ab45a4722 100644 --- a/app/views/manager/dossiers/transfer_edit.html.erb +++ b/app/views/manager/dossiers/transfer_edit.html.erb @@ -12,7 +12,7 @@
    User
    - <%= link_to @dossier.user.email, manager_user_path(@dossier.user) %> + <%= link_to @dossier.user_email_for(:notification), manager_user_path(@dossier.user) %>
    Text summary
    diff --git a/app/views/manager/instructeurs/show.html.erb b/app/views/manager/instructeurs/show.html.erb index 4ce7efb4b..b2ef2c6e1 100644 --- a/app/views/manager/instructeurs/show.html.erb +++ b/app/views/manager/instructeurs/show.html.erb @@ -29,7 +29,7 @@ as well as a link to its edit page. 'Modifier', [:edit, namespace, page.resource], class: "button", - ) if valid_action?(:edit) && show_action?(:edit, page.resource) %> + ) if accessible_action?(page.resource, :edit) %> <%= link_to 'Réinviter', reinvite_manager_instructeur_path(instructeur), method: :post, class: 'button' %> diff --git a/app/views/manager/outdated_procedures/_collection.html.erb b/app/views/manager/outdated_procedures/_collection.html.erb index 1a966d96b..6f48a14e1 100644 --- a/app/views/manager/outdated_procedures/_collection.html.erb +++ b/app/views/manager/outdated_procedures/_collection.html.erb @@ -78,7 +78,7 @@ to display a collection of resources in an HTML table. <% collection_presenter.attributes_for(resource).each do |attribute| %> - <% if show_action? :show, resource -%> + <% if accessible_action?(resource, :show) -%> + ) if accessible_action?(page.resource_name, :new) %>
    diff --git a/app/views/manager/procedures/show.html.erb b/app/views/manager/procedures/show.html.erb index 38e6919e9..703c492d1 100644 --- a/app/views/manager/procedures/show.html.erb +++ b/app/views/manager/procedures/show.html.erb @@ -31,7 +31,7 @@ as well as a link to its edit page. t("administrate.actions.edit_resource", name: page.page_title), [:edit, namespace, page.resource], class: "button", - ) if valid_action? :edit %> + ) if accessible_action?(page.resource, :edit) %> <%= link_to 'Aperçu', apercu_admin_procedure_path(procedure), class: 'button' %> @@ -77,7 +77,7 @@ as well as a link to its edit page.

    J'utilise cette option ETQ support quand un usager a besoin de devenir administrateur sur une démarche

    <% end %> - <% if procedure.administrateurs.any? { |admin| admin.email == current_super_admin.email } %> + <% if procedure.administrateurs.any? { |admin| admin.email == current_super_admin.email } && procedure.instructeurs.any? { |instructeur| instructeur.email == current_super_admin.email } %>

    Vous êtes administrateur de cette démarche. Aller à la démarche <%= link_to("ETQ admin", admin_procedure_path(procedure), **external_link_attributes) %> ou @@ -93,16 +93,15 @@ as well as a link to its edit page. <% elsif attribute.name == 'tags' %> <%= form_for procedure, url: add_tags_manager_procedure_path(procedure), html: { class: 'form procedure-form__column--form fr-background-alt--blue-france mt-1' } do %> - <%= hidden_field_tag 'procedure[tags]', nil %> - <%= react_component("ComboMultiple", - options: Procedure.tags, - selected: procedure.tags, - disabled: [], - label: 'Tags', - group: '.procedure-form__column--form', - name: 'tags', - describedby: 'procedure-tags', - acceptNewValues: true) %> + + <%= render ReactComponent.new "ComboBox/MultiComboBox", + items: Procedure.tags, + selected_keys: procedure.tags, + value_separator: ',|;', + allows_custom_value: true, + name: 'procedure[tags][]', + 'aria-label': 'Tags' %> + <% end %> diff --git a/app/views/manager/published_procedures/index.html.erb b/app/views/manager/published_procedures/index.html.erb new file mode 100644 index 000000000..05754ba9e --- /dev/null +++ b/app/views/manager/published_procedures/index.html.erb @@ -0,0 +1,66 @@ +<%# +# Index + +This view is the template for the index page. +It is responsible for rendering the search bar, header and pagination. +It renders the `_table` partial to display details about the resources. + +## Local variables: + +- `page`: + An instance of [Administrate::Page::Collection][1]. + Contains helper methods to help display a table, + and knows which attributes should be displayed in the resource's table. +- `resources`: + An instance of `ActiveRecord::Relation` containing the resources + that match the user's search criteria. + By default, these resources are passed to the table partial to be displayed. +- `search_term`: + A string containing the term the user has searched for, if any. +- `show_search_bar`: + A boolean that determines if the search bar should be shown. + +[1]: http://www.rubydoc.info/gems/administrate/Administrate/Page/Collection +%> + +<% content_for(:title) do %> + <%= display_resource_name(page.resource_name) %> +<% end %> + +

    + +
    + <%= render( + "collection", + collection_presenter: page, + collection_field_name: resource_name, + page: page, + resources: resources, + table_title: "page-title" + ) %> + + <%= render "pagination", resources: resources %> +
    diff --git a/app/views/manager/users/show.html.erb b/app/views/manager/users/show.html.erb index 41f353302..ac1881a35 100644 --- a/app/views/manager/users/show.html.erb +++ b/app/views/manager/users/show.html.erb @@ -26,6 +26,14 @@ as well as a link to its edit page.
    + <% if user.unverified_email? %> + <%= link_to( + "Débloquer mails", + [:unblock_mails, namespace, page.resource], + method: :post, + class: "button") %> + <% end %> + <%= link_to( "Modifier", edit_manager_user_path(page.resource), diff --git a/app/views/notification_mailer/send_notification.html.haml b/app/views/notification_mailer/send_notification.html.haml index b64553e7a..af6b90e4d 100644 --- a/app/views/notification_mailer/send_notification.html.haml +++ b/app/views/notification_mailer/send_notification.html.haml @@ -9,5 +9,8 @@ - if @services_publics_plus_url.present? = render 'layouts/mailers/services_publics_plus' +- if @jdma_html.present? + = render 'layouts/mailers/jdma' + - content_for :footer do = render 'layouts/mailers/service_footer', service: @service, dossier: @dossier diff --git a/app/views/notification_mailer/send_notification_for_tiers.html.haml b/app/views/notification_mailer/send_notification_for_tiers.html.haml index 83c73e69d..565c80cc7 100644 --- a/app/views/notification_mailer/send_notification_for_tiers.html.haml +++ b/app/views/notification_mailer/send_notification_for_tiers.html.haml @@ -22,7 +22,7 @@ %p = t("layouts.mailers.for_tiers.second_part") - = "#{mail_to(@dossier.user.email)}." + = "#{mail_to(@dossier.user_email_for(:notification))}." %p = t(:best_regards, scope: [:views, :shared, :greetings]) diff --git a/app/views/phishing_alert_mailer/notify.html.haml b/app/views/phishing_alert_mailer/notify.html.haml new file mode 100644 index 000000000..131a86784 --- /dev/null +++ b/app/views/phishing_alert_mailer/notify.html.haml @@ -0,0 +1,17 @@ += content_for(:title, @subject) + +%p Bonjour + +%p Nous pensons que votre compte #{@user.email} a été la cible d'une tentative #{link_to("d'hameçonnage (phishing)", "https://www.service-public.fr/particuliers/vosdroits/F34800") }. + +%p Par mesure de précaution, nous avons réinitialisé votre mot de passe. + +%h3 Que devez-vous faire maintenant ? + +%ol + %li Pour accéder à votre compte, vous devez définir un nouveau mot de passe sur le site #{Current.application_name}. Sur la page de connexion, cliquez sur le lien "Mot de passe oublié" et suivez les instructions. + %li Nous vous recommandons de vérifier vos dossiers et de nous signaler tout problème en nous contactant à l'adresse suivante : #{mail_to(CONTACT_EMAIL)}. + +%p Nous restons à votre disposition pour toute question. + += render partial: "layouts/mailers/signature" diff --git a/app/views/recherche/index.html.haml b/app/views/recherche/index.html.haml index 7fb4bce78..bb2964eb5 100644 --- a/app/views/recherche/index.html.haml +++ b/app/views/recherche/index.html.haml @@ -102,6 +102,7 @@ dossier_is_followed: @followed_dossiers_id.include?(p.dossier_id), close_to_expiration: nil, hidden_by_administration: nil, + hidden_by_expired: nil, sva_svr: p.sva_svr_decision_on.present?, has_blocking_pending_correction: p.pending_correction? && Flipper.enabled?(:blocking_pending_correction, ProcedureFlipperActor.new(procedure_id)), turbo: false, diff --git a/app/views/root/_footer.html.haml b/app/views/root/_footer.html.haml index f1d296ce1..8c0ac4015 100644 --- a/app/views/root/_footer.html.haml +++ b/app/views/root/_footer.html.haml @@ -6,54 +6,48 @@ .fr-col-12.fr-col-sm-3.fr-col-md-3 %h3.fr-footer__top-cat= t("links.footer.top_labels.communication") %ul.fr-footer__top-list - %li.fr-footer__top-link - = link_to t("links.footer.releases.label"), t("links.footer.releases.url"), title: t("links.footer.releases.title"), class: "fr-footer__top-link" - %li.fr-footer__top-link - = link_to t("links.footer.contact.label"), contact_path, title: t("links.footer.contact.title"), class: "fr-footer__top-link" + %li + = link_to t("links.footer.releases.label"), t("links.footer.releases.url"), title: t("links.footer.releases.title"), class: "fr-footer__top-link", hreflang: "fr" + %li + = link_to t("links.footer.contact.label"), contact_path, class: "fr-footer__top-link" .fr-col-12.fr-col-sm-3.fr-col-md-3 %h3.fr-footer__top-cat= t("links.footer.top_labels.legals") %ul.fr-footer__top-list - %li.fr-footer__top-link - = link_to t("links.footer.mentions_legales.label"), MENTIONS_LEGALES_URL, title: t("links.footer.mentions_legales.title"), class: "fr-footer__top-link", rel: "noopener noreferrer" - %li.fr-footer__top-link - = link_to t("links.footer.suivi.label"), suivi_path, title: t("links.footer.suivi.title"), class: "fr-footer__top-link" - %li.fr-footer__top-link - = link_to t("links.footer.stats.label"), stats_path, title: t("links.footer.stats.title"), class: "fr-footer__top-link" - %li.fr-footer__top_link + %li + = link_to t("links.footer.mentions_legales.label"), MENTIONS_LEGALES_URL, class: "fr-footer__top-link", rel: "noopener noreferrer" + %li + = link_to t("links.footer.suivi.label"), suivi_path, class: "fr-footer__top-link", hreflang: "fr" + %li + = link_to t("links.footer.stats.label"), stats_path, class: "fr-footer__top-link", hreflang: "fr" + %li = link_to t("links.footer.carte.label"), carte_path, title: t("links.footer.carte.title"), class: "fr-footer__top-link" - %li.fr-footer__top-link - = link_to t("links.footer.cgu.label"), t("links.footer.cgu.url"), title: t("links.footer.cgu.title"), class: "fr-footer__top-link", rel: "noopener noreferrer" + %li + %a.fr-footer__top-link{ :href => t("links.footer.cgu.url"), :rel => "noopener noreferrer", :hreflang => "fr" } + %abbr{ title: t("links.footer.cgu.title") } + = t("links.footer.cgu.label") .fr-col-12.fr-col-sm-3.fr-col-md-3 %h3.fr-footer__top-cat= t("links.footer.top_labels.resources") %ul.fr-footer__top-list - %li.fr-footer__top-link - = link_to t("links.footer.doc.label"), t("links.footer.doc.url"), title: t("links.footer.doc.title"), class: "fr-footer__top-link", rel: "noopener noreferrer" - %li.fr-footer__top-link - = link_to t("links.footer.api_doc.label"), t("links.footer.api_doc.url"), title: t("links.footer.api_doc.title"), class: "fr-footer__top-link", rel: "noopener noreferrer" - %li.fr-footer__top-link - = link_to t("links.common.faq.label"), t("links.common.faq.url"), title: t("links.common.faq.title"), class: "fr-footer__top-link", rel: "noopener noreferrer" - %li.fr-footer__top-link - = link_to t("links.footer.code.label"), t("links.footer.code.url"), title: t("links.footer.code.title"), class: "fr-footer__top-link", rel: "noopener noreferrer" + %li + = link_to t("links.footer.doc.label"), t("links.footer.doc.url"), title: t("links.footer.doc.title"), class: "fr-footer__top-link", rel: "noopener noreferrer", hreflang: "fr" + %li + = link_to t("links.footer.api_doc.label"), t("links.footer.api_doc.url"), title: t("links.footer.api_doc.title"), class: "fr-footer__top-link", rel: "noopener noreferrer", hreflang: "fr" + %li + = link_to t("links.footer.code.label"), t("links.footer.code.url"), class: "fr-footer__top-link", rel: "noopener noreferrer", hreflang: "fr" .fr-col-12.fr-col-sm-3.fr-col-md-3 %h3.fr-footer__top-cat= t("links.footer.top_labels.diagnostic") %ul.fr-footer__top-list - %li.fr-footer__top-link - = link_to t("links.footer.status_page.label"), t("links.footer.status_page.url"), title: t("links.footer.status_page.title"), class: "fr-footer__top-link", rel: "noopener noreferrer" - %li.fr-footer__top-link - = link_to t("links.footer.security.label"), t("links.footer.security.url"), title: t("links.footer.security.title"), class: "fr-footer__top-link", rel: "noopener noreferrer" + %li + = link_to t("links.footer.status_page.label"), t("links.footer.status_page.url"), title: t("links.footer.status_page.title"), class: "fr-footer__top-link", rel: "noopener noreferrer", hreflang: "fr" + %li + = link_to t("links.footer.security.label"), t("links.footer.security.url"), title: t("links.footer.security.title"), class: "fr-footer__top-link", rel: "noopener noreferrer", hreflang: "fr" .fr-container .fr-footer__body - .fr-footer__brand.fr-enlarge-link{ lang: "fr" } - %p.fr-logo - gouvernement - = link_to t("links.footer.dinum.url"), title: t("links.footer.dinum.title"), class: "fr-footer__brand-link" do - = image_tag("footer/logo-dinum.svg", class: "fr-footer__logo logo-beta-gouv-fr", alt: t("links.footer.dinum.alt")) - .fr-footer__content %p.fr-footer__content-desc = t('links.footer.description_1') - = link_to(t('links.footer.link_1_label'), t('links.footer.link_1_url'), title: new_tab_suffix(t('links.footer.link_1_label')), **external_link_attributes) + "." + = link_to(t('links.footer.link_1_label'), t('links.footer.link_1_url'), title: new_tab_suffix(t('links.footer.link_1_label')), hreflang:'fr', **external_link_attributes) + "." %p.fr-footer__content-desc - = link_to t('links.footer.link_2_label'), t("links.footer.code.url"), title: new_tab_suffix(t('links.footer.link_2_label')), **external_link_attributes + = link_to t('links.footer.link_2_label'), t("links.footer.code.url"), title: new_tab_suffix(t('links.footer.link_2_label')), hreflang:'fr', **external_link_attributes = t('links.footer.description_2') = render partial: "shared/footer_content_list" diff --git a/app/views/root/administration.html.haml b/app/views/root/administration.html.haml index c6ff2667e..cf1687a5a 100644 --- a/app/views/root/administration.html.haml +++ b/app/views/root/administration.html.haml @@ -16,13 +16,6 @@ .fr-py-6w.fr-background-alt--blue-france .container .role-panel-wrapper.role-administrations-panel - .role-panel-70 - %h2 Est-ce fait pour mon administration ? - %p.fr-h5 Découvrez notre outil et posez nous vos questions lors de notre démonstration en ligne ou lisez notre documentation - - = link_to "Consulter notre vidéo de démonstration", DEMO_VIDEO_URL, class: "fr-btn fr-btn--lg fr-mr-1w fr-mb-2w", **external_link_attributes - = link_to "Documentation", DOC_URL, class: "fr-btn fr-btn--secondary fr-btn--lg", **external_link_attributes - .role-panel-30.role-more-info-image.fr-mt-2w %img.role-image{ :src => image_url("landing/roles/usagers.svg"), alt: "" } @@ -74,28 +67,22 @@ .fr-py-6w.fr-background-alt--blue-france .container %h2.center.fr-mb-4w #{Current.application_name} en chiffres - %ul.numbers - %li.number - .number-value + %dl.numbers + .number + %dt.number-label + administrations partenaires + %dd.number-value = number_with_delimiter(Administrateur.with_publiees_ou_closes.uniq.count, :locale => :fr) - .number-label< - administrations - %br<> - partenaires - %li.number - .number-value + .number + %dt.number-label + dossiers déposés + %dd.number-value = number_with_delimiter(Dossier.state_not_brouillon.count, :locale => :fr) - .number-label< - dossiers - %br<> - déposés - %li.number - .number-value + .number + %dt.number-label + de réduction des délais de traitement + %dd.number-value = "#{number_with_delimiter(50, :locale => :fr)} %" - .number-label< - de réduction - %br<> - des délais de traitement = render partial: "root/users" if LANDING_USERS_ENABLED @@ -105,17 +92,3 @@ = render Dsfr::CardVerticalComponent.new(title: "Vous êtes prêt pour dématérialiser ?", desc: "Réduisez vos temps d’instruction de 50 %") do |c| - c.with_footer_button do = link_to("Créer votre compte administrateur", DEMANDE_INSCRIPTION_ADMIN_PAGE_URL, class: "fr-btn", **external_link_attributes) - - .fr-col-md-6.fr-col-12 - = render Dsfr::CardVerticalComponent.new(title: "Vous voulez en savoir plus ?", desc: "Participez à notre prochain Webinaire") do |c| - - c.with_footer_button do - = link_to("Inscription à notre prochain webinaire", INSCRIPTION_WEBINAIRE_URL, class: "fr-btn", **external_link_attributes) - - .fr-py-6w.fr-background-alt--blue-france - .container - .cta-panel-wrapper - %div - %h2 Une question, un problème ? - %p.fr-h5 Consultez notre FAQ - %div - = link_to "Voir la FAQ", t("links.common.faq.url"), class: "fr-btn fr-btn--lg", **external_link_attributes diff --git a/app/views/root/landing.html.haml b/app/views/root/landing.html.haml index 851a8bb47..564735896 100644 --- a/app/views/root/landing.html.haml +++ b/app/views/root/landing.html.haml @@ -11,7 +11,7 @@ = t(".promise") .hero-illustration - %img{ :src => image_url("landing/hero/dematerialiser.svg"), alt: '', width: 499, height: 280, loading: 'lazy' } + %img{ :src => image_url("landing/hero/dematerialiser.svg"), alt: '', width: 499, height: 280, loading: 'lazy', 'aria-hidden': 'true' } .fr-background-alt--blue-france.fr-py-6w .container @@ -23,35 +23,25 @@ %h2= t(".have_a_procedure") %p.fr-h5= t(".fill_procedure") - = link_to t(".how_to_find_procedure"), t("links.common.faq.comment_trouver_ma_demarche_url"), class: "fr-btn fr-btn--lg fr-mr-1w fr-mb-2w", title: new_tab_suffix(t(".how_to_find_procedure")), **external_link_attributes = link_to t("views.users.sessions.new.connection"), new_user_session_path, class: "fr-btn fr-btn--secondary fr-btn--lg" .fr-py-6w .container %h2.center.fr-mb-4w= t(".our_numbers", name: Current.application_name) - cache [I18n.locale, "numbers-panel"], expires_in: 3.hours do - %ul.numbers - %li.number - .number-value + %dl.numbers + .number + %dt.number-label= t(".numbers.administrations") + %dd.number-value = number_with_delimiter(@stat&.administrations_partenaires) - .number-label= t(".numbers.administrations") - %li.number - .number-value + .number + %dt.number-label= t(".numbers.files") + %dd.number-value = number_with_delimiter(@stat&.dossiers_not_brouillon) - .number-label= t(".numbers.files") - %li.number - .number-value + .number + %dt.number-label.number-label-third= t(".numbers.processing_time") + %dd.number-value = "#{number_with_delimiter(50)} %" - .number-label.number-label-third= t(".numbers.processing_time") - - .fr-background-alt--blue-france.fr-py-6w - .container - .cta-panel-wrapper - %div - %h2= t(".question") - %p.fr-h5= t(".answer_in_faq") - %div - = link_to t(".online_help"), t("links.common.faq.url"), class: "fr-btn fr-btn--lg", title: new_tab_suffix(t(".online_help")), **external_link_attributes .fr-py-6w .container diff --git a/app/views/root/patron.html.haml b/app/views/root/patron.html.haml index 968b94d70..e6b2dd10e 100644 --- a/app/views/root/patron.html.haml +++ b/app/views/root/patron.html.haml @@ -153,11 +153,6 @@ %span.label.refused .label.refused %span.label.without-continuation .label.without-continuation - %h1 Badges - - %span.badge 1 - %span.badge.warning 1 - %h1 Cards .card diff --git a/app/views/root/suivi.html.haml b/app/views/root/suivi.html.haml index 7ddcb9a7f..af08c0d0c 100644 --- a/app/views/root/suivi.html.haml +++ b/app/views/root/suivi.html.haml @@ -8,8 +8,6 @@ %p Ce site dépose un petit fichier texte (un « cookie ») sur votre ordinateur lorsque vous le consultez. Cela nous permet de mesurer le nombre de visites et de comprendre quelles sont les pages les plus consultées. - %iframe{ :src => MATOMO_IFRAME_URL } - %h2.fr-my-4w Ce site n’affiche pas de bannière de consentement aux cookies, pourquoi ? %p C’est vrai, vous n’avez pas eu à cliquer sur un bloc qui recouvre la moitié de la page pour dire que vous êtes d’accord avec le dépôt de cookies. @@ -18,7 +16,7 @@ Rien d’exceptionnel, pas de passe-droit. Nous respectons simplement la loi, qui dit que certains outils de suivi d’audience, correctement configurés pour respecter la vie privée, sont exemptés d’autorisation préalable. %br %br - Nous utilisons pour cela Matomo, un outil libre, paramétré pour être en conformité avec la recommandation « Cookies » de la CNIL. Cela signifie que votre adresse IP, par exemple, est anonymisée avant d’être enregistrée. Il est donc impossible d’associer vos visites sur ce site à votre personne. + Nous utilisons pour cela Plausible, un outil libre, paramétré pour être en conformité avec la recommandation « Cookies » de la CNIL. Cela signifie que votre adresse IP, par exemple, est anonymisée avant d’être enregistrée. Il est donc impossible d’associer vos visites sur ce site à votre personne. %h2.fr-my-4w Comment désactiver le suivi statistique sur mon navigateur ? %p diff --git a/app/views/shared/_footer_content_list.html.haml b/app/views/shared/_footer_content_list.html.haml index ed162a378..03d4009df 100644 --- a/app/views/shared/_footer_content_list.html.haml +++ b/app/views/shared/_footer_content_list.html.haml @@ -1,9 +1,5 @@ %ul.fr-footer__content-list %li.fr-footer__content-item - = link_to t('users.procedure_footer.official_links.legifrance.title'), t('users.procedure_footer.official_links.legifrance.url'), title: new_tab_suffix(t('users.procedure_footer.official_links.legifrance.title')), class: 'fr-footer__content-link', **external_link_attributes + = link_to t('users.procedure_footer.official_links.dgnum.title'), t('users.procedure_footer.official_links.dgnum.url'), title: new_tab_suffix(t('users.procedure_footer.official_links.dgnum.title')), class: 'fr-footer__content-link', **external_link_attributes %li.fr-footer__content-item - = link_to t('users.procedure_footer.official_links.gouvernement.title'), t('users.procedure_footer.official_links.gouvernement.url'), title: new_tab_suffix(t('users.procedure_footer.official_links.gouvernement.title')), class: 'fr-footer__content-link', **external_link_attributes - %li.fr-footer__content-item - = link_to t('users.procedure_footer.official_links.service_public.title'), t('users.procedure_footer.official_links.service_public.url'), title: new_tab_suffix(t('users.procedure_footer.official_links.service_public.title')), class: 'fr-footer__content-link', **external_link_attributes - %li.fr-footer__content-item - = link_to t('users.procedure_footer.official_links.data_gouv.title'), t('users.procedure_footer.official_links.data_gouv.url'), title: new_tab_suffix(t('users.procedure_footer.official_links.data_gouv.title')), class: 'fr-footer__content-link', **external_link_attributes + = link_to t('users.procedure_footer.official_links.ens.title'), t('users.procedure_footer.official_links.ens.url'), title: new_tab_suffix(t('users.procedure_footer.official_links.ens.title')), class: 'fr-footer__content-link', **external_link_attributes diff --git a/app/views/shared/_footer_copy.html.haml b/app/views/shared/_footer_copy.html.haml index 86dc24fcb..d3818a219 100644 --- a/app/views/shared/_footer_copy.html.haml +++ b/app/views/shared/_footer_copy.html.haml @@ -1,2 +1,2 @@ .fr-footer__bottom-copy - %p= t("links.footer.copy_html", link: link_to(t("links.footer.license"), "https://github.com/etalab/licence-ouverte/blob/master/LO.md", title: new_tab_suffix("licence etalab-2.0"), **external_link_attributes)) + %p= t("links.footer.copy_html", link: link_to(t("links.footer.license"), "https://github.com/etalab/licence-ouverte/blob/master/LO.md", title: new_tab_suffix(t("links.footer.license")), hreflang: "fr", **external_link_attributes)) diff --git a/app/views/shared/_france_connect_login.html.haml b/app/views/shared/_france_connect_login.html.haml index 688f2a2cc..2837e06e8 100644 --- a/app/views/shared/_france_connect_login.html.haml +++ b/app/views/shared/_france_connect_login.html.haml @@ -1,6 +1,6 @@ - if FranceConnectService.enabled? .france-connect-login - %h2.fr-h6 + = tag.public_send(local_assigns.fetch(:heading_level, :h2), class: "fr-h6") do = t('views.shared.france_connect_login.title') %p = t('views.shared.france_connect_login.description') diff --git a/app/views/shared/_piece_justificative_template.html.haml b/app/views/shared/_piece_justificative_template.html.haml index abb90edbf..6a6c0226f 100644 --- a/app/views/shared/_piece_justificative_template.html.haml +++ b/app/views/shared/_piece_justificative_template.html.haml @@ -1 +1 @@ -= render Dsfr::DownloadComponent.new(attachment: champ.type_de_champ.piece_justificative_template, url: champs_piece_justificative_template_path(champ), name: "Modèle à télécharger", ephemeral_link: administrateur_signed_in? ) += render Dsfr::DownloadComponent.new(attachment: champ.type_de_champ.piece_justificative_template, url: champs_piece_justificative_template_path(champ.dossier, champ.stable_id, row_id: champ.row_id), name: t('views.shared.piece_justificative.name'), ephemeral_link: administrateur_signed_in?, title: t('views.shared.piece_justificative.title', filename: champ.type_de_champ.piece_justificative_template.blob.filename.to_s) ) diff --git a/app/views/shared/_procedure_description.html.haml b/app/views/shared/_procedure_description.html.haml index 922d790c8..2b6bd2593 100644 --- a/app/views/shared/_procedure_description.html.haml +++ b/app/views/shared/_procedure_description.html.haml @@ -1,8 +1,5 @@ .procedure-logos - - procedure_logo_alt = '' - - if procedure.service.present? - - procedure_logo_alt = "#{procedure.service.nom} − #{procedure.service.organisme}" - = image_tag procedure.logo_url, alt: procedure_logo_alt + = image_tag procedure.logo_url, alt: '' - if procedure.euro_flag = image_tag("flag_of_europe.svg", id: 'euro_flag', class: (!procedure.euro_flag ? "hidden" : "")) %h1.fr-h2 @@ -11,7 +8,7 @@ - if procedure.persisted? && procedure.estimated_duration_visible? %p %small - %span.fr-icon-timer-line + %span.fr-icon-timer-line{ "aria-hidden" => "true" } = t('shared.procedure_description.estimated_fill_duration', estimated_minutes: estimated_fill_duration_minutes(procedure)) - if procedure.auto_archive_on @@ -48,21 +45,23 @@ #accordion-116.fr-collapse = h render SimpleFormatComponent.new(procedure.description_pj, allow_a: true) - - elsif procedure.pieces_jointes_list? - %section.fr-accordion.pieces_jointes - %h2.fr-accordion__title - %button.fr-accordion__btn{ "aria-controls" => "accordion-116", "aria-expanded" => "false" } - = t('shared.procedure_description.pieces_jointes') - #accordion-116.fr-collapse - - if procedure.pieces_jointes_list_without_conditionnal.present? - %ul - = render partial: "shared/procedure_pieces_jointes_list", collection: procedure.pieces_jointes_list_without_conditionnal, as: :pj + - else + - pj_without_condition, pj_with_condition = procedure.public_wrapped_partionned_pjs + - if pj_without_condition.present? || pj_with_condition.present? + %section.fr-accordion.pieces_jointes + %h2.fr-accordion__title + %button.fr-accordion__btn{ "aria-controls" => "accordion-116", "aria-expanded" => "false" } + = t('shared.procedure_description.pieces_jointes') + #accordion-116.fr-collapse + - if pj_without_condition.present? + %ul + = render partial: "shared/procedure_pieces_jointes_list", collection: pj_without_condition, as: :pj - - if procedure.pieces_jointes_list_with_conditionnal.present? - %h3.fr-text--sm.fr-mb-0.fr-mt-2w - = t('shared.procedure_description.pieces_jointes_conditionnal_list_title') - %ul - = render partial: "shared/procedure_pieces_jointes_list", collection: procedure.pieces_jointes_list_with_conditionnal, as: :pj + - if pj_with_condition.present? + %h3.fr-text--sm.fr-mb-0.fr-mt-2w + = t('shared.procedure_description.pieces_jointes_conditionnal_list_title') + %ul + = render partial: "shared/procedure_pieces_jointes_list", collection: pj_with_condition, as: :pj - estimated_delay_component = Procedure::EstimatedDelayComponent.new(procedure: procedure) - if estimated_delay_component.render? diff --git a/app/views/shared/_tab_item.html.haml b/app/views/shared/_tab_item.html.haml index cd4103ef8..a9de4a2a7 100644 --- a/app/views/shared/_tab_item.html.haml +++ b/app/views/shared/_tab_item.html.haml @@ -3,5 +3,5 @@ %span.notifications{ 'aria-label': 'notifications' } = link_to(url, 'aria-selected': active ? true : nil, class: 'fr-tabs__tab', role: 'tab' ) do - if badge.present? - %span.badge.fr-mr-1w= badge + %span.fr-badge.fr-badge--blue-ecume.fr-mr-1w= badge = label diff --git a/app/views/shared/avis/_form.html.haml b/app/views/shared/avis/_form.html.haml index 43f3dad28..2b5e00b05 100644 --- a/app/views/shared/avis/_form.html.haml +++ b/app/views/shared/avis/_form.html.haml @@ -10,16 +10,9 @@ = render NestedForms::FormOwnerComponent.new = form_for avis, url: url, html: { multipart: true, data: { controller: 'persisted-form', persisted_form_key_value: dom_id(@dossier, :avis_by_instructeur) } } do |f| - = hidden_field_tag 'avis[emails]', nil .fr-input-group - = react_component("ComboMultiple", - options: current_expert_not_instructeur? ? [] : @experts_emails, - selected: [], disabled: [], - label: 'Emails', - group: '.ask-avis', - name: 'emails', - describedby: 'avis-emails-description', - acceptNewValues: !@dossier.procedure.experts_require_administrateur_invitation) + %react-fragment + = render ReactComponent.new "ComboBox/MultiComboBox", items: current_expert_not_instructeur? ? [] : @experts_emails, name: f.field_name(:emails, multiple: true), id: 'avis_emails', 'aria-label': 'Emails', 'aria-describedby': 'avis-emails-description', allows_custom_value: !@dossier.procedure.experts_require_administrateur_invitation .fr-input-group = f.label :introduction, t('helpers.label.introduction'), class: 'fr-label' diff --git a/app/views/shared/champs/carte/_show.html.haml b/app/views/shared/champs/carte/_show.html.haml index 4b957d132..98534e434 100644 --- a/app/views/shared/champs/carte/_show.html.haml +++ b/app/views/shared/champs/carte/_show.html.haml @@ -1,4 +1,5 @@ - if champ.geometry? - = react_component("MapReader", { featureCollection: champ.to_feature_collection, options: champ.render_options } ) + %react-fragment.width-100 + = render ReactComponent.new "MapReader", feature_collection: champ.to_feature_collection, options: champ.render_options .geo-areas = render Dossiers::GeoAreasComponent.new(champ:, editing: false) diff --git a/app/views/shared/champs/piece_justificative/_show.html.haml b/app/views/shared/champs/piece_justificative/_show.html.haml index dd81dde84..46c3c0f3d 100644 --- a/app/views/shared/champs/piece_justificative/_show.html.haml +++ b/app/views/shared/champs/piece_justificative/_show.html.haml @@ -1,4 +1,9 @@ .fr-downloads-group - %ul - - champ.piece_justificative_file.attachments.each do |attachment| - %li= render Attachment::ShowComponent.new(attachment:, new_tab: true) + - if profile == 'instructeur' + .gallery-items-list + - champ.piece_justificative_file.attachments.with_all_variant_records.each do |attachment| + = render Attachment::GalleryItemComponent.new(attachment:, gallery_demande: true) + - else + %ul + - champ.piece_justificative_file.attachments.each do |attachment| + %li= render Attachment::ShowComponent.new(attachment:, new_tab: true) diff --git a/app/views/shared/champs/rna/_association.html.haml b/app/views/shared/champs/rna/_association.html.haml index f0520db90..9fae5d37e 100644 --- a/app/views/shared/champs/rna/_association.html.haml +++ b/app/views/shared/champs/rna/_association.html.haml @@ -1,7 +1,6 @@ - case error - when :invalid - %p.fr-error-text - Le numéro RNA doit commencer par un W majuscule suivi de 9 chiffres ou lettres + %p.fr-error-text= t('.invalid_number') - when :not_found %p.fr-error-text= t('.not_found') - when :network_error diff --git a/app/views/shared/champs/rna/_show.html.haml b/app/views/shared/champs/rna/_show.html.haml index ce989b8e7..588ff8206 100644 --- a/app/views/shared/champs/rna/_show.html.haml +++ b/app/views/shared/champs/rna/_show.html.haml @@ -22,13 +22,4 @@ - c.with_value do %p= l(champ.data[scope].to_date) - - if champ.data['address'].present? - = render Dossiers::RowShowComponent.new(label: t("activemodel.attributes.rna_champ.data.address")) do |c| - - c.with_value do - %p= champ.full_address - - - ['code_insee', 'code_postal'].each do |scope| - - if champ.data['address'][scope].present? - = render Dossiers::RowShowComponent.new(label: t("activemodel.attributes.rna_champ.data.#{scope}")) do |c| - - c.with_value do - %p= champ.data['address'][scope] + = render partial: "shared/dossiers/normalized_address", locals: { address: AddressProxy.new(champ) } diff --git a/app/views/shared/champs/rnf/_show.html.haml b/app/views/shared/champs/rnf/_show.html.haml index da251eff6..32f8e14e2 100644 --- a/app/views/shared/champs/rnf/_show.html.haml +++ b/app/views/shared/champs/rnf/_show.html.haml @@ -19,12 +19,4 @@ = render Dossiers::RowShowComponent.new(label: t("activemodel.attributes.rnf_champ.data.#{scope}")) do |c| - c.with_value do %p= l(champ.data[scope].to_date) - - - if champ.data['address'].present? - - ['label', 'cityCode', 'postalCode'].each do |scope| - = render Dossiers::RowShowComponent.new(label: t("activemodel.attributes.rnf_champ.data.#{scope}")) do |c| - - c.with_value do - %p= champ.data['address'][scope] - = render Dossiers::RowShowComponent.new(label: t("activemodel.attributes.rnf_champ.data.department")) do |c| - - c.with_value do - %p= "#{champ.data['address']['departmentCode']} – #{champ.data['address']['departmentName']}" + = render partial: "shared/dossiers/normalized_address", locals: { address: AddressProxy.new(champ) } diff --git a/app/views/shared/champs/siret/_etablissement.html.haml b/app/views/shared/champs/siret/_etablissement.html.haml index efaaa901c..fa4c07aeb 100644 --- a/app/views/shared/champs/siret/_etablissement.html.haml +++ b/app/views/shared/champs/siret/_etablissement.html.haml @@ -8,7 +8,6 @@ - when :not_found %p.fr-error-text Nous n’avons pas trouvé d’établissement correspondant à ce numéro de SIRET. - = link_to('Plus d’informations', t("links.common.faq.erreur_siret_url"), **external_link_attributes) - when :network_error %p.fr-error-text= t('errors.messages.siret_network_error') diff --git a/app/views/shared/champs/siret/_show.html.haml b/app/views/shared/champs/siret/_show.html.haml index 7f819c6f8..44e31d87f 100644 --- a/app/views/shared/champs/siret/_show.html.haml +++ b/app/views/shared/champs/siret/_show.html.haml @@ -1,2 +1,2 @@ - if champ.etablissement.present? - = render partial: "shared/dossiers/identite_entreprise", locals: { etablissement: champ.etablissement, profile: profile } + = render partial: "shared/dossiers/identite_entreprise", locals: { etablissement: champ.etablissement, profile: profile, champ: } diff --git a/app/views/shared/dossiers/_demande.html.haml b/app/views/shared/dossiers/_demande.html.haml index 4a7e9d1d9..62d8f989f 100644 --- a/app/views/shared/dossiers/_demande.html.haml +++ b/app/views/shared/dossiers/_demande.html.haml @@ -2,64 +2,63 @@ - content_for(:notice_info) do = render partial: "shared/dossiers/france_connect_informations_notice", locals: { user_information: dossier.user.france_connect_informations.first } -.fr-container.counter-start-header-section.dossier-show{ class: class_names("dossier-show-instructeur" => profile =="instructeur") } - .fr-grid-row.fr-grid-row--center - .fr-col-12.fr-col-xl-8 - - if profile == 'instructeur' && dossier.termine_and_accuse_lecture? - = render Dsfr::CalloutComponent.new(title: nil) do |c| - - c.with_html_body do - = t('views.shared.dossiers.demande.accuse_lecture') - - if dossier.accuse_lecture_agreement_at.present? - = t('views.shared.dossiers.demande.accuse_lecture_with_agreement', agreement: l(dossier.accuse_lecture_agreement_at, format: :long)) - - else - = t('views.shared.dossiers.demande.accuse_lecture_without_agreement') +.counter-start-header-section.dossier-show.gallery.gallery-demande{ class: class_names("dossier-show-instructeur" => profile =="instructeur"), "data-controller": "lightbox" } - %h2.fr-h6.fr-background-alt--grey.fr-mb-0 - .flex-grow.fr-py-3v.fr-px-2w= t('views.shared.dossiers.demande.en_construction') + - if profile == 'instructeur' && dossier.termine_and_accuse_lecture? + = render Dsfr::CalloutComponent.new(title: nil) do |c| + - c.with_html_body do + = t('views.shared.dossiers.demande.accuse_lecture') + - if dossier.accuse_lecture_agreement_at.present? + = t('views.shared.dossiers.demande.accuse_lecture_with_agreement', agreement: l(dossier.accuse_lecture_agreement_at, format: :long)) + - else + = t('views.shared.dossiers.demande.accuse_lecture_without_agreement') - - if dossier.depose_at.present? - = render partial: "shared/dossiers/infos_generales", locals: { dossier: dossier, profile: profile } + %h2.fr-h6.fr-background-alt--grey.fr-mb-0 + .flex-grow.fr-py-3v.fr-px-2w= t('views.shared.dossiers.demande.en_construction') + + - if dossier.depose_at.present? + = render partial: "shared/dossiers/infos_generales", locals: { dossier: dossier, profile: profile } - - if dossier.for_tiers? - %h2.fr-h6.fr-background-alt--grey.fr-mb-0.flex - .flex-grow.fr-py-3v.fr-px-2w= t('views.shared.dossiers.demande.mandataire_identity') + - if dossier.for_tiers? + %h2.fr-h6.fr-background-alt--grey.fr-mb-0.flex + .flex-grow.fr-py-3v.fr-px-2w= t('views.shared.dossiers.demande.mandataire_identity') - - if dossier.individual.present? && profile == 'usager' && !dossier.read_only? - = link_to t('views.shared.dossiers.demande.edit_identity'), identite_dossier_path(dossier), class: 'fr-py-3v fr-btn fr-btn--tertiary-no-outline' + - if dossier.individual.present? && profile == 'usager' && !dossier.read_only? + = link_to t('views.shared.dossiers.demande.edit_identity'), identite_dossier_path(dossier), class: 'fr-py-3v fr-btn fr-btn--tertiary-no-outline' - = render partial: "shared/dossiers/mandataire_infos", locals: { user_deleted: dossier.user_deleted?, email: dossier.user_email_for(:display), dossier: dossier } + = render partial: "shared/dossiers/mandataire_infos", locals: { user_deleted: dossier.user_deleted?, email: dossier.user_email_for(:display), dossier: dossier } - .tab-title - %h2.fr-h6.fr-background-alt--grey.fr-mb-0.flex - .flex-grow.fr-py-3v.fr-px-2w - = t('views.shared.dossiers.demande.requester_identity') + .tab-title + %h2.fr-h6.fr-background-alt--grey.fr-mb-0.flex + .flex-grow.fr-py-3v.fr-px-2w + = t('views.shared.dossiers.demande.requester_identity') - - if dossier.identity_updated_at.present? && demande_seen_at&.<(dossier.identity_updated_at) - %span.fr-badge.fr-badge--new.fr-badge--sm - = t('views.shared.dossiers.demande.requester_identity_updated_at', date: try_format_datetime(dossier.identity_updated_at)) + - if dossier.identity_updated_at.present? && demande_seen_at&.<(dossier.identity_updated_at) + %span.fr-badge.fr-badge--new.fr-badge--sm + = t('views.shared.dossiers.demande.requester_identity_updated_at', date: try_format_datetime(dossier.identity_updated_at)) - - if dossier.etablissement.present? && profile == 'usager' && !dossier.read_only? - = link_to t('views.shared.dossiers.demande.edit_siret'), siret_dossier_path(dossier), class: 'fr-py-3v fr-btn fr-btn--tertiary-no-outline' + - if dossier.etablissement.present? && profile == 'usager' && !dossier.read_only? + = link_to t('views.shared.dossiers.demande.edit_siret'), siret_dossier_path(dossier), class: 'fr-py-3v fr-btn fr-btn--tertiary-no-outline' - - if dossier.individual.present? && profile == 'usager' && !dossier.read_only? - = link_to t('views.shared.dossiers.demande.edit_identity'), identite_dossier_path(dossier), class: 'fr-py-3v fr-btn fr-btn--tertiary-no-outline' + - if dossier.individual.present? && profile == 'usager' && !dossier.read_only? + = link_to t('views.shared.dossiers.demande.edit_identity'), identite_dossier_path(dossier), class: 'fr-py-3v fr-btn fr-btn--tertiary-no-outline' - = render partial: "shared/dossiers/user_infos", locals: { user_deleted: dossier.user_deleted?, email: dossier.user_email_for(:display), for_tiers: dossier.for_tiers?, beneficiaire_mail: dossier.for_tiers? ? dossier.individual.email : ""} + = render partial: "shared/dossiers/user_infos", locals: { user_deleted: dossier.user_deleted?, email: dossier.user_email_for(:display), for_tiers: dossier.for_tiers?, beneficiaire_mail: dossier.for_tiers? ? dossier.individual.email : ""} - - if dossier.individual.present? - = render partial: "shared/dossiers/identite_individual", locals: { dossier: dossier } + - if dossier.individual.present? + = render partial: "shared/dossiers/identite_individual", locals: { dossier: dossier } - - if dossier.etablissement.present? - .fr-mt-1w.fr-mb-4w.fr-px-2w - = render partial: "shared/dossiers/identite_entreprise", locals: { etablissement: dossier.etablissement, profile: profile } + - if dossier.etablissement.present? + .fr-mt-1w.fr-mb-4w.fr-px-2w + = render partial: "shared/dossiers/identite_entreprise", locals: { etablissement: dossier.etablissement, profile: profile } - %h2.fr-h6.fr-background-alt--grey.fr-mb-0.flex - .flex-grow.fr-py-3v.fr-px-2w= t('views.shared.dossiers.demande.form') + %h2.fr-h6.fr-background-alt--grey.fr-mb-0.flex + .flex-grow.fr-py-3v.fr-px-2w= t('views.shared.dossiers.demande.form') - - types_de_champ = dossier.revision.types_de_champ_public - - if types_de_champ.any? || dossier.procedure.routing_enabled? - = render ViewableChamp::SectionComponent.new(dossier:, types_de_champ:, demande_seen_at:, profile:) + - types_de_champ = dossier.revision.types_de_champ_public + - if types_de_champ.any? || dossier.procedure.routing_enabled? + = render ViewableChamp::SectionComponent.new(dossier:, types_de_champ:, demande_seen_at:, profile:) diff --git a/app/views/shared/dossiers/_edit.html.haml b/app/views/shared/dossiers/_edit.html.haml index 8a24a008f..c797d35f2 100644 --- a/app/views/shared/dossiers/_edit.html.haml +++ b/app/views/shared/dossiers/_edit.html.haml @@ -10,7 +10,7 @@ = render NestedForms::FormOwnerComponent.new = form_for dossier_for_editing, url: brouillon_dossier_url(dossier), method: :patch, html: { id: 'dossier-edit-form', class: 'form', multipart: true, novalidate: 'novalidate' } do |f| - = render Dossiers::ErrorsFullMessagesComponent.new(dossier: @dossier, errors: @errors || []) + = render Dossiers::ErrorsFullMessagesComponent.new(dossier: dossier) %header.mb-6 .fr-highlight %p.fr-text--sm @@ -21,8 +21,10 @@ = render Procedure::NoticeComponent.new(procedure: dossier.procedure) - = render EditableChamp::SectionComponent.new(dossier: dossier_for_editing, types_de_champ: dossier_for_editing.revision.types_de_champ_public) + %fieldset.fr-fieldset= render EditableChamp::SectionComponent.new(dossier: dossier_for_editing, types_de_champ: dossier_for_editing.revision.types_de_champ_public) = render Dossiers::PendingCorrectionCheckboxComponent.new(dossier: dossier) + = render Dossiers::InvalidIneligibiliteRulesComponent.new(dossier: dossier) + = render Dossiers::EditFooterComponent.new(dossier: dossier_for_editing, annotation: false) diff --git a/app/views/shared/dossiers/_edit_annotations.html.haml b/app/views/shared/dossiers/_edit_annotations.html.haml index 5457cd0c7..653b75cd2 100644 --- a/app/views/shared/dossiers/_edit_annotations.html.haml +++ b/app/views/shared/dossiers/_edit_annotations.html.haml @@ -3,7 +3,7 @@ %section.counter-start-header-section = render NestedForms::FormOwnerComponent.new = form_for dossier, url: annotations_instructeur_dossier_path(dossier.procedure, dossier), html: { class: 'form', multipart: true } do |f| - = render EditableChamp::SectionComponent.new(dossier:, types_de_champ: dossier.revision.types_de_champ_private) + %fieldset.fr-fieldset= render EditableChamp::SectionComponent.new(dossier:, types_de_champ: dossier.revision.types_de_champ_private) = render Dossiers::EditFooterComponent.new(dossier: dossier, annotation: true) - else diff --git a/app/views/shared/dossiers/_header.html.haml b/app/views/shared/dossiers/_header.html.haml index c162b080d..5a93fc5b4 100644 --- a/app/views/shared/dossiers/_header.html.haml +++ b/app/views/shared/dossiers/_header.html.haml @@ -2,7 +2,7 @@ = procedure_libelle(dossier.procedure) = status_badge_user(dossier, 'super') %h2 - = t('views.users.dossiers.show.header.dossier_number', dossier_id: dossier.id) + = t('views.users.dossiers.show.header.dossier_number_html', dossier_id: dossier.id) = t('views.users.dossiers.show.header.created_date', date_du_dossier: I18n.l(dossier.created_at)) = render(partial: 'users/dossiers/expiration_banner', locals: {dossier: dossier}) diff --git a/app/views/shared/dossiers/_identite_entreprise.html.haml b/app/views/shared/dossiers/_identite_entreprise.html.haml index a7b88fc7f..7d2bd5dbc 100644 --- a/app/views/shared/dossiers/_identite_entreprise.html.haml +++ b/app/views/shared/dossiers/_identite_entreprise.html.haml @@ -73,12 +73,7 @@ - c.with_value do %p= etablissement.entreprise.numero_tva_intracommunautaire - = render Dossiers::RowShowComponent.new(label: "Adresse") do |c| - - c.with_value do - %p - - etablissement.adresse.split("\n").compact_blank.each do |line| - = line - %br + = render partial: "shared/dossiers/normalized_address", locals: { address: AddressProxy.new(defined?(champ) ? champ : etablissement)} = render Dossiers::RowShowComponent.new(label: "Capital social") do |c| - c.with_value do diff --git a/app/views/shared/dossiers/_normalized_address.html.haml b/app/views/shared/dossiers/_normalized_address.html.haml new file mode 100644 index 000000000..698a01cfa --- /dev/null +++ b/app/views/shared/dossiers/_normalized_address.html.haml @@ -0,0 +1,23 @@ += render Dossiers::RowShowComponent.new(label: t("activemodel.attributes.normalized_address.full_address")) do |c| + - c.with_value do + %p + = address.street_address + %br + = [address.city_name, address.postal_code].join(" ") + + +- ['city_code', 'postal_code'].each do |scope| + - if address.public_send(scope).present? + = render Dossiers::RowShowComponent.new(label: t("activemodel.attributes.normalized_address.#{scope}")) do |c| + - c.with_value do + %p= address.public_send(scope) + +- if address.departement_name.present? + = render Dossiers::RowShowComponent.new(label: t("activemodel.attributes.normalized_address.department")) do |c| + - c.with_value do + %p= "#{address.departement_name} – #{address.departement_code}" + +- if address.region_name.present? + = render Dossiers::RowShowComponent.new(label: t("activemodel.attributes.normalized_address.region")) do |c| + - c.with_value do + %p= "#{address.region_name} – #{address.region_code}" diff --git a/app/views/shared/dossiers/_update_contact_information.html.haml b/app/views/shared/dossiers/_update_contact_information.html.haml new file mode 100644 index 000000000..a4abc0620 --- /dev/null +++ b/app/views/shared/dossiers/_update_contact_information.html.haml @@ -0,0 +1,28 @@ +#contact_information + - service = dossier&.service || procedure.service + - if service.present? + %h3.fr-footer__top-cat= I18n.t('users.procedure_footer.managed_by.header') + .fr-footer__top-link.fr-pb-3w + %span{ lang: :fr }= service.pretty_nom + %div{ lang: :fr } + = render SimpleFormatComponent.new(service.adresse, class_names_map: {paragraph: 'fr-footer__content-desc'}) + %h3.fr-footer__top-cat= I18n.t('users.procedure_footer.contact.header') + %ul.fr-footer__top-list + - if dossier.present? && dossier.messagerie_available? + %li + = link_to I18n.t('users.procedure_footer.contact.in_app_mail.link'), messagerie_dossier_path(dossier), class: 'fr-footer__link' + - elsif service.present? + %li + %span.fr-footer__top-link + = I18n.t('users.procedure_footer.contact.email.link') + = link_to service.email, "mailto:#{service.email}", class: "fr-footer__link" + + - if service.telephone.present? + %li + %span.fr-footer__top-link + = I18n.t('users.procedure_footer.contact.phone.label') + = link_to I18n.t('users.procedure_footer.contact.phone.link', service_telephone: service.telephone), service.telephone_url, class: 'fr-footer__link' + + - if service.horaires.present? + %li + = "#{I18n.t('users.procedure_footer.contact.schedule.prefix')}#{formatted_horaires(service.horaires)}" diff --git a/app/views/shared/dossiers/messages/_form.html.haml b/app/views/shared/dossiers/messages/_form.html.haml index f36c6c434..f8f9eb09c 100644 --- a/app/views/shared/dossiers/messages/_form.html.haml +++ b/app/views/shared/dossiers/messages/_form.html.haml @@ -10,10 +10,11 @@ = render Dsfr::InputComponent.new(form: f, attribute: :body, input_type: :text_area, opts: { rows: 5, placeholder: placeholder, title: placeholder, class: 'fr-input message-textarea'}) - if local_assigns.has_key?(:dossier) - .fr-mt-3w{ data: { controller: "file-input-reset" } } - = render Attachment::EditComponent.new(attached_file: commentaire.piece_jointe) - %button.hidden.fr-btn.fr-btn--tertiary-no-outline.fr-btn--icon-left.fr-icon-delete-line{ data: { 'file-input-reset-target': 'reset', action: 'file-input-reset#reset' } } - = t('views.shared.messages.remove_file') + .fr-mt-3w.fr-input-group + = f.label :piece_jointe, class: "fr-label", for: dom_id(commentaire, :piece_jointe) + %div{ data: { controller: "file-input-reset", delete_label: t('views.shared.messages.remove_file') } } + = render Attachment::MultipleComponent.new(attached_file: commentaire.piece_jointe) + %ul{ data: { 'file-input-reset-target': 'fileList' } } .fr-mt-3w = f.submit t('views.shared.dossiers.messages.form.send_message'), class: 'fr-btn', data: { disable: true } diff --git a/app/views/shared/help/_help_dropdown_dossier.html.haml b/app/views/shared/help/_help_dropdown_dossier.html.haml index f14542c8a..82fc2e87a 100644 --- a/app/views/shared/help/_help_dropdown_dossier.html.haml +++ b/app/views/shared/help/_help_dropdown_dossier.html.haml @@ -1,17 +1,17 @@ -= render Dropdown::MenuComponent.new(wrapper: :span, wrapper_options: { class: ['help-dropdown']}, menu_options: { id: "help-menu" }) do |menu| - - menu.with_button_inner_html do - = t('help') +.fr-translate.fr-nav + .fr-nav__item + %button.help-btn.fr-translate__btn.fr-btn{ "aria-controls" => "help-menu", "aria-expanded" => "false" } + = t('help') - - title = dossier.brouillon? ? t("help_dropdown.help_brouillon_title") : t("help_dropdown.help_filled_dossier") + #help-menu.help-content.fr-collapse.fr-menu + - title = dossier.brouillon? ? t("help_dropdown.help_brouillon_title") : t("help_dropdown.help_filled_dossier") + %ul.fr-menu__list - - if dossier.messagerie_available? - - menu.with_item do - = render partial: 'shared/help/dropdown_items/messagerie_item', locals: { dossier: dossier, title: title } + - if dossier.messagerie_available? + %li.flex + = render partial: 'shared/help/dropdown_items/messagerie_item', locals: { dossier: dossier, title: title } - - elsif dossier.procedure.service.present? - - menu.with_item do - = render partial: 'shared/help/dropdown_items/service_item', - locals: { service: dossier.procedure.service, title: title } - - - menu.with_item do - = render partial: 'shared/help/dropdown_items/faq_item' + - elsif dossier.procedure.service.present? + %li.flex + = render partial: 'shared/help/dropdown_items/service_item', + locals: { service: dossier.procedure.service, title: title } diff --git a/app/views/shared/help/_help_dropdown_instructeur.html.haml b/app/views/shared/help/_help_dropdown_instructeur.html.haml index 329b80e50..184977ba2 100644 --- a/app/views/shared/help/_help_dropdown_instructeur.html.haml +++ b/app/views/shared/help/_help_dropdown_instructeur.html.haml @@ -1,8 +1,9 @@ -= render Dropdown::MenuComponent.new(wrapper: :span, wrapper_options: { class: ['help-dropdown']}, menu_options: { id: "help-menu" }) do |menu| - - menu.with_button_inner_html do - = t('help') +.fr-translate.fr-nav + .fr-nav__item + %button.help-btn.fr-translate__btn.fr-btn{ "aria-controls" => "help-menu", "aria-expanded" => "false" } + = t('help') - - menu.with_item do - = render partial: 'shared/help/dropdown_items/faq_item' - - menu.with_item do - = render partial: 'shared/help/dropdown_items/email_item' + #help-menu.help-content.fr-collapse.fr-menu + %ul.fr-menu__list + %li + = render partial: 'shared/help/dropdown_items/email_item' diff --git a/app/views/shared/help/_help_dropdown_procedure.html.haml b/app/views/shared/help/_help_dropdown_procedure.html.haml index dc4ddc14e..5e5daa1b5 100644 --- a/app/views/shared/help/_help_dropdown_procedure.html.haml +++ b/app/views/shared/help/_help_dropdown_procedure.html.haml @@ -1,9 +1,10 @@ -= render Dropdown::MenuComponent.new(wrapper: :span, wrapper_options: { class: ['help-dropdown']}, menu_options: { id: "help-menu" }) do |menu| - - menu.with_button_inner_html do - = t('help') +.fr-translate.fr-nav + .fr-nav__item + %button.help-btn.fr-translate__btn.fr-btn{ "aria-controls" => "help-menu", "aria-expanded" => "false" } + = t('help') - - if procedure.service.present? - - menu.with_item do - = render partial: 'shared/help/dropdown_items/service_item', locals: { service: procedure.service, title: t('help_dropdown.procedure_title') } - - menu.with_item do - = render partial: 'shared/help/dropdown_items/faq_item' + #help-menu.help-content.fr-collapse.fr-menu + %ul.fr-menu__list + - if procedure.service.present? + %li.flex + = render partial: 'shared/help/dropdown_items/service_item', locals: { service: procedure.service, title: t('help_dropdown.procedure_title') } diff --git a/app/views/shared/help/dropdown_items/_email_item.html.haml b/app/views/shared/help/dropdown_items/_email_item.html.haml index dae504d16..029ffa4ed 100644 --- a/app/views/shared/help/dropdown_items/_email_item.html.haml +++ b/app/views/shared/help/dropdown_items/_email_item.html.haml @@ -1,5 +1,5 @@ -= mail_to CONTACT_EMAIL, role: 'menuitem' do += mail_to CONTACT_EMAIL, class: 'flex' do %span.fr-icon-mail-fill.fr-text-action-high--blue-france{ "aria-hidden": "true" } - .dropdown-description.fr-text--sm - %span.help-dropdown-title= t('help_dropdown.technical_contact_title') - %p.fr-text--sm= t('help_dropdown.technical_contact_description', contact_email: CONTACT_EMAIL) + .fr-pl-1w + %h1= t('help_dropdown.technical_contact_title') + %p= t('help_dropdown.technical_contact_description', contact_email: CONTACT_EMAIL) diff --git a/app/views/shared/help/dropdown_items/_faq_item.html.haml b/app/views/shared/help/dropdown_items/_faq_item.html.haml index e4a4e60ef..264d303e4 100644 --- a/app/views/shared/help/dropdown_items/_faq_item.html.haml +++ b/app/views/shared/help/dropdown_items/_faq_item.html.haml @@ -1,6 +1,4 @@ -= link_to t("links.common.faq.url"), title: new_tab_suffix(t('help_dropdown.general_title')), **external_link_attributes, role: 'menuitem' do - %span.fr-icon-question-fill.fr-text-action-high--blue-france{ "aria-hidden": "true" } - .dropdown-description.fr-text--sm - %span.help-dropdown-title - = t('help_dropdown.problem_title') - %p.fr-text--sm= t('help_dropdown.problem_description') +%span.fr-icon-question-fill.fr-text-action-high--blue-france{ "aria-hidden": "true" } +.fr-pl-1w + %h1= t('help_dropdown.problem_title') + = link_to t('help_dropdown.problem_description'), t("links.common.faq.url"), title: new_tab_suffix(t('help_dropdown.problem_description')), **external_link_attributes diff --git a/app/views/shared/help/dropdown_items/_messagerie_item.html.haml b/app/views/shared/help/dropdown_items/_messagerie_item.html.haml index 0316831bc..d0684ffee 100644 --- a/app/views/shared/help/dropdown_items/_messagerie_item.html.haml +++ b/app/views/shared/help/dropdown_items/_messagerie_item.html.haml @@ -1,5 +1,4 @@ -= link_to messagerie_dossier_path(dossier), role: 'menuitem' do - %span.fr-icon-mail-fill.fr-text-action-high--blue-france{ "aria-hidden": "true" } - .dropdown-description.fr-text--sm - %span.help-dropdown-title= title - %p.fr-text--sm= t('help_dropdown.contact_instructeur') +%span.fr-icon-mail-fill.fr-text-action-high--blue-france{ "aria-hidden": "true" } +.fr-pl-1w + %h1= title + = link_to t('help_dropdown.contact_instructeur'), messagerie_dossier_path(dossier) diff --git a/app/views/shared/help/dropdown_items/_service_item.html.haml b/app/views/shared/help/dropdown_items/_service_item.html.haml index 208601f51..4774799a2 100644 --- a/app/views/shared/help/dropdown_items/_service_item.html.haml +++ b/app/views/shared/help/dropdown_items/_service_item.html.haml @@ -1,14 +1,23 @@ %span.fr-icon-user-fill.fr-text-action-high--blue-france{ "aria-hidden": "true" } -.dropdown-description.fr-text--sm - %span.help-dropdown-title= title - .help-dropdown-service-action - %p.fr-text--sm= t('help_dropdown.contact_administration') - %p.fr-text--sm.help-dropdown-service-item - %span.fr-icon-mail-fill.fr-icon--sm{ "aria-hidden": "true" } - = link_to service.email, "mailto:#{service.email}", role: 'menuitem' - %p.fr-text--sm - %span.fr-icon-phone-fill.fr-icon--sm{ "aria-hidden": "true" } - = link_to service.telephone, service.telephone_url, role: 'menuitem' - %p.fr-text--sm - %span.fr-icon-time-fill.fr-icon--sm{ "aria-hidden": "true" } - = service.horaires +.fr-pl-1w + %h1= title + %p.fr-mb-1w= t('help_dropdown.contact_administration') + %dl + .flex.fr-mb-1v + %dt.fr-mr-1v + %span.fr-icon-mail-fill.fr-icon--sm{ "aria-hidden": "true" } + %span.visually-hidden= t('layouts.mailers.service_footer.by_email') + %dd + = link_to service.email, "mailto:#{service.email}" + .flex.fr-mb-1v + %dt.fr-mr-1v + %span.fr-icon-phone-fill.fr-icon--sm{ "aria-hidden": "true" } + %span.visually-hidden= t('layouts.mailers.service_footer.by_phone') + %dd + = link_to service.telephone, service.telephone_url + .flex + %dt.fr-mr-1v + %span.fr-icon-time-fill.fr-icon--sm{ "aria-hidden": "true" } + %span.visually-hidden= t('layouts.mailers.service_footer.schedule') + %dd + = service.horaires diff --git a/app/views/shared/kaminari/_first_page.html.haml b/app/views/shared/kaminari/_first_page.html.haml index 4a781e745..724f52f9f 100644 --- a/app/views/shared/kaminari/_first_page.html.haml +++ b/app/views/shared/kaminari/_first_page.html.haml @@ -1,2 +1,2 @@ %li - = link_to_unless current_page.first?, t('views.pagination.first').html_safe, url, remote: remote, class: 'fr-pagination__link fr-pagination__link--first', 'aria-disabled': true, title: 'Première page', role: 'link' + = link_to_unless current_page.first?, t('views.pagination.first').html_safe, url, remote: remote, class: 'fr-pagination__link fr-pagination__link--first', title: 'Première page', role: 'link' diff --git a/app/views/shared/kaminari/_last_page.html.haml b/app/views/shared/kaminari/_last_page.html.haml index 7f4bfd218..af2811abf 100644 --- a/app/views/shared/kaminari/_last_page.html.haml +++ b/app/views/shared/kaminari/_last_page.html.haml @@ -1,2 +1,2 @@ %li - = link_to_unless current_page.last?, t('views.pagination.last').html_safe, url, rel: 'next', remote: remote, class: 'fr-pagination__link fr-pagination__link--last', 'aria-disabled': true, title: "Dernière page", role: 'link' + = link_to_unless current_page.last?, t('views.pagination.last').html_safe, url, rel: 'next', remote: remote, class: 'fr-pagination__link fr-pagination__link--last', title: "Dernière page", role: 'link' diff --git a/app/views/shared/kaminari/_next_page.html.haml b/app/views/shared/kaminari/_next_page.html.haml index 405bbaca9..54531dd60 100644 --- a/app/views/shared/kaminari/_next_page.html.haml +++ b/app/views/shared/kaminari/_next_page.html.haml @@ -1,2 +1,2 @@ %li - = link_to_unless current_page.last?, t('views.pagination.next').html_safe, url, rel: 'next', remote: remote, class: 'fr-pagination__link fr-pagination__link--next fr-pagination__link--lg-label', 'aria-disabled': true, role: 'link' + = link_to_unless current_page.last?, t('views.pagination.next').html_safe, url, rel: 'next', remote: remote, class: 'fr-pagination__link fr-pagination__link--next fr-pagination__link--lg-label', role: 'link' diff --git a/app/views/shared/kaminari/_prev_page.html.haml b/app/views/shared/kaminari/_prev_page.html.haml index a62f6dc73..265419213 100644 --- a/app/views/shared/kaminari/_prev_page.html.haml +++ b/app/views/shared/kaminari/_prev_page.html.haml @@ -1,2 +1,2 @@ %li - = link_to_unless current_page.first?, t('views.pagination.previous').html_safe, url, rel: 'prev', remote: remote, class: 'fr-pagination__link fr-pagination__link--prev fr-pagination__link--lg-label', 'aria-disabled': true, role: 'link' + = link_to_unless current_page.first?, t('views.pagination.previous').html_safe, url, rel: 'prev', remote: remote, class: 'fr-pagination__link fr-pagination__link--prev fr-pagination__link--lg-label', role: 'link' diff --git a/app/views/shared/procedures/_stats.html.haml b/app/views/shared/procedures/_stats.html.haml index b39856c9e..fd6fb1c85 100644 --- a/app/views/shared/procedures/_stats.html.haml +++ b/app/views/shared/procedures/_stats.html.haml @@ -1,45 +1,56 @@ -.statistiques{ 'data-controller': 'chartkick' } - %h1.new-h1= title - .stat-cards +.fr-container.fr-my-4w + %h1= title + .fr-grid-row.fr-grid-row--gutters - if @usual_traitement_time.present? - .stat-card.big-number-card - %span.big-number-card-title= t('.usual_processing_time') - = render Procedure::EstimatedDelayComponent.new(procedure: @procedure) + .fr-col-xs-12 + .fr-callout + %h2.fr-callout__title= t('.usual_processing_time') + = render Procedure::EstimatedDelayComponent.new(procedure: @procedure) - .stat-cards - .stat-card.stat-card-half.pull-left - %span.stat-card-title= t('.processing_time') - .stat-card-details= t('.since_procedure_creation') - .chart-container - .chart - - colors = %w(#C3D9FF #0069CC #1C7EC9) # from _colors.scss - = column_chart @usual_traitement_time_by_month, ytitle: t('.nb_days'), legend: "bottom", label: t('.processing_time_graph_description') + .fr-col-xs-12.fr-col-sm-12.fr-col-lg-6 + .fr-callout{ data: { controller: 'chartkick' } } + %h2.fr-callout__title= t('.processing_time') + %p.fr-callout__text.fr-text--md= t('.since_procedure_creation') - .stat-card.stat-card-half.pull-left - %span.stat-card-title= t('.status_evolution') - .stat-card-details= t('.status_evolution_details') - .chart-container - .chart - = area_chart @dossiers_funnel, ytitle: t('.dossiers_count'), label: t('.dossiers_count') + .fr-mt-4w + .chart-procedures-chart{ data: { 'chartkick-target': 'chart' } } + = column_chart @usual_traitement_time_by_month, + library: Chartkick.options[:default_library_config], + ytitle: t('.nb_days'), legend: "bottom", label: t('.processing_time_graph_description') - .stat-cards - .stat-card.stat-card-half.pull-left - %span.stat-card-title= t('.acceptance_rate') - .stat-card-details= t('.acceptance_rate_details') - .chart-container - .chart - = pie_chart @termines_states, - code: true, - colors: %w(#387EC3 #AE2C2B #FAD859), - label: t('.rate'), - suffix: '%', - library: { plotOptions: { pie: { dataLabels: { enabled: true, format: '{point.name} : {point.percentage: .1f}%' } } } } + .fr-col-xs-12.fr-col-sm-12.fr-col-lg-6 + .fr-callout + %h2.fr-callout__title= t('.status_evolution') + %p.fr-callout__text.fr-text--md= t('.status_evolution_details') + .fr-mt-4w + .chart + = area_chart @dossiers_funnel, + library: Chartkick.options[:default_library_config], + ytitle: t('.dossiers_count'), label: t('.dossiers_count') - .stat-card.stat-card-half.pull-left - %span.stat-card-title= t('.weekly_distribution') - .stat-card-details= t('.weekly_distribution_details') - .chart-container - .chart - = line_chart @termines_by_week, colors: ["#387EC3", "#AE2C2B", "#FAD859"], ytitle: t('.dossiers_count') -.clearfix + .fr-col-xs-12.fr-col-sm-12.fr-col-lg-6 + .fr-callout + %h2.fr-callout__title= t('.acceptance_rate') + %p.fr-callout__text.fr-text--md= t('.acceptance_rate_details') + + .fr-mt-4w + .chart + = pie_chart @termines_states, + library: Chartkick.options[:default_library_config], + code: true, + colors: ["var(--background-flat-success)", "var(--background-flat-error)", "#FAD859" ], + label: t('.rate'), + suffix: '%' + + .fr-col-xs-12.fr-col-sm-12.fr-col-lg-6 + .fr-callout + %h2.fr-callout__title= t('.weekly_distribution') + %p.fr-callout__text.fr-text--md= t('.weekly_distribution_details') + + .fr-mt-4w + .chart + = line_chart @termines_by_week, + library: Chartkick.options[:default_library_config], + colors: ["var(--background-flat-success)", "var(--background-flat-error)", "#FAD859" ], + ytitle: t('.dossiers_count') diff --git a/app/views/static_pages/accessibility_statement.html.haml b/app/views/static_pages/accessibility_statement.html.haml index 7859f8554..3f2666061 100644 --- a/app/views/static_pages/accessibility_statement.html.haml +++ b/app/views/static_pages/accessibility_statement.html.haml @@ -9,20 +9,20 @@ .fr-col-xl-8 %h1.fr-mb-4w = t('views.accessibility_statement.title') - %p.fr-mb-2w= t('views.accessibility_statement.line_one') - %p.fr-mb-2w= t('views.accessibility_statement.line_two', app_name: Current.application_name, host: URI(Current.application_base_url).host) + %p.fr-mb-2w= t('views.accessibility_statement.line_one_html') + %p.fr-mb-2w= t('views.accessibility_statement.line_two_html', app_name: Current.application_name, host: URI(Current.application_base_url).host) %div %h2 = t('views.accessibility_statement.compliance.title') - %p.fr-mb-2w= t('views.accessibility_statement.compliance.line_one_html') - + %p.fr-mb-2w= t('views.accessibility_statement.compliance.content_html') %h3 = t('views.accessibility_statement.results.title') - %p.fr-mb-2w= t('views.accessibility_statement.results.line_one') - %p.fr-mb-2w= t('views.accessibility_statement.results.line_three') + %p.fr-mb-2w= t('views.accessibility_statement.results.content') %p.fr-mb-2w + = t('views.accessibility_statement.results.programme.intro') = link_to t("views.accessibility_statement.results.programme.label"), t("views.accessibility_statement.results.programme.url"), title: t("views.accessibility_statement.results.programme.title"), target: "_blank", rel: "noopener noreferrer" + = "." %div %h2 @@ -31,6 +31,9 @@ = t('views.accessibility_statement.no_accessible.subtitle_one') .fr-mb-2w = t('views.accessibility_statement.no_accessible.examples_html') + %h3 + = t('views.accessibility_statement.no_accessible.subtitle_two') + = t('views.accessibility_statement.no_accessible.content_html') %div %h2 @@ -45,6 +48,10 @@ = "HTML 5" %li = "CSS 3" + %li + = "SVG" + %li + = "ARIA" %li = "Javascript" %li @@ -93,18 +100,6 @@ = link_to t("views.accessibility_statement.preparation.page_five.label"), new_user_session_path %li = t("views.accessibility_statement.preparation.page_six") - %li - = link_to t("views.accessibility_statement.preparation.page_seven.label"), t("views.accessibility_statement.preparation.page_seven.url"), - title: t("views.accessibility_statement.preparation.page_seven.title"), **external_link_attributes - %li - = link_to t("views.accessibility_statement.preparation.page_eight.label"), t("views.accessibility_statement.preparation.page_eight.url"), - title: t("views.accessibility_statement.preparation.page_eight.title"), **external_link_attributes - %li - = t("views.accessibility_statement.preparation.page_nine") - %li - = t("views.accessibility_statement.preparation.page_ten") - %li - = t("views.accessibility_statement.preparation.page_eleven") %li = t("views.accessibility_statement.preparation.page_twelve") %li @@ -131,11 +126,18 @@ %h2 = t('views.accessibility_statement.remedies.title') %p.fr-mb-2w= t('views.accessibility_statement.remedies.line_one') - %p.fr-mb-2w= t('views.accessibility_statement.remedies.line_two') - %ul.fr-mb-2w + %h3 + = t('views.accessibility_statement.remedies.arcom_title') + %p.fr-mb-2w= t('views.accessibility_statement.remedies.arcom_content_html') + %h3 + = t('views.accessibility_statement.remedies.ddd_title') + %p.fr-mb-2w= t('views.accessibility_statement.remedies.ddd_intro') + %ul %li = link_to t("views.accessibility_statement.remedies.remedies_one.label"), t("views.accessibility_statement.remedies.remedies_one.url"), title: t("views.accessibility_statement.remedies.remedies_one.title"), target: "_blank", rel: "noopener noreferrer" + %br + = t('views.accessibility_statement.remedies.remedies_one.info') %li = link_to t("views.accessibility_statement.remedies.remedies_two.label"), t("views.accessibility_statement.remedies.remedies_two.url"), title: t("views.accessibility_statement.remedies.remedies_two.title"), target: "_blank", rel: "noopener noreferrer" %li - = t('views.accessibility_statement.remedies.remedies_three') + = t('views.accessibility_statement.remedies.remedies_three_html') diff --git a/app/views/static_pages/legal_notice.html.haml b/app/views/static_pages/legal_notice.html.haml index ffa99bd18..889d0f86f 100644 --- a/app/views/static_pages/legal_notice.html.haml +++ b/app/views/static_pages/legal_notice.html.haml @@ -13,9 +13,12 @@ .fr-mb-4w %h2 = t('views.legal_notice.editing') - %p.fr-mb-2w= t('views.legal_notice.editing_content.line_one') - %p.fr-mb-2w= t('views.legal_notice.editing_content.line_two') - %p.fr-mb-2w= t('views.legal_notice.editing_content.line_three') + %p + = t('views.legal_notice.editing_content.line_one') + %br + = t('views.legal_notice.editing_content.line_two') + %br + = t('views.legal_notice.editing_content.line_three') .fr-mb-4w %h2 @@ -25,8 +28,18 @@ .fr-mb-4w %h2 = t('views.legal_notice.hosting') - %p.fr-mb-2w= t('views.legal_notice.hosting_content.line_one') - %p.fr-mb-2w= t('views.legal_notice.hosting_content.line_two') - %p.fr-mb-2w= t('views.legal_notice.hosting_content.line_three') - %p.fr-mb-2w= t('views.legal_notice.hosting_content.line_four') - %p.fr-mb-2w= t('views.legal_notice.hosting_content.line_five') + %h3 + = t('views.legal_notice.hosting_content.firm') + %dl.pl-0 + .flex + %dt= t('views.legal_notice.hosting_content.RCS_term') + %dd= t('views.legal_notice.hosting_content.RCS_value') + .flex + %dt= t('views.legal_notice.hosting_content.APE_term') + %dd= t('views.legal_notice.hosting_content.APE_value') + .flex + %dt= t('views.legal_notice.hosting_content.TVA_term_html') + %dd= t('views.legal_notice.hosting_content.TVA_value') + .flex + %dt= t('views.legal_notice.hosting_content.headquarters_term') + %dd= t('views.legal_notice.hosting_content.headquarters_value') diff --git a/app/views/stats/index.html.haml b/app/views/stats/index.html.haml index d1712f63a..96c847e47 100644 --- a/app/views/stats/index.html.haml +++ b/app/views/stats/index.html.haml @@ -2,71 +2,75 @@ - content_for :footer do = render partial: "root/footer" -.statistiques{ 'data-controller': 'chartkick' } - %h1.new-h1 Statistiques +.fr-container.fr-my-4w + %h1 Statistiques d’utilisation de la plateforme - .stat-cards - .stat-card.stat-card-half.big-number-card.pull-left - %span.big-number-card-title.long-title TOTAL DÉMARCHES DÉMAT. OU EN COURS DE DÉMAT. - %span.big-number-card-number - = number_with_delimiter(@procedures_numbers[:total]) - %span.big-number-card-detail - #{number_with_delimiter(@procedures_numbers[:last_30_days_count])} (#{@procedures_numbers[:evolution]} %) sur les 30 derniers jours - %span.big-number-card-detail - = link_to "Voir carte de déploiement", carte_path + .fr-grid-row.fr-grid-row--gutters + .fr-col-xs-12.fr-col-sm-12.fr-col-lg-6 + .fr-callout{ data: { controller: 'chartkick' } } + %h2.fr-callout__title Démarches dématérialisées (total) + %p.fr-callout__text.big-number-card-number.fr-mb-2w + %span.big-number-card-number= number_with_delimiter(@procedures_numbers[:total]) + %p.fr-callout__text.fr-text--md.text-center + #{number_with_delimiter(@procedures_numbers[:last_30_days_count])} (#{@procedures_numbers[:evolution]} %) sur les 30 derniers jours + %br + = link_to "Voir carte de déploiement", carte_path - .stat-card.stat-card-half.big-number-card.pull-left - %span.big-number-card-title TOTAL DOSSIERS DÉPOSÉS - %span.big-number-card-number - = number_with_delimiter(@dossiers_numbers[:total]) - %span.big-number-card-detail - #{number_with_delimiter(@dossiers_numbers[:last_30_days_count])} (#{@dossiers_numbers[:evolution]} %) sur les 30 derniers jours - %span.big-number-card-detail - = link_to "Voir carte de déploiement", carte_path(map_filter: { kind: :nb_dossiers }) + %fieldset.fr-segmented.fr-segmented--sm.pull-right.fr-mt-2w.fr-my-1w + .fr-segmented__elements + .fr-segmented__element + %input{ value: "1", checked: true, type: "radio", id: "segmented-procedures-1", name: "segmented-procedures", data: { action: 'chartkick#toggleChart', 'toggle-chart': '.monthly-procedures-chart' } } + %label.fr-label{ for: "segmented-procedures-1" } + Par mois + .fr-segmented__element + %input{ value: "2", type: "radio", id: "segmented-procedures-2", name: "segmented-procedures", data: { action: 'chartkick#toggleChart', 'toggle-chart': '.cumulative-procedures-chart' } } + %label.fr-label{ for: "segmented-procedures-2" } + Cumul + + .fr-mt-4w + .chart.monthly-procedures-chart{ data: { 'chartkick-target': 'chart' } } + = column_chart @procedures_in_the_last_4_months, library: Chartkick.options[:default_library_config] + .chart.cumulative-procedures-chart.hidden{ data: { 'chartkick-target': 'chart' } } + = area_chart @procedures_cumulative, library: Chartkick.options[:default_library_config] + + .fr-col-xs-12.fr-col-sm-12.fr-col-lg-6 + .fr-callout{ data: { controller: 'chartkick' } } + %h2.fr-callout__title Dossiers déposés (total) + %p.fr-callout__text.big-number-card-number.fr-mb-2w + = number_with_delimiter(@dossiers_numbers[:total]) + %p.fr-callout__text.fr-text--md.text-center + #{number_with_delimiter(@dossiers_numbers[:last_30_days_count])} (#{@dossiers_numbers[:evolution]} %) sur les 30 derniers jours + %br + = link_to "Voir carte de déploiement", carte_path(map_filter: { kind: :nb_dossiers }) + + %fieldset.fr-segmented.fr-segmented--sm.pull-right.fr-mt-2w.fr-my-1w + .fr-segmented__elements + .fr-segmented__element + %input{ value: "1", checked: true, type: "radio", id: "segmented-dossiers-1", name: "segmented-dossiers", data: { action: 'chartkick#toggleChart', 'toggle-chart': '.monthly-dossiers-chart' } } + %label.fr-label{ for: "segmented-dossiers-1" } + Par mois + .fr-segmented__element + %input{ value: "2", type: "radio", id: "segmented-dossiers-2", name: "segmented-dossiers", data: { action: 'chartkick#toggleChart', 'toggle-chart': '.cumulative-dossiers-chart' } } + %label.fr-label{ for: "segmented-dossiers-2" } + Cumul - .stat-card.stat-card-half.pull-left - %ul.segmented-control.pull-right - %li.segmented-control-item.segmented-control-item-active{ data: { 'toggle-chart': '.monthly-procedures-chart' } } - Par mois - %li.segmented-control-item{ data: { 'toggle-chart': '.cumulative-procedures-chart' } } - Cumul - %span.stat-card-title.pull-left Démarches dématérialisées - .clearfix + .fr-mt-4w + .chart.monthly-dossiers-chart{ data: { 'chartkick-target': 'chart' } } + = column_chart @dossiers_in_the_last_4_months, library: Chartkick.options[:default_library_config] + .chart.cumulative-dossiers-chart.hidden{ data: { 'chartkick-target': 'chart' } } + = area_chart @dossiers_cumulative, library: Chartkick.options[:default_library_config] - .chart-container - .chart.monthly-procedures-chart - = column_chart @procedures_in_the_last_4_months - .chart.cumulative-procedures-chart.hidden - = area_chart @procedures_cumulative + .fr-col-xs-12.fr-col-sm-12.fr-col-lg-6 + .fr-callout + %h2.fr-callout__title Répartition des dossiers - .stat-card.stat-card-half.pull-left - %ul.segmented-control.pull-right - %li.segmented-control-item.segmented-control-item-active{ data: { 'toggle-chart': '.monthly-dossiers-chart' } } - Par mois - %li.segmented-control-item{ data: { 'toggle-chart': '.cumulative-dossiers-chart' } } - Cumul - %span.stat-card-title.pull-left Dossiers déposés - .clearfix - - .chart-container - .chart.monthly-dossiers-chart - = column_chart @dossiers_in_the_last_4_months - .chart.cumulative-dossiers-chart.hidden - = area_chart @dossiers_cumulative - - .stat-card.stat-card-half.pull-left - %span.stat-card-title - Répartition des dossiers - - .chart-container - .chart - = pie_chart @dossiers_states_for_pie, - colors: ["#000091", "#7F7FC8", "#9A9AFF", "#00006D"] - - .clearfix + .fr-mt-4w + .chart + = pie_chart @dossiers_states_for_pie, library: Chartkick.options[:default_library_config], + colors: ["#000091", "#7F7FC8", "#9A9AFF", "#00006D"] - if super_admin_signed_in? - %h2.new-h2 Téléchargement + %h2.fr-h4 Téléchargement - = link_to "Télécharger les statistiques (CSV)", stats_download_path(format: :csv), class: 'fr-btn fr-btn-primary mb-4' + = link_to "Télécharger les statistiques (CSV)", stats_download_path(format: :csv), class: 'fr-btn fr-btn-primary fr-mb-4w' diff --git a/app/views/super_admins/release_notes/_main_navigation.html.haml b/app/views/super_admins/release_notes/_main_navigation.html.haml index 1485a91fa..5dd71e776 100644 --- a/app/views/super_admins/release_notes/_main_navigation.html.haml +++ b/app/views/super_admins/release_notes/_main_navigation.html.haml @@ -1,5 +1,5 @@ - content_for(:main_navigation) do - %nav.fr-nav#header-navigation{ role: "navigation", aria: { label: 'Menu principal annonces' } } + #header-navigation.fr-nav %ul.fr-nav__list %li.fr-nav__item = link_to "Toutes les annonces", super_admins_release_notes_path, class: "fr-nav__link", target: "_self", aria: { current: action == :index ? "page" : nil } diff --git a/app/views/support/admin.html.haml b/app/views/support/admin.html.haml deleted file mode 100644 index dff48a0cc..000000000 --- a/app/views/support/admin.html.haml +++ /dev/null @@ -1,49 +0,0 @@ -- content_for(:title, 'Contact') - -#contact-form - .container - %h1.new-h1 - = t('.contact_team') - - .description - = t('.admin_intro_html', contact_path: contact_path) - %br - %p.mandatory-explanation= t('asterisk_html', scope: [:utils]) - - = form_tag contact_path, method: :post, class: 'form' do |f| - - if !user_signed_in? - .contact-champ - = label_tag :email do - = t('.pro_mail') - %span.mandatory * - = text_field_tag :email, params[:email], required: true - - .contact-champ - = label_tag :type do - = t('.your_question') - %span.mandatory * - = select_tag :type, options_for_select(@options, params[:type]) - - .contact-champ - = label_tag :phone do - = t('.pro_phone_number') - = text_field_tag :phone - - .contact-champ - = label_tag :subject do - = t('subject', scope: [:utils]) - = text_field_tag :subject, params[:subject], required: false - - .contact-champ - = label_tag :text do - = t('message', scope: [:utils]) - %span.mandatory * - = text_area_tag :text, params[:text], rows: 6, required: true - - = invisible_captcha - - = hidden_field_tag :tags, @tags&.join(',') - = hidden_field_tag :admin, true - - .send-wrapper - = button_tag t('send_mail', scope: [:utils]), type: :submit, class: 'button send primary' diff --git a/app/views/support/index.html.haml b/app/views/support/index.html.haml deleted file mode 100644 index eadc29c96..000000000 --- a/app/views/support/index.html.haml +++ /dev/null @@ -1,76 +0,0 @@ -- content_for(:title, t('.contact')) -- content_for :footer do - = render partial: "root/footer" - -#contact-form - .container - %h1.new-h1 - = t('.contact') - - = form_tag contact_path, method: :post, multipart: true, class: 'fr-form-group', data: {controller: :support } do - - .description - .recommandations - = t('.intro_html') - %p.mandatory-explanation= t('asterisk_html', scope: [:utils]) - - - if !user_signed_in? - .fr-input-group - = label_tag :email, class: 'fr-label' do - Email - = render EditableChamp::AsteriskMandatoryComponent.new - = email_field_tag :email, params[:email], required: true, autocomplete: 'email', class: 'fr-input' - - %fieldset.fr-fieldset{ name: "type" } - %legend.fr-fieldset__legend - = t('.your_question') - = render EditableChamp::AsteriskMandatoryComponent.new - .fr-fieldset__content - - @options.each do |(question, question_type, link)| - .fr-radio-group - = radio_button_tag :type, question_type, false, required: true, data: {"support-target": "inputRadio" } - = label_tag "type_#{question_type}", { 'aria-controls': link ? "card-#{question_type}" : nil, class: 'fr-label' } do - = question - - - if link.present? - .fr-ml-3w.hidden{ id: "card-#{question_type}", "aria-hidden": true , data: { "support-target": "content" } } - = render Dsfr::CalloutComponent.new(title: t('.our_answer')) do |c| - - c.with_body do - %p - -# i18n-tasks-use t("support.index.#{question_type}.answer_html") - = t('answer_html', scope: [:support, :index, question_type], base_url: Current.application_base_url, "link_#{question_type}": link) - - .fr-input-group - = label_tag :dossier_id, t('file_number', scope: [:utils]), class: 'fr-label' - = text_field_tag :dossier_id, @dossier_id, class: 'fr-input' - - .fr-input-group - = label_tag :subject, class: 'fr-label' do - = t('subject', scope: [:utils]) - = render EditableChamp::AsteriskMandatoryComponent.new - = text_field_tag :subject, params[:subject], required: true, class: 'fr-input' - - .fr-input-group - = label_tag :text, class: 'fr-label' do - = t('message', scope: [:utils]) - = render EditableChamp::AsteriskMandatoryComponent.new - = text_area_tag :text, params[:text], rows: 6, required: true, class: 'fr-input' - - .fr-upload-group - = label_tag :piece_jointe, class: 'fr-label' do - = t('pj', scope: [:utils]) - %span.fr-hint-text - = t('.notice_upload_group') - - %p.notice.hidden{ data: { 'contact-type-only': Helpscout::FormAdapter::TYPE_AMELIORATION } } - = t('.notice_pj_product') - %p.notice.hidden{ data: { 'contact-type-only': Helpscout::FormAdapter::TYPE_AUTRE } } - = t('.notice_pj_other') - = file_field_tag :piece_jointe, class: 'fr-upload', max: 200.megabytes - - = hidden_field_tag :tags, @tags&.join(',') - - = invisible_captcha - - .send-wrapper.fr-my-3w - = button_tag t('send_mail', scope: [:utils]), type: :submit, class: 'fr-btn send' diff --git a/app/views/user_mailer/custom_confirmation_instructions.html.haml b/app/views/user_mailer/custom_confirmation_instructions.html.haml new file mode 100644 index 000000000..c9ac6a792 --- /dev/null +++ b/app/views/user_mailer/custom_confirmation_instructions.html.haml @@ -0,0 +1,22 @@ +- content_for(:title, 'Confirmez votre email') +%p + Bonjour + = @user.email + ! + +%p + Veuillez confirmer votre email en cliquant sur le lien ci-dessous: + = round_button 'Je confirme', france_connect_confirm_email_url(@token), :primary + + +%p Ce lien est valide #{distance_of_time_in_words(FranceConnectInformation::CONFIRMATION_EMAIL_VALIDITY)}. + +%p + Tant que vous n'aurez pas confirmé votre email, vous ne recevrez aucune notification sur l'avancement de vos dossiers. + +%p + Si vous n’êtes pas à l’origine de cette demande, vous pouvez ignorer ce message. Et si vous avez besoin d’assistance, n’hésitez pas à nous contacter à + = succeed '.' do + = mail_to CONTACT_EMAIL + += render partial: "layouts/mailers/signature" diff --git a/app/views/user_mailer/france_connect_merge_confirmation.haml b/app/views/user_mailer/france_connect_merge_confirmation.haml index 2f6c99a33..211703aa5 100644 --- a/app/views/user_mailer/france_connect_merge_confirmation.haml +++ b/app/views/user_mailer/france_connect_merge_confirmation.haml @@ -1,5 +1,5 @@ - content_for(:title, @subject) -- merge_link = france_connect_particulier_mail_merge_with_existing_account_url(email_merge_token: @email_merge_token) +- merge_link = france_connect_particulier_merge_using_email_link_url(email_merge_token: @email_merge_token) %p Bonjour, diff --git a/app/views/user_mailer/invite_instructeur.html.haml b/app/views/user_mailer/invite_instructeur.html.haml index b7ba5b9e8..19e64170e 100644 --- a/app/views/user_mailer/invite_instructeur.html.haml +++ b/app/views/user_mailer/invite_instructeur.html.haml @@ -18,10 +18,6 @@ Lors de vos prochaines connexions sur #{Current.application_name} cliquez sur le bouton « Se connecter » positionné sur le haut de page ou bien sur ce lien :  = link_to new_user_session_url, new_user_session_url -- if AgentConnectService.enabled? - %p - Vous êtes un agent de l'état et avez accès à AgentConnect ? Vous pouvez utiliser la connexion AgentConnect en suivant ce lien :  - = link_to agent_connect_url, agent_connect_url %p Nous vous invitons aussi à consulter notre tutoriel à destination des nouveaux instructeurs : = link_to(INSTRUCTEUR_TUTORIAL_URL, INSTRUCTEUR_TUTORIAL_URL) diff --git a/app/views/user_mailer/invite_tiers.html.haml b/app/views/user_mailer/invite_tiers.html.haml new file mode 100644 index 000000000..3b3e1ce53 --- /dev/null +++ b/app/views/user_mailer/invite_tiers.html.haml @@ -0,0 +1,27 @@ +- content_for(:title, "Vérification de votre mail sur #{Current.application_name}") + +%p + Bonjour, + + %p + - if @dossier.present? + Un dossier sur la démarche : #{@dossier.procedure.libelle} a été démarré en votre nom par #{@dossier.user.email}. + - else + Un dossier a été démarré en votre nom sur #{Current.application_name}" + + %p + Pour continuer à recevoir les mails concernant votre dossier, vous devez confirmer votre adresse email en cliquant sur ce bouton : + = round_button 'Je confirme', users_confirm_email_url(token: @token), :primary + + %p + Vous pouvez aussi utiliser ce lien : + = link_to(users_confirm_email_url(token: @token), users_confirm_email_url(token: @token)) + + %p + - if @dossier.present? + Pour en savoir plus, veuillez vous rapprocher de #{@dossier.user.email}. + - else + Nous restons à votre disposition si vous avez besoin d’accompagnement à l'adresse #{link_to CONTACT_EMAIL, "mailto:#{CONTACT_EMAIL}"}. + + += render partial: "layouts/mailers/signature" diff --git a/app/views/user_mailer/resend_confirmation_email.html.haml b/app/views/user_mailer/resend_confirmation_email.html.haml new file mode 100644 index 000000000..a189423c4 --- /dev/null +++ b/app/views/user_mailer/resend_confirmation_email.html.haml @@ -0,0 +1,18 @@ +- content_for(:title, "Vérification de votre mail sur #{Current.application_name}") + +%p + Bonjour, + +%p + Votre précédente confirmation de mail n'a pas fonctionné, vous pouvez essayer de nouveau en cliquant sur ce bouton : + = round_button 'Je confirme', users_confirm_email_url(token: @token), :primary + +%p + Vous pouvez aussi utiliser ce lien : + = link_to(users_confirm_email_url(token: @token), users_confirm_email_url(token: @token)) + +%p + Nous restons à votre disposition si vous avez besoin d’accompagnement à l'adresse #{link_to CONTACT_EMAIL, "mailto:#{CONTACT_EMAIL}"}. + + += render partial: "layouts/mailers/signature" diff --git a/app/views/users/_main_navigation.html.haml b/app/views/users/_main_navigation.html.haml index 4d10e5a49..c78c44cae 100644 --- a/app/views/users/_main_navigation.html.haml +++ b/app/views/users/_main_navigation.html.haml @@ -1,8 +1,12 @@ -%nav#header-navigation.fr-nav{ role: :navigation, "aria-label" => t('main_menu', scope: [:layouts, :header]) } +#header-navigation.fr-nav %ul.fr-nav__list - if params[:controller] == 'users/commencer' %li.fr-nav__item - = link_to t('back', scope: [:layouts, :header]), url_for(:back), title: t('back_title', scope: [:layouts, :header]), class: 'fr-nav__link', "aria-controls" => "modal-header__menu" + = link_to t('back', scope: [:layouts, :header]), url_for(:back), title: t('back_title', scope: [:layouts, :header]), class: 'fr-nav__link' %li.fr-nav__item - = link_to t('files', scope: [:layouts, :header]), dossiers_path, class: 'fr-nav__link', aria: { current: controller_name == 'dossiers' ? 'true' : nil, controls: "modal-header__menu" } + = link_to t('files', scope: [:layouts, :header]), dossiers_path, class: 'fr-nav__link', aria: { current: (controller_name == 'dossiers' && action_name != 'deleted_dossiers') ? 'true' : nil } + + - if current_user.deleted_dossiers.present? + %li.fr-nav__item + = link_to 'Historique des dossiers supprimés', deleted_dossiers_path(), class: 'fr-nav__link', aria: { current: action_name == 'deleted_dossiers' ? 'true' : nil } diff --git a/app/views/users/_procedure_footer.html.haml b/app/views/users/_procedure_footer.html.haml index 409f0369b..74a993f88 100644 --- a/app/views/users/_procedure_footer.html.haml +++ b/app/views/users/_procedure_footer.html.haml @@ -1,37 +1,10 @@ %footer.fr-footer.footer-procedure#footer{ role: "contentinfo" } - - service = dossier&.service || procedure.service .fr-footer__top.fr-mb-0 .fr-container + %h2.sr-only= t("links.footer.top_labels.hidden_title_procedure") .fr-grid-row.fr-grid-row--start.fr-grid-row--gutters .fr-col-12.fr-col-sm-4.fr-col-md-4 - - if service.present? - %h3.fr-footer__top-cat= I18n.t('users.procedure_footer.managed_by.header') - .fr-footer__top-link.fr-pb-2w - %span{ lang: :fr }= service.pretty_nom - %div{ lang: :fr } - = render SimpleFormatComponent.new(service.adresse, class_names_map: {paragraph: 'fr-footer__content-desc'}) - %h3.fr-footer__top-cat= I18n.t('users.procedure_footer.contact.header') - %ul.fr-footer__top-list - - if dossier.present? && dossier.messagerie_available? - %li - = link_to I18n.t('users.procedure_footer.contact.in_app_mail.link'), messagerie_dossier_path(dossier), class: 'fr-footer__top-link' - - elsif service.present? - %li - %span.fr-footer__top-link - = I18n.t('users.procedure_footer.contact.email.link') - = link_to service.email, "mailto:#{service.email}", class: "fr-footer__top-link" - - - if service.present? - - if service.telephone.present? || service.horaires.present? - %li - - horaires = "#{I18n.t('users.procedure_footer.contact.schedule.prefix')}#{formatted_horaires(service.horaires)}" - - if service.telephone.present? - = link_to service.telephone_url, class: 'fr-footer__top-link' do - %p - = I18n.t('users.procedure_footer.contact.phone.link', service_telephone: service.telephone) - - if service.horaires.present? - %p - = horaires + = render partial: 'shared/dossiers/update_contact_information', locals: { dossier: dossier, procedure: procedure } - politiques = politiques_conservation_de_donnees(procedure) - if politiques.present? @@ -40,34 +13,30 @@ %ul.fr-footer__top-list - politiques.each do |politique| %li - = link_to t("users.procedure_footer.legals.data_retention_url"), class: "fr-footer__top-link", title: new_tab_suffix(t("users.procedure_footer.legals.data_retention_title")), **external_link_attributes do + = link_to t("users.procedure_footer.legals.data_retention_url"), class: "fr-footer__link", title: new_tab_suffix(t("users.procedure_footer.legals.data_retention_title", data_retention_title: politiques_conservation_de_donnees(procedure).join)), **external_link_attributes do = politique - if procedure.deliberation.attached? %li - = link_to url_for(procedure.deliberation), rel: 'noopener', class: 'fr-footer__top-link' do + = link_to url_for(procedure.deliberation), rel: 'noopener', class: 'fr-footer__link' do = I18n.t("users.procedure_footer.legals.terms") - else %li - = link_to I18n.t("users.procedure_footer.legals.terms"), procedure.cadre_juridique, rel: 'noopener', class: 'fr-footer__top-link' + = link_to I18n.t("users.procedure_footer.legals.terms"), procedure.cadre_juridique, rel: 'noopener', class: 'fr-footer__link' - if procedure.lien_dpo.present? %li - = link_to url_or_email_to_lien_dpo(procedure), rel: 'noopener', class: 'fr-footer__top-link' do + = link_to url_or_email_to_lien_dpo(procedure), rel: 'noopener', class: 'fr-footer__link' do = I18n.t("users.procedure_footer.legals.dpo") %li - = link_to I18n.t('users.procedure_footer.contact.stats.link'), statistiques_path(procedure.path), class: 'fr-footer__top-link', rel: 'noopener' + = link_to I18n.t('users.procedure_footer.contact.stats.link'), statistiques_path(procedure.path), class: 'fr-footer__link', rel: 'noopener' .fr-col-12.fr-col-sm-4.fr-col-md-4 - unless procedure.close? %h3.fr-footer__top-cat= I18n.t('users.procedure_footer.dematerialisation.header') .fr-download - %p - = link_to I18n.t('users.procedure_footer.dematerialisation.title_1'), commencer_dossier_vide_for_revision_path(procedure.active_revision), class: 'fr-footer__top-link fr-download__link' + = link_to I18n.t('users.procedure_footer.dematerialisation.title_1'), commencer_dossier_vide_for_revision_path(procedure.active_revision), download: 'true', class: 'fr-download__link' %h3.fr-footer__top-cat= I18n.t('users.procedure_footer.support.header') - .fr-footer__brand.fr-enlarge-link - = link_to t("users.procedure_footer.dematerialisation.link"), title: t("users.procedure_footer.dematerialisation.alt"), class: "fr-footer__brand-link" do - = image_tag("footer/logo-france-services.svg", class: "fr-footer__logo logo-france-service-fr", alt: t("users.procedure_footer.dematerialisation.alt")) .fr-footer__bottom.fr-mt-0 .fr-container diff --git a/app/views/users/confirmations/new.html.haml b/app/views/users/confirmations/new.html.haml index 587531801..970a0fac0 100644 --- a/app/views/users/confirmations/new.html.haml +++ b/app/views/users/confirmations/new.html.haml @@ -10,14 +10,16 @@ %h1.fr-mt-6w.fr-h2.center = t('views.confirmation.new.title') - %p.center{ aria: { hidden: true } }= image_tag("user/confirmation-email.svg", alt: t('views.confirmation.new.image_alt')) + %p.center= image_tag("user/confirmation-email.svg", alt: '') = render Dsfr::AlertComponent.new(title: '', state: :info, heading_level: 'h2', extra_class_names: 'fr-mt-6w fr-mb-3w') do |c| - c.with_body do %p= t('views.confirmation.new.email_cta_html', email: resource.email) %p= t('views.confirmation.new.email_guidelines_html') + %p.fr-text--sm.fr-text-mention--grey.fr-mb-1w + = t('views.confirmation.new.email_missing') + = form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { class: 'fr-mb-6w'}) do |f| - %legend.fr-hint-text.fr-mb-3w= t('views.confirmation.new.email_missing') = f.hidden_field :email = f.submit t('views.confirmation.new.resent'), class: 'fr-btn fr-btn--secondary' diff --git a/app/views/users/dossiers/_deleted_dossiers_list.html.haml b/app/views/users/dossiers/_deleted_dossiers_list.html.haml deleted file mode 100644 index 86c8bf38c..000000000 --- a/app/views/users/dossiers/_deleted_dossiers_list.html.haml +++ /dev/null @@ -1,31 +0,0 @@ -- if deleted_dossiers.present? - .fr-h6.fr-mb-2w - = page_entries_info deleted_dossiers - - - deleted_dossiers.each do |dossier| - .card - .flex.justify-between - %div - %h2.card-title - = dossier.procedure.libelle - - %p.fr-icon--sm.fr-icon-delete-line.fr-mb-0 - = t('views.users.dossiers.dossiers_list.deleted', date: l(dossier.updated_at.to_date)) - = "-" - = t("activerecord.attributes.deleted_dossier.reason.#{dossier.reason}") - - .text-right - %p.fr-mb-0 - = t('views.users.dossiers.dossiers_list.n_dossier') - = dossier.dossier_id - - %span.fr-badge.fr-badge--sm.fr-badge--warning - = t('views.users.dossiers.dossiers_list.deleted_badge') - - = paginate deleted_dossiers, views_prefix: 'shared' - -- else - .blank-tab - %h2.empty-text= t('views.users.dossiers.dossiers_list.no_result_title') - %p.empty-text-details - = t('views.users.dossiers.dossiers_list.no_result_text_html', app_base: Current.application_base_url) diff --git a/app/views/users/dossiers/_dossier_actions.html.haml b/app/views/users/dossiers/_dossier_actions.html.haml index 336a5746a..05eea1305 100644 --- a/app/views/users/dossiers/_dossier_actions.html.haml +++ b/app/views/users/dossiers/_dossier_actions.html.haml @@ -9,10 +9,14 @@ - if has_actions - if has_edit_action - if dossier.brouillon? - = link_to t('views.users.dossiers.dossier_action.edit_draft'), (url_for_dossier(dossier)), class: 'fr-btn fr-btn--sm fr-mr-1w' + = link_to t('views.users.dossiers.dossier_action.edit_draft'), (url_for_dossier(dossier)), class: 'fr-btn fr-btn--sm fr-mr-1w fr-icon-draft-line fr-btn--icon-left' - else - = link_to t('views.users.dossiers.dossier_action.edit_dossier'), modifier_dossier_path(dossier), class: 'fr-btn fr-btn--sm fr-btn--tertiary fr-mr-1w' + = link_to t('views.users.dossiers.dossier_action.edit_dossier'), modifier_dossier_path(dossier), class: 'fr-btn fr-btn--sm fr-mr-1w fr-icon-draft-line fr-btn--icon-left' + + - if has_new_dossier_action + = link_to (commencer_url(dossier.procedure.path)), class: 'fr-btn fr-btn--sm fr-btn--tertiary fr-mr-1w fr-icon-file-fill fr-btn--icon-left' do + = t('views.users.dossiers.dossier_action.start_other_dossier') = render Dropdown::MenuComponent.new(wrapper: :div, wrapper_options: {class: 'invite-user-actions'}, menu_options: {id: dom_id(dossier, :actions_menu)}, button_options: {class: 'fr-btn--sm fr-btn--tertiary'}) do |menu| - menu.with_button_inner_html do @@ -29,12 +33,6 @@ = t('views.users.dossiers.dossier_action.transfer_dossier') - if has_new_dossier_action - - menu.with_item do - = link_to(commencer_url(dossier.procedure.path), role: 'menuitem') do - = dsfr_icon('fr-icon-file-fill', :sm) - .dropdown-description - = t('views.users.dossiers.dossier_action.start_other_dossier') - - menu.with_item do = link_to(clone_dossier_path(dossier), method: :post, role: 'menuitem') do = dsfr_icon('fr-icon-file-copy-line', :sm) diff --git a/app/views/users/dossiers/_dossiers_list.html.haml b/app/views/users/dossiers/_dossiers_list.html.haml index 8ab0cfa5f..da58f4be2 100644 --- a/app/views/users/dossiers/_dossiers_list.html.haml +++ b/app/views/users/dossiers/_dossiers_list.html.haml @@ -1,13 +1,13 @@ - if dossiers.present? - .fr-h6.fr-mb-2w + %h2.fr-h6.fr-mb-2w = page_entries_info dossiers - dossiers.each do |dossier| .card .flex.justify-between %div - %h2.card-title - - if ["dossiers-transferes", "dossiers-supprimes-recemment"].exclude?(@statut) + %h3.card-title + - if ["dossiers-transferes", "dossiers-supprimes"].exclude?(@statut) = link_to(url_for_dossier(dossier), class: 'cell-link') do = dossier.procedure.libelle - else @@ -16,9 +16,12 @@ %p.fr-icon--sm.fr-icon-user-line = demandeur_dossier(dossier) - - if dossier.hidden_by_user? + - if dossier.hidden_by_expired? %p.fr-icon--sm.fr-icon-delete-line - = t('views.users.dossiers.dossiers_list.deleted', date: l(dossier.hidden_by_user_at.to_date)) + = t('views.users.dossiers.dossiers_list.deleted_by_automatic', date: l(dossier.hidden_by_expired_at.to_date)) + - elsif dossier.hidden_by_user? + %p.fr-icon--sm.fr-icon-delete-line + = t('views.users.dossiers.dossiers_list.deleted_by_user', date: l(dossier.hidden_by_user_at.to_date)) - else %p.fr-icon--sm.fr-icon-edit-box-line - if dossier.depose_at.present? @@ -40,11 +43,7 @@ = t('views.users.dossiers.dossiers_list.n_dossier') = number_with_html_delimiter(dossier.id) - - if @statut == "dossiers-supprimes-recemment" - %span.fr-badge.fr-badge--sm.fr-badge--warning - = t('views.users.dossiers.dossiers_list.deleted_badge') - - else - = status_badge_user(dossier, 'fr-mb-1w') + = status_badge_user(dossier, 'fr-mb-1w') - if dossier.pending_correction? %br @@ -55,17 +54,17 @@ - c.with_body do %p - if dossier.brouillon? && dossier.procedure.closing_reason_internal_procedure? - = t('views.users.dossiers.dossiers_list.procedure_closed.brouillon.internal_procedure_html', link: link_to(t('views.users.dossiers.dossiers_list.procedure_closed.procedure'), commencer_path(dossier.procedure.replaced_by_procedure.path), **external_link_attributes, title: new_tab_suffix(t('views.users.dossiers.dossiers_list.procedure_closed.closing_details'))).html_safe) - - elsif dossier.brouillon? && dossier.procedure.closing_reason_other? - = t('views.users.dossiers.dossiers_list.procedure_closed.brouillon.other_html', link: link_to(t('views.users.dossiers.dossiers_list.procedure_closed.here'), closing_details_path(dossier.procedure.path), **external_link_attributes, title: new_tab_suffix(t('views.users.dossiers.dossiers_list.procedure_closed.closing_details'))).html_safe) + = t('views.users.dossiers.dossiers_list.procedure_closed.brouillon.internal_procedure_html', link: link_to(t('views.users.dossiers.dossiers_list.procedure_closed.procedure'), commencer_path(dossier.procedure.replaced_by_procedure.path), **external_link_attributes, title: new_tab_suffix(t('views.users.dossiers.dossiers_list.procedure_closed.title_new_procedure'))).html_safe) + - elsif dossier.brouillon? + = t('views.users.dossiers.dossiers_list.procedure_closed.brouillon.other_html', link: link_to(t('views.users.dossiers.dossiers_list.procedure_closed.more_details'), closing_details_path(dossier.procedure.path), **external_link_attributes, title: new_tab_suffix(t('views.users.dossiers.dossiers_list.procedure_closed.title_closing_details'))).html_safe) - elsif dossier.en_construction_ou_instruction? && dossier.procedure.closing_reason_internal_procedure? - = t('views.users.dossiers.dossiers_list.procedure_closed.en_cours.internal_procedure_html') - - elsif dossier.en_construction_ou_instruction? && dossier.procedure.closing_reason_other? - = t('views.users.dossiers.dossiers_list.procedure_closed.en_cours.other_html', link: link_to(t('views.users.dossiers.dossiers_list.procedure_closed.here'), closing_details_path(dossier.procedure.path), **external_link_attributes, title: new_tab_suffix(t('views.users.dossiers.dossiers_list.procedure_closed.closing_details'))).html_safe) + = t('views.users.dossiers.dossiers_list.procedure_closed.en_cours.internal_procedure_html', link: link_to(t('views.users.dossiers.dossiers_list.procedure_closed.procedure'), commencer_path(dossier.procedure.replaced_by_procedure.path), **external_link_attributes, title: new_tab_suffix(t('views.users.dossiers.dossiers_list.procedure_closed.title_new_procedure'))).html_safe) + - elsif dossier.en_construction_ou_instruction? + = t('views.users.dossiers.dossiers_list.procedure_closed.en_cours.other_html', link: link_to(t('views.users.dossiers.dossiers_list.procedure_closed.more_details'), closing_details_path(dossier.procedure.path), **external_link_attributes, title: new_tab_suffix(t('views.users.dossiers.dossiers_list.procedure_closed.title_closing_details'))).html_safe) - elsif dossier.termine? && dossier.procedure.closing_reason_internal_procedure? - = t('views.users.dossiers.dossiers_list.procedure_closed.termine.internal_procedure_html', link: link_to(t('views.users.dossiers.dossiers_list.procedure_closed.this_procedure'), commencer_path(dossier.procedure.replaced_by_procedure.path), **external_link_attributes, title: new_tab_suffix(t('views.users.dossiers.dossiers_list.procedure_closed.closing_details'))).html_safe) - - elsif dossier.termine? && dossier.procedure.closing_reason_other? - = t('views.users.dossiers.dossiers_list.procedure_closed.termine.other_html', link: link_to(t('views.users.dossiers.dossiers_list.procedure_closed.here'), closing_details_path(dossier.procedure.path), **external_link_attributes, title: new_tab_suffix(t('views.users.dossiers.dossiers_list.procedure_closed.closing_details'))).html_safe) + = t('views.users.dossiers.dossiers_list.procedure_closed.termine.internal_procedure_html', link: link_to(t('views.users.dossiers.dossiers_list.procedure_closed.this_procedure'), commencer_path(dossier.procedure.replaced_by_procedure.path), **external_link_attributes, title: new_tab_suffix(t('views.users.dossiers.dossiers_list.procedure_closed.title_new_procedure'))).html_safe) + - elsif dossier.termine? + = t('views.users.dossiers.dossiers_list.procedure_closed.termine.other_html', link: link_to(t('views.users.dossiers.dossiers_list.procedure_closed.more_details'), closing_details_path(dossier.procedure.path), **external_link_attributes, title: new_tab_suffix(t('views.users.dossiers.dossiers_list.procedure_closed.title_closing_details'))).html_safe) - if dossier.pending_correction? = render Dsfr::AlertComponent.new(state: :warning, size: :sm, extra_class_names: "fr-mb-2w") do |c| @@ -79,9 +78,9 @@ - c.with_body do %p - if dossier.transfer.from_support? - = t('views.users.dossiers.transfers.receiver_demande_en_cours_from_support', id: dossier.id, email: dossier.user.email) + = t('views.users.dossiers.transfers.receiver_demande_en_cours_from_support', id: dossier.id, email: dossier.user_email_for(:notification)) - else - = t('views.users.dossiers.transfers.receiver_demande_en_cours', id: dossier.id, email: dossier.user.email) + = t('views.users.dossiers.transfers.receiver_demande_en_cours', id: dossier.id, email: dossier.user_email_for(:notification)) %p = link_to t('views.users.dossiers.transfers.accept'), transfer_path(dossier.transfer), class: "fr-link fr-mr-1w", method: :put = link_to t('views.users.dossiers.transfers.reject'), transfer_path(dossier.transfer), class: "fr-link", method: :delete @@ -97,14 +96,24 @@ = link_to t('views.users.dossiers.transfers.revoke'), transfer_path(dossier.transfer), class: 'fr-link', method: :delete - - if ["dossiers-transferes", "dossiers-supprimes-recemment"].exclude?(@statut) + - if ["dossiers-transferes", "dossiers-supprimes"].exclude?(@statut) .flex.justify-end = render partial: 'dossier_actions', locals: { dossier: dossier } - - if @statut == "dossiers-supprimes-recemment" + - if @statut == "dossiers-supprimes" .flex.justify-end - = link_to restore_dossier_path(dossier.id), method: :patch, class: "fr-btn fr-btn--sm" do - Restaurer + - if dossier.hidden_by_reason != 'expired' + = link_to restore_dossier_path(dossier.id), method: :patch, class: "fr-btn fr-btn--sm" do + Restaurer + + - else + - if dossier.expiration_can_be_extended? + = button_to users_dossier_repousser_expiration_and_restore_path(dossier), class: 'fr-btn fr-btn--sm' do + Restaurer et étendre la conservation + + + - else + = render(partial: 'users/dossiers/show/download_dossier', locals: { dossier: dossier }) = paginate dossiers, views_prefix: 'shared' @@ -131,4 +140,4 @@ %p.empty-text-details = t('views.users.dossiers.dossiers_list.no_result_text_html', app_base: Current.application_base_url) %p - = link_to t("root.landing.how_to_find_procedure"), t("links.common.faq.comment_trouver_ma_demarche_url"), class: "fr-btn fr-btn--lg fr-mr-1w fr-mb-2w", **external_link_attributes + = link_to t("root.landing.how_to_find_procedure"), t("links.common.faq.comment_trouver_ma_demarche_url"), class: "fr-btn fr-btn--lg fr-mr-1w fr-mb-2w" diff --git a/app/views/users/dossiers/_expiration_banner.html.haml b/app/views/users/dossiers/_expiration_banner.html.haml index 509d1eb81..908dffa6e 100644 --- a/app/views/users/dossiers/_expiration_banner.html.haml +++ b/app/views/users/dossiers/_expiration_banner.html.haml @@ -3,8 +3,9 @@ %p.expires_at %small= t("shared.dossiers.header.expires_at.#{dossier.state}", date: safe_expiration_date(dossier), duree_conservation_totale: dossier.duree_totale_conservation_in_months) - - if dossier.close_to_expiration? - = render Dsfr::CalloutComponent.new(title: t('users.dossiers.header.banner.title'), theme: :warning) do |c| + - if dossier.close_to_expiration? || dossier.has_expired? + - title = dossier.has_expired? ? 'title_expired' : 'title' + = render Dsfr::CalloutComponent.new(title: t("users.dossiers.header.banner.#{title}"), theme: :warning) do |c| - c.with_body do - if dossier.brouillon? = t('users.dossiers.header.banner.states.brouillon') diff --git a/app/views/users/dossiers/_merci.html.haml b/app/views/users/dossiers/_merci.html.haml index 383f502d9..d131104f6 100644 --- a/app/views/users/dossiers/_merci.html.haml +++ b/app/views/users/dossiers/_merci.html.haml @@ -1,26 +1,34 @@ .merci.text-center.mb-7 - .container - = image_tag('user/envoi-dossier.svg', alt: '', class: 'mt-8') - %h1.mt-4.mb-3.mx-0= t('views.users.dossiers.merci.thanks') - %h2.send.m-2.text-lg - = t('views.users.dossiers.merci.dossier_send_l1') - %strong= procedure.libelle - = t('views.users.dossiers.merci.dossier_send_l2') - %p.m-2 - = t('views.users.dossiers.merci.dossier_acces_l1') - %strong= t('views.users.dossiers.merci.dossier_acces_l2') - %p.m-2 - = t('views.users.dossiers.merci.dossier_edit_l1') - - if !dossier&.read_only? && !procedure.declarative_accepte? && !procedure.sva_svr_enabled? - %strong= t('views.users.dossiers.merci.dossier_edit_l2') - = t('views.users.dossiers.merci.dossier_edit_l3') - %strong= t('views.users.dossiers.merci.dossier_edit_l4') - - if procedure.active_dossier_submitted_message - %p.m-2= procedure.active_dossier_submitted_message.message_on_submit_by_usager + .fr-container + .fr-grid-row.fr-col-offset-md-2.fr-col-md-8 + .fr-col-12 + = image_tag('user/envoi-dossier.svg', alt: '', class: 'mt-8') + %h1.fr-mt-4w.fr-mb-3w.mx-0= t('views.users.dossiers.merci.thanks') + %h2.send.fr-m-2w.text-lg + = t('views.users.dossiers.merci.dossier_send_l1') + %strong= procedure.libelle + = t('views.users.dossiers.merci.dossier_send_l2') + %p.fr-m-2w + = t('views.users.dossiers.merci.dossier_acces_l1') + %strong= t('views.users.dossiers.merci.dossier_acces_l2') + %p.fr-m-2w + = t('views.users.dossiers.merci.dossier_edit_l1') + - if !dossier&.read_only? && !procedure.declarative_accepte? && !procedure.sva_svr_enabled? + %strong= t('views.users.dossiers.merci.dossier_edit_l2') + = t('views.users.dossiers.merci.dossier_edit_l3') + %strong= t('views.users.dossiers.merci.dossier_edit_l4') + - if procedure.active_dossier_submitted_message + %p.fr-m-2= procedure.active_dossier_submitted_message.message_on_submit_by_usager + %p.justify-center.flex.fr-mb-5w.fr-mt-2w + = render(partial: 'users/dossiers/show/download_dossier', locals: { dossier: dossier }) + = link_to t('views.users.dossiers.merci.acces_dossier'), dossier ? dossier_path(dossier) : "#dossier" , class: 'fr-btn fr-mx-2w' - .flex.column.align-center - = link_to t('views.users.dossiers.merci.acces_dossier'), dossier ? dossier_path(dossier) : "#dossier" , class: 'fr-btn fr-btn--xl fr-mt-5w' - = link_to t('views.users.dossiers.merci.submit_dossier'), commencer_url(procedure.path), class: 'fr-btn fr-btn--secondary fr-mt-3w' + %hr.fr-hr + = link_to t('views.users.dossiers.merci.submit_dossier'), commencer_url(procedure.path), class: 'fr-btn fr-btn--secondary fr-mt-2w' - .monavis - != procedure.monavis_embed + - if procedure.monavis_embed + .monavis + %p.fr-mt-5w.fr-mb-1w + %strong= t('views.users.dossiers.merci.jdma_l1') + %p= t('views.users.dossiers.merci.jdma_l2') + != procedure.monavis_embed_html_source("site") diff --git a/app/views/users/dossiers/_procedure_removed_banner.html.haml b/app/views/users/dossiers/_procedure_removed_banner.html.haml index cffffab84..a9156cdb5 100644 --- a/app/views/users/dossiers/_procedure_removed_banner.html.haml +++ b/app/views/users/dossiers/_procedure_removed_banner.html.haml @@ -17,4 +17,4 @@ = t('users.dossiers.header.banner.contact_service_html', service_name: dossier.procedure.service.nom, service_phone_number: Phonelib.parse(dossier.procedure.service.telephone_url).full_national, service_email: dossier.procedure.service.email) - if !dossier.brouillon? - = render(partial: 'users/dossiers/show/print_dossier', locals: { dossier: dossier }) + = render(partial: 'users/dossiers/show/download_dossier', locals: { dossier: dossier }) diff --git a/app/views/users/dossiers/deleted_dossiers.html.haml b/app/views/users/dossiers/deleted_dossiers.html.haml new file mode 100644 index 000000000..938f00cc3 --- /dev/null +++ b/app/views/users/dossiers/deleted_dossiers.html.haml @@ -0,0 +1,6 @@ +- content_for(:title, "Historique des dossiers supprimés") + += render partial: 'administrateurs/breadcrumbs', + locals: { steps: [['Historique des dossiers supprimés']] } + += render Dossiers::DeletedDossiersComponent.new(deleted_dossiers: @deleted_dossiers) diff --git a/app/views/users/dossiers/demande.html.haml b/app/views/users/dossiers/demande.html.haml index 346b2cfa1..ef21d650e 100644 --- a/app/views/users/dossiers/demande.html.haml +++ b/app/views/users/dossiers/demande.html.haml @@ -6,18 +6,15 @@ .dossier-container.fr-mb-4w = render partial: 'users/dossiers/show/header', locals: { dossier: @dossier } - - if @dossier.en_construction? - .fr-container - .fr-grid-row.fr-grid-row--center - .fr-col-xl-10 + .fr-container + .fr-grid-row.fr-grid-row--center + .fr-col-md-9 + - if @dossier.en_construction? = render Dossiers::EnConstructionNotSubmittedComponent.new(dossier: @dossier, user: current_user) - = render partial: 'shared/dossiers/demande', locals: { dossier: @dossier, demande_seen_at: nil, profile: 'usager' } + = render partial: 'shared/dossiers/demande', locals: { dossier: @dossier, demande_seen_at: nil, profile: 'usager' } - - - if !@dossier.read_only? - .fr-container.fr-mt-2w - .fr-grid-row - .fr-col-xl-8.fr-col-offset-xl-2 - %p= link_to t('views.users.dossiers.demande.edit_dossier'), modifier_dossier_path(@dossier), class: 'fr-btn fr-btn-sm', - title: t('views.users.dossiers.demande.edit_dossier_title') + - if !@dossier.read_only? + .fr-px-2w.fr-mt-2w + %p= link_to t('views.users.dossiers.demande.edit_dossier'), modifier_dossier_path(@dossier), class: 'fr-btn fr-btn-sm', + title: t('views.users.dossiers.demande.edit_dossier_title') diff --git a/app/views/users/dossiers/identite.html.haml b/app/views/users/dossiers/identite.html.haml index ed0f5f432..82bb5bbab 100644 --- a/app/views/users/dossiers/identite.html.haml +++ b/app/views/users/dossiers/identite.html.haml @@ -1,100 +1,33 @@ -- content_for(:title, "Nouveau dossier (#{@dossier.procedure.libelle})") +- content_for(:title, t(".title", scope: :metas, procedure_label: @dossier.procedure.libelle)) = render partial: "shared/dossiers/submit_is_over", locals: { dossier: @dossier } - if !dossier_submission_is_closed?(@dossier) - = form_for @dossier, url: update_identite_dossier_path(@dossier), html: { class: "form", "data-controller" => "for-tiers" } do |f| + - if @dossier.procedure.for_tiers_enabled? + = form_for @dossier, url: identite_dossier_path(@dossier), method: :patch, html: { class: "form" }, data: {turbo: true, controller: :autosubmit} do |f| - %fieldset#radio-rich-hint.fr-fieldset{ "aria-labelledby" => "radio-rich-hint-legend radio-rich-hint-messages" } - %legend#radio-rich-hint-legend.fr-fieldset__legend--regular.fr-fieldset__legend - = t('views.users.dossiers.identite.legend') + %p.fr-text--sm= t('utils.asterisk_html') - .fr-fieldset__element - .fr-radio-group.fr-radio-rich - = f.radio_button :for_tiers, false, required: true, id: "radio-self-manage", "data-action" => "click->for-tiers#toggleFieldRequirements", "data-for-tiers-target" => "forTiers" - %label.fr-label{ for: "radio-self-manage" } - = t('activerecord.attributes.dossier.for_tiers.false') - .fr-radio-rich__img - %span.fr-icon-user-fill - .fr-fieldset__element - .fr-radio-group.fr-radio-rich - = f.radio_button :for_tiers, true, required: true, id: "radio-tiers-manage", "data-action" => "click->for-tiers#toggleFieldRequirements", "data-for-tiers-target" => "forTiers" - %label.fr-label{ for: "radio-tiers-manage" } - = t('activerecord.attributes.dossier.for_tiers.true') - .fr-radio-rich__img - %span.fr-icon-parent-fill - - .mandataire-infos{ "data-for-tiers-target" => "mandataireBlock" } - .fr-alert.fr-alert--info.fr-mb-2w - %p.fr-notice__text - = t('views.users.dossiers.identite.callout_text') - = link_to(t('views.users.dossiers.identite.callout_link'), - 'https://www.legifrance.gouv.fr/codes/section_lc/LEGITEXT000006070721/LEGISCTA000006136404/#LEGISCTA000006136404', - title: new_tab_suffix(t('views.users.dossiers.identite.callout_link_title')), - **external_link_attributes) - - - %fieldset.fr-fieldset + %fieldset#radio-rich-hint.fr-fieldset %legend.fr-fieldset__legend--regular.fr-fieldset__legend - %h2.fr-h4= t('views.users.dossiers.identite.self_title') + = t('views.users.dossiers.identite.legend') + = render EditableChamp::AsteriskMandatoryComponent.new - .fr-fieldset__element.fr-fieldset__element--short-text - = render Dsfr::InputComponent.new(form: f, attribute: :mandataire_first_name, opts: { "data-for-tiers-target" => "mandataireFirstName" }) + .fr-fieldset__element + .fr-radio-group.fr-radio-rich + = f.radio_button :for_tiers, false, required: true, id: "radio-self-manage" + %label.fr-label{ for: "radio-self-manage" } + = t('activerecord.attributes.dossier.for_tiers.false') + .fr-radio-rich__img + %span.fr-icon-user-fill + .fr-fieldset__element + .fr-radio-group.fr-radio-rich + = f.radio_button :for_tiers, true, required: true, id: "radio-tiers-manage" + %label.fr-label{ for: "radio-tiers-manage" } + = t('activerecord.attributes.dossier.for_tiers.true') + .fr-radio-rich__img + %span.fr-icon-parent-fill - .fr-fieldset__element.fr-fieldset__element--short-text - = render Dsfr::InputComponent.new(form: f, attribute: :mandataire_last_name, opts: { "data-for-tiers-target" => "mandataireLastName" }) + = f.submit t('views.users.dossiers.identite.continue'), class: 'hidden' - = f.fields_for :individual, include_id: false do |individual| - .individual-infos - %fieldset.fr-fieldset - %legend.fr-fieldset__legend--regular.fr-fieldset__legend{ "data-for-tiers-target" => "mandataireTitle" } - %h2.fr-h4= t('views.users.dossiers.identite.self_title') - - %legend.fr-fieldset__legend--regular.fr-fieldset__legend.hidden{ "data-for-tiers-target" => "beneficiaireTitle" } - %h2.fr-h4= t('views.users.dossiers.identite.beneficiaire_title') - - - %legend.fr-fieldset__legend--regular.fr-fieldset__legend - = t('activerecord.attributes.individual.gender') - = render EditableChamp::AsteriskMandatoryComponent.new - .fr-fieldset__element - .fr-radio-group - = individual.radio_button :gender, Individual::GENDER_FEMALE, required: true, id: "identite_champ_radio_#{Individual::GENDER_FEMALE}" - %label.fr-label{ for: "identite_champ_radio_#{Individual::GENDER_FEMALE}" } - = Individual.human_attribute_name('gender.female') - .fr-fieldset__element - .fr-radio-group - = individual.radio_button :gender, Individual::GENDER_MALE, required: true, id: "identite_champ_radio_#{Individual::GENDER_MALE}" - %label.fr-label{ for: "identite_champ_radio_#{Individual::GENDER_MALE}" } - = Individual.human_attribute_name('gender.male') - - .fr-fieldset__element.fr-fieldset__element--short-text - = render Dsfr::InputComponent.new(form: individual, attribute: :prenom, opts: { autocomplete: 'given-name' }) - - .fr-fieldset__element.fr-fieldset__element--short-text - = render Dsfr::InputComponent.new(form: individual, attribute: :nom, opts: { autocomplete: 'family-name' }) - - %fieldset.fr-fieldset{ "data-for-tiers-target" => "beneficiaireNotificationBlock" } - %legend.fr-fieldset__legend--regular.fr-fieldset__legend - = t('activerecord.attributes.individual.notification_method') - = render EditableChamp::AsteriskMandatoryComponent.new - - - Individual.notification_methods.each do |method, _| - .fr-fieldset__element - .fr-radio-group - = individual.radio_button :notification_method, method, id: "notification_method_#{method}", "data-action" => "for-tiers#toggleFieldRequirements", "data-for-tiers-target" => "notificationMethod" - %label.fr-label{ for: "notification_method_#{method}" } - = t("activerecord.attributes.individual.notification_methods.#{method}") - - - .fr-fieldset__element.fr-fieldset__element--short-text.hidden{ "data-for-tiers-target" => "email" } - = render Dsfr::InputComponent.new(form: individual, attribute: :email) - - - - if @dossier.procedure.ask_birthday? - .fr-fieldset__element - = render Dsfr::InputComponent.new(form: individual, attribute: :birthdate, input_type: :date_field, - opts: { placeholder: 'Format : AAAA-MM-JJ', max: Date.today.iso8601, min: "1900-01-01", autocomplete: 'bday' }) - - - = f.submit t('views.users.dossiers.identite.continue'), class: "fr-btn" + = render Dossiers::IndividualFormComponent.new(dossier: @dossier) diff --git a/app/views/users/dossiers/identite.turbo_stream.haml b/app/views/users/dossiers/identite.turbo_stream.haml new file mode 100644 index 000000000..b7c032d59 --- /dev/null +++ b/app/views/users/dossiers/identite.turbo_stream.haml @@ -0,0 +1 @@ += turbo_stream.replace 'identite-form', render(Dossiers::IndividualFormComponent.new(dossier: @dossier)) diff --git a/app/views/users/dossiers/index.html.haml b/app/views/users/dossiers/index.html.haml index b481b94c3..b3e2be368 100644 --- a/app/views/users/dossiers/index.html.haml +++ b/app/views/users/dossiers/index.html.haml @@ -7,65 +7,63 @@ .fr-container %h1.page-title.fr-h2= t('views.users.dossiers.index.dossiers') - .fr-grid-row.fr-grid-row--gutters - - if current_user.dossiers.count > 2 || current_user.dossiers_invites.count > 2 - .fr-col - #search-2.fr-search-bar - = form_tag dossiers_path, method: :get, :role => "search", class: "flex width-100 fr-mb-5w" do - = hidden_field_tag :procedure_id, params[:procedure_id] - = label_tag "q", t('views.users.dossiers.search.search_file'), class: 'fr-label' - = text_field_tag "q", "#{@search_terms if @search_terms.present?}", placeholder: t('views.users.dossiers.search.search_file'), class: "fr-input" - %button.fr-btn.fr-btn--sm - = t('views.users.dossiers.search.simple') - - if @procedures_for_select.size > 1 - .fr-col - = render Dossiers::UserProcedureFilterComponent.new(procedures_for_select: @procedures_for_select) + - if current_user.dossiers.count > 2 || current_user.dossiers_invites.count > 2 || @procedures_for_select.size > 1 + .fr-grid-row.fr-grid-row--gutters + - if current_user.dossiers.count > 2 || current_user.dossiers_invites.count > 2 + .fr-col.fr-mb-5w + #search-2.fr-search-bar + = form_tag dossiers_path, method: :get, :role => "search", class: "width-100" do + = hidden_field_tag :procedure_id, params[:procedure_id] + = label_tag "q", t('views.users.dossiers.search.label'), class: 'fr-label fr-mb-1w' + .flex + = text_field_tag "q", "#{@search_terms if @search_terms.present?}", placeholder: t('views.users.dossiers.search.prompt'), class: "fr-input" + %button.fr-btn.fr-btn--sm + = t('views.users.dossiers.search.label') + - if @procedures_for_select.size > 1 + .fr-col.fr-mb-5w + = render Dossiers::UserProcedureFilterComponent.new(procedures_for_select: @procedures_for_select) - if @search_terms.blank? - - cache([I18n.locale, current_user.id, @statut, current_user.dossiers, current_user.dossiers_invites], expires_in: 1.hour) do - %nav.fr-tabs{ role: 'navigation', 'aria-label': t('views.users.dossiers.secondary_menu') } - %ul.fr-tabs__list{ role: 'tablist' } - - if @user_dossiers.present? - = tab_item(t('pluralize.en_cours', count: @user_dossiers.count), - dossiers_path(statut: 'en-cours', procedure_id: params[:procedure_id]), - active: @statut == 'en-cours', - badge: number_with_html_delimiter(@user_dossiers.count)) - - if @dossiers_traites.present? - // TODO: when renaming this tab in "Terminé", update notify_near_deletion_to_user email wording accordingly. - = tab_item(t('pluralize.traites', count: @dossiers_traites.count), - dossiers_path(statut: 'traites', procedure_id: params[:procedure_id]), - active: @statut == 'traites', - badge: number_with_html_delimiter(@dossiers_traites.count)) + - if [@user_dossiers, @dossiers_traites, @dossiers_invites, @dossiers_close_to_expiration, @dossiers_supprimes, @dossier_transferes].any?(&:present?) + - cache([I18n.locale, current_user.id, @statut, current_user.dossiers, current_user.dossiers_invites], expires_in: 1.hour) do + %nav.fr-tabs{ role: 'navigation', 'aria-label': t('views.users.dossiers.secondary_menu') } + %ul.fr-tabs__list{ role: 'tablist' } + - if @user_dossiers.present? + = tab_item(t('pluralize.en_cours', count: @user_dossiers.count), + dossiers_path(statut: 'en-cours', procedure_id: params[:procedure_id]), + active: @statut == 'en-cours', + badge: number_with_html_delimiter(@user_dossiers.count)) - - if @dossiers_invites.present? - = tab_item(t('pluralize.dossiers_invites', count: @dossiers_invites.count), - dossiers_path(statut: 'dossiers-invites', procedure_id: params[:procedure_id]), - active: @statut == 'dossiers-invites', - badge: number_with_html_delimiter(@dossiers_invites.count)) + - if @dossiers_traites.present? + // TODO: when renaming this tab in "Terminé", update notify_near_deletion_to_user email wording accordingly. + = tab_item(t('pluralize.traites', count: @dossiers_traites.count), + dossiers_path(statut: 'traites', procedure_id: params[:procedure_id]), + active: @statut == 'traites', + badge: number_with_html_delimiter(@dossiers_traites.count)) - - if @dossiers_close_to_expiration.count > 0 - = tab_item(t('pluralize.dossiers_close_to_expiration', count: @dossiers_close_to_expiration.count), - dossiers_path(statut: 'dossiers-expirant', procedure_id: params[:procedure_id]), - active: @statut == 'dossiers-expirant', - badge: number_with_html_delimiter(@dossiers_close_to_expiration.count)) + - if @dossiers_invites.present? + = tab_item(t('pluralize.dossiers_invites', count: @dossiers_invites.count), + dossiers_path(statut: 'dossiers-invites', procedure_id: params[:procedure_id]), + active: @statut == 'dossiers-invites', + badge: number_with_html_delimiter(@dossiers_invites.count)) - - if @dossiers_supprimes_recemment.present? - = tab_item(t('pluralize.dossiers_supprimes_recemment', count: @dossiers_supprimes_recemment.count), - dossiers_path(statut: 'dossiers-supprimes-recemment', procedure_id: params[:procedure_id]), - active: @statut == 'dossiers-supprimes-recemment', - badge: number_with_html_delimiter(@dossiers_supprimes_recemment.count)) + - if @dossiers_close_to_expiration.count > 0 + = tab_item(t('pluralize.dossiers_close_to_expiration', count: @dossiers_close_to_expiration.count), + dossiers_path(statut: 'dossiers-expirant', procedure_id: params[:procedure_id]), + active: @statut == 'dossiers-expirant', + badge: number_with_html_delimiter(@dossiers_close_to_expiration.count)) - - if @dossiers_supprimes_definitivement.present? - = tab_item(t('pluralize.dossiers_supprimes_definitivement', count: @dossiers_supprimes_definitivement.count), - dossiers_path(statut: 'dossiers-supprimes-definitivement', procedure_id: params[:procedure_id]), - active: @statut == 'dossiers-supprimes-definitivement', - badge: number_with_html_delimiter(@dossiers_supprimes_definitivement.count)) + - if @dossiers_supprimes.present? + = tab_item(t('pluralize.dossiers_supprimes', count: @dossiers_supprimes.count), + dossiers_path(statut: 'dossiers-supprimes', procedure_id: params[:procedure_id]), + active: @statut == 'dossiers-supprimes', + badge: number_with_html_delimiter(@dossiers_supprimes.count)) - - if @dossier_transferes.present? - = tab_item(t('pluralize.dossiers_transferes', count: @dossier_transferes.count), - dossiers_path(statut: 'dossiers-transferes', procedure_id: params[:procedure_id]), - active: @statut == 'dossiers-transferes', - badge: number_with_html_delimiter(@dossier_transferes.count)) + - if @dossier_transferes.present? + = tab_item(t('pluralize.dossiers_transferes', count: @dossier_transferes.count), + dossiers_path(statut: 'dossiers-transferes', procedure_id: params[:procedure_id]), + active: @statut == 'dossiers-transferes', + badge: number_with_html_delimiter(@dossier_transferes.count)) .fr-container .fr-grid-row.fr-grid-row--center @@ -87,9 +85,4 @@ - else = render Dossiers::UserFilterComponent.new(statut: @statut, filter: @filter, procedure_id: @procedure_id ) - - - if @statut == "dossiers-supprimes-definitivement" - -# /!\ in this context, @dossiers is a collection of DeletedDossier not Dossier - = render partial: "deleted_dossiers_list", locals: { deleted_dossiers: @dossiers } - - else - = render partial: "dossiers_list", locals: { dossiers: @dossiers, filter: @filter, statut: @statut, search: false } + = render partial: "dossiers_list", locals: { dossiers: @dossiers, filter: @filter, statut: @statut, search: false } diff --git a/app/views/users/dossiers/papertrail.pdf.prawn b/app/views/users/dossiers/papertrail.pdf.prawn index 898163eac..c1449555c 100644 --- a/app/views/users/dossiers/papertrail.pdf.prawn +++ b/app/views/users/dossiers/papertrail.pdf.prawn @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'prawn/measurement_extensions' #----- A4 page size @@ -52,7 +54,7 @@ prawn_document(margin: [top_margin, right_margin, bottom_margin, left_margin], p pdf.fill_color grey pdf.text "#{Individual.human_attribute_name(:prenom)} : #{@dossier.individual.prenom}", size: 10, character_spacing: -0.2, align: :justify pdf.text "#{Individual.human_attribute_name(:nom)} : #{@dossier.individual.nom.upcase}", size: 10, character_spacing: -0.2, align: :justify - pdf.text "#{User.human_attribute_name(:email)} : #{@dossier.user.email}", size: 10, character_spacing: -0.2, align: :justify + pdf.text "#{User.human_attribute_name(:email)} : #{@dossier.user_email_for(:display)}", size: 10, character_spacing: -0.2, align: :justify end end @@ -61,7 +63,7 @@ prawn_document(margin: [top_margin, right_margin, bottom_margin, left_margin], p pdf.fill_color grey pdf.text "Dénomination : " + raison_sociale_or_name(@dossier.etablissement), size: 10, character_spacing: -0.2, align: :justify pdf.text "SIRET : " + @dossier.etablissement.siret, size: 10, character_spacing: -0.2, align: :justify - pdf.text "#{User.human_attribute_name(:email)} : #{@dossier.user.email}", size: 10, character_spacing: -0.2, align: :justify + pdf.text "#{User.human_attribute_name(:email)} : #{@dossier.user_email_for(:display)}", size: 10, character_spacing: -0.2, align: :justify end end diff --git a/app/views/users/dossiers/show/_download_dossier.html.haml b/app/views/users/dossiers/show/_download_dossier.html.haml new file mode 100644 index 000000000..ed9245955 --- /dev/null +++ b/app/views/users/dossiers/show/_download_dossier.html.haml @@ -0,0 +1 @@ += link_to "#{t('views.users.dossiers.merci.download_dossier')} (PDF)", dossier ? dossier_path(dossier, format: :pdf) : "#", download: "Mon dossier", target: "_blank", rel: "noopener", class: 'fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-download-line' diff --git a/app/views/users/dossiers/show/_header.html.haml b/app/views/users/dossiers/show/_header.html.haml index 2d0a73858..167029020 100644 --- a/app/views/users/dossiers/show/_header.html.haml +++ b/app/views/users/dossiers/show/_header.html.haml @@ -5,7 +5,7 @@ = status_badge_user(dossier, 'super') = pending_correction_badge(:for_user) if dossier.pending_correction? %h2 - = t('views.users.dossiers.show.header.dossier_number', dossier_id: dossier.id) + = t('views.users.dossiers.show.header.dossier_number_html', dossier_id: dossier.id) - if dossier.depose_at.present? = t('views.users.dossiers.show.header.submit_date', date_du_dossier: I18n.l(dossier.depose_at)) @@ -14,12 +14,13 @@ - if dossier.show_procedure_state_warning? = render(partial: 'users/dossiers/procedure_removed_banner', locals: { dossier: dossier }) - elsif current_user.owns?(dossier) - .header-actions - = render partial: 'invites/dropdown', locals: { dossier: dossier, morphing: false } - - if dossier.can_be_updated_by_user? && !current_page?(modifier_dossier_path(dossier)) - = link_to t('views.users.dossiers.demande.edit_dossier'), modifier_dossier_path(dossier), class: 'fr-btn fr-btn-sm', - title: t('views.users.dossiers.demande.edit_dossier_title') - = render(partial: 'users/dossiers/show/print_dossier', locals: { dossier: dossier }) + .header-actions.fr-mb-3w + = render(partial: 'users/dossiers/show/download_dossier', locals: { dossier: dossier }) + .ml-auto + = render partial: 'invites/dropdown', locals: { dossier: dossier, morphing: false } + - if dossier.can_be_updated_by_user? && !current_page?(modifier_dossier_path(dossier)) + = link_to t('views.users.dossiers.demande.edit_dossier'), modifier_dossier_path(dossier), class: 'fr-btn fr-btn-sm fr-ml-1w', + title: t('views.users.dossiers.demande.edit_dossier_title') %nav.fr-tabs %ul.fr-tabs__list{ role: 'tablist' } diff --git a/app/views/users/dossiers/show/_print_dossier.html.haml b/app/views/users/dossiers/show/_print_dossier.html.haml deleted file mode 100644 index 1e2392431..000000000 --- a/app/views/users/dossiers/show/_print_dossier.html.haml +++ /dev/null @@ -1 +0,0 @@ -= link_to t('views.users.dossiers.show.header.print'), dossier_path(dossier, format: :pdf), target: "_blank", rel: "noopener", title: t('views.users.dossiers.show.header.print_dossier'), class: 'fr-btn fr-icon-printer-line fr-btn--tertiary' diff --git a/app/views/users/dossiers/transferer_all.html.haml b/app/views/users/dossiers/transferer_all.html.haml deleted file mode 100644 index c271974eb..000000000 --- a/app/views/users/dossiers/transferer_all.html.haml +++ /dev/null @@ -1,7 +0,0 @@ -.container.mt-4 - Transferer les #{@transfer.dossiers.size} dossiers de votre compte vers le compte d’un autre usager : - - = form_for @transfer, url: transfers_path, html: { class: 'form mt-2' } do |f| - = f.label :email, 'Email du compte destinataire' - = f.email_field :email, required: true - = f.submit "Envoyer la demande de transfert", class: 'button primary' diff --git a/app/views/users/dossiers/update.turbo_stream.haml b/app/views/users/dossiers/update.turbo_stream.haml index 91a898ab0..2cd14be47 100644 --- a/app/views/users/dossiers/update.turbo_stream.haml +++ b/app/views/users/dossiers/update.turbo_stream.haml @@ -1 +1,10 @@ = render partial: 'shared/dossiers/update_champs', locals: { to_show: @to_show, to_hide: @to_hide, to_update: @to_update, dossier: @dossier } + +- if !params.key?(:validate) + - if @can_passer_en_construction_was && !@can_passer_en_construction_is + = turbo_stream.append('contenu', render(Dossiers::InvalidIneligibiliteRulesComponent.new(dossier: @dossier))) + - else @ineligibilite_rules_is_computable + = turbo_stream.remove(dom_id(@dossier, :ineligibilite_rules_broken)) + +- if @update_contact_information + = turbo_stream.update "contact_information", partial: 'shared/dossiers/update_contact_information', locals: { dossier: @dossier, procedure: @dossier.procedure } diff --git a/app/views/users/passwords/reset_link_sent.html.haml b/app/views/users/passwords/reset_link_sent.html.haml index 200631a1f..fbbf6daab 100644 --- a/app/views/users/passwords/reset_link_sent.html.haml +++ b/app/views/users/passwords/reset_link_sent.html.haml @@ -3,33 +3,25 @@ - content_for :footer do = render partial: 'root/footer' -#link-sent.container - = image_tag('user/confirmation-email.svg', "aria-hidden": true) - %h1 - = t('views.users.passwords.reset_link_sent.got_it') - %br - = t('views.users.passwords.reset_link_sent.open_your_mailbox') +.fr-container.fr-my-4w + .fr-grid-row.fr-grid-row--center + .fr-col-12.fr-col-md-9.fr-col-lg-7 + %h1.fr-h2 + = t('views.users.passwords.reset_link_sent.email_sent_html', email: @email, application_name: Current.application_name) - %section.link-sent-info - %p - = t('views.users.passwords.reset_link_sent.email_sent_html', email: @email, application_name: Current.application_name) - %p - = t('views.users.passwords.reset_link_sent.click_link_to_reset_password') - %p - = t('views.users.shared.email_can_take_a_while_html') - - %section.link-sent-help - %h2.link-sent-help-title= t('views.users.passwords.reset_link_sent.no_mail') - %ol.link-sent-help-list - %li - = t('views.users.passwords.reset_link_sent.check_spams') - %li - = t('views.users.passwords.reset_link_sent.check_account', email: @email, application_name: Current.application_name) - - if FranceConnectService.enabled? - %li - = t('views.users.passwords.reset_link_sent.check_france_connect_html', href: france_connect_particulier_path) - - %li - = t('views.users.passwords.reset_link_sent.check_gpdr') - %p - = t('views.users.shared.contact_us_if_any_trouble_html', href: contact_url) + = render Dsfr::AlertComponent.new(title: t('views.users.passwords.reset_link_sent.no_mail'), state: '', extra_class_names: 'fr-alert--info' ) do |c| + - c.with_body do + %ol + %li + = t('views.users.shared.email_can_take_a_while_html') + %li + = t('views.users.passwords.reset_link_sent.check_spams') + %li + = t('views.users.passwords.reset_link_sent.check_account_html', email: @email, application_name: Current.application_name) + - if FranceConnectService.enabled? + %li + = t('views.users.passwords.reset_link_sent.check_france_connect_html', href: france_connect_particulier_path) + %li + = t('views.users.passwords.reset_link_sent.check_gpdr') + %p + = t('views.users.shared.contact_us_if_any_trouble_html', href: contact_url) diff --git a/app/views/users/registrations/new.html.haml b/app/views/users/registrations/new.html.haml index 62a30b357..41e5c23ea 100644 --- a/app/views/users/registrations/new.html.haml +++ b/app/views/users/registrations/new.html.haml @@ -1,4 +1,5 @@ = content_for(:page_id, 'auth') += content_for(:title, t('metas.signup.title')) .auth-form = devise_error_messages! @@ -13,14 +14,16 @@ %h2.fr-h6= I18n.t('views.registrations.new.subtitle') .fr-fieldset__element - %p.fr-text--sm= t('utils.mandatory_champs') + %p.fr-text--sm= t('utils.asterisk_html') .fr-fieldset__element= render Dsfr::InputComponent.new(form: f, attribute: :email, input_type: :email_field, opts: { autocomplete: 'email', autofocus: true }) .fr-fieldset__element - = render Dsfr::InputComponent.new(form: f, attribute: :password, input_type: :password_field, opts: { autocomplete: 'new-password', minlength: PASSWORD_MIN_LENGTH }) do |c| - - c.with_describedby do - = render partial: "devise/password_rules", locals: { id: c.describedby_id } + = render Dsfr::InputComponent.new(form: f, attribute: :password, input_type: :password_field, + opts: { autofocus: 'true', autocomplete: 'new-password', data: { controller: 'turbo-input', turbo_input_url_value: show_password_complexity_path, email_input_target: 'next'}, aria: {describedby: 'password_hint'}}) - %ul.fr-btns-group - %li= f.submit t('views.shared.account.create'), class: "fr-btn" + #password_complexity + = render PasswordComplexityComponent.new + + .fr-btns-group + = f.submit t('views.shared.account.create'), id: 'submit-password', disabled: :disabled, class: "fr-btn fr-mt-2w", data: { disable_with: t('views.users.passwords.edit.submit_loading') } diff --git a/app/views/users/sessions/link_sent.html.haml b/app/views/users/sessions/link_sent.html.haml index b0cbd891f..1b64f8d87 100644 --- a/app/views/users/sessions/link_sent.html.haml +++ b/app/views/users/sessions/link_sent.html.haml @@ -3,27 +3,23 @@ - content_for :footer do = render partial: 'root/footer' -.fr-container.fr-my-5w - .fr-grid-row - .fr-col-12.fr-col-offset-md-1.fr-col-md-7 - %h1.fr-mt-6w Encore une petite étape ! +.fr-container + .fr-col-12.fr-col-md-6.fr-col-offset-md-3 + %h1.fr-mt-6w.fr-h2.center + = t('views.confirmation.new.title') - %section - %p.fr-text--lead - Nous venons de vous envoyer un courriel sur votre boite email #{@email}. - Veuillez l’ouvrir et cliquer sur le lien de connexion sécurisée à #{Current.application_name}. + %p.center= image_tag("user/confirmation-email.svg", alt: '') - %p.fr-text--lead - Ce lien est valide une semaine et peut être réutilisé plusieurs fois. + = render Dsfr::AlertComponent.new(title: '', state: :info, heading_level: 'h2', extra_class_names: 'fr-mt-6w fr-mb-3w') do |c| + - c.with_body do + %p= t('views.users.sessions.link_sent.email_cta_html', email: @email) + %p= t('views.confirmation.new.email_guidelines_html') - %p.fr-text--sm.fr-text-mention--grey - Ce courriel peut mettre jusqu’à 15 minutes pour arriver. Si vous n’avez pas reçu de courriel (n’hésitez pas à vérifier dans les indésirables), cliquez sur le bouton ci-dessous. + %p.fr-text--sm.fr-text-mention--grey.fr-mb-1w + = t('views.confirmation.new.email_missing') - = button_to instructeurs_reset_link_sent_path, class: 'fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-mail-line', method: 'POST' do - Renvoyer le courriel + = button_to instructeurs_reset_link_sent_path, class: 'fr-btn fr-btn--secondary', method: 'POST' do + = t('views.confirmation.new.resent') - %section - %p.fr-mt-3w - Si vous voyez cette page trop souvent, consultez notre aide : #{link_to t("links.common.faq.confirmer_compte_chaque_connexion_url"), t("links.common.faq.confirmer_compte_chaque_connexion_url"), **external_link_attributes} - %p.fr-mt-3w - = t('views.users.shared.contact_us_if_any_trouble_html', href: contact_admin_url) + %p.fr-text--sm.fr-text-mention--grey.fr-mt-3w.fr-mb-6w + = t('views.users.shared.contact_us_if_any_trouble_html', href: contact_admin_url) diff --git a/app/views/users/sessions/new.html.haml b/app/views/users/sessions/new.html.haml index a5ae68915..880d346d6 100644 --- a/app/views/users/sessions/new.html.haml +++ b/app/views/users/sessions/new.html.haml @@ -13,12 +13,12 @@ %h2.fr-h6= I18n.t('views.users.sessions.new.subtitle') .fr-fieldset__element - %p.fr-text--sm= t('utils.mandatory_champs') + %p.fr-text--sm= t('utils.asterisk_html') .fr-fieldset__element= render Dsfr::InputComponent.new(form: f, attribute: :email, input_type: :email_field, opts: { autocomplete: 'email', autofocus: true }) .fr-fieldset__element - = render Dsfr::InputComponent.new(form: f, attribute: :password, input_type: :password_field, opts: { autocomplete: 'current-password' }) + = render Dsfr::InputComponent.new(form: f, attribute: :password, input_type: :password_field, opts: { autocomplete: 'current-password', 'data-email-input-target': 'next' }) %p= link_to t('views.users.sessions.new.reset_password'), new_user_password_path, class: "fr-link" @@ -30,11 +30,9 @@ = f.label :remember_me, t('views.users.sessions.new.remember_me'), class: 'remember-me' .fr-fieldset__element - %ul.fr-btns-group - %li= f.submit t('views.users.sessions.new.connection'), class: "fr-btn fr-btn--lg" + .fr-btns-group= f.submit t('views.users.sessions.new.connection'), class: "fr-btn" - if AgentConnectService.enabled? %p.fr-hr-or= t('views.shared.france_connect_login.separator') %h2.important-header.mb-1= t('views.users.sessions.new.state_civil_servant') - %ul.fr-btns-group - %li= link_to t('views.users.sessions.new.connect_with_agent_connect'), agent_connect_path, class: "fr-btn fr-btn--secondary" + .fr-btns-group= link_to t('views.users.sessions.new.connect_with_agent_connect'), agent_connect_path, class: "fr-btn fr-btn--secondary" diff --git a/bin/setup b/bin/setup index 9b8efb38d..22286e576 100755 --- a/bin/setup +++ b/bin/setup @@ -20,6 +20,7 @@ FileUtils.chdir APP_ROOT do # Install JavaScript dependencies system! 'bun --version' system! 'bun install' + system! 'bunx playwright install chromium' if ENV["UPDATE_WEBDRIVER"] puts "\n== Updating webdrivers ==" diff --git a/bin/update b/bin/update index a763ffe26..c0d9ecefe 100755 --- a/bin/update +++ b/bin/update @@ -17,6 +17,7 @@ FileUtils.chdir APP_ROOT do system('bundle check') || system!('bundle install') system! 'bun --version' system! 'bun install' + system! 'bunx playwright install chromium' if ENV["UPDATE_WEBDRIVER"] puts "\n== Updating webdrivers ==" @@ -37,9 +38,12 @@ FileUtils.chdir APP_ROOT do puts "\n== Running after_party tasks ==" system! 'bin/rails after_party:run' + puts "\n== Running on deploy maintenance tasks ==" + system! 'bin/rails deploy:maintenance_tasks' + puts "\n== Removing old logs ==" system! 'bin/rails log:clear' - puts "\n== Done ==" - puts "You can now start (or restart) the application server with `bin/rails server`." + puts "\n== Restarting application server ==" + system! 'bin/rails restart' end diff --git a/bun.lockb b/bun.lockb index 1270c43db..79b3381c9 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 000000000..a7dc5d4ec --- /dev/null +++ b/codecov.yml @@ -0,0 +1,10 @@ +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true +comment: + require_changes: true diff --git a/config.ru b/config.ru index 4a3c09a68..2e0308469 100644 --- a/config.ru +++ b/config.ru @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # This file is used by Rack-based servers to start the application. require_relative "config/environment" diff --git a/config/application.rb b/config/application.rb index 638dfa89e..00dfa8033 100644 --- a/config/application.rb +++ b/config/application.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_relative "boot" require "rails/all" @@ -11,7 +13,7 @@ Dotenv::Railtie.load module TPS class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. - config.load_defaults 6.1 + config.load_defaults 7.0 # Configuration for the application, engines, and railties goes here. # @@ -21,6 +23,7 @@ module TPS Rails.autoloaders.main.ignore(Rails.root.join('lib/cops')) Rails.autoloaders.main.ignore(Rails.root.join('lib/linters')) Rails.autoloaders.main.ignore(Rails.root.join('lib/tasks/task_helper.rb')) + Rails.autoloaders.main.collapse('app/tasks/maintenance/concerns') config.paths.add Rails.root.join('spec/mailers/previews').to_s, eager_load: true config.autoload_paths << "#{Rails.root}/app/jobs/concerns" @@ -51,14 +54,16 @@ module TPS config.action_dispatch.ip_spoofing_check = false # Set the queue name for the mail delivery jobs to 'mailers' - config.action_mailer.deliver_later_queue_name = 'mailers' + config.action_mailer.deliver_later_queue_name = 'critical' # otherwise, :low # Allow the error messages format to be customized config.active_model.i18n_customize_full_message = true # Set the queue name for the analysis jobs to 'active_storage_analysis' - config.active_storage.queues.analysis = :active_storage_analysis - config.active_storage.queues.purge = :purge + config.active_storage.queues.analysis = :default + config.active_storage.queues.purge = :low + + config.active_support.cache_format_version = 7.0 config.to_prepare do # Make main application helpers available in administrate @@ -103,6 +108,7 @@ module TPS config.active_record.encryption.primary_key = Rails.application.secrets.active_record_encryption.fetch(:primary_key) config.active_record.encryption.key_derivation_salt = Rails.application.secrets.active_record_encryption.fetch(:key_derivation_salt) + config.active_record.partial_inserts = false config.exceptions_app = self.routes diff --git a/config/boot.rb b/config/boot.rb index 3cda23b4d..38a47b2c5 100644 --- a/config/boot.rb +++ b/config/boot.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) require "bundler/setup" # Set up gems listed in the Gemfile. diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 404123563..d064270c1 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -3,19 +3,19 @@ { "warning_type": "Cross-Site Scripting", "warning_code": 2, - "fingerprint": "1b805585567775589825c0eda58cb84c074fc760d0a7afb101c023a51427f2b5", + "fingerprint": "26f504696b074d18ef3f5568dc8f6a46d1283a67fe37822498fa25d0409664ab", "check_name": "CrossSiteScripting", "message": "Unescaped model attribute", "file": "app/views/users/dossiers/_merci.html.haml", - "line": 26, + "line": 34, "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", - "code": "current_user.dossiers.includes(:procedure).find(params[:id]).procedure.monavis_embed", + "code": "current_user.dossiers.includes(:procedure).find(params[:id]).procedure.monavis_embed_html_source(\"site\")", "render_path": [ { "type": "controller", "class": "Users::DossiersController", "method": "merci", - "line": 302, + "line": 323, "file": "app/controllers/users/dossiers_controller.rb", "rendered": { "name": "users/dossiers/merci", @@ -51,7 +51,7 @@ "check_name": "SQL", "message": "Possible SQL injection", "file": "app/graphql/connections/cursor_connection.rb", - "line": 150, + "line": 152, "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", "code": "items.order(order_column => ((:desc or :asc)), :id => ((:desc or :asc))).limit(limit).where(\"(#{order_table}.#{order_column}, #{order_table}.id) < (?, ?)\", timestamp, id)", "render_path": null, @@ -67,6 +67,86 @@ ], "note": "" }, + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "7dc4935d5b68365bedb8f6b953f01b396cff4daa533c98ee56a84249ca5a1f90", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/tasks/maintenance/concerns/statements_helpers_concern.rb", + "line": 19, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "ApplicationRecord.connection.execute(\"SET LOCAL statement_timeout = '#{timeout}'\")", + "render_path": null, + "location": { + "type": "method", + "class": "Maintenance::StatementsHelpersConcern", + "method": "with_statement_timeout" + }, + "user_input": "timeout", + "confidence": "Medium", + "cwe_id": [ + 89 + ], + "note": "" + }, + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "83b5a474065af330c47603d1f60fc31edaab55be162825385d53b77c1c98a6d7", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/models/columns/json_path_column.rb", + "line": 26, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "dossiers.with_type_de_champ(stable_id).where(\"champs.value_json @? '#{jsonpath} ? (@ like_regex \\\"#{quote_string(search_terms.join(\"|\"))}\\\" flag \\\"i\\\")'\")", + "render_path": null, + "location": { + "type": "method", + "class": "Columns::JSONPathColumn", + "method": "filtered_ids" + }, + "user_input": "jsonpath", + "confidence": "Weak", + "cwe_id": [ + 89 + ], + "note": "escaped by hand" + }, + { + "warning_type": "Cross-Site Scripting", + "warning_code": 2, + "fingerprint": "a7d18cc3434b4428a884f1217791f9a9db67839e73fb499f1ccd0f686f08eccc", + "check_name": "CrossSiteScripting", + "message": "Unescaped parameter value", + "file": "app/views/faq/show.html.haml", + "line": 13, + "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", + "code": "Redcarpet::Markdown.new(Redcarpet::TrustedRenderer.new(view_context), :autolink => true).render(loader_service.find(\"#{params[:category]}/#{params[:slug]}\").content)", + "render_path": [ + { + "type": "controller", + "class": "FAQController", + "method": "show", + "line": 14, + "file": "app/controllers/faq_controller.rb", + "rendered": { + "name": "faq/show", + "file": "app/views/faq/show.html.haml" + } + } + ], + "location": { + "type": "template", + "template": "faq/show" + }, + "user_input": "params[:category]", + "confidence": "Weak", + "cwe_id": [ + 79 + ], + "note": "Theses params are not rendered" + }, { "warning_type": "SQL Injection", "warning_code": 0, @@ -74,7 +154,7 @@ "check_name": "SQL", "message": "Possible SQL injection", "file": "app/graphql/connections/cursor_connection.rb", - "line": 153, + "line": 155, "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", "code": "items.order(order_column => ((:desc or :asc)), :id => ((:desc or :asc))).limit(limit).where(\"(#{order_table}.#{order_column}, #{order_table}.id) > (?, ?)\", timestamp, id)", "render_path": null, @@ -93,20 +173,20 @@ { "warning_type": "SQL Injection", "warning_code": 0, - "fingerprint": "bd1df30f95135357b646e21a03d95498874faffa32e3804fc643e9b6b957ee14", + "fingerprint": "afd2a1a41bd87fa62e065671670bd9bd8cc503ca4cbd3cfdb74a38a794146926", "check_name": "SQL", "message": "Possible SQL injection", "file": "app/models/concerns/dossier_filtering_concern.rb", - "line": 32, + "line": 35, "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", - "code": "where(\"#{values.count} OR #{\"(#{ProcedurePresentation.sanitized_column(table, column)} ILIKE ?)\"}\", *values.map do\n \"%#{value}%\"\n end)", + "code": "where(\"#{DossierFilterService.sanitized_column(table, column)} LIKE ANY (ARRAY[?])\", search_terms.map do\n \"%#{sanitize_sql_like(_1)}%\"\n end)", "render_path": null, "location": { "type": "method", "class": "DossierFilteringConcern", "method": null }, - "user_input": "values.count", + "user_input": "DossierFilterService.sanitized_column(table, column)", "confidence": "Medium", "cwe_id": [ 89 @@ -153,7 +233,7 @@ "check_name": "CrossSiteScripting", "message": "Unescaped model attribute", "file": "app/views/notification_mailer/send_notification_for_tiers.html.haml", - "line": 29, + "line": 31, "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", "code": "Current.application_name.gsub(\".\", \"⁠.\")", "render_path": null, @@ -169,6 +249,6 @@ "note": "Current is not a model" } ], - "updated": "2024-03-27 17:15:54 +0100", + "updated": "2024-11-12 17:33:07 +0100", "brakeman_version": "6.1.2" } diff --git a/config/database.yml b/config/database.yml index 9612bd9fd..d30715a17 100644 --- a/config/database.yml +++ b/config/database.yml @@ -14,6 +14,7 @@ development: host: <%= ENV["DB_HOST"] %> username: <%= ENV["DB_USERNAME"] %> password: <%= ENV["DB_PASSWORD"] %> + port: <%= ENV["DB_PORT"] || 5432 %> # Workaround for https://github.com/ged/ruby-pg/issues/311 gssencmode: disable @@ -23,6 +24,7 @@ test: host: localhost username: tps_test password: tps_test + port: <%= ENV["DB_PORT"] || 5432 %> # Workaround for https://github.com/ged/ruby-pg/issues/311 gssencmode: disable @@ -32,3 +34,4 @@ production: &production host: <%= ENV["DB_HOST"] %> username: <%= ENV["DB_USERNAME"] %> password: <%= ENV["DB_PASSWORD"] %> + port: <%= ENV["DB_PORT"] || 5432 %> diff --git a/config/deploy.rb b/config/deploy.rb index e289888f8..bd6694041 100644 --- a/config/deploy.rb +++ b/config/deploy.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'mina/bundler' require 'mina/git' require 'mina/rails' diff --git a/config/env.example b/config/env.example index fe8967840..190b691ad 100644 --- a/config/env.example +++ b/config/env.example @@ -22,6 +22,7 @@ DB_HOST="localhost" DB_POOL="" DB_USERNAME="tps_development" DB_PASSWORD="tps_development" +DB_PORT=5432 # Protect access to the instance with a static login/password (useful for staging environments) BASIC_AUTH_ENABLED="disabled" diff --git a/config/env.example.optional b/config/env.example.optional index df8621781..050e5d49b 100644 --- a/config/env.example.optional +++ b/config/env.example.optional @@ -32,6 +32,9 @@ DS_ENV="staging" # AGENT_CONNECT_GOUV_SECRET="" # AGENT_CONNECT_GOUV_REDIRECT="" +# url to redirect user to when 2FA is not configured mon compte pro FI is used +# MON_COMPTE_PRO_2FA_NOT_CONFIGURED_URL="https://app-sandbox.moncomptepro.beta.gouv.fr/connection-and-account?notification=2fa_not_configured" + # Certigna usage # CERTIGNA_ENABLED="disabled" # "enabled" by default @@ -61,6 +64,9 @@ DS_ENV="staging" # Instance customization: URL of the Routage documentation # ROUTAGE_URL="" # +# Instance customization: URL of the EligibiliteDossier documentation +# ELIGIBILITE_URL="" +# # Instance customization: URL of the accessibility statement # ACCESSIBILITE_URL="" @@ -137,6 +143,7 @@ MATOMO_IFRAME_URL="https://matomo.example.org/index.php?module=CoreAdminHome&act # DOLIST_ACCOUNT_ID="" # DOLIST_NO_REPLY_EMAIL="" # DOLIST_API_KEY="" +# DOLIST_DEFAULT_SENDER_ID="" # SMTP Provider: SIB (Brevo) # SENDINBLUE_SMTP_ADDRESS="" @@ -153,10 +160,6 @@ DOLIST_API_BALANCING_VALUE="50" # Used only by a migration to choose your default regarding procedure archive dossiers after duree_conservation_dossiers_dans_ds # DEFAULT_PROCEDURE_EXPIRES_WHEN_TERMINE_ENABLED=true -# Enable vite legacy build (IE11). Legacy build is used in production (except if set to "disabled"). -# You might want to enable it in other environements for testing. Build time will be greatly impacted. -VITE_LEGACY="" - # around july 2022, we changed the duree_conservation_dossiers_dans_ds, allow instances to choose their own duration NEW_MAX_DUREE_CONSERVATION=12 @@ -243,9 +246,11 @@ REDIS_SIDEKIQ_MASTER='master_name' REDIS_SIDEKIQ_PASSWORD='sentinel_and_redis_password' REDIS_SIDEKIQ_USERNAME='sentinel_and_redis_username' -# configuration for prometheus metrics web server +# configuration for prometheus metrics web server on /metrics # launched with sidekiq -PROMETHEUS_EXPORTER_BIND="0.0.0.0" +# adjust according to your prometheus probe, 127.0.0.1 or your local/admin net address +# it's advised to avoid 0.0.0.0 or if you do, please configure ACL elsewhere (webserver, reverse proxy, ...) +PROMETHEUS_EXPORTER_BIND="127.0.0.1" PROMETHEUS_EXPORTER_PORT="9394" PROMETHEUS_EXPORTER_ENABLED="disabled" @@ -269,3 +274,14 @@ CRON_JOBS_DISABLED="" # disable SIDEKIQ_RELIABLE_FETCH # SKIP_RELIABLE_FETCH="true" + +# optional license key for lightgallery +VITE_LIGHTGALLERY_LICENSE_KEY = "" + +# Email used to find the Instructeur who fixes data on production. +# This email will be visible to users whom dossier had been fixed by our maintenance_tasks +# By default we use CONTACT_EMAIL, but you can customize it +MAINTENANCE_INSTRUCTEUR_EMAIL="" + +# want to stay on delayed job ? set as 'delayed_job' +RAILS_QUEUE_ADAPTER=" \ No newline at end of file diff --git a/config/environment.rb b/config/environment.rb index cac531577..7df99e89c 100644 --- a/config/environment.rb +++ b/config/environment.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Load the Rails application. require_relative "application" diff --git a/config/environments/development.rb b/config/environments/development.rb index 4932f0bb4..e6fdffbb7 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/core_ext/integer/time" require Rails.root.join("app/lib/balancer_delivery_method") diff --git a/config/environments/production.rb b/config/environments/production.rb index 64ed28732..cf942cd6c 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/core_ext/integer/time" require Rails.root.join("app/lib/balancer_delivery_method") @@ -62,22 +64,26 @@ Rails.application.configure do # Use a different cache store in production. if ENV['REDIS_CACHE_URL'].present? - redis_options = { url: ENV['REDIS_CACHE_URL'] } - redis_options[:ssl] = (ENV['REDIS_CACHE_SSL'] == 'enabled') + redis_options = { + url: ENV['REDIS_CACHE_URL'], + connect_timeout: 0.2, + error_handler: -> (method:, returning:, exception:) { + Sentry.capture_exception exception, level: 'warning', + tags: { method: method, returning: returning } + } + } + + redis_options[:ssl] = ENV['REDIS_CACHE_SSL'] == 'enabled' + if ENV['REDIS_CACHE_SSL_VERIFY_NONE'] == 'enabled' redis_options[:ssl_params] = { verify_mode: OpenSSL::SSL::VERIFY_NONE } end - redis_options[:error_handler] = -> (method:, returning:, exception:) { - Sentry.capture_exception exception, level: 'warning', - tags: { method: method, returning: returning } - } - config.cache_store = :redis_cache_store, redis_options end # Use a real queuing backend for Active Job (and separate queues per environment). - config.active_job.queue_adapter = :delayed_job + config.active_job.queue_adapter = ENV.fetch('RAILS_QUEUE_ADAPTER') { :sidekiq } # config.active_job.queue_name_prefix = "tps_production" config.action_mailer.perform_caching = false diff --git a/config/environments/test.rb b/config/environments/test.rb index 7880fc7b5..f678a4d9f 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "active_support/core_ext/integer/time" # The test environment is used exclusively to run your application's diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 82d29a1c9..697dc1102 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -102,6 +102,8 @@ ignore_unused: - 'activerecord.models.*' - 'activerecord.attributes.*' - 'activemodel.attributes.map_filter.*' +- 'activemodel.attributes.helpscout/form.*' +- 'activemodel.errors.models.*' - 'activerecord.errors.*' - 'errors.messages.blank' - 'errors.messages.content_type_invalid' @@ -133,7 +135,7 @@ ignore_unused: ## Ignore these keys completely: ignore: -- 'shared.champs.drop_down_list{,.other}' # pluralization "other" false positive +- 'shared.champs.drop_down_list{,.other,.other_label}' # pluralization "other" false positive ## Sometimes, it isn't possible for i18n-tasks to match the key correctly, ## e.g. in case of a relative key defined in a helper method. diff --git a/config/initializers/01_application_name.rb b/config/initializers/01_application_name.rb index 0378fb48d..c80472307 100644 --- a/config/initializers/01_application_name.rb +++ b/config/initializers/01_application_name.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # This file is named '01-application-name.rb' to load it before the other # initializers, and thus make the APPLICATION_ constants available in # the other initializers. diff --git a/config/initializers/02_urls.rb b/config/initializers/02_urls.rb index d6e031dae..8dc3211e7 100644 --- a/config/initializers/02_urls.rb +++ b/config/initializers/02_urls.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # rubocop:disable DS/ApplicationName # API URLs API_ADRESSE_URL = ENV.fetch("API_ADRESSE_URL", "https://api-adresse.data.gouv.fr") @@ -7,7 +9,7 @@ API_GEO_URL = ENV.fetch("API_GEO_URL", "https://geo.api.gouv.fr") API_PARTICULIER_URL = ENV.fetch("API_PARTICULIER_URL", "https://particulier.api.gouv.fr/api") API_TCHAP_URL = ENV.fetch("API_TCHAP_URL", "https://matrix.agent.tchap.gouv.fr/_matrix/identity/api/v1") API_COJO_URL = ENV.fetch("API_COJO_URL", nil) -API_RNF_URL = ENV.fetch("API_RNF_URL", "https://rnf.dso.numerique-interieur.com") +API_RNF_URL = ENV.fetch("API_RNF_URL", "https://rnf.apps.app1.numerique-interieur.com") API_RECHERCHE_ENTREPRISE_URL = ENV.fetch("API_RECHERCHE_ENTREPRISE_URL", "https://recherche-entreprises.api.gouv.fr") HELPSCOUT_API_URL = ENV.fetch("HELPSCOUT_API_URL", "https://api.helpscout.net/v2") SENDINBLUE_API_URL = ENV.fetch("SENDINBLUE_API_URL", "https://in-automate.sendinblue.com/api/v2") @@ -37,6 +39,7 @@ CGU_URL = ENV.fetch("CGU_URL", [DOC_URL, "cgu"].join("/")) MENTIONS_LEGALES_URL = ENV.fetch("MENTIONS_LEGALES_URL", "/mentions-legales") ACCESSIBILITE_URL = ENV.fetch("ACCESSIBILITE_URL", "/declaration-accessibilite") ROUTAGE_URL = ENV.fetch("ROUTAGE_URL", [DOC_URL, "/pour-aller-plus-loin/routage"].join("/")) +ELIGIBILITE_URL = ENV.fetch("ELIGIBILITE_URL", [DOC_URL, "/pour-aller-plus-loin/eligibilite-des-dossiers"].join("/")) API_DOC_URL = [DOC_URL, "api-graphql"].join("/") WEBHOOK_DOC_URL = [DOC_URL, "pour-aller-plus-loin", "webhook"].join("/") WEBHOOK_ALTERNATIVE_DOC_URL = [DOC_URL, "api-graphql", "cas-dusages-exemple-dimplementation", "synchroniser-les-dossiers-modifies-sur-ma-demarche"].join("/") diff --git a/config/initializers/acsv.rb b/config/initializers/acsv.rb index d099dbe6a..f0094c9a1 100644 --- a/config/initializers/acsv.rb +++ b/config/initializers/acsv.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'csv' # PR : https://github.com/wvengen/ruby-acsv/pull/3 diff --git a/config/initializers/action_view_record_identifier.rb b/config/initializers/action_view_record_identifier.rb index 43105f861..9315f9525 100644 --- a/config/initializers/action_view_record_identifier.rb +++ b/config/initializers/action_view_record_identifier.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActionView::RecordIdentifier alias original_dom_class dom_class alias original_record_key_for_dom_id record_key_for_dom_id diff --git a/config/initializers/active_model_serializer.rb b/config/initializers/active_model_serializer.rb index d4702c3d5..c9335bec9 100644 --- a/config/initializers/active_model_serializer.rb +++ b/config/initializers/active_model_serializer.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + ActiveModelSerializers.config.default_includes = '**' diff --git a/config/initializers/active_storage.rb b/config/initializers/active_storage.rb index 042333ae3..620e3f022 100644 --- a/config/initializers/active_storage.rb +++ b/config/initializers/active_storage.rb @@ -1,10 +1,13 @@ +# frozen_string_literal: true + Rails.application.config.active_storage.service_urls_expire_in = 1.hour +Rails.application.config.active_storage.variant_processor = :mini_magick Rails.application.config.active_storage.analyzers.delete ActiveStorage::Analyzer::ImageAnalyzer Rails.application.config.active_storage.analyzers.delete ActiveStorage::Analyzer::VideoAnalyzer ActiveSupport.on_load(:active_storage_blob) do - include BlobTitreIdentiteWatermarkConcern + include BlobImageProcessorConcern include BlobVirusScannerConcern include BlobSignedIdConcern @@ -15,7 +18,7 @@ ActiveSupport.on_load(:active_storage_blob) do end ActiveSupport.on_load(:active_storage_attachment) do - include AttachmentTitreIdentiteWatermarkConcern + include AttachmentImageProcessorConcern include AttachmentVirusScannerConcern end diff --git a/config/initializers/administrate.rb b/config/initializers/administrate.rb index 3cea41da7..51c5437b9 100644 --- a/config/initializers/administrate.rb +++ b/config/initializers/administrate.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + Administrate::Engine.add_stylesheet('manager.css') diff --git a/config/initializers/after_party.rb b/config/initializers/after_party.rb index e9ef95728..337c1f4ee 100644 --- a/config/initializers/after_party.rb +++ b/config/initializers/after_party.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + AfterParty.setup do |_config| require "after_party/active_record.rb" end diff --git a/config/initializers/agent_connect.rb b/config/initializers/agent_connect.rb index f1a7af19f..9d886751e 100644 --- a/config/initializers/agent_connect.rb +++ b/config/initializers/agent_connect.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + if ENV['AGENT_CONNECT_BASE_URL'].present? discover = OpenIDConnect::Discovery::Provider::Config.discover!("#{ENV.fetch('AGENT_CONNECT_BASE_URL')}/api/v2") diff --git a/config/initializers/ancestry.rb b/config/initializers/ancestry.rb index 961215897..a2441167b 100644 --- a/config/initializers/ancestry.rb +++ b/config/initializers/ancestry.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true # use the newer format Ancestry.default_ancestry_format = :materialized_path2 diff --git a/config/initializers/application_controller_renderer.rb b/config/initializers/application_controller_renderer.rb index 89d2efab2..6d56e4390 100644 --- a/config/initializers/application_controller_renderer.rb +++ b/config/initializers/application_controller_renderer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Be sure to restart your server when you modify this file. # ActiveSupport::Reloader.to_prepare do diff --git a/config/initializers/application_version.rb b/config/initializers/application_version.rb new file mode 100644 index 000000000..c615edc05 --- /dev/null +++ b/config/initializers/application_version.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class ApplicationVersion + @@current = nil + + # Detect the current release version, which helps Sentry identifying the current release + # or can be used as cache key when for some contents susceptible to change between releases. + # + # The deploy process can write a "version" file at root + # containing a string identifying the release, like the sha256 commit used by its release. + # It defaults to a random string if the file is not found (so each restart will behave like a new version) + def self.current + @@current ||= begin + version = Rails.root.join('version') + version.readable? ? version.read.strip : SecureRandom.hex + end + @@current.presence + end +end diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb index 12057061a..a8dfbeca0 100644 --- a/config/initializers/assets.rb +++ b/config/initializers/assets.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Be sure to restart your server when you modify this file. # Version of your assets, change this if you want to expire all your assets. diff --git a/config/initializers/attribute_types.rb b/config/initializers/attribute_types.rb index 153838f86..a43d3b536 100644 --- a/config/initializers/attribute_types.rb +++ b/config/initializers/attribute_types.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class SimpleJsonType < ActiveModel::Type::Value def cast(value) return nil if value.blank? diff --git a/config/initializers/authorized_content_types.rb b/config/initializers/authorized_content_types.rb index fe5410266..497757957 100644 --- a/config/initializers/authorized_content_types.rb +++ b/config/initializers/authorized_content_types.rb @@ -1,15 +1,33 @@ -AUTHORIZED_CONTENT_TYPES = [ - # multimedia +# frozen_string_literal: true + +AUTHORIZED_PDF_TYPES = [ + 'application/pdf', # text x 4628654 + 'application/x-pdf', # text x 30 + 'image/pdf', # text x 23 + 'text/pdf' # text x 12 +] + +AUTHORIZED_IMAGE_TYPES = [ 'image/jpeg', # multimedia x 1467465 'image/png', # multimedia x 126662 'image/tiff', # multimedia x 3985 'image/bmp', # multimedia x 3656 - 'video/mp4', # multimedia x 2075 'image/webp', # multimedia x 529 - 'video/quicktime', # multimedia x 486 'image/gif', # multimedia x 463 + 'image/vnd.dwg' # multimedia x 137 auto desk +] + +RARE_IMAGE_TYPES = [ + 'image/tiff' # multimedia x 3985 +] + +PROCESSABLE_TYPES = AUTHORIZED_IMAGE_TYPES + AUTHORIZED_PDF_TYPES + +AUTHORIZED_CONTENT_TYPES = PROCESSABLE_TYPES + [ + # multimedia + 'video/mp4', # multimedia x 2075 + 'video/quicktime', # multimedia x 486 'video/3gpp', # multimedia x 216 - 'image/vnd.dwg', # multimedia x 137 auto desk 'audio/mpeg', # multimedia x 26 'video/x-ms-wm', # multimedia x 15 video microsoft ? 'audio/mp4', # audio .mp4, .m4a @@ -45,7 +63,6 @@ AUTHORIZED_CONTENT_TYPES = [ 'text/xml', # program x 10 # text / sheet / presentation - 'application/pdf', # text x 4628654 'application/vnd.ms-excel', # text x 166674 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', # text x 103879 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', # text x 86336 @@ -69,18 +86,15 @@ AUTHORIZED_CONTENT_TYPES = [ 'application/vnd.ms-word.document.macroenabled.12', # text x 61 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', # text x 59 'application/vnd.openxmlformats-officedocument.presentationml.slideshow', # text x 32 - 'application/x-pdf', # text x 30 'application/kswps', # inconnu x 26 , text ? 'application/x-iwork-numbers-sffnumbers', # text x 25 'text/rtf', # text x 25 - 'image/pdf', # text x 23 'application/vnd.ms-xpsdocument', # text x 23 'application/vnd.ms-excel.sheet.binary.macroenabled.12', # text x 21 'application/vnd.ms-powerpoint.presentation.macroenabled.12', # text x 15 'application/x-msword', # text x 15 'application/vnd.oasis.opendocument.spreadsheet-template', # text x 14 'application/vnd.oasis.opendocument.text-master', # text x 12 - 'text/pdf', # text x 12 'application/x-abiword', # text x 11 'application/x-iwork-keynote-sffnumbers', # text x 11 'application/x-iwork-keynote-sffkey', # text x 10 diff --git a/config/initializers/backtrace_silencers.rb b/config/initializers/backtrace_silencers.rb index 33699c309..74f30e887 100644 --- a/config/initializers/backtrace_silencers.rb +++ b/config/initializers/backtrace_silencers.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Be sure to restart your server when you modify this file. # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. diff --git a/config/initializers/chartkick.rb b/config/initializers/chartkick.rb index 4f44c1a38..75e18c8a2 100644 --- a/config/initializers/chartkick.rb +++ b/config/initializers/chartkick.rb @@ -1,6 +1,36 @@ +# frozen_string_literal: true + Chartkick.options = { content_for: :charts_js, - colors: ["#000091"], + colors: ["var(--background-action-high-blue-france)"], thousands: ' ', - decimal: ',' + decimal: ',', + default_library_config: { + chart: { backgroundColor: 'var(--background-contrast-grey)' }, + xAxis: { + lineColor: 'var(--border-action-high-grey)', + labels: { style: { color: "var(--text-default-grey)" } } + }, + yAxis: { + gridLineColor: 'var(--border-plain-grey)', + lineColor: 'var(--border-action-high-grey)', + labels: { style: { color: "var(--text-default-grey)" } } + }, + legend: { + itemStyle: { + color: "var(--text-default-grey)" + } + }, + plotOptions: { + pie: { + dataLabels: { + color: "var(--text-default-grey)", + enabled: true, format: '{point.name} : {point.percentage: .1f}%', + style: { + textOutline: 'none' + } + } + } + } + } } diff --git a/config/initializers/contacts.rb b/config/initializers/contacts.rb index af4271630..49931371c 100644 --- a/config/initializers/contacts.rb +++ b/config/initializers/contacts.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # rubocop:disable DS/ApplicationName # todo: will be externally configurable if !defined?(CONTACT_EMAIL) diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index d213d1dfc..21a0a176c 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Be sure to restart your server when you modify this file. # Define an application-wide content security policy @@ -7,24 +9,21 @@ Rails.application.config.content_security_policy do |policy| images_whitelist = ["*.openstreetmap.org", "*.cloud.ovh.net", "*"] images_whitelist << URI(DS_PROXY_URL).host if DS_PROXY_URL.present? - images_whitelist << URI(MATOMO_IFRAME_URL).host if MATOMO_IFRAME_URL.present? policy.img_src(:self, :data, :blob, *images_whitelist) # Javascript: allow us, SendInBlue and Matomo. # We need unsafe_inline because miniprofiler and us have some inline buttons :( - scripts_whitelist = ["*.crisp.chat", "crisp.chat", "cdn.jsdelivr.net", "maxcdn.bootstrapcdn.com", "code.jquery.com", "unpkg.com"] - scripts_whitelist << URI(MATOMO_IFRAME_URL).host if MATOMO_IFRAME_URL.present? + scripts_whitelist = ["*.crisp.chat", "crisp.chat", "cdn.jsdelivr.net", "maxcdn.bootstrapcdn.com", "code.jquery.com", "unpkg.com", "*.dgnum.eu"] policy.script_src(:self, :unsafe_eval, :unsafe_inline, :blob, *scripts_whitelist) # CSS: We have a lot of inline style, and some