diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index de098ef7b263..d730336dadf5 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -186,6 +186,16 @@ jobs: type=semver,pattern={{version}},value=${{ needs.setup.outputs.version }} images: | ${{ env.REGISTRY_IMAGE }} + - name: Restore vendor/bundle + id: restore-vendor-bundle + uses: actions/cache/restore@v4 + with: + path: | + vendor/bundle + key: ${{ matrix.platform }}-vendor-bundle-${{ github.ref }} + - name: Include vendor/bundle in this build (so we can use it from the cache above) + run: | + sed -i 's/vendor\/bundle//g' .dockerignore - name: Build image id: build uses: docker/build-push-action@v6 @@ -195,12 +205,26 @@ jobs: target: ${{ matrix.target }} build-args: | BIM_SUPPORT=${{ matrix.bim_support }} + BUILDKIT_PROGRESS=plain pull: true load: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=s3,blobs_prefix=cache/${{ github.repository }}/,manifests_prefix=cache/${{ github.repository }}/,region=${{ env.RUNS_ON_AWS_REGION }},bucket=${{ env.RUNS_ON_S3_BUCKET_CACHE }} cache-to: type=s3,blobs_prefix=cache/${{ github.repository }}/,manifests_prefix=cache/${{ github.repository }}/,region=${{ env.RUNS_ON_AWS_REGION }},bucket=${{ env.RUNS_ON_S3_BUCKET_CACHE }},mode=max + - name: Extract vendor/bundle from container + run: | + docker create --name bundle ${{ steps.build.outputs.imageid }} + rm -rf vendor/bundle || true + docker cp bundle:/app/vendor/bundle vendor/bundle + docker rm bundle + - name: Save vendor/bundle + id: save-vendor-bundle + uses: actions/cache/save@v4 + with: + path: | + vendor/bundle + key: ${{ steps.restore-vendor-bundle.outputs.cache-primary-key }} - name: Test # We only test the native container. If that fails the builds for the others # will be cancelled as well. diff --git a/.gitignore b/.gitignore index 0a27bf8e565e..a677a999f576 100644 --- a/.gitignore +++ b/.gitignore @@ -134,6 +134,8 @@ structure.sql lefthook-local.yml .rubocop-local.yml +/.lefthook-local/ + frontend/package-lock.json # Testing and nextcloud infrastructure diff --git a/Gemfile b/Gemfile index 366d19ea5233..b604d3d3c9f8 100644 --- a/Gemfile +++ b/Gemfile @@ -36,7 +36,7 @@ ruby File.read(File.expand_path(".ruby-version", __dir__)).strip gem "actionpack-xml_parser", "~> 2.0.0" gem "activemodel-serializers-xml", "~> 1.0.1" -gem "activerecord-import", "~> 1.8.0" +gem "activerecord-import", "~> 2.0.0" gem "activerecord-session_store", "~> 2.1.0" gem "ox" gem "rails", "~> 7.1.3" @@ -61,7 +61,7 @@ gem "friendly_id", "~> 5.5.0" gem "acts_as_list", "~> 1.2.0" gem "acts_as_tree", "~> 2.9.0" -gem "awesome_nested_set", "~> 3.7.0" +gem "awesome_nested_set", "~> 3.8.0" gem "closure_tree", "~> 7.4.0" gem "rubytree", "~> 2.1.0" # Only used in down migrations now. @@ -140,7 +140,7 @@ gem "rack-attack", "~> 6.7.0" gem "secure_headers", "~> 7.0.0" # Browser detection for incompatibility checks -gem "browser", "~> 6.0.0" +gem "browser", "~> 6.2.0" # Providing health checks gem "okcomputer", "~> 1.18.1" @@ -171,6 +171,9 @@ gem "paper_trail", "~> 15.2.0" gem "op-clamav-client", "~> 3.4", require: "clamav" +# Recurring meeting events definition +gem "ice_cube", "~> 0.17.0" + group :production do # we use dalli as standard memcache client # requires memcached 1.4+ @@ -188,7 +191,7 @@ gem "puma", "~> 6.5" gem "puma-plugin-statsd", "~> 2.0" gem "rack-timeout", "~> 0.7.0", require: "rack/timeout/base" -gem "nokogiri", "~> 1.16.0" +gem "nokogiri", "~> 1.17.0" gem "carrierwave", "~> 1.3.4" gem "carrierwave_direct", "~> 2.1.0" @@ -229,7 +232,7 @@ gem "factory_bot", "~> 6.5.0", require: false # require factory_bot_rails for convenience in core development gem "factory_bot_rails", "~> 6.4.4", require: false -gem "turbo_power", "~> 0.6.2" +gem "turbo_power", "~> 0.7.0" gem "turbo-rails", "~> 2.0.0" gem "httpx" @@ -269,7 +272,7 @@ group :test do gem "rails-controller-testing", "~> 1.0.2" gem "capybara", "~> 3.40.0" - gem "capybara_accessible_selectors", git: "https://github.com/citizensadvice/capybara_accessible_selectors", branch: "main" + gem "capybara_accessible_selectors", git: "https://github.com/citizensadvice/capybara_accessible_selectors", tag: "v0.12.0" gem "capybara-screenshot", "~> 1.0.17" gem "cuprite", "~> 0.15.0" gem "rspec-wait" @@ -379,7 +382,7 @@ platforms :mri, :mingw, :x64_mingw do end # Support application loading when no database exists yet. - gem "activerecord-nulldb-adapter", "~> 1.0.0" + gem "activerecord-nulldb-adapter", "~> 1.1.0" # Have application level locks on the database to have a mutex shared between workers/hosts. # We e.g. employ this to safeguard the creation of journals. @@ -398,6 +401,6 @@ gemfiles.each do |file| send(:eval_gemfile, file) if File.readable?(file) end -gem "openproject-octicons", "~>19.19.0" -gem "openproject-octicons_helper", "~>19.19.0" -gem "openproject-primer_view_components", "~>0.49.2" +gem "openproject-octicons", "~>19.20.0 " +gem "openproject-octicons_helper", "~>19.20.0 " +gem "openproject-primer_view_components", "~>0.52.0" diff --git a/Gemfile.lock b/Gemfile.lock index e42bda9e0386..2ff8ccffa25d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ GIT remote: https://github.com/citizensadvice/capybara_accessible_selectors - revision: aed2860e5b5df7f39284fdc35a5bf57bda7e1ad9 - branch: main + revision: 347bbe06cb420416855e80bb4e3a3016b2d5872c + tag: v0.12.0 specs: capybara_accessible_selectors (0.11.0) capybara (~> 3.36) @@ -206,7 +206,7 @@ PATH remote: modules/two_factor_authentication specs: openproject-two_factor_authentication (1.0.0) - aws-sdk-sns (~> 1.90.0) + aws-sdk-sns (~> 1.92.0) messagebird-rest (~> 1.4.2) rotp (~> 6.1) webauthn (~> 3.0) @@ -226,35 +226,35 @@ GEM remote: https://rubygems.org/ specs: Ascii85 (2.0.1) - actioncable (7.1.5) - actionpack (= 7.1.5) - activesupport (= 7.1.5) + actioncable (7.1.5.1) + actionpack (= 7.1.5.1) + activesupport (= 7.1.5.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.5) - actionpack (= 7.1.5) - activejob (= 7.1.5) - activerecord (= 7.1.5) - activestorage (= 7.1.5) - activesupport (= 7.1.5) + actionmailbox (7.1.5.1) + actionpack (= 7.1.5.1) + activejob (= 7.1.5.1) + activerecord (= 7.1.5.1) + activestorage (= 7.1.5.1) + activesupport (= 7.1.5.1) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.1.5) - actionpack (= 7.1.5) - actionview (= 7.1.5) - activejob (= 7.1.5) - activesupport (= 7.1.5) + actionmailer (7.1.5.1) + actionpack (= 7.1.5.1) + actionview (= 7.1.5.1) + activejob (= 7.1.5.1) + activesupport (= 7.1.5.1) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.2) - actionpack (7.1.5) - actionview (= 7.1.5) - activesupport (= 7.1.5) + actionpack (7.1.5.1) + actionview (= 7.1.5.1) + activesupport (= 7.1.5.1) nokogiri (>= 1.8.5) racc rack (>= 2.2.4) @@ -265,36 +265,36 @@ GEM actionpack-xml_parser (2.0.1) actionpack (>= 5.0) railties (>= 5.0) - actiontext (7.1.5) - actionpack (= 7.1.5) - activerecord (= 7.1.5) - activestorage (= 7.1.5) - activesupport (= 7.1.5) + actiontext (7.1.5.1) + actionpack (= 7.1.5.1) + activerecord (= 7.1.5.1) + activestorage (= 7.1.5.1) + activesupport (= 7.1.5.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.5) - activesupport (= 7.1.5) + actionview (7.1.5.1) + activesupport (= 7.1.5.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.1.5) - activesupport (= 7.1.5) + activejob (7.1.5.1) + activesupport (= 7.1.5.1) globalid (>= 0.3.6) - activemodel (7.1.5) - activesupport (= 7.1.5) + activemodel (7.1.5.1) + activesupport (= 7.1.5.1) activemodel-serializers-xml (1.0.3) activemodel (>= 5.0.0.a) activesupport (>= 5.0.0.a) builder (~> 3.1) - activerecord (7.1.5) - activemodel (= 7.1.5) - activesupport (= 7.1.5) + activerecord (7.1.5.1) + activemodel (= 7.1.5.1) + activesupport (= 7.1.5.1) timeout (>= 0.4.0) - activerecord-import (1.8.1) + activerecord-import (2.0.0) activerecord (>= 4.2) - activerecord-nulldb-adapter (1.0.1) - activerecord (>= 5.2.0, < 7.2) + activerecord-nulldb-adapter (1.1.1) + activerecord (>= 6.0, < 8.1) activerecord-session_store (2.1.0) actionpack (>= 6.1) activerecord (>= 6.1) @@ -302,13 +302,13 @@ GEM multi_json (~> 1.11, >= 1.11.2) rack (>= 2.0.8, < 4) railties (>= 6.1) - activestorage (7.1.5) - actionpack (= 7.1.5) - activejob (= 7.1.5) - activerecord (= 7.1.5) - activesupport (= 7.1.5) + activestorage (7.1.5.1) + actionpack (= 7.1.5.1) + activejob (= 7.1.5.1) + activerecord (= 7.1.5.1) + activesupport (= 7.1.5.1) marcel (~> 1.0) - activesupport (7.1.5) + activesupport (7.1.5.1) base64 benchmark (>= 0.3) bigdecimal @@ -330,7 +330,7 @@ GEM public_suffix (>= 2.0.2, < 7.0) aes_key_wrap (1.1.0) afm (0.2.2) - airbrake (13.0.4) + airbrake (13.0.5) airbrake-ruby (~> 6.0) airbrake-ruby (6.2.2) rbtree3 (~> 0.6) @@ -341,10 +341,10 @@ GEM attr_required (1.0.2) auto_strip_attributes (2.6.0) activerecord (>= 4.0) - awesome_nested_set (3.7.0) - activerecord (>= 4.0.0, < 8.0) + awesome_nested_set (3.8.0) + activerecord (>= 4.0.0, < 8.1) aws-eventstream (1.3.0) - aws-partitions (1.1013.0) + aws-partitions (1.1023.0) aws-sdk-core (3.214.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) @@ -353,11 +353,11 @@ GEM aws-sdk-kms (1.96.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.174.0) + aws-sdk-s3 (1.176.1) aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sdk-sns (1.90.0) + aws-sdk-sns (1.92.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) aws-sigv4 (1.10.1) @@ -391,7 +391,7 @@ GEM msgpack (~> 1.2) brakeman (6.2.2) racc - browser (6.0.0) + browser (6.2.0) builder (3.3.0) byebug (11.1.3) capybara (3.40.0) @@ -441,15 +441,15 @@ GEM bigdecimal rexml crass (1.0.6) - css_parser (1.19.1) + css_parser (1.21.0) addressable - csv (3.3.0) + csv (3.3.1) cuprite (0.15.1) capybara (~> 3.0) ferrum (~> 0.15.0) daemons (1.4.1) dalli (3.2.8) - date (3.4.0) + date (3.4.1) date_validator (0.12.0) activemodel (>= 3) activesupport (>= 3) @@ -465,11 +465,11 @@ GEM disposable (0.6.3) declarative (>= 0.0.9, < 1.0.0) representable (>= 3.1.1, < 4) - doorkeeper (5.8.0) + doorkeeper (5.8.1) railties (>= 5) - dotenv (3.1.4) - dotenv-rails (3.1.4) - dotenv (= 3.1.4) + dotenv (3.1.7) + dotenv-rails (3.1.7) + dotenv (= 3.1.7) railties (>= 6.1) drb (2.2.1) dry-auto_inject (1.0.1) @@ -545,20 +545,20 @@ GEM tzinfo eventmachine (1.2.7) eventmachine_httpserver (0.2.1) - excon (1.2.0) + excon (1.2.2) factory_bot (6.5.0) activesupport (>= 5.0.0) factory_bot_rails (6.4.4) factory_bot (~> 6.5) railties (>= 5.0.0) - faraday (2.12.0) - faraday-net_http (>= 2.0, < 3.4) + faraday (2.12.2) + faraday-net_http (>= 2.0, < 3.5) json logger faraday-follow_redirects (0.3.0) faraday (>= 1, < 3) - faraday-net_http (3.3.0) - net-http + faraday-net_http (3.4.0) + net-http (>= 0.5.0) fastimage (2.3.1) ferrum (0.15) addressable (~> 2.5) @@ -567,7 +567,7 @@ GEM websocket-driver (~> 0.7) ffi (1.17.0) flamegraph (0.9.5) - fog-aws (3.29.0) + fog-aws (3.30.0) base64 (~> 0.2.0) fog-core (~> 2.6) fog-json (~> 1.1) @@ -620,9 +620,11 @@ GEM google-apis-core (>= 0.15.0, < 2.a) google-cloud-env (2.2.1) faraday (>= 1.0, < 3.a) - googleauth (1.11.2) + google-logging-utils (0.1.0) + googleauth (1.12.1) faraday (>= 1.0, < 3.a) - google-cloud-env (~> 2.1) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) jwt (>= 1.4, < 3.0) multi_json (~> 1.11) os (>= 0.9, < 2.0) @@ -652,7 +654,7 @@ GEM http-2 (1.0.2) http_parser.rb (0.6.0) httpclient (2.8.3) - httpx (1.3.3) + httpx (1.4.0) http-2 (>= 1.0.0) i18n (1.14.6) concurrent-ruby (~> 1.0) @@ -675,13 +677,13 @@ GEM ice_cube (0.17.0) ice_nine (0.11.2) interception (0.5) - io-console (0.7.2) - irb (1.14.1) + io-console (0.8.0) + irb (1.14.2) rdoc (>= 4.0.0) reline (>= 0.4.2) iso8601 (0.13.0) jmespath (1.6.2) - json (2.8.2) + json (2.9.1) json-jwt (1.16.7) activesupport (>= 4.2) aes_key_wrap @@ -707,7 +709,7 @@ GEM launchy (3.0.1) addressable (~> 2.8) childprocess (~> 5.0) - lefthook (1.8.4) + lefthook (1.10.0) letter_opener (1.10.0) launchy (>= 2.2, < 4) letter_opener_web (3.0.0) @@ -722,7 +724,7 @@ GEM omniauth (~> 1.1) omniauth-openid-connect (>= 0.2.1) rails (>= 3.2.21) - logger (1.6.1) + logger (1.6.4) lograge (0.14.0) actionpack (>= 4) activesupport (>= 4) @@ -758,11 +760,11 @@ GEM mime-types (3.6.0) logger mime-types-data (~> 3.2015) - mime-types-data (3.2024.1105) + mime-types-data (3.2024.1203) mini_magick (5.0.1) mini_mime (1.1.5) mini_portile2 (2.8.8) - minitest (5.25.1) + minitest (5.25.4) msgpack (1.7.5) multi_json (1.15.0) mustermann (3.0.3) @@ -770,7 +772,7 @@ GEM mustermann-grape (1.1.0) mustermann (>= 1.0.0) mutex_m (0.3.0) - net-http (0.4.1) + net-http (0.6.0) uri net-imap (0.5.1) date @@ -783,10 +785,10 @@ GEM net-smtp (0.5.0) net-protocol nio4r (2.7.4) - nokogiri (1.16.7) + nokogiri (1.17.2) mini_portile2 (~> 2.8.2) racc (~> 1.4) - oj (3.16.7) + oj (3.16.8) bigdecimal (>= 3.0) ostruct (>= 0.2) okcomputer (1.18.5) @@ -807,15 +809,15 @@ GEM validate_email validate_url webfinger (~> 2.0) - openproject-octicons (19.19.0) - openproject-octicons_helper (19.19.0) + openproject-octicons (19.20.0) + openproject-octicons_helper (19.20.0) actionview - openproject-octicons (= 19.19.0) + openproject-octicons (= 19.20.0) railties - openproject-primer_view_components (0.49.2) + openproject-primer_view_components (0.52.0) actionview (>= 5.0.0) activesupport (>= 5.0.0) - openproject-octicons (>= 19.17.0) + openproject-octicons (>= 19.20.0) view_component (>= 3.1, < 4.0) openproject-token (4.0.0) activemodel @@ -868,7 +870,8 @@ GEM pry-rescue (1.6.0) interception (>= 0.5) pry (>= 0.12.0) - psych (5.2.0) + psych (5.2.2) + date stringio public_suffix (6.0.1) puffing-billy (4.0.0) @@ -913,20 +916,20 @@ GEM rackup (1.0.1) rack (< 3) webrick - rails (7.1.5) - actioncable (= 7.1.5) - actionmailbox (= 7.1.5) - actionmailer (= 7.1.5) - actionpack (= 7.1.5) - actiontext (= 7.1.5) - actionview (= 7.1.5) - activejob (= 7.1.5) - activemodel (= 7.1.5) - activerecord (= 7.1.5) - activestorage (= 7.1.5) - activesupport (= 7.1.5) + rails (7.1.5.1) + actioncable (= 7.1.5.1) + actionmailbox (= 7.1.5.1) + actionmailer (= 7.1.5.1) + actionpack (= 7.1.5.1) + actiontext (= 7.1.5.1) + actionview (= 7.1.5.1) + activejob (= 7.1.5.1) + activemodel (= 7.1.5.1) + activerecord (= 7.1.5.1) + activestorage (= 7.1.5.1) + activesupport (= 7.1.5.1) bundler (>= 1.15.0) - railties (= 7.1.5) + railties (= 7.1.5.1) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -935,15 +938,15 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.0) + rails-html-sanitizer (1.6.2) loofah (~> 2.21) - nokogiri (~> 1.14) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rails-i18n (7.0.10) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) - railties (7.1.5) - actionpack (= 7.1.5) - activesupport (= 7.1.5) + railties (7.1.5.1) + actionpack (= 7.1.5.1) + activesupport (= 7.1.5.1) irb rackup (>= 1.0.0) rake (>= 12.2) @@ -954,22 +957,22 @@ GEM rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) - rb_sys (0.9.102) + rb_sys (0.9.103) rbtrace (0.5.1) ffi (>= 1.0.6) msgpack (>= 0.4.3) optimist (>= 3.0.0) rbtree3 (0.7.1) - rdoc (6.8.1) + rdoc (6.9.1) psych (>= 4.0.0) - recaptcha (5.17.0) + recaptcha (5.18.0) redcarpet (3.6.0) redis (5.3.0) redis-client (>= 0.22.0) - redis-client (0.22.2) + redis-client (0.23.0) connection_pool - regexp_parser (2.9.2) - reline (0.5.11) + regexp_parser (2.9.3) + reline (0.6.0) io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) @@ -981,7 +984,7 @@ GEM actionpack (>= 5.2) railties (>= 5.2) retriable (3.1.2) - rexml (3.3.9) + rexml (3.4.0) rinku (2.0.6) roar (1.2.0) representable (~> 3.1) @@ -1009,20 +1012,20 @@ GEM rspec-support (~> 3.13) rspec-retry (0.6.2) rspec-core (> 3.3) - rspec-support (3.13.1) + rspec-support (3.13.2) rspec-wait (1.0.1) rspec (>= 3.4) - rubocop (1.68.0) + rubocop (1.69.2) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.4, < 3.0) - rubocop-ast (>= 1.32.2, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.36.2, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.36.1) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.37.0) parser (>= 3.3.1.0) rubocop-capybara (2.21.0) rubocop (~> 1.41) @@ -1038,7 +1041,7 @@ GEM rack (>= 1.1) rubocop (>= 1.52.0, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rspec (3.2.0) + rubocop-rspec (3.3.0) rubocop (~> 1.61) rubocop-rspec_rails (2.30.0) rubocop (~> 1.61) @@ -1055,8 +1058,8 @@ GEM nokogiri (>= 1.13.10) rexml ruby2_keywords (0.0.5) - rubytree (2.1.0) - json (~> 2.0, > 2.3.1) + rubytree (2.1.1) + json (~> 2.0, > 2.9) rubyzip (2.3.2) safety_net_attestation (0.4.0) jwt (~> 2.0) @@ -1064,10 +1067,10 @@ GEM crass (~> 1.0.2) nokogiri (>= 1.12.0) secure_headers (7.0.0) - securerandom (0.3.2) - selenium-devtools (0.130.0) + securerandom (0.4.1) + selenium-devtools (0.131.0) selenium-webdriver (~> 4.2) - selenium-webdriver (4.26.0) + selenium-webdriver (4.27.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -1118,12 +1121,12 @@ GEM table_print (1.5.7) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) - test-prof (1.4.2) + test-prof (1.4.3) text-hyphen (1.5.0) thor (1.3.2) thread_safe (0.3.6) timecop (0.9.10) - timeout (0.4.2) + timeout (0.4.3) tpm-key_attestation (0.12.1) bindata (~> 2.4) openssl (> 2.0) @@ -1133,7 +1136,7 @@ GEM turbo-rails (2.0.11) actionpack (>= 6.0.0) railties (>= 6.0.0) - turbo_power (0.6.2) + turbo_power (0.7.0) turbo-rails (>= 1.3.0) typed_dag (2.0.2) rails (>= 5.0.4) @@ -1143,7 +1146,7 @@ GEM tzinfo (>= 1.0.0) uber (0.1.0) unicode-display_width (2.6.0) - uri (0.13.1) + uri (1.0.2) validate_email (0.1.6) activemodel (>= 3.0) mail (>= 2.2.5) @@ -1152,7 +1155,7 @@ GEM public_suffix vcr (6.3.1) base64 - vernier (1.4.0) + vernier (1.5.0) view_component (3.20.0) activesupport (>= 5.2.0, < 8.1) concurrent-ruby (~> 1.0) @@ -1181,7 +1184,7 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webrick (1.9.0) + webrick (1.9.1) websocket (1.2.11) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) @@ -1201,8 +1204,8 @@ PLATFORMS DEPENDENCIES actionpack-xml_parser (~> 2.0.0) activemodel-serializers-xml (~> 1.0.1) - activerecord-import (~> 1.8.0) - activerecord-nulldb-adapter (~> 1.0.0) + activerecord-import (~> 2.0.0) + activerecord-nulldb-adapter (~> 1.1.0) activerecord-session_store (~> 2.1.0) acts_as_list (~> 1.2.0) acts_as_tree (~> 2.9.0) @@ -1210,14 +1213,14 @@ DEPENDENCIES airbrake (~> 13.0.0) appsignal (~> 3.10.0) auto_strip_attributes (~> 2.5) - awesome_nested_set (~> 3.7.0) + awesome_nested_set (~> 3.8.0) aws-sdk-core (~> 3.107) aws-sdk-s3 (~> 1.91) axe-core-rspec bcrypt (~> 3.1.6) bootsnap (~> 1.18.0) brakeman (~> 6.2.0) - browser (~> 6.0.0) + browser (~> 6.2.0) budgets! capybara (~> 3.40.0) capybara-screenshot (~> 1.0.17) @@ -1269,6 +1272,7 @@ DEPENDENCIES httpx i18n-js (~> 4.2.3) i18n-tasks (~> 1.0.13) + ice_cube (~> 0.17.0) json_schemer (~> 2.3.0) json_spec (~> 1.1.4) ladle @@ -1287,7 +1291,7 @@ DEPENDENCIES multi_json (~> 1.15.0) my_page! net-ldap (~> 0.19.0) - nokogiri (~> 1.16.0) + nokogiri (~> 1.17.0) oj (~> 3.16.0) okcomputer (~> 1.18.1) omniauth! @@ -1309,10 +1313,10 @@ DEPENDENCIES openproject-job_status! openproject-ldap_groups! openproject-meeting! - openproject-octicons (~> 19.19.0) - openproject-octicons_helper (~> 19.19.0) + openproject-octicons (~> 19.20.0) + openproject-octicons_helper (~> 19.20.0) openproject-openid_connect! - openproject-primer_view_components (~> 0.49.2) + openproject-primer_view_components (~> 0.52.0) openproject-recaptcha! openproject-reporting! openproject-storages! @@ -1394,7 +1398,7 @@ DEPENDENCIES timecop (~> 0.9.0) ttfunk (~> 1.7.0) turbo-rails (~> 2.0.0) - turbo_power (~> 0.6.2) + turbo_power (~> 0.7.0) turbo_tests! typed_dag (~> 2.0.2) tzinfo-data (~> 1.2024.1) diff --git a/README.md b/README.md index 33d6cc10ae2d..c64ea5a710be 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ You found a bug? Please [report it](https://www.openproject.org/docs/development OpenProject is supported by its Community members, both companies and individuals. -We are always looking for new members to our Community, so if you are interested in improving OpenProject we would be glad to welcome and support you getting into the code. There are guides as well, e.g. a [Quick Start for Developers](https://www.openproject.org/development/setting-up-development-environment/), but don't hesitate to simply [contact us](https://www.openproject.org/contact) if you have questions. +We are always looking for new members to our Community, so if you are interested in improving OpenProject we would be glad to welcome and support you getting into the code. There are guides as well, e.g. a [Quick Start for Developers](https://www.openproject.org/docs/development/development-environment/), but don't hesitate to simply [contact us](https://www.openproject.org/contact) if you have questions. Working on OpenProject comes with the satisfaction of working on a widely used open source application. diff --git a/app/assets/images/lookbook/hover_card.png b/app/assets/images/lookbook/hover_card.png index 8dde8feb902b..f2389dd7ded0 100644 Binary files a/app/assets/images/lookbook/hover_card.png and b/app/assets/images/lookbook/hover_card.png differ diff --git a/app/assets/images/lookbook/user_hover_card.png b/app/assets/images/lookbook/user_hover_card.png new file mode 100644 index 000000000000..b225bcfad038 Binary files /dev/null and b/app/assets/images/lookbook/user_hover_card.png differ diff --git a/app/components/_index.sass b/app/components/_index.sass index 45a89e33bc3f..59f6c3cdfa43 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -6,6 +6,7 @@ @import "work_packages/activities_tab/journals/item_component/add_reactions" @import "work_packages/activities_tab/journals/item_component/reactions" @import "shares/modal_body_component" +@import "work_packages/reminder/modal_body_component" @import "shares/invite_user_form_component" @import "work_packages/details/tab_component" @import "work_packages/progress/modal_body_component" @@ -18,3 +19,5 @@ @import "op_primer/border_box_table_component" @import "work_packages/exports/modal_dialog_component" @import "work_package_relations_tab/index_component" +@import "users/hover_card_component" +@import "enterprise_edition/banner_component" diff --git a/app/components/concerns/op_turbo/streamable.rb b/app/components/concerns/op_turbo/streamable.rb index dfad026a7825..89180a56ddc2 100644 --- a/app/components/concerns/op_turbo/streamable.rb +++ b/app/components/concerns/op_turbo/streamable.rb @@ -33,6 +33,8 @@ class MissingComponentWrapper < StandardError; end # rubocop:enable OpenProject/AddPreviewForViewComponent INLINE_ACTIONS = %i[dialog flash].freeze + # Turbo allows the response method for these actions only: + ACTIONS_WITH_METHOD = %i[update replace].freeze extend ActiveSupport::Concern @@ -43,7 +45,7 @@ def wrapper_key end included do - def render_as_turbo_stream(view_context:, action: :update) + def render_as_turbo_stream(view_context:, action: :update, method: nil) case action when :update, *INLINE_ACTIONS @inner_html_only = true @@ -63,8 +65,13 @@ def render_as_turbo_stream(view_context:, action: :update) "Wrap your component in a `component_wrapper` block in order to use turbo-stream methods" end + if method && !action.in?(ACTIONS_WITH_METHOD) + raise ArgumentError, "The #{action} action does not supports a method" + end + OpTurbo::StreamComponent.new( action:, + method:, target: wrapper_key, template: ).render_in(view_context) diff --git a/app/components/enterprise_edition/banner_component.html.erb b/app/components/enterprise_edition/banner_component.html.erb new file mode 100644 index 000000000000..5161729b5d58 --- /dev/null +++ b/app/components/enterprise_edition/banner_component.html.erb @@ -0,0 +1,19 @@ +<%= + grid_layout("op-ee-banner", **@system_arguments) do |grid| + grid.with_area(:'icon-container') do + content_tag :div, class: "op-ee-banner--shield" do + render(Primer::Beta::Octicon.new(icon: 'op-enterprise-addons', + size: :medium, + classes: "op-ee-banner--icon")) + end + end + grid.with_area(:'title-container') { render(Primer::Beta::Text.new) { title } } + grid.with_area(:'description-container') { render(Primer::Beta::Text.new) { description } } + grid.with_area(:'link-container') do + render(Primer::Beta::Link.new(href: href)) do |link| + link.with_trailing_visual_icon(icon: 'link-external') + link_title + end + end + end +%> diff --git a/app/components/enterprise_edition/banner_component.rb b/app/components/enterprise_edition/banner_component.rb new file mode 100644 index 000000000000..f7ac7ed39bab --- /dev/null +++ b/app/components/enterprise_edition/banner_component.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module EnterpriseEdition + # Add a general description of component here + # Add additional usage considerations or best practices that may aid the user to use the component correctly. + # @accessibility Add any accessibility considerations + class BannerComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + # @param feature_key [Symbol, NilClass] The key of the feature to show the banner for. + # @param title [String] The title of the banner. + # @param description [String] The description of the banner. + # @param href [String] The URL to link to. + # @param skip_render [Boolean] Whether to skip rendering the banner. + # @param system_arguments [Hash] <%= link_to_system_arguments_docs %> + def initialize(feature_key, + title: nil, + description: nil, + link_title: nil, + href: nil, + skip_render: !EnterpriseToken.show_banners?, + **system_arguments) + @system_arguments = system_arguments + @system_arguments[:tag] = "div" + super + + @feature_key = feature_key + @title = title + @description = description + @link_title = link_title + @href = href + @skip_render = skip_render + end + + private + + attr_reader :skip_render, + :feature_key + + def title + @title || I18n.t("ee.upsale.#{feature_key}.title", default: I18n.t("ee.upsale.title")) + end + + def description + @description || begin + I18n.t("ee.upsale.#{feature_key}.description") + rescue StandardError + I18n.t("ee.upsale.#{feature_key}.description_html") + end + rescue I18n::MissingTranslationData => e + raise e.exception( + <<~TEXT.squish + The expected '#{I18n.locale}.ee.upsale.#{feature_key}.description' key does not exist. + Ideally, provide it in the locale file. + If that isn't applicable, a description parameter needs to be provided. + TEXT + ) + end + + def link_title + @link_title || I18n.t("ee.upsale.#{feature_key}.link_title", default: I18n.t("ee.upsale.link_title")) + end + + def href + href_value = @href || OpenProject::Static::Links.links.dig(:enterprise_docs, feature_key, :href) + + unless href_value + raise "Neither a custom href is provided nor is a value set " \ + "in OpenProject::Static::Links.enterprise_docs[#{feature_key}][:href]" + end + + href_value + end + + def render? + !skip_render + end + end +end diff --git a/app/components/enterprise_edition/banner_component.sass b/app/components/enterprise_edition/banner_component.sass new file mode 100644 index 000000000000..d8ca7bc2f193 --- /dev/null +++ b/app/components/enterprise_edition/banner_component.sass @@ -0,0 +1,68 @@ +/*! + / -- copyright + / OpenProject is an open source project management software. + / Copyright (C) 2024 the OpenProject GmbH + / + / This program is free software; you can redistribute it and/or + / modify it under the terms of the GNU General Public License version 3. + / + / OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + / Copyright (C) 2006-2013 Jean-Philippe Lang + / Copyright (C) 2010-2013 the ChiliProject Team + / + / This program is free software; you can redistribute it and/or + / modify it under the terms of the GNU General Public License + / as published by the Free Software Foundation; either version 2 + / of the License, or (at your option) any later version. + / + / This program is distributed in the hope that it will be useful, + / but WITHOUT ANY WARRANTY; without even the implied warranty of + / MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + / GNU General Public License for more details. + / + / You should have received a copy of the GNU General Public License + / along with this program; if not, write to the Free Software + / Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + / + / See COPYRIGHT and LICENSE files for more details. + / ++ + / + +$op-ee-banner--shield-width: 32px + +// This is not named op-enterprise-banner because as of now, there is still a legacy angular component that uses that block name. +.op-ee-banner + display: grid + grid-template-columns: $op-ee-banner--shield-width auto auto + grid-template-areas: "icon-container title-container" "icon-container description-container" "icon-container link-container" + grid-column-gap: 0.5rem + justify-content: left + @media screen and (min-width: $breakpoint-md) + grid-template-areas: "icon-container title-container title-container" "icon-container description-container link-container" + + &--icon-container + @extend .upsale-colored + align-self: start + justify-self: center + + &--shield + @extend .upsale-border-colored + width: $op-ee-banner--shield-width + height: 42px + border-width: 10px 5px 10px 5px + border-radius: 0 0 10px 10px + border-style: solid + display: flex + align-items: center + justify-content: center + + &--icon + width: $op-ee-banner--shield-width + height: $op-ee-banner--shield-width + + &--title-container + @extend .upsale-colored + font-weight: bold + + &--link-container + align-self: end diff --git a/app/components/messages/show_page_header_component.html.erb b/app/components/messages/show_page_header_component.html.erb new file mode 100644 index 000000000000..4cec4fedc5d1 --- /dev/null +++ b/app/components/messages/show_page_header_component.html.erb @@ -0,0 +1,56 @@ +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { @topic.subject } + header.with_breadcrumbs(breadcrumb_items) + + watcher_action_button(header, @topic) + + if !@topic.locked? && authorize_for('messages', 'reply') + header.with_action_button(tag: :a, + scheme: :default, + mobile_icon: :quote, + mobile_label: t(:button_quote), + size: :medium, + href: url_for({ action: 'quote', id: @topic }), + aria: { label: I18n.t(:button_delete) }, + data: { 'action': 'forum-messages#quote', test_selector: "message-quote-button" }, + title: t(:button_quote)) do |button| + button.with_leading_visual_icon(icon: :quote) + t(:button_quote) + end + end + + if @message.editable_by?(User.current) + header.with_action_button(tag: :a, + scheme: :default, + mobile_icon: :pencil, + mobile_label: t(:button_edit), + size: :medium, + href: edit_topic_path(@topic), + aria: { label: t(:button_edit) }, + data: { test_selector: "message-edit-button" }, + title: t(:button_edit)) do |button| + button.with_leading_visual_icon(icon: :pencil) + t(:button_edit) + end + end + + if @message.destroyable_by?(User.current) + header.with_action_button(tag: :a, + scheme: :danger, + mobile_icon: :trash, + mobile_label: t(:button_delete), + size: :medium, + href: topic_path(@topic), + aria: { label: I18n.t(:button_delete) }, + data: { + confirm: I18n.t(:text_are_you_sure), + method: :delete + }, + title: I18n.t(:button_delete)) do |button| + button.with_leading_visual_icon(icon: :trash) + t(:button_delete) + end + end + end +%> diff --git a/app/components/messages/show_page_header_component.rb b/app/components/messages/show_page_header_component.rb new file mode 100644 index 000000000000..23a606034599 --- /dev/null +++ b/app/components/messages/show_page_header_component.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module Messages + class ShowPageHeaderComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + include ApplicationHelper + include WatchersHelper + + def initialize(topic:, message:, forum:, project:) + super + @topic = topic + @message = message + @forum = forum + @project = project + end + + def breadcrumb_items + [ + { href: project_overview_path(@project.id), text: @project.name }, + { href: project_forums_path(@project), text: t(:label_forum_plural) }, + { href: project_forum_path(@project, @forum), text: @forum.name }, + @topic.subject + ] + end + end +end diff --git a/app/components/my/access_token/access_token_created_dialog_component.html.erb b/app/components/my/access_token/access_token_created_dialog_component.html.erb index cd773f77eda9..395e20e6b180 100644 --- a/app/components/my/access_token/access_token_created_dialog_component.html.erb +++ b/app/components/my/access_token/access_token_created_dialog_component.html.erb @@ -37,7 +37,7 @@ See COPYRIGHT and LICENSE files for more details. message.with_heading(tag: :h2) { I18n.t("my.access_token.create_dialog.header", type: "API") } end - dialog.with_additional_content do + dialog.with_additional_details do flex_layout do |flex| flex.with_row(mb: 2) do render(Primer::OpenProject::InputGroup.new) do |input_group| diff --git a/app/components/op_primer/border_box_table_component.html.erb b/app/components/op_primer/border_box_table_component.html.erb index 4c96232aa95f..a026da0f9aa1 100644 --- a/app/components/op_primer/border_box_table_component.html.erb +++ b/app/components/op_primer/border_box_table_component.html.erb @@ -58,6 +58,12 @@ See COPYRIGHT and LICENSE files for more details. end end end + + if has_footer? + component.with_footer(classes: grid_class, color: :muted) do + footer + end + end end %> diff --git a/app/components/op_primer/border_box_table_component.rb b/app/components/op_primer/border_box_table_component.rb index b005fe4be6d0..1e1160790966 100644 --- a/app/components/op_primer/border_box_table_component.rb +++ b/app/components/op_primer/border_box_table_component.rb @@ -43,7 +43,7 @@ class << self # # This results in the description columns to be hidden on mobile def mobile_columns(*names) - return @mobile_columns || columns if names.empty? + return Array(@mobile_columns || columns) if names.empty? @mobile_columns = names.map(&:to_sym) end @@ -54,7 +54,7 @@ def mobile_columns(*names) # # This results in the description columns to be hidden on mobile def mobile_labels(*names) - return @mobile_labels if names.empty? + return Array(@mobile_labels) if names.empty? @mobile_labels = names.map(&:to_sym) end @@ -106,6 +106,10 @@ def has_actions? false end + def has_footer? + false + end + def sortable? false end @@ -133,5 +137,9 @@ def blank_description def blank_icon nil end + + def footer + raise ArgumentError, "Need to provide footer content" + end end end diff --git a/app/components/op_primer/flash_component.erb b/app/components/op_primer/flash_component.html.erb similarity index 100% rename from app/components/op_primer/flash_component.erb rename to app/components/op_primer/flash_component.html.erb diff --git a/app/components/op_primer/flash_component.rb b/app/components/op_primer/flash_component.rb index fed84bf511f7..15a445c03316 100644 --- a/app/components/op_primer/flash_component.rb +++ b/app/components/op_primer/flash_component.rb @@ -38,6 +38,8 @@ def initialize(**system_arguments) system_arguments[:test_selector] ||= "op-primer-flash-message" system_arguments[:dismiss_scheme] ||= :remove system_arguments[:dismiss_label] ||= I18n.t(:button_close) + system_arguments[:data] ||= {} + system_arguments[:data]["flash-target"] = "flash" @autohide = system_arguments[:scheme] == :success && system_arguments[:dismiss_scheme] != :none diff --git a/app/components/projects/life_cycle_type_component.html.erb b/app/components/projects/life_cycle_type_component.html.erb new file mode 100644 index 000000000000..921c1cf191a2 --- /dev/null +++ b/app/components/projects/life_cycle_type_component.html.erb @@ -0,0 +1,8 @@ +<%= flex_layout(align_items: :center) do |type_container| + type_container.with_column(mr: 1, classes: icon_color_class) do + render Primer::Beta::Octicon.new(icon: icon) + end + type_container.with_column do + render(Primer::Beta::Text.new(**text_options)) { text } + end +end %> diff --git a/app/components/projects/life_cycle_type_component.rb b/app/components/projects/life_cycle_type_component.rb new file mode 100644 index 000000000000..453f5d90c5d6 --- /dev/null +++ b/app/components/projects/life_cycle_type_component.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2010-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ +module Projects + class LifeCycleTypeComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + def text + model.model_name.human + end + + def icon + case model + when Project::StageDefinition + :"git-commit" + when Project::GateDefinition + :diamond + else + raise NotImplementedError, "Unknown model #{model.class} to render a LifeCycleTypeComponent with" + end + end + + def icon_color_class + helpers.hl_inline_class("life_cycle_step_definition", model) + end + + def text_options + # The tag: :div is is a hack to fix the line height difference + # caused by font_size: :small. That line height difference + # would otherwise lead to the text being not on the same height as the icon + { color: :muted, font_size: :small, tag: :div }.merge(options) + end + end +end diff --git a/app/components/projects/row_component.rb b/app/components/projects/row_component.rb index 3f540eac0ecd..66ff579a9d0e 100644 --- a/app/components/projects/row_component.rb +++ b/app/components/projects/row_component.rb @@ -29,8 +29,9 @@ #++ module Projects class RowComponent < ::RowComponent - delegate :favored_project_ids, to: :table delegate :identifier, to: :project + delegate :favored_project_ids, to: :table + delegate :project_life_cycle_step_by_definition, to: :table def project model.first @@ -69,6 +70,8 @@ def currently_favored? def column_value(column) if custom_field_column?(column) custom_field_column(column) + elsif life_cycle_step_column?(column) + life_cycle_step_column(column) else send(column.attribute) end @@ -94,6 +97,16 @@ def custom_field_column(column) end end + def life_cycle_step_column(column) + return nil unless user_can_view_project_stages_and_gates? + + life_cycle_step = project_life_cycle_step_by_definition(column.life_cycle_step_definition, project) + + return nil if life_cycle_step.blank? + + fmt_date_or_range(life_cycle_step.start_date, life_cycle_step.end_date) + end + def created_at helpers.format_date(project.created_at) end @@ -370,12 +383,37 @@ def user_can_view_project? User.current.allowed_in_project?(:view_project_attributes, project) end + def user_can_view_project_stages_and_gates? + User.current.allowed_in_project?(:view_project_stages_and_gates, project) + end + def custom_field_column?(column) column.is_a?(::Queries::Projects::Selects::CustomField) end + def life_cycle_step_column?(column) + column.is_a?(::Queries::Projects::Selects::LifeCycleStep) + end + def current_page table.model.current_page.to_s end + + private + + # If only the `start_date` is given, will return a formatted version of that date as string. + # When `end_date` is given as well, will return a representation of the date range from start to end. + # @example + # fmt_date_or_range(Date.new(2024, 12, 4)) + # "04/12/2024" + # + # fmt_date_or_range(Date.new(2024, 12, 4), Date.new(2024, 12, 10)) + # "04/12/2024 - 10/12/2024" + def fmt_date_or_range(start_date, end_date = nil) + [start_date, end_date] + .compact + .map { |d| helpers.format_date(d) } + .join(" - ") + end end end diff --git a/app/components/projects/settings/index_page_header_component.html.erb b/app/components/projects/settings/index_page_header_component.html.erb new file mode 100644 index 000000000000..06c1f59002e5 --- /dev/null +++ b/app/components/projects/settings/index_page_header_component.html.erb @@ -0,0 +1,100 @@ +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { t(:label_information_plural) } + header.with_breadcrumbs( [ + { href: project_overview_path(@project.id), text: @project.name }, + { href: project_settings_general_path(@project.id), text: I18n.t("label_project_settings") }, + t(:label_information_plural) + ]) + + if User.current.allowed_in_project?(:add_subprojects, @project) + header.with_action_button(scheme: :primary, + mobile_icon: :plus, + mobile_label: t(:label_subproject_new), + aria: { label: t(:label_subproject_new) }, + title: t(:label_subproject_new), + tag: :a, + href: new_project_path(parent_id: @project.id)) do |button| + button.with_leading_visual_icon(icon: :plus) + t(:label_subproject) + end + end + + header.with_action_button(tag: :a, + mobile_icon: :pencil, + mobile_label: t('projects.settings.change_identifier'), + size: :medium, + href: project_identifier_path(@project), + aria: { label: t('projects.settings.change_identifier') }, + title: t('projects.settings.change_identifier')) do |button| + button.with_leading_visual_icon(icon: :pencil) + t('projects.settings.change_identifier') + end + + header.with_action_menu( + menu_arguments: { + anchor_align: :end + }, + button_arguments: { + icon: "op-kebab-vertical", + "aria-label": t(:label_more), + test_selector: "project-settings-more-menu" + } + ) do |menu| + if @project.copy_allowed? + menu.with_item( + label:t(:button_copy), + href: copy_project_path(@project), + content_arguments: { + data: { turbo: false }, + test_selector: "project-settings--copy" + }, + accesskey: helpers.accesskey(:copy), + ) do |item| + item.with_leading_visual_icon(icon: :copy) + end + end + + if User.current.allowed_in_project?(:archive_project, @project) + menu.with_item( + tag: :a, + label: t(:button_archive), + href: project_archive_path(@project, status: '', name: @project.name), + content_arguments: { + data: { confirm: t('project.archive.are_you_sure', name: @project.name), method: :post, }, + test_selector: "project-settings--archive" + } + ) do |item| + item.with_leading_visual_icon(icon: 'lock') + end + end + if User.current.admin? + label = @project.templated ? 'remove_from_templates' : 'make_template' + menu.with_item( + tag: :a, + label: t("project.template.#{label}"), + href: project_templated_path(@project), + content_arguments: { + data: { method: @project.templated ? :delete : :post }, + test_selector: "project-settings--mark-template" + } + ) do |item| + item.with_leading_visual_icon(icon: @project.templated ? :"file-removed" : :"file-added") + end + + menu.with_item( + tag: :a, + scheme: :danger, + label: t(:button_delete), + href: confirm_destroy_project_path(@project), + content_arguments: { + data: { turbo: false }, + test_selector: "project-settings--delete" + } + ) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + end + end +%> diff --git a/app/components/projects/settings/index_page_header_component.rb b/app/components/projects/settings/index_page_header_component.rb new file mode 100644 index 000000000000..a57128ce4f05 --- /dev/null +++ b/app/components/projects/settings/index_page_header_component.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +class Projects::Settings::IndexPageHeaderComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + def initialize(project:) + super + + @project = project + end +end diff --git a/app/components/projects/settings/life_cycle_steps/index_component.html.erb b/app/components/projects/settings/life_cycle_steps/index_component.html.erb new file mode 100644 index 000000000000..8ab58ef17910 --- /dev/null +++ b/app/components/projects/settings/life_cycle_steps/index_component.html.erb @@ -0,0 +1,88 @@ +<%= + flex_layout(data: wrapper_data_attributes) do |flex| + flex.with_row do + render(Primer::OpenProject::SubHeader.new) do |subheader| + subheader.with_filter_input(name: "border-box-filter", + label: t('projects.settings.life_cycle.filter.label'), + visually_hide_label: true, + placeholder: t('projects.settings.life_cycle.filter.label'), + leading_visual: { + icon: :search, + size: :small + }, + show_clear_button: true, + clear_button_id: clear_button_id, + data: { + action: "input->projects--settings--border-box-filter#filterLists", + "projects--settings--border-box-filter-target": "filter" + }) + end + end + + flex.with_row do + render(border_box_container(mb: 3, data: { test_selector: "project-life-cycle-administration" })) do |component| + component.with_header(font_weight: :bold, py: 2) do + flex_layout(justify_content: :space_between, align_items: :center) do |header_container| + header_container.with_column(py: 2) do + # adding py: 2 here to match the padding of the actions_container + # otherwise the header height changes when the actions gets hidden when filtering + render(Primer::Beta::Text.new(font_weight: :bold)) do + I18n.t('projects.settings.life_cycle.section_header') + end + end + header_container.with_column(flex_layout: true, justify_content: :flex_end) do |actions_container| + actions_container.with_column(data: { 'projects--settings--border-box-filter-target': 'bulkActionContainer' }) do + render(Primer::Beta::Button.new( + tag: :a, + href: enable_all_project_settings_life_cycle_steps_path(project_id: project), + scheme: :invisible, + font_weight: :bold, + color: :subtle, + 'aria-label': t('projects.settings.actions.label_enable_all'), + data: { 'turbo-method': :post, test_selector: "enable-all-life-cycle-steps" } + )) do |button| + button.with_leading_visual_icon(icon: 'check-circle', color: :subtle) + t('projects.settings.actions.label_enable_all') + end + end + actions_container.with_column(data: { 'projects--settings--border-box-filter-target': 'bulkActionContainer' }) do + render(Primer::Beta::Button.new( + tag: :a, + href: disable_all_project_settings_life_cycle_steps_path(project_id: project), + scheme: :invisible, + font_weight: :bold, + color: :subtle, + 'aria-label': t('projects.settings.actions.label_disable_all'), + data: { 'turbo-method': :post, test_selector: "disable-all-life-cycle-steps" } + )) do |button| + button.with_leading_visual_icon(icon: 'x-circle', color: :subtle) + t('projects.settings.actions.label_disable_all') + end + end + end + end + end + if life_cycle_definitions.empty? + component.with_row do + render(Primer::Beta::Text.new(color: :subtle)) { t("projects.settings.life_cycle.non_defined") } + end + else + life_cycle_definitions_and_step_active.each do |definition, active| + component.with_row(data: { 'projects--settings--border-box-filter-target': 'searchItem' }, + test_selector: "project-life-cycle-step-#{definition.id}") do + render(Projects::Settings::LifeCycleSteps::StepComponent.new(definition:, active?: active)) + end + end + end + end + end + flex.with_row do + render Primer::Beta::Text.new(display: :none, + data: { + "projects--settings--border-box-filter-target": "noResultsText", + }) do + I18n.t("js.autocompleter.notFoundText") + end + end + end +%> diff --git a/app/components/projects/settings/life_cycle_steps/index_component.rb b/app/components/projects/settings/life_cycle_steps/index_component.rb new file mode 100644 index 000000000000..bd1c174f1ccc --- /dev/null +++ b/app/components/projects/settings/life_cycle_steps/index_component.rb @@ -0,0 +1,64 @@ +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2010-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module Projects + module Settings + module LifeCycleSteps + class IndexComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + options :project, + :life_cycle_definitions + + private + + def life_cycle_definitions_and_step_active + active_ids = project.life_cycle_steps.where(active: true).pluck(:definition_id).to_set + + life_cycle_definitions.all.map do |definition| + [definition, definition.id.in?(active_ids)] + end + end + + def wrapper_data_attributes + { + controller: "projects--settings--border-box-filter", + "application-target": "dynamic", + "projects--settings--border-box-filter-clear-button-id-value": clear_button_id + } + end + + def clear_button_id + "border-box-filter-clear-button" + end + end + end + end +end diff --git a/app/components/projects/settings/life_cycle_steps/index_page_header_component.html.erb b/app/components/projects/settings/life_cycle_steps/index_page_header_component.html.erb new file mode 100644 index 000000000000..8c7eb8f55bcc --- /dev/null +++ b/app/components/projects/settings/life_cycle_steps/index_page_header_component.html.erb @@ -0,0 +1,11 @@ +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { t('projects.settings.life_cycle.header.title') } + header.with_description do + t('projects.settings.life_cycle.header.description_html', + overview_url: project_path(project), + admin_settings_url: admin_settings_project_life_cycle_step_definitions_path) + end + header.with_breadcrumbs(breadcrumb_items) + end +%> diff --git a/app/components/projects/settings/life_cycle_steps/index_page_header_component.rb b/app/components/projects/settings/life_cycle_steps/index_page_header_component.rb new file mode 100644 index 000000000000..8be423936dbe --- /dev/null +++ b/app/components/projects/settings/life_cycle_steps/index_page_header_component.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2010-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module Projects::Settings::LifeCycleSteps + class IndexPageHeaderComponent < ApplicationComponent + include ApplicationHelper + + options :project + + def breadcrumb_items + [{ href: project_overview_path(project), text: project.name }, + { href: project_settings_general_path(project), text: I18n.t("label_project_settings") }, + t("projects.settings.life_cycle.header.title")] + end + end +end diff --git a/app/components/projects/settings/life_cycle_steps/step_component.html.erb b/app/components/projects/settings/life_cycle_steps/step_component.html.erb new file mode 100644 index 000000000000..9eda07459e3c --- /dev/null +++ b/app/components/projects/settings/life_cycle_steps/step_component.html.erb @@ -0,0 +1,30 @@ +<%= + flex_layout(align_items: :center, + justify_content: :space_between) do |step_container| + step_container.with_column(flex_layout: true) do |title_container| + title_container.with_column(pt: 1, mr: 3) do + render(Primer::Beta::Text.new(classes: 'filter-target-visible-text')) { definition.name } + end + title_container.with_column(pt: 1) do + render(Projects::LifeCycleTypeComponent.new(definition)) + end + end + # py: 1 quick fix: prevents the row from bouncing as the toggle switch currently changes height while toggling + step_container.with_column(py: 1, mr: 2) do + # buggy currently: + # small variant + status_label_position: :start leads to a small bounce while toggling + # behavior can be seen on primer's viewbook as well -> https://view-components-storybook.eastus.cloudapp.azure.com/view-components/lookbook/inspect/primer/alpha/toggle_switch/small + # quick fix: don't display loading indicator which is causing the bounce + render(Primer::Alpha::ToggleSwitch.new( + src: toggle_project_settings_life_cycle_step_path(id: definition.id), + csrf_token: form_authenticity_token, + data: { test_selector: "toggle-project-life-cycle-#{definition.id}" }, + aria: { label: toggle_aria_label }, + checked: active?, + size: :small, + status_label_position: :start, + classes: "op-primer-adjustments__toggle-switch--hidden-loading-indicator", + )) + end + end +%> diff --git a/app/components/projects/settings/life_cycle_steps/step_component.rb b/app/components/projects/settings/life_cycle_steps/step_component.rb new file mode 100644 index 000000000000..2dd0f59b6479 --- /dev/null +++ b/app/components/projects/settings/life_cycle_steps/step_component.rb @@ -0,0 +1,46 @@ +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2010-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module Projects + module Settings + module LifeCycleSteps + class StepComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + options :definition, + :active? + + def toggle_aria_label + I18n.t("projects.settings.life_cycle.step.use_in_project", step: definition.name) + end + end + end + end +end diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb index 374bb4ed0642..fae8cdf15c38 100644 --- a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -5,7 +5,7 @@ }) do |custom_field_container| custom_field_container.with_column(flex_layout: true) do |title_container| title_container.with_column(pt: 1, mr: 2) do - render(Primer::Beta::Text.new) do + render(Primer::Beta::Text.new(classes: 'filter-target-visible-text')) do @project_custom_field.name end end diff --git a/app/components/projects/settings/project_custom_field_sections/index_component.html.erb b/app/components/projects/settings/project_custom_field_sections/index_component.html.erb index b4044ee99669..f54e78c9f20b 100644 --- a/app/components/projects/settings/project_custom_field_sections/index_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/index_component.html.erb @@ -3,7 +3,7 @@ flex_layout do |flex| flex.with_row do render(Primer::OpenProject::SubHeader.new) do |subheader| - subheader.with_filter_input(name: "project-custom-fields-mapping-filter", + subheader.with_filter_input(name: "border-box-filter", label: t('projects.settings.project_custom_fields.filter.label'), visually_hide_label: true, placeholder: t('projects.settings.project_custom_fields.filter.label'), @@ -14,8 +14,8 @@ show_clear_button: true, clear_button_id: clear_button_id, data: { - action: "input->projects--settings--project-custom-fields-mapping-filter#filterLists", - "projects--settings--project-custom-fields-mapping-filter-target": "filter" + action: "input->projects--settings--border-box-filter#filterLists", + "projects--settings--border-box-filter-target": "filter" }) end end diff --git a/app/components/projects/settings/project_custom_field_sections/index_component.rb b/app/components/projects/settings/project_custom_field_sections/index_component.rb index 25b8a86e8b71..b405aa381d38 100644 --- a/app/components/projects/settings/project_custom_field_sections/index_component.rb +++ b/app/components/projects/settings/project_custom_field_sections/index_component.rb @@ -45,14 +45,14 @@ def initialize(project:, project_custom_field_sections:) def wrapper_data_attributes { - controller: "projects--settings--project-custom-fields-mapping-filter", + controller: "projects--settings--border-box-filter", "application-target": "dynamic", - "projects--settings--project-custom-fields-mapping-filter-clear-button-id-value": clear_button_id + "projects--settings--border-box-filter-clear-button-id-value": clear_button_id } end def clear_button_id - "project-custom-fields-mapping-filter-clear-button" + "border-box-filter-clear-button" end end end diff --git a/app/components/projects/settings/project_custom_field_sections/index_page_header_component.html.erb b/app/components/projects/settings/project_custom_field_sections/index_page_header_component.html.erb index 7cbbd5903e1d..b56371d9d989 100644 --- a/app/components/projects/settings/project_custom_field_sections/index_page_header_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/index_page_header_component.html.erb @@ -1,8 +1,7 @@ <%= render Primer::OpenProject::PageHeader.new do |header| %> - <%= header.with_title(variant: :default) { t('projects.settings.project_custom_fields.header.title') } %> - <%= header.with_description { t('projects.settings.project_custom_fields.header.description', - overview_url: project_path(@project), - admin_settings_url: admin_settings_project_custom_fields_path - ).html_safe } %> + <%= header.with_title { t('projects.settings.project_custom_fields.header.title') } %> + <%= header.with_description { t('projects.settings.project_custom_fields.header.description_html', + overview_url: project_path(project), + admin_settings_url: admin_settings_project_custom_fields_path) } %> <%= header.with_breadcrumbs(breadcrumb_items) %> <% end %> diff --git a/app/components/projects/settings/project_custom_field_sections/index_page_header_component.rb b/app/components/projects/settings/project_custom_field_sections/index_page_header_component.rb index 13014aae6aab..fe1b93434a1f 100644 --- a/app/components/projects/settings/project_custom_field_sections/index_page_header_component.rb +++ b/app/components/projects/settings/project_custom_field_sections/index_page_header_component.rb @@ -33,14 +33,11 @@ module Projects::Settings::ProjectCustomFieldSections class IndexPageHeaderComponent < ApplicationComponent include ApplicationHelper - def initialize(project: nil) - super - @project = project - end + options :project def breadcrumb_items - [{ href: project_overview_path(@project.id), text: @project.name }, - { href: project_settings_general_path(@project.id), text: I18n.t("label_project_settings") }, + [{ href: project_overview_path(project), text: project.name }, + { href: project_settings_general_path(project), text: I18n.t("label_project_settings") }, t("settings.project_attributes.heading")] end end diff --git a/app/components/projects/settings/project_custom_field_sections/show_component.html.erb b/app/components/projects/settings/project_custom_field_sections/show_component.html.erb index 727d8901228b..f2c5ee4e3a7f 100644 --- a/app/components/projects/settings/project_custom_field_sections/show_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/show_component.html.erb @@ -13,7 +13,7 @@ end end section_header_container.with_column(flex_layout: true, justify_content: :flex_end) do |actions_container| - actions_container.with_column(data: { 'projects--settings--project-custom-fields-mapping-filter-target': 'bulkActionContainer' }) do + actions_container.with_column(data: { 'projects--settings--border-box-filter-target': 'bulkActionContainer' }) do render(Primer::Beta::Button.new( tag: :a, href: enable_all_of_section_project_settings_project_custom_fields_path( @@ -25,14 +25,14 @@ scheme: :invisible, font_weight: :bold, color: :subtle, - 'aria-label': t('projects.settings.project_custom_fields.actions.label_enable_all'), + 'aria-label': t('projects.settings.actions.label_enable_all'), data: { 'turbo-method': :put, 'turbo-stream': true, test_selector: "enable-all-project-custom-field-mappings-#{@project_custom_field_section.id}" } )) do |button| button.with_leading_visual_icon(icon: 'check-circle', color: :subtle) - t('projects.settings.project_custom_fields.actions.label_enable_all') + t('projects.settings.actions.label_enable_all') end end - actions_container.with_column(data: { 'projects--settings--project-custom-fields-mapping-filter-target': 'bulkActionContainer' }) do + actions_container.with_column(data: { 'projects--settings--border-box-filter-target': 'bulkActionContainer' }) do render(Primer::Beta::Button.new( tag: :a, href: disable_all_of_section_project_settings_project_custom_fields_path( @@ -44,11 +44,11 @@ scheme: :invisible, font_weight: :bold, color: :subtle, - 'aria-label': t('projects.settings.project_custom_fields.actions.label_disable_all'), + 'aria-label': t('projects.settings.actions.label_disable_all'), data: { 'turbo-method': :put, 'turbo-stream': true, test_selector: "disable-all-project-custom-field-mappings-#{@project_custom_field_section.id}" } )) do |button| button.with_leading_visual_icon(icon: 'x-circle', color: :subtle) - t('projects.settings.project_custom_fields.actions.label_disable_all') + t('projects.settings.actions.label_disable_all') end end end @@ -60,7 +60,7 @@ end else @project_custom_fields.each do |project_custom_field| - component.with_row(data: { 'projects--settings--project-custom-fields-mapping-filter-target': 'searchItem' }) do + component.with_row(data: { 'projects--settings--border-box-filter-target': 'searchItem' }) do render(Projects::Settings::ProjectCustomFieldSections::CustomFieldRowComponent.new( project: @project, project_custom_field:, diff --git a/app/components/projects/table_component.html.erb b/app/components/projects/table_component.html.erb index a114faeff841..9f33feb4aeea 100644 --- a/app/components/projects/table_component.html.erb +++ b/app/components/projects/table_component.html.erb @@ -56,7 +56,7 @@ See COPYRIGHT and LICENSE files for more details. <% else %> <% if use_quick_action_table_headers? %> - <%= quick_action_table_header column.attribute, order_options(column, turbo: true) %> + <%= quick_action_table_header column, order_options(column, turbo: true) %> <% else %>
diff --git a/app/components/projects/table_component.rb b/app/components/projects/table_component.rb index 7958c81df2ad..93378fcfa7b3 100644 --- a/app/components/projects/table_component.rb +++ b/app/components/projects/table_component.rb @@ -195,6 +195,14 @@ def favored_project_ids @favored_project_ids ||= Favorite.where(user: current_user, favored_type: "Project").pluck(:favored_id) end + def project_life_cycle_step_by_definition(definition, project) + @project_life_cycle_steps_by_definition ||= Project::LifeCycleStep + .visible + .index_by { |s| [s.definition_id, s.project_id] } + + @project_life_cycle_steps_by_definition[[definition.id, project.id]] + end + def sorted_by_lft? query.orders.first&.attribute == :lft end diff --git a/app/components/shares/invite_user_form_component.html.erb b/app/components/shares/invite_user_form_component.html.erb index dea952c59aa6..f1bc280c58b9 100644 --- a/app/components/shares/invite_user_form_component.html.erb +++ b/app/components/shares/invite_user_form_component.html.erb @@ -10,7 +10,7 @@ ) do |form| grid_layout('invite-user-form', tag: :div) do |invite_form| invite_form.with_area('invitee') do - render(Shares::Invitee.new(form)) + render(Shares::Invitee.new(form, allow_hover_cards:)) end invite_form.with_area('permission') do diff --git a/app/components/shares/invite_user_form_component.rb b/app/components/shares/invite_user_form_component.rb index f8d40024bae9..07f5559506f2 100644 --- a/app/components/shares/invite_user_form_component.rb +++ b/app/components/shares/invite_user_form_component.rb @@ -32,14 +32,15 @@ class InviteUserFormComponent < ApplicationComponent # rubocop:disable OpenProje include OpTurbo::Streamable include OpPrimer::ComponentHelpers - attr_reader :entity, :strategy, :errors + attr_reader :entity, :strategy, :errors, :allow_hover_cards - def initialize(strategy:, errors: nil) + def initialize(strategy:, errors: nil, allow_hover_cards: false) super @strategy = strategy @entity = strategy.entity @errors = errors + @allow_hover_cards = allow_hover_cards end def new_share diff --git a/app/components/shares/manage_shares_component.html.erb b/app/components/shares/manage_shares_component.html.erb index 9cca5dea7e57..af18bb5f4229 100644 --- a/app/components/shares/manage_shares_component.html.erb +++ b/app/components/shares/manage_shares_component.html.erb @@ -1,7 +1,7 @@ <%= if strategy.manageable? modal_content.with_row do - render(Shares::InviteUserFormComponent.new(strategy:, errors: errors)) + render(Shares::InviteUserFormComponent.new(strategy:, errors:, allow_hover_cards:)) end end @@ -100,10 +100,15 @@ end else strategy.shares.each do |share| - render(Shares::ShareRowComponent.new(share:, strategy:, container: border_box)) + render(Shares::ShareRowComponent.new(share:, strategy:, container: border_box, allow_hover_cards:)) end end end end + if allow_hover_cards + modal_content.with_row do + helpers.angular_component_tag 'opce-custom-modal-overlay', class: 'op-user-share-modal-overlay' + end + end %> diff --git a/app/components/shares/manage_shares_component.rb b/app/components/shares/manage_shares_component.rb index fa61c6afdd57..22246aed01a3 100644 --- a/app/components/shares/manage_shares_component.rb +++ b/app/components/shares/manage_shares_component.rb @@ -36,6 +36,7 @@ class ManageSharesComponent < ApplicationComponent # rubocop:disable OpenProject attr_reader :strategy, :entity, :errors, + :allow_hover_cards, :modal_content def initialize(strategy:, modal_content:, errors: nil) @@ -45,6 +46,7 @@ def initialize(strategy:, modal_content:, errors: nil) @entity = strategy.entity @errors = errors @modal_content = modal_content + @allow_hover_cards = strategy.allow_hover_cards? end def self.wrapper_key diff --git a/app/components/shares/project_queries/upsale_component.html.erb b/app/components/shares/project_queries/upsale_component.html.erb index 0568cec7557f..946bbda996a5 100644 --- a/app/components/shares/project_queries/upsale_component.html.erb +++ b/app/components/shares/project_queries/upsale_component.html.erb @@ -1,15 +1,13 @@ <%= modal_content.with_row(data: { 'test-selector': 'op-share-dialog-upsale-block' }) do render Primer::OpenProject::FeedbackMessage.new(icon_arguments: { icon: :"op-enterprise-addons", classes: "upsale-colored" }, border: true) do |component| - component.with_heading(tag: :h2, classes: 'upsale-colored').with_content(I18n.t(:label_enterprise_addon)) + component.with_heading(tag: :h2, classes: 'upsale-colored').with_content(I18n.t(:"ee.upsale.title")) component.with_description { I18n.t('sharing.project_queries.upsale.message') } href = "#{OpenProject::Static::Links.links[:upsale][:href]}/?utm_source=unknown&utm_medium=community-edition&utm_campaign=project-list-sharing-modal" - component.with_secondary_action(href:) do - flex_layout(justify_content: :center) do |flex| - flex.with_column(mr: 1) { I18n.t("admin.enterprise.enterprise_link") } - flex.with_column { render(Primer::Beta::Octicon.new(icon: :'link-external')) } - end + component.with_secondary_action(href:) do |link| + link.with_trailing_visual_icon(icon: "link-external") + I18n.t("admin.enterprise.enterprise_link") end end end diff --git a/app/components/shares/share_row_component.html.erb b/app/components/shares/share_row_component.html.erb index b7045299e475..47c931db3e76 100644 --- a/app/components/shares/share_row_component.html.erb +++ b/app/components/shares/share_row_component.html.erb @@ -13,7 +13,8 @@ end user_row_grid.with_area(:avatar, tag: :div) do - render(Users::AvatarComponent.new(user: principal, show_name: false, size: :medium)) + render(Users::AvatarComponent.new(user: principal, show_name: false, size: :medium, + hover_card: { active: allow_hover_cards, target: :custom })) end user_row_grid.with_area(:user_details, tag: :div, classes: 'ellipsis') do diff --git a/app/components/shares/share_row_component.rb b/app/components/shares/share_row_component.rb index 45df3ab20e0b..ae3b98457683 100644 --- a/app/components/shares/share_row_component.rb +++ b/app/components/shares/share_row_component.rb @@ -36,7 +36,7 @@ class ShareRowComponent < ApplicationComponent # rubocop:disable OpenProject/Add include OpTurbo::Streamable include OpPrimer::ComponentHelpers - def initialize(share:, strategy:, container: nil) + def initialize(share:, strategy:, container: nil, allow_hover_cards: false) super @share = share @@ -45,6 +45,7 @@ def initialize(share:, strategy:, container: nil) @principal = share.principal @available_roles = strategy.available_roles @container = container + @allow_hover_cards = allow_hover_cards end def wrapper_uniq_by @@ -53,7 +54,7 @@ def wrapper_uniq_by private - attr_reader :share, :entity, :principal, :container, :available_roles, :strategy + attr_reader :share, :entity, :principal, :container, :available_roles, :strategy, :allow_hover_cards def share_editable? @share_editable ||= User.current != share.principal && sharing_manageable? diff --git a/app/components/shares/work_packages/modal_upsale_component.html.erb b/app/components/shares/work_packages/modal_upsale_component.html.erb index d1c89c491a12..036bc35a673b 100644 --- a/app/components/shares/work_packages/modal_upsale_component.html.erb +++ b/app/components/shares/work_packages/modal_upsale_component.html.erb @@ -1,15 +1,13 @@ <%= component_wrapper(tag: 'turbo-frame') do render Primer::OpenProject::FeedbackMessage.new(icon_arguments: { icon: :"op-enterprise-addons", classes: "upsale-colored" }, border: true) do |component| - component.with_heading(tag: :h2, classes: 'upsale-colored').with_content(I18n.t(:label_enterprise_addon)) + component.with_heading(tag: :h2, classes: 'upsale-colored').with_content(I18n.t(:"ee.upsale.title")) component.with_description { I18n.t('mail.sharing.work_packages.enterprise_text') } href = "#{OpenProject::Static::Links.links[:upsale][:href]}/?utm_source=unknown&utm_medium=community-edition&utm_campaign=work-package-sharing-modal" - component.with_secondary_action(href:) do - flex_layout(justify_content: :center) do |flex| - flex.with_column(mr: 1) { I18n.t("admin.enterprise.enterprise_link") } - flex.with_column { render(Primer::Beta::Octicon.new(icon: :'link-external')) } - end + component.with_secondary_action(href:) do |link| + link.with_trailing_visual_icon(icon: "link-external") + I18n.t("admin.enterprise.enterprise_link") end end end diff --git a/app/components/types/edit_page_header_component.html.erb b/app/components/types/edit_page_header_component.html.erb index 70c2494d3950..27e38756b3c1 100644 --- a/app/components/types/edit_page_header_component.html.erb +++ b/app/components/types/edit_page_header_component.html.erb @@ -35,7 +35,7 @@ See COPYRIGHT and LICENSE files for more details. header.with_tab_nav(label: nil) do |tab_nav| @tabs.each do |tab| tab_nav.with_tab(selected: selected_tab(@tabs) == tab, href: tab[:path]) do |t| - t.with_text { I18n.t("js.#{tab[:label]}") } + t.with_text { I18n.t(tab[:label]) } end end end if @tabs.present? diff --git a/app/components/users/avatar_component.rb b/app/components/users/avatar_component.rb index 907f226ce736..c0c84ed78e65 100644 --- a/app/components/users/avatar_component.rb +++ b/app/components/users/avatar_component.rb @@ -32,7 +32,8 @@ class AvatarComponent < ApplicationComponent include AvatarHelper include OpPrimer::ComponentHelpers - def initialize(user:, show_name: true, link: true, size: "default", classes: "", title: nil, name_classes: "") + def initialize(user:, show_name: true, link: true, size: "default", classes: "", title: nil, name_classes: "", + hover_card: { active: true, target: :default }) super @user = user @@ -40,6 +41,7 @@ def initialize(user:, show_name: true, link: true, size: "default", classes: "", @link = link @size = size @title = title + @hover_card = hover_card @classes = classes @name_classes = name_classes end @@ -49,14 +51,19 @@ def render? end def call - helpers.avatar( - @user, + options = { size: @size, link: @link, hide_name: !@show_name, title: @title, class: @classes, - name_classes: @name_classes + name_classes: @name_classes, + hover_card: @hover_card + } + + helpers.avatar( + @user, + **options ) end end diff --git a/app/components/users/hover_card_component.html.erb b/app/components/users/hover_card_component.html.erb new file mode 100644 index 000000000000..7af47f81965e --- /dev/null +++ b/app/components/users/hover_card_component.html.erb @@ -0,0 +1,86 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= + if @user.present? + flex_layout(classes: 'op-user-hover-card', data: { test_selector: "user-hover-card-#{@user.id}" }) do |flex| + flex.with_row do + render(Users::AvatarComponent.new(user: @user, show_name: false, link: false, hover_card: { active: false })) + end + + flex.with_row do + flex_layout(classes: 'op-user-hover-card--info') do |f| + f.with_column(classes: 'op-user-hover-card--name') do + render(Primer::Beta::Text.new(font_weight: :semibold, data: { test_selector: 'user-hover-card-name' })) do + @user.name + end + end + + if show_email? + f.with_column(classes: 'op-user-hover-card--email') do + render(Primer::Beta::Text.new(font_size: :small, + color: :muted, + data: { test_selector: 'user-hover-card-email' })) do + @user.mail + end + end + end + end + end + + flex.with_row do + flex_layout(classes: 'op-user-hover-card--group-list') do |f| + f.with_column do + render(Primer::Beta::Octicon.new(icon: :people)) + end + + f.with_column do + render(Primer::Beta::Text.new(color: :muted, data: { test_selector: 'user-hover-card-groups' })) do + group_membership_summary + end + end + end + end + + flex.with_row do + render(Primer::Beta::Button.new(tag: :a, + href: helpers.allowed_management_user_profile_path(@user), + data: { test_selector: 'user-hover-card-profile-btn' })) do + I18n.t("users.open_profile") + end + end + end + else + render Primer::Beta::Blankslate.new(border: false, narrow: true) do |component| + component.with_visual_icon(icon: "x-circle") + # Show a generic error message to avoid leaking information + component.with_heading(tag: :h3).with_content(I18n.t("http.response.unexpected")) + end + end +%> diff --git a/app/components/users/hover_card_component.rb b/app/components/users/hover_card_component.rb new file mode 100644 index 000000000000..6afd44af19b2 --- /dev/null +++ b/app/components/users/hover_card_component.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Users::HoverCardComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + def initialize(id:) + super + + @id = id + @user = User.find_by(id: @id) + end + + def show_email? + (@user == User.current) || User.current.allowed_globally?(:view_user_email) + end + + # Constructs a string in the form of: + # "Member of group4, group5" + # or + # "Member of group1, group2 and 3 more" + # The latter string is cut off since the complete list of group names would exceed the allowed `max_length`. + def group_membership_summary(max_length = 40) + groups = @user.groups.visible + return no_group_text if groups.empty? + + group_links = linked_group_names(groups) + + cutoff_index = calculate_cutoff_index(groups.map(&:name), max_length) + build_summary(group_links, cutoff_index) + end + + private + + def linked_group_names(groups) + groups.map { |group| link_to(h(group.name), show_group_path(group)) } + end + + def no_group_text + t("users.groups.no_results_title_text") + end + + # Calculate the index at which to cut off the group names, based on plain text length + def calculate_cutoff_index(names, max_length) + current_length = 0 + + names.each_with_index do |name, index| + new_length = current_length + name.length + (index > 0 ? 2 : 0) # 2 for ", " separator + return index if new_length > max_length + + current_length = new_length + end + + names.size # No cutoff needed -> return the total size + end + + def build_summary(links, cutoff_index) + summary_links = safe_join(links[0...cutoff_index], ", ") + remaining_count = links.size - cutoff_index + remaining_count_link = link_to(t("users.groups.more", count: remaining_count), user_path(@user)) + + if remaining_count > 0 + t("users.groups.summary_with_more", names: summary_links, count_link: remaining_count_link).html_safe + else + t("users.groups.summary", names: summary_links).html_safe + end + end +end diff --git a/app/components/users/hover_card_component.sass b/app/components/users/hover_card_component.sass new file mode 100644 index 000000000000..5f8fc8e212b6 --- /dev/null +++ b/app/components/users/hover_card_component.sass @@ -0,0 +1,24 @@ +// Correct the z-index of the regular hover card container so that it is above the dropdown of user auto completers +.spot-modal-overlay:has(.op-user-hover-card) + z-index: 9600 + +// On the project list page, there is a bug when a hover card is invoked within the share dialog. The global spot +// modal overlay will darken the background while the hover card is active, since its semi-transparent bg shading is +// added on top of the other dialog background shaders. We don't want an additional spot modal background here, +// so we disable it for this edge case. +.controller-projects.action-index, .controller-meetings.action-show + .spot-modal-overlay:not(:has(.op-user-hover-card)) + background: transparent + +.op-user-hover-card + gap: 1rem + overflow: hidden + + &--info + gap: 0.5rem + + &--name, &--email + @include text-shortener() + + &--group-list + gap: 0.5rem diff --git a/app/components/users/show_page_header_component.html.erb b/app/components/users/show_page_header_component.html.erb index 9a8ac99ceed6..73a8d63c799f 100644 --- a/app/components/users/show_page_header_component.html.erb +++ b/app/components/users/show_page_header_component.html.erb @@ -28,7 +28,9 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= render(Primer::OpenProject::PageHeader.new) do |header| - header.with_title { "#{avatar @user} #{h(@user.name)}".html_safe } + header.with_title do + "#{avatar(@user, hover_card: { active: false })} #{h(@user.name)}".html_safe + end header.with_breadcrumbs(breadcrumb_items) if @current_user.allowed_globally?(:manage_user) diff --git a/app/components/work_package_relations_tab/index_component.html.erb b/app/components/work_package_relations_tab/index_component.html.erb index c12694bc30e6..15d71639e656 100644 --- a/app/components/work_package_relations_tab/index_component.html.erb +++ b/app/components/work_package_relations_tab/index_component.html.erb @@ -22,7 +22,7 @@ Relation::TYPES.each do |relation_type, type_configuration_hash| label_key = "#{I18N_NAMESPACE}.relations.#{type_configuration_hash[:name]}_singular" menu.with_item( - label: t(label_key).capitalize, + label: t(label_key).upcase_first, href: new_relation_path(relation_type:), test_selector: new_button_test_selector(relation_type:), content_arguments: { @@ -36,7 +36,7 @@ if should_render_add_child? menu.with_item( - label: t("#{I18N_NAMESPACE}.relations.label_child_singular").capitalize, + label: t("#{I18N_NAMESPACE}.relations.label_child_singular").upcase_first, href: new_work_package_children_relation_path(@work_package), test_selector: new_button_test_selector(relation_type: :child), content_arguments: { @@ -63,7 +63,7 @@ flex.with_row(mb: 4) do render_relation_group( - title: t("#{base_key}_plural").capitalize, + title: t("#{base_key}_plural").upcase_first, relation_type:, items: relations_of_type ) do |relation| @@ -79,7 +79,7 @@ flex.with_row do render_relation_group( - title: t("#{base_key}_plural").capitalize, + title: t("#{base_key}_plural").upcase_first, relation_type: :children, items: children ) do |child| diff --git a/app/components/work_package_relations_tab/index_component.rb b/app/components/work_package_relations_tab/index_component.rb index f886f99da77f..247cc5195945 100644 --- a/app/components/work_package_relations_tab/index_component.rb +++ b/app/components/work_package_relations_tab/index_component.rb @@ -1,5 +1,11 @@ # frozen_string_literal: true +# Component for rendering the relations tab content of a work package +# +# This includes: +# - Controls for adding new relations if the user has permission +# - Related work packages grouped by relation type (follows, precedes, blocks, etc.) +# - Child work packages class WorkPackageRelationsTab::IndexComponent < ApplicationComponent FRAME_ID = "work-package-relations-tab-content" NEW_RELATION_ACTION_MENU = "new-relation-action-menu" @@ -9,15 +15,22 @@ class WorkPackageRelationsTab::IndexComponent < ApplicationComponent include Turbo::FramesHelper include OpTurbo::Streamable - attr_reader :work_package, :relations, :children, :directionally_aware_grouped_relations + attr_reader :work_package, :relations, :children, :directionally_aware_grouped_relations, :relation_to_scroll_to - def initialize(work_package:, relations:, children:) + # Initialize the component with required data + # + # @param work_package [WorkPackage] The work package whose relations are being displayed + # @param relations [Array] The relations associated with this work package + # @param children [Array] Child work packages + # @param relation_to_scroll_to [Relation, WorkPackage, nil] Optional relation or child to scroll to when rendering + def initialize(work_package:, relations:, children:, relation_to_scroll_to: nil) super() @work_package = work_package @relations = relations @children = children @directionally_aware_grouped_relations = group_relations_by_directional_context + @relation_to_scroll_to = relation_to_scroll_to end def self.wrapper_key @@ -49,27 +62,55 @@ def group_relations_by_directional_context def any_relations? = relations.any? || children.any? def render_relation_group(title:, relation_type:, items:, &_block) - render(border_box_container(padding: :condensed, - data: { test_selector: "op-relation-group-#{relation_type}" })) do |border_box| - border_box.with_header(py: 3) do - flex_layout(align_items: :center) do |flex| - flex.with_column(mr: 2) do - render(Primer::Beta::Text.new(font_size: :normal, font_weight: :bold)) { title } - end - flex.with_column do - render(Primer::Beta::Counter.new(count: items.size, round: true, scheme: :primary)) - end + render(border_box_container( + padding: :condensed, + data: { test_selector: "op-relation-group-#{relation_type}" } + )) do |border_box| + render_header(border_box, title, items) + render_items(border_box, items, &_block) + end + end + + def render_header(border_box, title, items) + border_box.with_header(py: 3) do + flex_layout(align_items: :center) do |flex| + flex.with_column(mr: 2) do + render(Primer::Beta::Text.new(font_size: :normal, font_weight: :bold)) { title } + end + flex.with_column do + render(Primer::Beta::Counter.new(count: items.size, round: true, scheme: :primary)) end end + end + end - items.each do |item| - border_box.with_row(test_selector: row_test_selector(item)) do - yield(item) - end + def render_items(border_box, items) + items.each do |item| + border_box.with_row( + test_selector: row_test_selector(item), + data: data_attribute(item) + ) do + yield(item) end end end + def data_attribute(item) + if scroll_to?(item) + { + controller: "work-packages--relations-tab--scroll", + application_target: "dynamic", + "work-packages--relations-tab--scroll-target": "scrollToRow" + } + end + end + + def scroll_to?(item) + relation_to_scroll_to \ + && item.id == relation_to_scroll_to.id \ + && item.instance_of?(relation_to_scroll_to.class) + end + def new_relation_path(relation_type:) raise ArgumentError, "Invalid relation type: #{relation_type}" unless Relation::TYPES.key?(relation_type) @@ -85,11 +126,15 @@ def new_button_test_selector(relation_type:) end def row_test_selector(item) + related_work_package_id = find_related_work_package_id(item) + "op-relation-row-#{related_work_package_id}" + end + + def find_related_work_package_id(item) if item.is_a?(Relation) - target = item.to == work_package ? item.from : item.to - "op-relation-row-#{target.id}" - else # Work Package object - "op-relation-row-#{item.id}" + item.from_id == work_package.id ? item.to_id : item.from_id + else + item.id end end end diff --git a/app/components/work_package_relations_tab/relation_component.html.erb b/app/components/work_package_relations_tab/relation_component.html.erb index 12788bdb76d0..b4cdd78822b4 100644 --- a/app/components/work_package_relations_tab/relation_component.html.erb +++ b/app/components/work_package_relations_tab/relation_component.html.erb @@ -58,20 +58,44 @@ flex_layout do |flex| end end - if should_display_start_and_end_dates? - flex.with_row(flex_layout: true, align_items: :center, mb: 2) do |start_and_end_dates_row| - start_and_end_dates_row.with_column(mr: 1) do - icon = if follows? - :calendar - elsif precedes? - :pin - end + if should_display_dates_row? + flex.with_row(flex_layout: true, align_items: :center, color: :muted, mb: 2) do |dates_row| + if precedes? && lag_present? + dates_row.with_column(mr: 1) do + render(Primer::Beta::Octicon.new(icon: "arrow-both")) + end + dates_row.with_column(mr: 3) do + render(Primer::Beta::Text.new) do + lag_as_text(relation.lag) + end + end + end + + if related_work_package.start_date.present? || related_work_package.due_date.present? + dates_row.with_column(mr: 1) do + icon = if follows? + :calendar + elsif precedes? + :pin + end - render(Primer::Beta::Octicon.new(icon:, color: :muted)) + render(Primer::Beta::Octicon.new(icon:)) + end + dates_row.with_column do + render(Primer::Beta::Text.new) do + "#{format_date(related_work_package.start_date)} - #{format_date(related_work_package.due_date)}" + end + end end - start_and_end_dates_row.with_column do - render(Primer::Beta::Text.new(color: :muted)) do - "#{format_date(related_work_package.start_date)} - #{format_date(related_work_package.due_date)}" + + if follows? && lag_present? + dates_row.with_column(ml: 3, mr: 1) do + render(Primer::Beta::Octicon.new(icon: "arrow-both")) + end + dates_row.with_column(mr: 1) do + render(Primer::Beta::Text.new) do + lag_as_text(relation.lag) + end end end end diff --git a/app/components/work_package_relations_tab/relation_component.rb b/app/components/work_package_relations_tab/relation_component.rb index c3d210607bc2..5df345651d22 100644 --- a/app/components/work_package_relations_tab/relation_component.rb +++ b/app/components/work_package_relations_tab/relation_component.rb @@ -61,7 +61,11 @@ def should_display_description? relation.description.present? end - def should_display_start_and_end_dates? + def lag_present? + relation.lag.present? && relation.lag != 0 + end + + def should_display_dates_row? return false if parent_child_relationship? relation.follows? || relation.precedes? @@ -91,6 +95,10 @@ def destroy_path end end + def lag_as_text(lag) + "#{I18n.t('work_package_relations_tab.lag.subject')}: #{I18n.t('datetime.distance_in_words.x_days', count: lag)}" + end + def action_menu_test_selector "op-relation-row-#{underlying_resource_id}-action-menu" end diff --git a/app/components/work_package_relations_tab/work_package_relation_dialog_component.html.erb b/app/components/work_package_relations_tab/work_package_relation_dialog_component.html.erb index 2a804ff50d0b..7d436a98c102 100644 --- a/app/components/work_package_relations_tab/work_package_relation_dialog_component.html.erb +++ b/app/components/work_package_relations_tab/work_package_relation_dialog_component.html.erb @@ -21,7 +21,11 @@ form: FORM_ID, data: { turbo: true }, type: :submit)) do - t(:button_save) + if @relation.id.present? + t(:button_save) + else + t(:button_add) + end end end end diff --git a/app/components/work_package_relations_tab/work_package_relation_form_component.html.erb b/app/components/work_package_relations_tab/work_package_relation_form_component.html.erb index 0a6f63688a42..83879e8a97c6 100644 --- a/app/components/work_package_relations_tab/work_package_relation_form_component.html.erb +++ b/app/components/work_package_relations_tab/work_package_relation_form_component.html.erb @@ -19,6 +19,7 @@ # These are not available inside the render_inline_form block # so we need to re-define them here. Figure out solution for this. relation = @relation + lag_shown = show_lag? to_id_field_value = relation.to.present? ? "#{related_work_package.type.name.upcase} ##{related_work_package.id} - #{related_work_package.subject}" : nil url = ::API::V3::Utilities::PathHelper::ApiV3Path.work_package_available_relation_candidates(@work_package.id, type: relation.relation_type_for(@work_package)) render_inline_form(f) do |my_form| @@ -58,6 +59,17 @@ label: Relation.human_attribute_name(:description), autofocus: relation.persisted? ) + + if lag_shown + my_form.text_field( + name: :lag, + type: :number, + min: 0, + label: I18n.t("work_package_relations_tab.lag.title"), + caption: I18n.t("work_package_relations_tab.lag.caption"), + input_width: :small, + ) + end end end end %> diff --git a/app/components/work_package_relations_tab/work_package_relation_form_component.rb b/app/components/work_package_relations_tab/work_package_relation_form_component.rb index 938ce175ddf6..264d08770fab 100644 --- a/app/components/work_package_relations_tab/work_package_relation_form_component.rb +++ b/app/components/work_package_relations_tab/work_package_relation_form_component.rb @@ -64,4 +64,8 @@ def submit_url_options url: work_package_relations_path(@work_package) } end end + + def show_lag? + @relation.relation_type == Relation::TYPE_PRECEDES || @relation.relation_type == Relation::TYPE_FOLLOWS + end end diff --git a/app/components/work_packages/exports/generate/modal_dialog_component.html.erb b/app/components/work_packages/exports/generate/modal_dialog_component.html.erb index 2ca7ef37b7e4..9ed56454b52d 100644 --- a/app/components/work_packages/exports/generate/modal_dialog_component.html.erb +++ b/app/components/work_packages/exports/generate/modal_dialog_component.html.erb @@ -1,7 +1,7 @@ <%= render(Primer::Alpha::Dialog.new( title: I18n.t("pdf_generator.dialog.title"), id: MODAL_ID, - size: :large + size: :medium_portrait )) do |dialog| dialog.with_header(variant: :large) dialog.with_body do diff --git a/app/components/work_packages/reminder/modal_body_component.html.erb b/app/components/work_packages/reminder/modal_body_component.html.erb new file mode 100644 index 000000000000..ba6347a66282 --- /dev/null +++ b/app/components/work_packages/reminder/modal_body_component.html.erb @@ -0,0 +1,52 @@ +<%= component_wrapper(tag: "turbo-frame") do %> + <%= primer_form_with( + model: reminder, + url: submit_path, + id: FORM_ID + ) do |f| %> + <%= flex_layout(classes: "reminder-modal-body--form-flex-container") do |form_flex_container| %> + <%= form_flex_container.with_row do %> + <%= render(WorkPackages::Reminder::RemindAtDate.new(f, initial_value: remind_at_date_initial_value)) %> + <% end %> + <%= form_flex_container.with_row do %> + <%= render(WorkPackages::Reminder::RemindAtTime.new(f, initial_value: remind_at_time_initial_value)) %> + <% end %> + <%= form_flex_container.with_row do %> + <%= render(WorkPackages::Reminder::Note.new(f)) %> + <% end %> + <% if reminder.persisted? %> + <%= form_flex_container.with_row(flex_layout: true, + justify_content: :space_between) do |actions_row| %> + <%= actions_row.with_column do %> + <%= render(Primer::Beta::Button.new( + scheme: :danger, + type: :submit, + formaction: work_package_reminder_path(remindable, reminder), + formmethod: :delete + )) { I18n.t(:button_remove_reminder) } %> + <% end %> + <%= actions_row.with_column(flex_layout: true) do |actions_sub_row| %> + <%= actions_sub_row.with_column(mr: 2) do %> + <%= render(Primer::Beta::Button.new(**cancel_button_props)) { I18n.t(:button_cancel) } %> + <% end %> + <%= actions_sub_row.with_column do %> + <%= render(Primer::Beta::Button.new(scheme: :primary, + type: :submit)) { I18n.t(:button_save) } %> + <% end %> + <% end%> + <% end %> + <% else%> + <%= form_flex_container.with_row(flex_layout: true, + justify_content: :flex_end) do |actions_row| %> + <%= actions_row.with_column(mr: 2) do %> + <%= render(Primer::Beta::Button.new(**cancel_button_props)) { I18n.t(:button_cancel) } %> + <% end %> + <%= actions_row.with_column do %> + <%= render(Primer::Beta::Button.new(scheme: :primary, + type: :submit)) { I18n.t(:button_set_reminder) } %> + <% end %> + <% end %> + <% end%> + <% end %> + <% end %> +<% end %> diff --git a/app/components/work_packages/reminder/modal_body_component.rb b/app/components/work_packages/reminder/modal_body_component.rb new file mode 100644 index 000000000000..b723e4621973 --- /dev/null +++ b/app/components/work_packages/reminder/modal_body_component.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module WorkPackages + module Reminder + class ModalBodyComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + FORM_ID = "reminder-form" + + attr_reader :remindable, :reminder, :errors + + def initialize(remindable:, reminder:, errors: nil) + super + + @remindable = remindable + @reminder = reminder + @errors = errors + end + + class << self + def wrapper_key + "reminder_modal_body" + end + end + + def submit_path + if @reminder.persisted? + work_package_reminder_path(@remindable, @reminder) + else + work_package_reminders_path(@remindable) + end + end + + def submit_button_text + if @reminder.persisted? + I18n.t(:button_save) + else + I18n.t(:button_set_reminder) + end + end + + def cancel_button_props + { + scheme: :secondary, + data: { + controller: "primer-to-angular-modal", + application_target: "dynamic", + action: "click->primer-to-angular-modal#close", + test_selector: "op-reminder-modal-close-button" + } + } + end + + def remind_at_date_initial_value + format_time_as_date(@reminder.remind_at, format: "%Y-%m-%d") + end + + def remind_at_time_initial_value + format_time(@reminder.remind_at, include_date: false, format: "%H:%M") + end + end + end +end diff --git a/app/components/work_packages/reminder/modal_body_component.sass b/app/components/work_packages/reminder/modal_body_component.sass new file mode 100644 index 000000000000..3383e0b00196 --- /dev/null +++ b/app/components/work_packages/reminder/modal_body_component.sass @@ -0,0 +1,3 @@ +.reminder-modal-body + &--form-flex-container + gap: 1rem diff --git a/app/views/projects/settings/_form_toolbar.html.erb b/app/components/work_packages/types/subject_configuration_component.html.erb similarity index 90% rename from app/views/projects/settings/_form_toolbar.html.erb rename to app/components/work_packages/types/subject_configuration_component.html.erb index 0ec768f89424..95792457632a 100644 --- a/app/views/projects/settings/_form_toolbar.html.erb +++ b/app/components/work_packages/types/subject_configuration_component.html.erb @@ -27,6 +27,8 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -
  • - (<%= check_all_links form_name %>) -
  • +<%= + primer_form_with(**form_options) do |f| + render(WorkPackages::Types::SubjectConfigurationForm.new(f)) + end +%> diff --git a/app/components/work_packages/types/subject_configuration_component.rb b/app/components/work_packages/types/subject_configuration_component.rb new file mode 100644 index 000000000000..88850e1b7e9d --- /dev/null +++ b/app/components/work_packages/types/subject_configuration_component.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module WorkPackages + module Types + class SubjectConfigurationComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def form_options + { + url: "https://example.com", + method: :put, + model:, + data: { + application_target: "dynamic", + controller: "admin--subject-configuration", + admin__subject_configuration_hide_pattern_input_value: true + } + } + end + end + end +end diff --git a/app/contracts/api_tokens/create_contract.rb b/app/contracts/api_tokens/create_contract.rb index f9c3d93484f4..b4835e55eb6c 100644 --- a/app/contracts/api_tokens/create_contract.rb +++ b/app/contracts/api_tokens/create_contract.rb @@ -32,7 +32,7 @@ module APITokens class CreateContract < BaseContract attribute :token_name - validates :token_name, presence: { message: I18n.t("my.access_token.errors.token_name_blank") } + validates :token_name, presence: { message: proc { I18n.t("my.access_token.errors.token_name_blank") } } validate :token_name_is_unique, unless: :token_name_is_blank? private diff --git a/app/contracts/model_contract.rb b/app/contracts/model_contract.rb index efa06281f331..35cdf5b58c62 100644 --- a/app/contracts/model_contract.rb +++ b/app/contracts/model_contract.rb @@ -48,7 +48,7 @@ class ModelContract < BaseContract # This of course is only true if that contract validates the model and # if the model has an errors object. def valid?(context = nil) - model.valid? if validate_model? + model.valid?(context) if validate_model? contract_valid?(context, clear_errors: !validate_model?) end @@ -61,7 +61,8 @@ def valid?(context = nil) # Clearing would then be done in the #valid? method by calling model.valid? # * Checks for readonly attributes being changed def contract_valid?(context = nil, clear_errors: false) - current_context, self.validation_context = validation_context, context # rubocop:disable Style/ParallelAssignment + current_context = validation_context + self.validation_context = context errors.clear if clear_errors diff --git a/app/contracts/project_life_cycle_steps/base_contract.rb b/app/contracts/project_life_cycle_steps/base_contract.rb new file mode 100644 index 000000000000..9e4124d06e63 --- /dev/null +++ b/app/contracts/project_life_cycle_steps/base_contract.rb @@ -0,0 +1,87 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module ProjectLifeCycleSteps + class BaseContract < ::ModelContract + validate :select_custom_fields_permission + validate :consecutive_steps_have_increasing_dates + + def valid?(context = :saving_life_cycle_steps) + super + end + + def select_custom_fields_permission + return if user.allowed_in_project?(:edit_project_stages_and_gates, model) + + errors.add :base, :error_unauthorized + end + + def consecutive_steps_have_increasing_dates + # Filter out steps with missing dates before proceeding with comparison + filtered_steps = model.available_life_cycle_steps.select(&:start_date) + + # Only proceed with comparisons if there are at least 2 valid steps + return if filtered_steps.size < 2 + + # Compare consecutive steps in pairs + filtered_steps.each_cons(2) do |previous_step, current_step| + if has_invalid_dates?(previous_step, current_step) + step = previous_step.is_a?(Project::Stage) ? "Stage" : "Gate" + field = current_step.is_a?(Project::Stage) ? :date_range : :date + model.errors.import( + current_step.errors.add(field, :non_continuous_dates, step:), + attribute: :"available_life_cycle_steps.#{field}" + ) + end + end + end + + private + + def start_date_for(step) + step.start_date + end + + def end_date_for(step) + case step + when Project::Gate + step.date + when Project::Stage + step.end_date || step.start_date # Use the start_date as fallback for single date stages + end + end + + def has_invalid_dates?(previous_step, current_step) + if previous_step.instance_of?(current_step.class) + start_date_for(current_step) <= end_date_for(previous_step) + else + start_date_for(current_step) < end_date_for(previous_step) + end + end + end +end diff --git a/app/contracts/project_life_cycle_steps/update_contract.rb b/app/contracts/project_life_cycle_steps/update_contract.rb new file mode 100644 index 000000000000..28b22227eaa4 --- /dev/null +++ b/app/contracts/project_life_cycle_steps/update_contract.rb @@ -0,0 +1,32 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module ProjectLifeCycleSteps + class UpdateContract < BaseContract + end +end diff --git a/app/contracts/reminders/base_contract.rb b/app/contracts/reminders/base_contract.rb new file mode 100644 index 000000000000..6f2767ccf5ca --- /dev/null +++ b/app/contracts/reminders/base_contract.rb @@ -0,0 +1,91 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Reminders + class BaseContract < ::ModelContract + MAX_NOTE_CHARS_LENGTH = 128 + + attribute :creator_id + attribute :remindable_id + attribute :remindable_type + attribute :remind_at + attribute :note + + validate :validate_creator_exists + validate :validate_acting_user + validate :validate_remindable_exists + validate :validate_manage_reminders_permissions + validate :validate_remind_at_present + validate :validate_remind_at_is_in_future + validate :validate_note_length + + def self.model = Reminder + + private + + def validate_creator_exists + errors.add :creator, :not_found unless User.exists?(model.creator_id) + end + + def validate_acting_user + errors.add :creator, :invalid unless model.creator_id == user.id + end + + def validate_remindable_exists + errors.add :remindable, :not_found if model.remindable.blank? + end + + def validate_remind_at_present + errors.add :remind_at, :blank if model.remind_at.blank? + end + + def validate_remind_at_is_in_future + if model.remind_at.present? && model.remind_at < Time.current + errors.add :remind_at, :datetime_must_be_in_future + end + end + + def validate_note_length + if model.note.present? && model.note.length > MAX_NOTE_CHARS_LENGTH + errors.add :note, :too_long, count: MAX_NOTE_CHARS_LENGTH + end + end + + def validate_manage_reminders_permissions + return if errors.added?(:remindable, :not_found) + + unless can_manage_reminders? + errors.add :base, :error_unauthorized + end + end + + def can_manage_reminders? + user.logged? && user.allowed_in_project?(:view_work_packages, model.remindable.project) + end + end +end diff --git a/app/contracts/reminders/create_contract.rb b/app/contracts/reminders/create_contract.rb new file mode 100644 index 000000000000..6bc3c01eb76a --- /dev/null +++ b/app/contracts/reminders/create_contract.rb @@ -0,0 +1,32 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Reminders + class CreateContract < BaseContract + end +end diff --git a/app/contracts/reminders/delete_contract.rb b/app/contracts/reminders/delete_contract.rb new file mode 100644 index 000000000000..06895868e6c4 --- /dev/null +++ b/app/contracts/reminders/delete_contract.rb @@ -0,0 +1,36 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Reminders + class DeleteContract < ::DeleteContract + delete_permission -> { + # The user can delete the reminder if they created it + model.creator_id == user.id + } + end +end diff --git a/app/contracts/reminders/update_contract.rb b/app/contracts/reminders/update_contract.rb new file mode 100644 index 000000000000..de462cfec17d --- /dev/null +++ b/app/contracts/reminders/update_contract.rb @@ -0,0 +1,41 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Reminders + class UpdateContract < BaseContract + validate :unchangeable_attributes + + private + + def unchangeable_attributes + if model.remindable_changed? || model.creator_id_changed? + errors.add(:base, :unchangeable) + end + end + end +end diff --git a/app/controllers/concerns/accounts/authorization.rb b/app/controllers/concerns/accounts/authorization.rb index 3d4227a43ded..93320f71eb19 100644 --- a/app/controllers/concerns/accounts/authorization.rb +++ b/app/controllers/concerns/accounts/authorization.rb @@ -196,5 +196,27 @@ def clone_authorization_ensured generally_allowed: authorization_ensured[:generally_allowed], controller: self } end + + # Authorize on the given permission + def authorize_with_permission(permission, global: false, **args) + authorization_checked_by_default_action(**args.slice(:only, :except)) + + before_action(**args) do + do_authorize(permission, global:) + end + end + + # Find a project based on params[:project_id] + # and authorize on a given permission + def load_and_authorize_with_permission_in_optional_project(permission, **args) + authorization_checked_by_default_action(**args.slice(:only, :except)) + + before_action(**args) do + @project = Project.find(params[:project_id]) if params[:project_id].present? + do_authorize(permission, global: params[:project_id].blank?) + rescue ActiveRecord::RecordNotFound + render_404 + end + end end end diff --git a/app/controllers/concerns/op_turbo/component_stream.rb b/app/controllers/concerns/op_turbo/component_stream.rb index 6d3b3fa27162..f7cd5e1369bc 100644 --- a/app/controllers/concerns/op_turbo/component_stream.rb +++ b/app/controllers/concerns/op_turbo/component_stream.rb @@ -42,23 +42,24 @@ def respond_to_with_turbo_streams(status: turbo_status, &format_block) alias_method :respond_with_turbo_streams, :respond_to_with_turbo_streams - def update_via_turbo_stream(component:, status: :ok) - modify_via_turbo_stream(component:, action: :update, status:) + def update_via_turbo_stream(component:, status: :ok, method: nil) + modify_via_turbo_stream(component:, action: :update, status:, method:) end - def replace_via_turbo_stream(component:, status: :ok) - modify_via_turbo_stream(component:, action: :replace, status:) + def replace_via_turbo_stream(component:, status: :ok, method: nil) + modify_via_turbo_stream(component:, action: :replace, status:, method:) end def remove_via_turbo_stream(component:, status: :ok) modify_via_turbo_stream(component:, action: :remove, status:) end - def modify_via_turbo_stream(component:, action:, status:) + def modify_via_turbo_stream(component:, action:, status:, method: nil) @turbo_status = status turbo_streams << component.render_as_turbo_stream( view_context:, - action: + action:, + method: ) end @@ -74,8 +75,8 @@ def add_before_via_turbo_stream(component:, target_component:) turbo_streams << target_component.insert_as_turbo_stream(component:, view_context:, action: :before) end - def render_error_flash_message_via_turbo_stream(**kwargs) - update_flash_message_via_turbo_stream(**kwargs.merge(scheme: :danger, icon: :stop)) + def render_error_flash_message_via_turbo_stream(**) + update_flash_message_via_turbo_stream(**, scheme: :danger, icon: :stop) end def update_flash_message_via_turbo_stream(message:, component: OpPrimer::FlashComponent, **) @@ -89,6 +90,12 @@ def scroll_into_view_via_turbo_stream(target, behavior: :auto, block: :start) .render_in(view_context) end + def add_caption_to_input_element_via_turbo_stream(target, caption:, clean_other_captions: true) + turbo_streams << OpTurbo::StreamComponent + .new(action: :addInputCaption, target:, caption:, clean_other_captions:) + .render_in(view_context) + end + def turbo_streams @turbo_streams ||= [] end diff --git a/app/controllers/forums_controller.rb b/app/controllers/forums_controller.rb index 434b7686ffd9..414248f8161a 100644 --- a/app/controllers/forums_controller.rb +++ b/app/controllers/forums_controller.rb @@ -35,7 +35,6 @@ class ForumsController < ApplicationController accept_key_auth :show include SortHelper - include WatchersHelper include PaginationHelper def index diff --git a/app/controllers/projects/settings/life_cycle_steps_controller.rb b/app/controllers/projects/settings/life_cycle_steps_controller.rb new file mode 100644 index 000000000000..c28a3b12ab93 --- /dev/null +++ b/app/controllers/projects/settings/life_cycle_steps_controller.rb @@ -0,0 +1,81 @@ +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2010-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +class Projects::Settings::LifeCycleStepsController < Projects::SettingsController + include OpTurbo::ComponentStream + + before_action :deny_access_on_feature_flag + + before_action :load_life_cycle_definitions, only: %i[index enable_all disable_all] + + menu_item :settings_life_cycle_steps + + def index; end + + def toggle + definition = Project::LifeCycleStepDefinition.where(id: params[:id]) + + upsert_steps(definition, active: params["value"]) + end + + def disable_all + upsert_steps(@life_cycle_definitions, active: false) + + redirect_to action: :index + end + + def enable_all + upsert_steps(@life_cycle_definitions, active: true) + + redirect_to action: :index + end + + private + + def load_life_cycle_definitions + @life_cycle_definitions = Project::LifeCycleStepDefinition.order(position: :asc) + end + + def deny_access_on_feature_flag + deny_access(not_found: true) unless OpenProject::FeatureDecisions.stages_and_gates_active? + end + + def upsert_steps(definitions, active:) + Project::LifeCycleStep.upsert_all( + definitions.map do |definition| + { + project_id: @project.id, + definition_id: definition.id, + active:, + type: definition.step_class + } + end, + unique_by: %i[project_id definition_id] + ) + end +end diff --git a/app/controllers/users/hover_card_controller.rb b/app/controllers/users/hover_card_controller.rb new file mode 100644 index 000000000000..714c09a96589 --- /dev/null +++ b/app/controllers/users/hover_card_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +class Users::HoverCardController < ApplicationController + no_authorization_required! :show + + def show + @id = params[:id] + render layout: nil + end +end diff --git a/app/controllers/work_package_children_relations_controller.rb b/app/controllers/work_package_children_relations_controller.rb index 2f1fb1574e5f..539961766bbe 100644 --- a/app/controllers/work_package_children_relations_controller.rb +++ b/app/controllers/work_package_children_relations_controller.rb @@ -46,7 +46,7 @@ def create child = WorkPackage.find(params[:work_package][:id]) service_result = set_relation(child:, parent: @work_package) - respond_with_relations_tab_update(service_result) + respond_with_relations_tab_update(service_result, relation_to_scroll_to: service_result.result) end def destroy @@ -63,13 +63,14 @@ def set_relation(child:, parent:) .call(parent:) end - def respond_with_relations_tab_update(service_result) + def respond_with_relations_tab_update(service_result, **) if service_result.success? @work_package.reload component = WorkPackageRelationsTab::IndexComponent.new( work_package: @work_package, relations: @work_package.relations.visible, - children: @work_package.children.visible + children: @work_package.children.visible, + ** ) replace_via_turbo_stream(component:) update_flash_message_via_turbo_stream( diff --git a/app/controllers/work_package_relations_controller.rb b/app/controllers/work_package_relations_controller.rb index 6be607a1669d..02e7f68b0134 100644 --- a/app/controllers/work_package_relations_controller.rb +++ b/app/controllers/work_package_relations_controller.rb @@ -63,7 +63,8 @@ def create @work_package.reload component = WorkPackageRelationsTab::IndexComponent.new(work_package: @work_package, relations: @work_package.relations.visible, - children: @work_package.children.visible) + children: @work_package.children.visible, + relation_to_scroll_to: service_result.result) replace_via_turbo_stream(component:) respond_with_turbo_streams else @@ -122,12 +123,12 @@ def set_relation def create_relation_params params.require(:relation) - .permit(:relation_type, :to_id, :description) + .permit(:relation_type, :to_id, :description, :lag) .merge(from_id: @work_package.id) end def update_relation_params params.require(:relation) - .permit(:description) + .permit(:description, :lag) end end diff --git a/app/controllers/work_packages/reminders_controller.rb b/app/controllers/work_packages/reminders_controller.rb new file mode 100644 index 000000000000..7866147aea1d --- /dev/null +++ b/app/controllers/work_packages/reminders_controller.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +class WorkPackages::RemindersController < ApplicationController + include OpTurbo::ComponentStream + layout false + before_action :find_work_package + before_action :build_or_find_reminder, only: %i[modal_body create] + before_action :find_reminder, only: %i[update destroy] + + before_action :authorize + + def modal_body + render modal_component_class.new( + remindable: @work_package, + reminder: @reminder + ) + end + + def create + service_result = Reminders::CreateService.new(user: current_user) + .call(reminder_params) + + if service_result.success? + update_flash_message_via_turbo_stream( + message: I18n.t("work_package.reminders.success_creation_message"), + scheme: :success + ) + respond_with_turbo_streams + else + prepare_errors_from_result(service_result) + + replace_via_turbo_stream( + component: modal_component_class.new( + remindable: @work_package, + reminder: @reminder, + errors: @errors + ) + ) + + respond_with_turbo_streams(status: :unprocessable_entity) + end + end + + def update + service_result = Reminders::UpdateService.new(user: current_user, + model: @reminder) + .call(reminder_params) + + if service_result.success? + update_flash_message_via_turbo_stream( + message: I18n.t("work_package.reminders.success_update_message"), + scheme: :success + ) + respond_with_turbo_streams + else + prepare_errors_from_result(service_result) + + replace_via_turbo_stream( + component: modal_component_class.new( + remindable: @work_package, + reminder: @reminder, + errors: @errors + ) + ) + + respond_with_turbo_streams(status: :unprocessable_entity) + end + end + + def destroy + service_result = Reminders::DeleteService.new(user: current_user, + model: @reminder) + .call + + if service_result.success? + update_flash_message_via_turbo_stream( + message: I18n.t("work_package.reminders.success_deletion_message"), + scheme: :success + ) + respond_with_turbo_streams + else + update_flash_message_via_turbo_stream( + message: service_result.errors.full_messages, + scheme: :danger + ) + respond_with_turbo_streams(status: :unprocessable_entity) + end + end + + private + + def modal_component_class + WorkPackages::Reminder::ModalBodyComponent + end + + def find_work_package + @work_package = WorkPackage.visible.find(params[:work_package_id]) + end + + # At the form level, we split the date and time into two form fields. + # In order to be a bit more informative of which field is causing + # the remind_at attribute to be in the past/invalid, we need to + # remap the error attribute to the appropriate field. + def prepare_errors_from_result(service_result) + # We set the reminder here for "create" case + # as the record comes from the service. + @reminder = service_result.result + @errors = service_result.errors + + case @errors.find { |error| error.attribute == :remind_at }&.type + when :blank + handle_blank_error + when :datetime_must_be_in_future + handle_future_error + end + + @errors.delete(:remind_at) + end + + def handle_blank_error + @errors.add(:remind_at_date, :blank) if remind_at_date.blank? + @errors.add(:remind_at_time, :blank) if remind_at_time.blank? + end + + def handle_future_error + @errors.add(:remind_at_date, :datetime_must_be_in_future) if @reminder.remind_at.to_date < today_in_user_time_zone + @errors.add(:remind_at_time, :datetime_must_be_in_future) if @reminder.remind_at < now_in_user_time_zone + end + + def now_in_user_time_zone + @now_in_user_time_zone ||= Time.current + .in_time_zone(User.current.time_zone) + end + + def today_in_user_time_zone + @today_in_user_time_zone ||= now_in_user_time_zone.to_date + end + + # We assume for now that there is only one reminder per work package + def build_or_find_reminder + @reminder = @work_package.reminders + .upcoming_and_visible_to(User.current) + .last || @work_package.reminders.build + end + + def find_reminder + @reminder = @work_package.reminders + .upcoming_and_visible_to(User.current) + .find(params[:id]) + rescue ActiveRecord::RecordNotFound + update_flash_message_via_turbo_stream( + message: I18n.t(:error_reminder_not_found), + scheme: :danger + ) + respond_with_turbo_streams(status: :not_found) + false + end + + def reminder_params + params.require(:reminder) + .permit(%i[remind_at_date remind_at_time note]) + .tap do |initial_params| + date = initial_params.delete(:remind_at_date) + time = initial_params.delete(:remind_at_time) + + initial_params[:remind_at] = build_remind_at_from_params(date, time) + initial_params[:remindable] = @work_package + initial_params[:creator] = User.current + end + end + + def build_remind_at_from_params(remind_at_date, remind_at_time) + if remind_at_date.present? && remind_at_time.present? + DateTime.parse("#{remind_at_date} #{User.current.time_zone.parse(remind_at_time)}") + end + end + + def remind_at_date + params[:reminder][:remind_at_date] + end + + def remind_at_time + params[:reminder][:remind_at_time] + end +end diff --git a/app/forms/application_form.rb b/app/forms/application_form.rb index cb52b5b980f0..2fe86546d088 100644 --- a/app/forms/application_form.rb +++ b/app/forms/application_form.rb @@ -40,6 +40,11 @@ def url_helpers Rails.application.routes.url_helpers end + # @return [ActionView::Base] the view helper instance + def helpers + @view_context.helpers + end + # @return [ActiveRecord::Base] the model instance given to the form builder def model @builder.object diff --git a/app/forms/projects/life_cycles/form.rb b/app/forms/projects/life_cycles/form.rb new file mode 100644 index 000000000000..6d0233b90460 --- /dev/null +++ b/app/forms/projects/life_cycles/form.rb @@ -0,0 +1,104 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +module Projects::LifeCycles + class Form < ApplicationForm + form do |f| + life_cycle_input(f) + end + + private + + def life_cycle_input(form) + case model + when Project::Stage + multi_value_life_cycle_input(form) + when Project::Gate + single_value_life_cycle_input(form) + else + raise NotImplementedError, "Unknown life cycle definition type #{model.class.name}" + end + end + + def qa_field_name + "life-cycle-step-#{model.id}" + end + + def base_input_attributes + { + label: "#{icon} #{text}".html_safe, # rubocop:disable Rails/OutputSafety + leading_visual: { icon: :calendar }, + datepicker_options: { + inDialog: true, + data: { action: "change->overview--project-life-cycles-form#previewForm" } + }, + wrapper_data_attributes: { + "qa-field-name": qa_field_name + } + } + end + + def single_value_life_cycle_input(form) + input_attributes = { name: :date, value: model.date } + + form.single_date_picker **base_input_attributes, **input_attributes + end + + def multi_value_life_cycle_input(form) + value = [model.start_date, model.end_date].compact.join(" - ") + + input_attributes = { name: :date_range, value: } + if model.working_days_count + input_attributes[:caption] = + I18n.t("project_stage.working_days_count", count: model.working_days_count) + end + + form.range_date_picker **base_input_attributes, **input_attributes + end + + def text + model.name + end + + def icon + icon_name = case model + when Project::Stage + :"git-commit" + when Project::Gate + :diamond + else + raise NotImplementedError, "Unknown model #{model.class} to render a LifeCycleForm with" + end + + render Primer::Beta::Octicon.new(icon: icon_name, classes: icon_color_class) + end + + def icon_color_class + helpers.hl_inline_class("life_cycle_step_definition", model.definition) + end + end +end diff --git a/app/forms/shares/invitee.rb b/app/forms/shares/invitee.rb index e8eb8f8407d9..c60ed79d1c3c 100644 --- a/app/forms/shares/invitee.rb +++ b/app/forms/shares/invitee.rb @@ -51,14 +51,17 @@ class Invitee < ApplicationForm multiple: true, focusDirectly: true, appendToComponent: true, - disabled: @disabled + disabled: @disabled, + isOpenedInModal: true, + hoverCards: @allow_hover_cards } ) end - def initialize(disabled: false) + def initialize(disabled: false, allow_hover_cards: false) super() @disabled = disabled + @allow_hover_cards = allow_hover_cards end end end diff --git a/app/forms/statuses/form.rb b/app/forms/statuses/form.rb index 522755be2aad..c8b3c46490e1 100644 --- a/app/forms/statuses/form.rb +++ b/app/forms/statuses/form.rb @@ -106,7 +106,8 @@ def initialize(submit_label: nil) statuses_form.color_select_list( label: attribute_name(:color_id), name: :color_id, - caption: I18n.t("statuses.edit.status_color_text") + caption: I18n.t("statuses.edit.status_color_text"), + input_width: :medium ) statuses_form.submit( diff --git a/app/forms/work_packages/activities_tab/journals/submit.rb b/app/forms/work_packages/activities_tab/journals/submit.rb index 08bce83ed9ea..defd84b26d54 100644 --- a/app/forms/work_packages/activities_tab/journals/submit.rb +++ b/app/forms/work_packages/activities_tab/journals/submit.rb @@ -28,7 +28,7 @@ module WorkPackages::ActivitiesTab::Journals class Submit < ApplicationForm form do |notes_form| - notes_form.submit(name: :submit, label: "Save", scheme: :primary, + notes_form.submit(name: :submit, label: I18n.t("button_save"), scheme: :primary, data: { test_selector: "op-submit-work-package-journal-form" }) end end diff --git a/app/forms/work_packages/reminder/note.rb b/app/forms/work_packages/reminder/note.rb new file mode 100644 index 000000000000..35d278583718 --- /dev/null +++ b/app/forms/work_packages/reminder/note.rb @@ -0,0 +1,45 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class WorkPackages::Reminder::Note < ApplicationForm + form do |reminder_form| + reminder_form.text_field( + name: :note, + required: false, + label: WorkPackage.human_attribute_name(:note), + placeholder: @placeholder, + visually_hide_label: false + ) + end + + def initialize + super + + @placeholder = I18n.t("work_package.reminders.note_placeholder") + end +end diff --git a/modules/meeting/app/forms/meeting/start_date.rb b/app/forms/work_packages/reminder/remind_at_date.rb similarity index 85% rename from modules/meeting/app/forms/meeting/start_date.rb rename to app/forms/work_packages/reminder/remind_at_date.rb index ff5824535374..68c0e01f2b4f 100644 --- a/modules/meeting/app/forms/meeting/start_date.rb +++ b/app/forms/work_packages/reminder/remind_at_date.rb @@ -26,14 +26,14 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Meeting::StartDate < ApplicationForm - form do |meeting_form| - meeting_form.text_field( - name: :start_date, +class WorkPackages::Reminder::RemindAtDate < ApplicationForm + form do |reminder_form| + reminder_form.text_field( + name: :remind_at_date, type: "date", value: @initial_value, - placeholder: Meeting.human_attribute_name(:start_date), - label: Meeting.human_attribute_name(:start_date), + placeholder: I18n.t(:label_date), + label: I18n.t(:label_date), leading_visual: { icon: :calendar }, required: true, autofocus: false @@ -41,6 +41,8 @@ class Meeting::StartDate < ApplicationForm end def initialize(initial_value: DateTime.now.strftime("%Y-%m-%d")) + super() + @initial_value = initial_value end end diff --git a/modules/meeting/app/forms/meeting/start_time.rb b/app/forms/work_packages/reminder/remind_at_time.rb similarity index 85% rename from modules/meeting/app/forms/meeting/start_time.rb rename to app/forms/work_packages/reminder/remind_at_time.rb index 36b43092839e..221b9ab27618 100644 --- a/modules/meeting/app/forms/meeting/start_time.rb +++ b/app/forms/work_packages/reminder/remind_at_time.rb @@ -26,23 +26,26 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Meeting::StartTime < ApplicationForm +class WorkPackages::Reminder::RemindAtTime < ApplicationForm include Redmine::I18n - form do |meeting_form| - meeting_form.text_field( - name: :start_time_hour, + form do |reminder_form| + reminder_form.text_field( + name: :remind_at_time, type: "time", value: @initial_value, - placeholder: Meeting.human_attribute_name(:start_time), - label: Meeting.human_attribute_name(:start_time), + placeholder: I18n.t(:label_time), + label: I18n.t(:label_time), leading_visual: { icon: :clock }, required: true, + autofocus: false, caption: formatted_time_zone_offset ) end def initialize(initial_value: DateTime.now.strftime("%H:%M")) + super() + @initial_value = initial_value end end diff --git a/app/forms/work_packages/types/subject_configuration_form.rb b/app/forms/work_packages/types/subject_configuration_form.rb new file mode 100644 index 000000000000..455b1213e5ad --- /dev/null +++ b/app/forms/work_packages/types/subject_configuration_form.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module WorkPackages + module Types + class SubjectConfigurationForm < ApplicationForm + form do |subject_form| + subject_form.radio_button_group(name: :subject_configuration) do |group| + group.radio_button( + value: "manual", + checked: !has_pattern?, + label: I18n.t("types.edit.subject_configuration.manually_editable_subjects.label"), + caption: I18n.t("types.edit.subject_configuration.manually_editable_subjects.caption"), + data: { action: "admin--subject-configuration#hidePatternInput" } + ) + group.radio_button( + value: "auto", + checked: has_pattern?, + label: I18n.t("types.edit.subject_configuration.automatically_generated_subjects.label"), + caption: I18n.t("types.edit.subject_configuration.automatically_generated_subjects.caption"), + data: { action: "admin--subject-configuration#showPatternInput" } + ) + end + + subject_form.group(data: { "admin--subject-configuration-target": "patternInput" }) do |toggleable_group| + toggleable_group.text_field( + name: :pattern, + label: I18n.t("types.edit.subject_configuration.pattern.label"), + caption: I18n.t("types.edit.subject_configuration.pattern.caption"), + required: true, + input_width: :large + ) + end + + subject_form.submit(name: :submit, label: I18n.t(:button_save), scheme: :primary) + end + + private + + def has_pattern? + false + end + end + end +end diff --git a/app/helpers/custom_fields_helper.rb b/app/helpers/custom_fields_helper.rb index f9880b5d14e6..1c81066a9009 100644 --- a/app/helpers/custom_fields_helper.rb +++ b/app/helpers/custom_fields_helper.rb @@ -219,7 +219,7 @@ def label_for_custom_field_format(format_string) label = format.label.is_a?(Proc) ? format.label.call : I18n.t(format.label) show_enterprise_text = format_string == "hierarchy" && !EnterpriseToken.allows_to?(:custom_field_hierarchies) - suffix = show_enterprise_text ? " (#{I18n.t(:label_enterprise_addon)})" : "" + suffix = show_enterprise_text ? " (#{I18n.t(:"ee.upsale.title")})" : "" "#{label}#{suffix}" end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index c01a2c1bc15c..2a733586906b 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -73,8 +73,7 @@ def projects_columns_options def selected_projects_columns_options Setting .enabled_projects_columns - .map { |c| projects_columns_options.find { |o| o[:id].to_s == c } } - .compact + .filter_map { |c| projects_columns_options.find { |o| o[:id].to_s == c } } end def protected_projects_columns_options diff --git a/app/helpers/sort_helper.rb b/app/helpers/sort_helper.rb index 3ec0eadd99e7..8a5bc7dfafc3 100644 --- a/app/helpers/sort_helper.rb +++ b/app/helpers/sort_helper.rb @@ -331,8 +331,8 @@ def sort_header_tag(column, allowed_params: nil, **options) # This is a more specific version of #sort_header_tag. # For "filter by" to work properly, you must pass a Hash for `filter_column_mapping`. def sort_header_with_action_menu(column, all_columns, filter_column_mapping = {}, allowed_params: nil, **options) - with_sort_header_options(column, allowed_params:, **options) do |col, cap, default_order, **opts| - action_menu(col, all_columns, cap, default_order, filter_column_mapping, **opts) + with_sort_header_options(column.attribute, allowed_params:, **options) do |_col, cap, default_order, **opts| + action_menu(column, all_columns, cap, default_order, filter_column_mapping, **opts) end end @@ -381,6 +381,9 @@ def build_columns_link(columns, allowed_params: nil, **html_options) def find_filter_for_column(column, filter_mapping) col = column.to_s + # Temporarily disabled filters for stages and gates columns for now. Remove this line for #59183 + return nil if column.start_with?("lcsd_") + filter_mapping.fetch(col, col) end @@ -389,9 +392,10 @@ def find_filter_for_column(column, filter_mapping) # Some of the method arguments are only needed for specific actions. def action_menu(column, table_columns, caption, default_order, filter_column_mapping = {}, allowed_params: nil, **html_options) - caption ||= column.to_s.humanize + attribute = column.attribute + caption ||= attribute.to_s.humanize - filter = find_filter_for_column(column, filter_column_mapping) + filter = find_filter_for_column(attribute, filter_column_mapping) sortable = html_options.delete(:sortable) # `param` is not needed in the `content_arguments`, but should remain in the `html_options`. @@ -399,26 +403,32 @@ def action_menu(column, table_columns, caption, default_order, filter_column_map # the action menu. content_args = html_options.merge(rel: :nofollow, param: nil) - render Primer::Alpha::ActionMenu.new(menu_id: "menu-#{column}") do |menu| - action_button(menu, caption, favorite: column == :favored) + render Primer::Alpha::ActionMenu.new(menu_id: "menu-#{attribute}") do |menu| + action_button(menu, column, caption, favorite: column == :favored) # Some columns are not sortable or do not offer a suitable filter. Omit those actions for them. - sort_actions(menu, column, default_order, content_args:, allowed_params:, **html_options) if sortable - filter_action(menu, column, filter, content_args:) if filter + sort_actions(menu, attribute, default_order, content_args:, allowed_params:, **html_options) if sortable + filter_action(menu, attribute, filter, content_args:) if filter - move_column_actions(menu, column, table_columns, content_args:, allowed_params:, **html_options) - add_and_remove_column_actions(menu, column, table_columns, content_args:, allowed_params:, **html_options) + move_column_actions(menu, attribute, table_columns, content_args:, allowed_params:, **html_options) + add_and_remove_column_actions(menu, attribute, table_columns, content_args:, allowed_params:, **html_options) end end - def action_button(menu, caption, favorite: false) + def action_button(menu, column, caption, favorite: false) + additional_menu_classes = ["generic-table--action-menu-button", + column.respond_to?(:action_menu_classes) ? column.action_menu_classes : nil] + .compact + .join(" ") + menu.with_show_button(scheme: :link, color: :default, text_transform: :uppercase, underline: false, display: :inline_flex, - classes: "generic-table--action-menu-button") do |button| + classes: additional_menu_classes) do |button| if favorite # This column only shows an icon, no text. render Primer::Beta::Octicon.new(icon: "star-fill", color: :subtle, "aria-label": I18n.t(:label_favorite)) else + button.with_leading_visual_icon(**column.visual_icon) if column.respond_to?(:visual_icon) button.with_trailing_action_icon(icon: :"triangle-down") h(caption).to_s diff --git a/app/helpers/types_helper.rb b/app/helpers/types_helper.rb index fcac23fc709a..2fcb024f7009 100644 --- a/app/helpers/types_helper.rb +++ b/app/helpers/types_helper.rb @@ -27,29 +27,45 @@ #++ module ::TypesHelper + # rubocop:disable Rails/HelperInstanceVariable def types_tabs - [ + tabs = [ { name: "settings", partial: "types/form/settings", path: edit_type_tab_path(id: @type.id, tab: :settings), - label: "types.edit.settings" + label: "types.edit.settings.tab" }, { name: "form_configuration", partial: "types/form/form_configuration", path: edit_type_tab_path(id: @type.id, tab: :form_configuration), - label: "types.edit.form_configuration" + label: "types.edit.form_configuration.tab" }, { name: "projects", partial: "types/form/projects", path: edit_type_tab_path(id: @type.id, tab: :projects), - label: "types.edit.projects" + label: "types.edit.projects.tab" } ] + + if OpenProject::FeatureDecisions.generate_work_package_subjects_active? + subject_configuration_tab = { + name: "subject_configuration", + path: edit_type_tab_path(id: @type.id, tab: :subject_configuration), + label: "types.edit.subject_configuration.tab", + view_component: WorkPackages::Types::SubjectConfigurationComponent + } + + tabs.insert(2, subject_configuration_tab) + end + + tabs end + # rubocop:enable Rails/HelperInstanceVariable + def icon_for_type(type) return unless type @@ -95,8 +111,8 @@ def active_group_attributes_map(group, available, inactive) return nil unless group.group_type == :attribute group.attributes - .select { |key| inactive.delete(key) } - .map! { |key| attr_form_map(key, available[key]) } + .select { |key| inactive.delete(key) } + .map! { |key| attr_form_map(key, available[key]) } end def query_to_query_props(group) diff --git a/app/helpers/watchers_helper.rb b/app/helpers/watchers_helper.rb index 22e7fb2743be..198d77037058 100644 --- a/app/helpers/watchers_helper.rb +++ b/app/helpers/watchers_helper.rb @@ -30,25 +30,85 @@ module WatchersHelper # Create a link to watch/unwatch object # # * :replace - a string or array of strings with css selectors that will be updated, whenever the watcher status is changed - def watcher_link(object, user, options = { replace: ".watcher_link", class: "watcher_link" }) - options = options.with_indifferent_access - raise ArgumentError, "Missing :replace option in options hash" if options["replace"].blank? + def watcher_link(object, user, options = {}) + options = { replace: ".watcher_link", class: "watcher_link" }.merge(options) - return "" unless user&.logged? && object.respond_to?(:watched_by?) + return "" unless valid_watcher_conditions?(object, user, options) watched = object.watched_by?(user) - html_options = options - path = send(:"#{(watched ? 'unwatch' : 'watch')}_path", object_type: object.class.to_s.underscore.pluralize, - object_id: object.id, - replace: options.delete("replace")) - html_options[:class] = html_options[:class].to_s + " button" + path = watcher_path(object, watched, options) + + html_options = prepare_html_options(watched, options) + + link_to_watcher_button(watched, path, html_options) + end + + def watcher_action_button(container, object) + watcher_button_args = watcher_button_arguments(object, User.current) + return if watcher_button_args.nil? + + container.with_action_button(**watcher_button_args) do |button| + button.with_leading_visual_icon(icon: watcher_button_args[:mobile_icon]) + watcher_button_args[:mobile_label] + end + end + + def watcher_button_arguments(object, user) + return nil unless user&.logged? && object.respond_to?(:watched_by?) + + watched = object.watched_by?(user) + + path = send(:"#{(watched ? 'unwatch' : 'watch')}_path", + object_type: object.class.to_s.underscore.pluralize, + object_id: object.id) method = watched ? :delete : :post label = watched ? I18n.t(:button_unwatch) : I18n.t(:button_watch) - link_to(content_tag(:i, "", class: watched ? "button--icon icon-watched" : " button--icon icon-unwatched") + " " + - content_tag(:span, label, class: "button--text"), path, html_options.merge(method:)) + { + tag: :a, + href: path, + scheme: :default, + aria: { label: label }, + data: { + method: + }, + mobile_icon: watched ? "eye-closed" : "eye", + mobile_label: label + } + end + + private + + def valid_watcher_conditions?(object, user, options) + raise ArgumentError, "Missing :replace option in options hash" if options[:replace].blank? + + user&.logged? && object.respond_to?(:watched_by?) + end + + def watcher_path(object, watched, options) + action = watched ? "unwatch" : "watch" + send(:"#{action}_path", object_type: object.class.to_s.underscore.pluralize, object_id: object.id, replace: options[:replace]) + end + + def prepare_html_options(watched, options) + options.merge( + class: "#{options[:class]} button", + method: watched ? :delete : :post + ) + end + + def link_to_watcher_button(watched, path, html_options) + label = watched ? I18n.t(:button_unwatch) : I18n.t(:button_watch) + icon_class = watched ? "icon-watched" : "icon-unwatched" + + link_to( + content_tag(:i, "", class: "button--icon #{icon_class}") + + content_tag(:span, label, class: "button--text"), + path, + html_options + ) end end diff --git a/app/mailers/reminders/notification_mailer.rb b/app/mailers/reminders/notification_mailer.rb new file mode 100644 index 000000000000..e193727ebef6 --- /dev/null +++ b/app/mailers/reminders/notification_mailer.rb @@ -0,0 +1,82 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Reminders::NotificationMailer < ApplicationMailer + include MailNotificationHelper + include Redmine::I18n + + helper :mail_notification + helper_method :reminder_summary_text, + :reminder_timestamp_text, + :reminder_note_text, + :work_package_subject_text_wrapper, + :text_email_wrapper + + def reminder_notification(notification) + @notification = notification + @user = notification.recipient + @work_package = notification.resource + @reminder = notification.reminder + + open_project_headers User: notification.recipient.name + message_id "reminder", notification.recipient + + send_localized_mail(notification.recipient) do + "#{Setting.app_title} - #{email_subject_suffix}" + end + end + + private + + def email_subject_suffix + note = @reminder.note.presence || @work_package.subject + I18n.t(:"mail.reminder_notifications.subject", note:) + end + + def reminder_summary_text + I18n.t(:"mail.reminder_notifications.heading") + end + + def reminder_timestamp_text + "#{format_time(@notification.created_at)}." + end + + def reminder_note_text + return if @reminder.note.blank? + + I18n.t(:"mail.reminder_notifications.note", note: @reminder.note) + end + + def work_package_subject_text_wrapper + "=" * ("# #{@work_package.id}#{@work_package.subject}".length + 4) + end + + def text_email_wrapper + "-" * 100 + end +end diff --git a/app/menus/notifications/menu.rb b/app/menus/notifications/menu.rb index cc6961c823ef..a4fd9781af43 100644 --- a/app/menus/notifications/menu.rb +++ b/app/menus/notifications/menu.rb @@ -63,7 +63,7 @@ def inbox_menu end def reason_filters - %w[mentioned assigned responsible watched dateAlert shared].map do |reason| + %w[mentioned assigned responsible watched dateAlert reminder shared].map do |reason| count = unread_by_reason[reason] menu_item(title: I18n.t("notifications.reasons.#{reason}"), icon_key: reason, @@ -128,7 +128,8 @@ def icon_map "responsible" => :"op-person-accountable", "watched" => :eye, "shared" => :"share-android", - "dateAlert" => :"op-calendar-alert" + "dateAlert" => :"op-calendar-alert", + "reminder" => :"op-alarm" } end diff --git a/app/menus/submenu.rb b/app/menus/submenu.rb index 8909c15f32fa..6bd27e89a4a5 100644 --- a/app/menus/submenu.rb +++ b/app/menus/submenu.rb @@ -29,7 +29,7 @@ class Submenu include Rails.application.routes.url_helpers attr_reader :view_type, :project, :params - def initialize(view_type:, project: nil, params: nil) + def initialize(view_type:, params:, project: nil) @view_type = view_type @project = project @params = params @@ -108,12 +108,13 @@ def query_params(id) { query_id: id } end - def menu_item(title:, icon_key: nil, count: nil, show_enterprise_icon: false, query_params: {}) + def menu_item(title:, icon_key: nil, count: nil, show_enterprise_icon: false, + query_params: {}, selected: selected?(query_params)) OpenProject::Menu::MenuItem.new(title:, href: query_path(query_params), icon: icon_map.fetch(icon_key, icon_key), count:, - selected: selected?(query_params), + selected:, favored: favored?(query_params), show_enterprise_icon:) end @@ -144,4 +145,8 @@ def icon_map def query_path(query_params) raise NotImplementedError end + + def url_helpers + @url_helpers ||= OpenProject::StaticRouting::StaticRouter.new.url_helpers + end end diff --git a/app/models/concerns/remindable.rb b/app/models/concerns/remindable.rb new file mode 100644 index 000000000000..d55e65bf912b --- /dev/null +++ b/app/models/concerns/remindable.rb @@ -0,0 +1,35 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Remindable + extend ActiveSupport::Concern + + included do + has_many :reminders, as: :remindable, dependent: :destroy + end +end diff --git a/app/models/custom_field.rb b/app/models/custom_field.rb index 9fc37427bde9..88504e26a014 100644 --- a/app/models/custom_field.rb +++ b/app/models/custom_field.rb @@ -66,7 +66,7 @@ def uniqueness_of_name_with_scope errors.add(:name, :taken) if name.in?(taken_names) end - validates :field_format, inclusion: { in: OpenProject::CustomFieldFormat.available_formats } + validates :field_format, inclusion: { in: -> { OpenProject::CustomFieldFormat.available_formats } } validate :validate_default_value validate :validate_regex @@ -326,12 +326,15 @@ def possible_version_values_options(obj) def possible_user_values_options(obj) mapped_with_deduced_project(obj) do |project| - if project&.persisted? - project.principals - else - Principal - .in_visible_project_or_me(User.current) - end + scope = if project&.persisted? + project.principals + else + Principal + .in_visible_project_or_me(User.current) + end + + scope + .select(User::USER_FORMATS_STRUCTURE[Setting.user_format].map(&:to_s), "id", "type") end end diff --git a/app/models/custom_field/hierarchy/hierarchy_item_adapter.rb b/app/models/custom_field/hierarchy/hierarchy_item_adapter.rb new file mode 100644 index 000000000000..14cd4053bc73 --- /dev/null +++ b/app/models/custom_field/hierarchy/hierarchy_item_adapter.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class CustomField::Hierarchy::HierarchyItemAdapter + delegate :id, :label, :short, :to_s, to: :item + + def initialize(item:) = @item = item + + def name = @item.label + + private + + attr_accessor :item +end diff --git a/app/models/custom_field/hierarchy/item.rb b/app/models/custom_field/hierarchy/item.rb index dcb12cc27597..3490e7009ef3 100644 --- a/app/models/custom_field/hierarchy/item.rb +++ b/app/models/custom_field/hierarchy/item.rb @@ -35,4 +35,10 @@ class CustomField::Hierarchy::Item < ApplicationRecord has_closure_tree order: "sort_order", numeric_order: true, dont_order_roots: true, dependent: :destroy scope :including_children, -> { includes(children: :children) } + + def to_s = short.nil? ? label : "#{label} (#{short})" + + def ancestry_path + self_and_ancestors.filter_map(&:to_s).reverse.join(" / ") + end end diff --git a/app/models/custom_field/order_statements.rb b/app/models/custom_field/order_statements.rb index 86984954feea..945e22fa02f0 100644 --- a/app/models/custom_field/order_statements.rb +++ b/app/models/custom_field/order_statements.rb @@ -31,7 +31,7 @@ module CustomField::OrderStatements # value of the custom field. def order_statement case field_format - when "string", "date", "bool", "link", "int", "float", "list", "user", "version" + when "string", "date", "bool", "link", "int", "float", "list", "user", "version", "hierarchy" "cf_order_#{id}.value" end end @@ -52,6 +52,8 @@ def order_join_statement join_for_order_by_user_sql when "version" join_for_order_by_version_sql + when "hierarchy" + join_for_order_by_hierarchy_sql end end @@ -73,7 +75,7 @@ def group_by_statement # Returns the expression to use in SELECT clause if it differs from one used # to group by def group_by_select_statement - return unless field_format == "list" + return unless field_format == "list" || field_format == "hierarchy" # MIN needed to not add this column to group by, ANY_VALUE can be used when # minimum required PostgreSQL becomes 16 @@ -90,7 +92,7 @@ def group_by_join_statement private - def can_be_used_for_grouping? = field_format.in?(%w[list date bool int float string link]) + def can_be_used_for_grouping? = field_format.in?(%w[list date bool int float string link hierarchy]) # Template for all the join statements. # @@ -167,4 +169,13 @@ def join_for_order_by_version_sql multi_value: ) end + + def join_for_order_by_hierarchy_sql + join_for_order_sql( + value: multi_value? ? "ARRAY_AGG(item.position_cache ORDER BY item.position_cache)" : "item.position_cache", + add_select: "#{multi_value? ? "ARRAY_TO_STRING(ARRAY_AGG(cv.value ORDER BY item.position_cache), '.')" : 'cv.value'} ids", + join: "INNER JOIN #{CustomField::Hierarchy::Item.quoted_table_name} item ON item.id = cv.value::bigint", + multi_value: + ) + end end diff --git a/app/models/notification.rb b/app/models/notification.rb index 65dff4c23ab5..e21493cc10db 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -40,17 +40,20 @@ class Notification < ApplicationRecord responsible: 9, date_alert_start_date: 10, date_alert_due_date: 11, - shared: 12 + shared: 12, + reminder: 13 }.freeze - enum reason: REASONS, - _prefix: true + enum :reason, REASONS, prefix: true belongs_to :recipient, class_name: "User" belongs_to :actor, class_name: "User" belongs_to :journal belongs_to :resource, polymorphic: true + has_one :reminder_notification, dependent: :destroy + has_one :reminder, through: :reminder_notification + include Scopes::Scoped scopes :unsent_reminders_before, :mail_reminder_unsent, diff --git a/app/models/permitted_params.rb b/app/models/permitted_params.rb index ba22823578ba..d145da84d561 100644 --- a/app/models/permitted_params.rb +++ b/app/models/permitted_params.rb @@ -290,6 +290,12 @@ def project whitelist.merge(custom_field_values(:project)) end + def project_life_cycles + params.require(:project).permit( + available_life_cycle_steps_attributes: %i[id date date_range] + ) + end + def project_custom_field_project_mapping params.require(:project_custom_field_project_mapping) .permit(*self.class.permitted_attributes[:project_custom_field_project_mapping]) diff --git a/app/models/project.rb b/app/models/project.rb index 89191c1eba8b..5bdb1f1f83f5 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -87,6 +87,14 @@ class Project < ApplicationRecord has_many :project_storages, dependent: :destroy, class_name: "Storages::ProjectStorage" has_many :storages, through: :project_storages has_many :life_cycle_steps, class_name: "Project::LifeCycleStep", dependent: :destroy + has_many :available_life_cycle_steps, + -> { visible.eager_load(:definition).order(position: :asc) }, + class_name: "Project::LifeCycleStep", + inverse_of: :project, + dependent: :destroy + + accepts_nested_attributes_for :available_life_cycle_steps + validates_associated :available_life_cycle_steps, on: :saving_life_cycle_steps store_attribute :settings, :deactivate_work_package_attachments, :boolean diff --git a/app/models/project/gate.rb b/app/models/project/gate.rb index 46ee6fffdb74..8c49a5360c5c 100644 --- a/app/models/project/gate.rb +++ b/app/models/project/gate.rb @@ -31,7 +31,6 @@ class Project::Gate < Project::LifeCycleStep # This ensures the type cannot be changed after initialising the class. validates :type, inclusion: { in: %w[Project::Gate], message: :must_be_a_gate } - validates :date, presence: true validate :end_date_not_allowed def end_date_not_allowed @@ -39,4 +38,8 @@ def end_date_not_allowed errors.add(:base, :end_date_not_allowed) end end + + def not_set? + date.blank? + end end diff --git a/app/models/project/gate_definition.rb b/app/models/project/gate_definition.rb index a51561075728..a0cfef69352e 100644 --- a/app/models/project/gate_definition.rb +++ b/app/models/project/gate_definition.rb @@ -32,4 +32,8 @@ class Project::GateDefinition < Project::LifeCycleStepDefinition foreign_key: :definition_id, inverse_of: :definition, dependent: :destroy + + def step_class + Project::Gate + end end diff --git a/app/models/project/life_cycle_step.rb b/app/models/project/life_cycle_step.rb index dfa4b11efda9..e703c0b26b70 100644 --- a/app/models/project/life_cycle_step.rb +++ b/app/models/project/life_cycle_step.rb @@ -27,23 +27,36 @@ #++ class Project::LifeCycleStep < ApplicationRecord - belongs_to :project, optional: false + belongs_to :project, optional: false, inverse_of: :available_life_cycle_steps belongs_to :definition, optional: false, class_name: "Project::LifeCycleStepDefinition" has_many :work_packages, inverse_of: :project_life_cycle_step, dependent: :nullify + delegate :name, :position, to: :definition + attr_readonly :definition_id, :type validates :type, inclusion: { in: %w[Project::Stage Project::Gate], message: :must_be_a_stage_or_gate } + validate :validate_type_and_class_name_are_identical + + scope :active, -> { where(active: true) } + + class << self + def visible(user = User.current) + allowed_projects = Project.allowed_to(user, :view_project_stages_and_gates) + active.where(project: allowed_projects) + end + end - def initialize(*args) - if instance_of? Project::LifeCycleStep - # Do not allow directly instantiating this class - raise NotImplementedError, "Cannot instantiate the base Project::LifeCycleStep class directly. " \ - "Use Project::Stage or Project::Gate instead." + def validate_type_and_class_name_are_identical + if type != self.class.name + errors.add(:type, :type_and_class_name_mismatch) end + end - super + def column_name + # The id of the associated definition is relevant for displaying the correct column headers + "lcsd_#{definition_id}" end end diff --git a/app/models/project/life_cycle_step_definition.rb b/app/models/project/life_cycle_step_definition.rb index d511cc284cc4..f80cc6116931 100644 --- a/app/models/project/life_cycle_step_definition.rb +++ b/app/models/project/life_cycle_step_definition.rb @@ -35,20 +35,27 @@ class Project::LifeCycleStepDefinition < ApplicationRecord has_many :projects, through: :life_cycle_steps belongs_to :color, optional: false - validates :name, presence: true + validates :name, presence: true, uniqueness: true validates :type, inclusion: { in: %w[Project::StageDefinition Project::GateDefinition], message: :must_be_a_stage_or_gate } + validate :validate_type_and_class_name_are_identical attr_readonly :type acts_as_list - def initialize(*args) - if instance_of? Project::LifeCycleStepDefinition - # Do not allow directly instantiating this class - raise NotImplementedError, "Cannot instantiate the base Project::LifeCycleStepDefinition class directly. " \ - "Use Project::StageDefinition or Project::GateDefinition instead." - end + def step_class + raise NotImplementedError + end + + def column_name + "lcsd_#{id}" + end + + private - super + def validate_type_and_class_name_are_identical + if type != self.class.name + errors.add(:type, :type_and_class_name_mismatch) + end end end diff --git a/app/models/project/stage.rb b/app/models/project/stage.rb index b49ca7787b71..2ee54f26cd3e 100644 --- a/app/models/project/stage.rb +++ b/app/models/project/stage.rb @@ -29,5 +29,36 @@ class Project::Stage < Project::LifeCycleStep # This ensures the type cannot be changed after initialising the class. validates :type, inclusion: { in: %w[Project::Stage], message: :must_be_a_stage } - validates :start_date, :end_date, presence: true + validate :validate_date_range + + def working_days_count + return nil if not_set? + + Day.working.from_range(from: start_date, to: end_date).count + end + + def date_range=(param) + self.start_date, self.end_date = param.split(" - ") + self.end_date ||= start_date # Allow single dates as range + end + + def not_set? + start_date.blank? || end_date.blank? + end + + def range_set? + !not_set? + end + + def range_incomplete? + start_date.blank? ^ end_date.blank? + end + + def validate_date_range + if range_incomplete? + errors.add(:date_range, :incomplete) + elsif range_set? && (start_date > end_date) + errors.add(:date_range, :start_date_must_be_before_end_date) + end + end end diff --git a/app/models/project/stage_definition.rb b/app/models/project/stage_definition.rb index 91ae98584def..f373d13cd646 100644 --- a/app/models/project/stage_definition.rb +++ b/app/models/project/stage_definition.rb @@ -32,4 +32,8 @@ class Project::StageDefinition < Project::LifeCycleStepDefinition foreign_key: :definition_id, inverse_of: :definition, dependent: :destroy + + def step_class + Project::Stage + end end diff --git a/app/models/queries/filters/shared/custom_fields/hierarchy.rb b/app/models/queries/filters/shared/custom_fields/hierarchy.rb index 81538bc4bdb4..c29e545b9bed 100644 --- a/app/models/queries/filters/shared/custom_fields/hierarchy.rb +++ b/app/models/queries/filters/shared/custom_fields/hierarchy.rb @@ -40,24 +40,9 @@ def ar_object_filter? def value_objects CustomField::Hierarchy::Item .where(id: @values) - .map { |item| HierarchyItemFilterAdapter.new(item:) } + .map { |item| CustomField::Hierarchy::HierarchyItemAdapter.new(item:) } end end - - class HierarchyItemFilterAdapter - attr_reader :name - - delegate :id, to: :item - - def initialize(item:) - @item = item - @name = item.label - end - - private - - attr_accessor :item - end end end end diff --git a/app/models/queries/filters/strategies/integer_list_optional.rb b/app/models/queries/filters/strategies/integer_list_optional.rb index df55d434c6c3..f7a0ff61c3d1 100644 --- a/app/models/queries/filters/strategies/integer_list_optional.rb +++ b/app/models/queries/filters/strategies/integer_list_optional.rb @@ -33,6 +33,7 @@ class IntegerListOptional < ::Queries::Filters::Strategies::Integer def operator_map super_value = super.dup super_value["="] = ::Queries::Operators::EqualsOr + super_value["*"] = ::Queries::Operators::All super_value end diff --git a/app/models/queries/projects.rb b/app/models/queries/projects.rb index bb25098c5322..64b721b7596a 100644 --- a/app/models/queries/projects.rb +++ b/app/models/queries/projects.rb @@ -62,6 +62,7 @@ module Queries::Projects select Selects::CustomField select Selects::Default select Selects::LatestActivityAt + select Selects::LifeCycleStep select Selects::RequiredDiskSpace select Selects::Status select Selects::Favored diff --git a/app/models/queries/projects/selects/life_cycle_step.rb b/app/models/queries/projects/selects/life_cycle_step.rb new file mode 100644 index 000000000000..16b14d901aa9 --- /dev/null +++ b/app/models/queries/projects/selects/life_cycle_step.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +class Queries::Projects::Selects::LifeCycleStep < Queries::Selects::Base + KEY = /\Alcsd_(\d+)\z/ + + def self.key + KEY + end + + def self.all_available + return [] unless available? + + Project::LifeCycleStepDefinition + .pluck(:id) + .map { |id| new(:"lcsd_#{id}") } + end + + def caption + life_cycle_step_definition.name + end + + def life_cycle_step_definition + return @life_cycle_step_definition if defined?(@life_cycle_step_definition) + + @life_cycle_step_definition = Project::LifeCycleStepDefinition + .find_by(id: attribute[KEY, 1]) + end + + def self.available? + OpenProject::FeatureDecisions.stages_and_gates_active? && + User.current.allowed_in_any_project?(:view_project_stages_and_gates) + end + + def available? + life_cycle_step_definition.present? + end + + def visual_icon + # Show the proper icon for the definition with the correct color. + icon = case life_cycle_step_definition + when Project::StageDefinition + :"git-commit" + when Project::GateDefinition + :diamond + else + raise "Unknown life cycle definition for: #{life_cycle_step_definition}" + end + + classes = helpers.hl_inline_class("life_cycle_step_definition", life_cycle_step_definition) + + { icon:, classes: } + end + + def action_menu_classes + "leading-visual-icon-header" + end + + private + + def helpers + @helpers ||= Object.new.extend(ColorsHelper) + end +end diff --git a/app/models/query/results/group_by.rb b/app/models/query/results/group_by.rb index c3097fdb03e7..6ee9d9ef2805 100644 --- a/app/models/query/results/group_by.rb +++ b/app/models/query/results/group_by.rb @@ -89,21 +89,55 @@ def transform_custom_field_keys(groups) if custom_field.list? transform_list_custom_field_keys(custom_field, groups) + elsif custom_field.field_format_hierarchy? + transform_hierarchy_custom_field_keys(custom_field, groups) else transform_single_custom_field_keys(custom_field, groups) end end + def transform_hierarchy_custom_field_keys(custom_field, groups) + items = hierarchy_items_for_keys(custom_field, groups) + + groups.transform_keys do |key| + if custom_field.multi_value? + Array(key&.split(".")).map { |subkey| items[subkey].first } + else + items[key] || [] + end + end + end + + # rubocop:disable Metrics/AbcSize + def hierarchy_items_for_keys(custom_field, groups) + keys = groups.keys.map { |k| k ? k.split(".") : [] }.flatten.uniq + + CustomFields::Hierarchy::HierarchicalItemService + .new + .get_descendants(item: custom_field.hierarchy_root, include_self: false) + .fmap do |list| + list.filter_map { |item| CustomField::Hierarchy::HierarchyItemAdapter.new(item:) if keys.include?(item.id.to_s) } + .group_by { |item| item.id.to_s } + end + .either( + ->(list) { list }, + ->(error) do + msg = "#{I18n.t('api_v3.errors.code_500')} #{error}" + raise ::API::Errors::InternalError.new(msg) + end + ) + end + + # rubocop:enable Metrics/AbcSize + def transform_list_custom_field_keys(custom_field, groups) options = custom_options_for_keys(custom_field, groups) groups.transform_keys do |key| if custom_field.multi_value? - (key ? key.split(".") : []).map do |subkey| - options[subkey].first - end + Array(key&.split(".")).map.map { |subkey| options[subkey].first } else - options[key] ? options[key].first : nil + options[key]&.first end end end @@ -111,7 +145,7 @@ def transform_list_custom_field_keys(custom_field, groups) def custom_options_for_keys(custom_field, groups) keys = groups.keys.map { |k| k ? k.split(".") : [] } # Because of multi select cfs we might end up having overlapping groups - # (e.g group "1" and group "1.3" and group "3" which represent concatenated ids). + # (e.g. group "1" and group "1.3" and group "3" which represent concatenated ids). # This can result in us having ids in the keys array multiple times (e.g. ["1", "1", "3", "3"]). # If we were to use the keys array with duplicates to find the actual custom options, # AR would throw an error as the number of records returned does not match the number diff --git a/app/models/reminder.rb b/app/models/reminder.rb new file mode 100644 index 000000000000..9b6213dd2a1a --- /dev/null +++ b/app/models/reminder.rb @@ -0,0 +1,63 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Reminder < ApplicationRecord + belongs_to :remindable, polymorphic: true + belongs_to :creator, class_name: "User" + + has_many :reminder_notifications, dependent: :destroy + has_many :notifications, through: :reminder_notifications + + # Currently, reminders are personal, meaning + # they are only visible to the user who created them. + def self.visible(user) + where(creator: user) + end + + def self.upcoming_and_visible_to(user) + visible(user) + .where(completed_at: nil) + .where.missing(:reminder_notifications) + end + + def unread_notifications? + unread_notifications.exists? + end + + def unread_notifications + notifications.where(read_ian: [false, nil]) + end + + def completed? + completed_at.present? + end + + def scheduled? + job_id.present? && !completed? + end +end diff --git a/app/models/reminder_notification.rb b/app/models/reminder_notification.rb new file mode 100644 index 000000000000..c005f39a6ee1 --- /dev/null +++ b/app/models/reminder_notification.rb @@ -0,0 +1,32 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class ReminderNotification < ApplicationRecord + belongs_to :reminder + belongs_to :notification, dependent: :destroy +end diff --git a/app/models/setting/aliases.rb b/app/models/setting/aliases.rb index 98aa5bbbf4fd..154aabab71db 100644 --- a/app/models/setting/aliases.rb +++ b/app/models/setting/aliases.rb @@ -51,5 +51,13 @@ def host_without_protocol def optional_port_from_host_name Setting.host_name&.split(":")&.[](1) end + + ## + # Get the names of working days + # @return [Array] the names of the working days + def working_day_names + weekdays = %i[monday tuesday wednesday thursday friday saturday sunday] + Setting.working_days.map { |day| weekdays[day - 1] } + end end end diff --git a/app/models/sharing_strategies/base_strategy.rb b/app/models/sharing_strategies/base_strategy.rb index bff01d94d896..25c00ff2e859 100644 --- a/app/models/sharing_strategies/base_strategy.rb +++ b/app/models/sharing_strategies/base_strategy.rb @@ -50,6 +50,10 @@ def manageable? raise NotImplementedError, "Override in a subclass and return true if the current user can manage sharing" end + def allow_hover_cards? + raise NotImplementedError, "Override in a subclass and return true if hover cards should appear hovering users" + end + def create_contract_class raise NotImplementedError, "Override in a subclass and return the contract class for creating a share" end diff --git a/app/models/sharing_strategies/project_query_strategy.rb b/app/models/sharing_strategies/project_query_strategy.rb index eb05b1970d78..7a898438bb62 100644 --- a/app/models/sharing_strategies/project_query_strategy.rb +++ b/app/models/sharing_strategies/project_query_strategy.rb @@ -54,6 +54,10 @@ def viewable? @entity.visible? end + def allow_hover_cards? + true + end + def create_contract_class Shares::ProjectQueries::CreateContract end diff --git a/app/models/sharing_strategies/work_package_strategy.rb b/app/models/sharing_strategies/work_package_strategy.rb index 556767dc08aa..018c6b0f5c71 100644 --- a/app/models/sharing_strategies/work_package_strategy.rb +++ b/app/models/sharing_strategies/work_package_strategy.rb @@ -53,6 +53,13 @@ def viewable? user.allowed_in_project?(:view_shared_work_packages, @entity.project) end + # Since the work package share dialog is embedded into an angular page, hover cards would compete for the + # portal outlet when rendering, causing bugs. Until the work package share dialog is refactored to be an + # async-dialog, we must disable hover cards for it. + def allow_hover_cards? + false + end + def share_description(share) # rubocop:disable Metrics/PerceivedComplexity,Metrics/AbcSize scope = %i[sharing user_details] diff --git a/app/models/type.rb b/app/models/type.rb index 2c7deb928070..0117f8aaa6b8 100644 --- a/app/models/type.rb +++ b/app/models/type.rb @@ -34,6 +34,8 @@ class Type < ApplicationRecord include ::Scopes::Scoped + attribute :patterns, Types::PatternCollectionType.new + before_destroy :check_integrity has_many :work_packages @@ -50,16 +52,11 @@ def copy_from_type(source_type) join_table: "#{table_name_prefix}custom_fields_types#{table_name_suffix}", association_foreign_key: "custom_field_id" - belongs_to :color, - optional: true, - class_name: "Color" + belongs_to :color, optional: true, class_name: "Color" acts_as_list - validates :name, - presence: true, - uniqueness: { case_sensitive: false }, - length: { maximum: 255 } + validates :name, presence: true, uniqueness: { case_sensitive: false }, length: { maximum: 255 } validates :is_default, :is_milestone, inclusion: { in: [true, false] } @@ -67,10 +64,7 @@ def copy_from_type(source_type) default_scope { order("position ASC") } - scope :without_standard, -> { - where(is_standard: false) - .order(:position) - } + scope :without_standard, -> { where(is_standard: false).order(:position) } def to_s; name end diff --git a/app/models/types/pattern.rb b/app/models/types/pattern.rb new file mode 100644 index 000000000000..62dcbd2b2c18 --- /dev/null +++ b/app/models/types/pattern.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Types + Pattern = Data.define(:blueprint, :enabled) do + def call(object) + # calculate string using object + blueprint.to_s + object.to_s + end + + def to_h + super.stringify_keys + end + end +end diff --git a/app/models/types/pattern_collection.rb b/app/models/types/pattern_collection.rb new file mode 100644 index 000000000000..52a6dda2e459 --- /dev/null +++ b/app/models/types/pattern_collection.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Types + PatternCollection = Data.define(:patterns) do + private_class_method :new + + def self.build(patterns:, contract: PatternCollectionContract.new) + contract.call(patterns).to_monad.fmap { |success| new(success.to_h) } + end + + def initialize(patterns:) + transformed = patterns.transform_values { Pattern.new(**_1) }.freeze + + super(patterns: transformed) + end + + def [](value) + patterns.fetch(value) + end + + def to_h + patterns.stringify_keys.transform_values(&:to_h) + end + end +end diff --git a/app/models/types/pattern_collection_contract.rb b/app/models/types/pattern_collection_contract.rb new file mode 100644 index 000000000000..2a8e7c16a1e1 --- /dev/null +++ b/app/models/types/pattern_collection_contract.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Types + class PatternCollectionContract < Dry::Validation::Contract + params do + required(:subject).hash do + required(:blueprint).filled(:string) + required(:enabled).filled(:bool) + end + end + end +end diff --git a/app/models/types/pattern_collection_type.rb b/app/models/types/pattern_collection_type.rb new file mode 100644 index 000000000000..9758ee50c339 --- /dev/null +++ b/app/models/types/pattern_collection_type.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Types + class PatternCollectionType < ActiveModel::Type::Value + def assert_valid_value(value) + cast(value) + end + + def cast(value) + PatternCollection.build(patterns: value).value_or { nil } + end + + def serialize(pattern) + return super if pattern.nil? + + YAML.dump(pattern.to_h) + end + + def deserialize(value) + return if value.blank? + + data = YAML.safe_load(value) + cast(data) + end + end +end diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index 041f930fe201..0e90fe646d18 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -140,7 +140,7 @@ def workdays end def immediate_reminders - super.presence || { mentioned: true }.with_indifferent_access + super.presence || { mentioned: true, personal_reminder: true }.with_indifferent_access end def pause_reminders diff --git a/app/models/work_package.rb b/app/models/work_package.rb index a1ec03892668..a16611e48b03 100644 --- a/app/models/work_package.rb +++ b/app/models/work_package.rb @@ -41,6 +41,7 @@ class WorkPackage < ApplicationRecord include WorkPackages::Relations include ::Scopes::Scoped include HasMembers + include Remindable include OpenProject::Journal::AttachmentHelper diff --git a/app/models/work_package/pdf_export/common/common.rb b/app/models/work_package/pdf_export/common/common.rb index c91d44836ab5..63c055a40044 100644 --- a/app/models/work_package/pdf_export/common/common.rb +++ b/app/models/work_package/pdf_export/common/common.rb @@ -35,7 +35,7 @@ module WorkPackage::PDFExport::Common::Common private - def get_pdf(_language) + def get_pdf ::WorkPackage::PDFExport::Common::View.new(current_language) end @@ -164,6 +164,13 @@ def draw_text_multiline_part(line, text_style, x_position, y_position) measure_text_height(line, text_style) end + def ellipsis_if_longer(text, available_width, text_style) + title_text_width = measure_text_width(text, text_style) + return text if title_text_width < available_width + + truncate_ellipsis(text, available_width, text_style) + end + def truncate_ellipsis(text, available_width, text_style) line = text.dup while line.present? && (measure_text_width("#{line}...", text_style) > available_width) @@ -285,4 +292,12 @@ def footer_date def current_page_nr pdf.page_number + @page_count - (with_cover? ? 1 : 0) end + + def write_horizontal_line(y_position, height, color) + draw_horizontal_line( + y_position, + pdf.bounds.left, pdf.bounds.right, + height, color + ) + end end diff --git a/app/models/work_package/pdf_export/document_generator.rb b/app/models/work_package/pdf_export/document_generator.rb index 37b69c6b4ea0..26a3a46ceb88 100644 --- a/app/models/work_package/pdf_export/document_generator.rb +++ b/app/models/work_package/pdf_export/document_generator.rb @@ -52,7 +52,7 @@ def initialize(work_package, _options = {}) end def setup_page! - self.pdf = get_pdf(current_language) + self.pdf = get_pdf end def export! diff --git a/app/models/work_package/pdf_export/export/cover.rb b/app/models/work_package/pdf_export/export/cover.rb index e50d0c101c30..a91eed6ead2f 100644 --- a/app/models/work_package/pdf_export/export/cover.rb +++ b/app/models/work_package/pdf_export/export/cover.rb @@ -40,17 +40,34 @@ def write_cover_page! def write_cover_hero max_width = pdf.bounds.width - styles.cover_hero_padding[:right_padding] float_top = write_background_image - float_top -= write_hero_title(float_top, max_width) if project + float_top -= write_hero_title(float_top, max_width) float_top -= write_hero_heading(float_top, max_width) - write_hero_subheading(float_top, max_width) unless User.current.nil? + float_top -= write_hero_dates(float_top, max_width) + write_hero_subheading(float_top, max_width) end def available_title_height(current_y) current_y - - styles.cover_hero_title_max_height - - styles.cover_hero_title_spacing - - styles.cover_hero_heading_spacing - - styles.cover_hero_subheading_max_height + cover_hero_title_max_height - + cover_hero_heading_max_height - + cover_hero_dates_max_height - + cover_hero_subheading_max_height + end + + def cover_hero_title_max_height + cover_page_title&.then { styles.cover_hero_title_max_height + styles.cover_hero_title_spacing } || 0 + end + + def cover_hero_heading_max_height + cover_page_heading&.then { styles.cover_hero_heading_spacing } || 0 + end + + def cover_hero_dates_max_height + cover_page_dates&.then { styles.cover_hero_dates_max_height } || 0 + end + + def cover_hero_subheading_max_height + cover_page_title&.then { styles.cover_hero_title_max_height + styles.cover_hero_title_spacing } || 0 end def write_cover_hr @@ -81,27 +98,44 @@ def validate_cover_text_color end def write_hero_title(top, width) + return 0 if cover_page_title.blank? + write_hero_text( top:, width:, - text: project.name, + text: cover_page_title, text_style: styles.cover_hero_title, height: styles.cover_hero_title_max_height ) + styles.cover_hero_title_spacing end def write_hero_heading(top, width) + return 0 if cover_page_heading.blank? + write_hero_text( top:, width:, - text: heading, + text: cover_page_heading, text_style: styles.cover_hero_heading, height: available_title_height(top) ) + styles.cover_hero_heading_spacing end + def write_hero_dates(top, width) + return 0 if cover_page_dates.blank? + + write_hero_text( + top:, width:, + text: cover_page_dates, + text_style: styles.cover_hero_dates, + height: styles.cover_hero_dates_max_height + ) + styles.cover_hero_dates_spacing + end + def write_hero_subheading(top, width) + return 0 if cover_page_subheading.blank? + write_hero_text( top:, width:, - text: User.current.name, + text: cover_page_subheading, text_style: styles.cover_hero_subheading, height: styles.cover_hero_subheading_max_height ) diff --git a/app/models/work_package/pdf_export/export/schema.json b/app/models/work_package/pdf_export/export/schema.json index 50c64700b44a..316f60469667 100644 --- a/app/models/work_package/pdf_export/export/schema.json +++ b/app/models/work_package/pdf_export/export/schema.json @@ -193,6 +193,42 @@ } ] }, + "dates" : { + "title" : "The dates block in the hero", + "type" : "object", + "x-example" : { + "heading" : { + "spacing" : 10, + "max_height" : 20, + "size" : 32, + "color" : "414d5f", + "styles" : [ + "bold" + ] + } + }, + "properties" : { + "max_height" : { + "title" : "Maximum height of the block", + "examples" : [ + 30 + ], + "$ref" : "#/$defs/measurement" + }, + "spacing" : { + "title" : "Minimum spacing between dates and subheading", + "examples" : [ + 10 + ], + "$ref" : "#/$defs/measurement" + } + }, + "allOf" : [ + { + "$ref" : "#/$defs/font" + } + ] + }, "subheading" : { "title" : "The last block in the hero", "type" : "object", diff --git a/app/models/work_package/pdf_export/export/standard.yml b/app/models/work_package/pdf_export/export/standard.yml index 915d6dd37c2e..ecfc8a0a904b 100644 --- a/app/models/work_package/pdf_export/export/standard.yml +++ b/app/models/work_package/pdf_export/export/standard.yml @@ -275,9 +275,14 @@ cover: styles: - bold size: 16 + dates: + spacing: 4 + max_height: 16 + color: '414d5f' + size: 10 + styles: + - bold subheading: max_height: 30 color: '414d5f' - styles: - - italic size: 10 diff --git a/app/models/work_package/pdf_export/export/style.rb b/app/models/work_package/pdf_export/export/style.rb index 4a1a152adf1f..8e4197138903 100644 --- a/app/models/work_package/pdf_export/export/style.rb +++ b/app/models/work_package/pdf_export/export/style.rb @@ -253,6 +253,18 @@ def cover_hero_heading_spacing resolve_pt(@styles.dig(:cover, :hero, :heading, :spacing), 0) end + def cover_hero_dates + resolve_font(@styles.dig(:cover, :hero, :dates)) + end + + def cover_hero_dates_spacing + resolve_pt(@styles.dig(:cover, :hero, :dates, :spacing), 0) + end + + def cover_hero_dates_max_height + resolve_pt(@styles.dig(:cover, :hero, :dates, :max_height), 0) + end + def cover_hero_subheading resolve_font(@styles.dig(:cover, :hero, :subheading)) end diff --git a/app/models/work_package/pdf_export/work_package_list_to_pdf.rb b/app/models/work_package/pdf_export/work_package_list_to_pdf.rb index 9a1ca49c949e..b62855f71c5b 100644 --- a/app/models/work_package/pdf_export/work_package_list_to_pdf.rb +++ b/app/models/work_package/pdf_export/work_package_list_to_pdf.rb @@ -86,7 +86,7 @@ def export! private def setup_page! - self.pdf = get_pdf(current_language) + self.pdf = get_pdf configure_page_size!(wants_report? ? :portrait : :landscape) end @@ -111,6 +111,22 @@ def with_cover? wants_report? end + def cover_page_title + project&.name + end + + def cover_page_heading + heading + end + + def cover_page_subheading + User.current&.name + end + + def cover_page_dates + nil + end + def render_work_packages_pdfs(work_packages, filename) write_cover_page! if with_cover? if wants_gantt? diff --git a/app/models/work_package/pdf_export/work_package_to_pdf.rb b/app/models/work_package/pdf_export/work_package_to_pdf.rb index 5dcfa09dd8c8..2006616c0fc4 100644 --- a/app/models/work_package/pdf_export/work_package_to_pdf.rb +++ b/app/models/work_package/pdf_export/work_package_to_pdf.rb @@ -63,7 +63,7 @@ def export! end def setup_page! - self.pdf = get_pdf(current_language) + self.pdf = get_pdf @page_count = 0 configure_page_size!(:portrait) end diff --git a/app/services/concerns/reminders/service_helpers.rb b/app/services/concerns/reminders/service_helpers.rb new file mode 100644 index 000000000000..b6e27e2d446a --- /dev/null +++ b/app/services/concerns/reminders/service_helpers.rb @@ -0,0 +1,57 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Reminders + module ServiceHelpers + extend ActiveSupport::Concern + + def reschedule_reminder(reminder) + destroy_scheduled_reminder_job(reminder) + mark_unread_notifications_as_read_for(reminder) + schedule_new_reminder_job(reminder) + end + + def schedule_new_reminder_job(reminder) + job = Reminders::ScheduleReminderJob.schedule(reminder) + reminder.update_columns(job_id: job.job_id) + end + + def destroy_scheduled_reminder_job(reminder) + return unless reminder.scheduled? + return unless job = GoodJob::Job.find_by(id: reminder.job_id) + + job.destroy unless job.finished? + end + + def mark_unread_notifications_as_read_for(reminder) + return unless reminder.unread_notifications? + + reminder.unread_notifications.update_all(read_ian: true, updated_at: Time.zone.now) + end + end +end diff --git a/app/services/custom_fields/hierarchy/hierarchical_item_service.rb b/app/services/custom_fields/hierarchy/hierarchical_item_service.rb index 8d9421054652..2123063571b4 100644 --- a/app/services/custom_fields/hierarchy/hierarchical_item_service.rb +++ b/app/services/custom_fields/hierarchy/hierarchical_item_service.rb @@ -105,7 +105,10 @@ def get_descendants(item:, include_self: true) # @param new_parent [CustomField::Hierarchy::Item] the new parent of the node # @return [Success(CustomField::Hierarchy::Item)] def move_item(item:, new_parent:) - Success(new_parent.append_child(item)) + updated_item = new_parent.append_child(item) + update_position_cache(new_parent.root) + + Success(updated_item) end # Reorder the item along its siblings. @@ -116,7 +119,6 @@ def reorder_item(item:, new_sort_order:) return Success() if item.siblings.empty? new_sort_order = [0, new_sort_order.to_i].max - return Success() if item.sort_order == new_sort_order update_item_order(item:, new_sort_order:) @@ -129,6 +131,10 @@ def soft_delete_item(item:) raise NotImplementedError end + # Returns a hash of Item => { Item => [Item] } + # @param item [CustomField::Hierarchy::Item] the start node + # @param depth [Integer] limits the max depth of the hash. see {ClosureTree#hash_tree} + # @return [Success({CustomField::Hierarchy::Item => Array, Hash})] def hashed_subtree(item:, depth:) if depth >= 0 Success(item.hash_tree(limit_depth: depth + 1)) @@ -137,6 +143,10 @@ def hashed_subtree(item:, depth:) end end + # Checks if an item is a descendant of another node + # @param item [CustomField::Hierarchy::Item] the item to be tested + # @param parent [CustomField::Hierarchy::Item] the node to be checked against + # @return [Success, Failure] def descendant_of?(item:, parent:) item.descendant_of?(parent) ? Success() : Failure() end @@ -147,6 +157,7 @@ def create_root_item(custom_field) item = CustomField::Hierarchy::Item.create(custom_field: custom_field) return Failure(item.errors) if item.new_record? + update_position_cache(item) Success(item) end @@ -157,7 +168,8 @@ def create_child_item(validation:, sort_order: nil) item = validation[:parent].children.create(**attributes) return Failure(item.errors) if item.new_record? - Success(item) + update_position_cache(item.root) + Success(item.reload) end def update_item_attributes(item:, attributes:) @@ -176,6 +188,37 @@ def update_item_order(item:, new_sort_order:) target_item = item.siblings.last target_item.append_sibling(item) end + + update_position_cache(item.root) + end + + def update_position_cache(root) + sql = <<-SQL.squish + UPDATE hierarchical_items + SET position_cache = subquery.position + FROM ( + SELECT hi.id + , SUM((1 + COALESCE(anc.sort_order, 0)) * + POWER(count_max.total_descendants, count_max.max_gens - depths.generations)) AS position + FROM hierarchical_items hi + INNER JOIN hierarchical_item_hierarchies hih ON hi.id = hih.descendant_id + JOIN hierarchical_item_hierarchies anc_h ON anc_h.descendant_id = hih.descendant_id + JOIN hierarchical_items anc ON anc.id = anc_h.ancestor_id + JOIN hierarchical_item_hierarchies depths ON depths.ancestor_id = #{root.id} AND depths.descendant_id = anc.id + , ( + SELECT COUNT(1) AS total_descendants, MAX(generations) + 1 AS max_gens + FROM hierarchical_items hi + INNER JOIN hierarchical_item_hierarchies hih ON hi.id = hih.ancestor_id + WHERE ancestor_id = #{root.id} + ) count_max + WHERE hih.ancestor_id = #{root.id} + GROUP BY hi.id) as subquery + WHERE hierarchical_items.id = subquery.id; + SQL + + OpenProject::Mutex.with_advisory_lock(CustomField::Hierarchy::Item, "position_update_anc_#{root.id}") do + CustomField::Hierarchy::Item.connection.exec_update(sql) + end end end end diff --git a/app/services/duration_converter.rb b/app/services/duration_converter.rb index e2efe5a771e7..c16c3b58b0bf 100644 --- a/app/services/duration_converter.rb +++ b/app/services/duration_converter.rb @@ -96,7 +96,7 @@ def valid?(duration) false end - def output(duration_in_hours) + def output(duration_in_hours, format: default_format) return duration_in_hours if duration_in_hours.nil? seconds = (duration_in_hours * 3600).to_i @@ -142,7 +142,7 @@ def do_parse(duration_string) **duration_length_options) / 3600.to_f end - def format + def default_format Setting.duration_format == "days_and_hours" ? :days_and_hours : :hours_only end diff --git a/app/services/principals/replace_references_service.rb b/app/services/principals/replace_references_service.rb index 14d46adac922..a3b7af255f81 100644 --- a/app/services/principals/replace_references_service.rb +++ b/app/services/principals/replace_references_service.rb @@ -30,6 +30,25 @@ # No data is to be removed. module Principals class ReplaceReferencesService + class << self + attr_reader :replacements, :foreign_keys + + def add_replacements(attributes_by_class_name) + attributes_by_class_name.each do |class_name, attributes| + Array(attributes).each { |attribute| add_replacement(class_name, attribute) } + end + end + + def add_replacement(class_name, attribute) + @replacements ||= {} + @replacements[class_name] ||= Set.new + @replacements[class_name] << attribute + + @foreign_keys ||= Set.new + @foreign_keys << attribute.to_s + end + end + def call(from:, to:) rewrite_active_models(from, to) rewrite_custom_value(from, to) @@ -42,15 +61,10 @@ def call(from:, to:) private def rewrite_active_models(from, to) - rewrite_author(from, to) - rewrite_creator(from, to) - rewrite_user(from, to) - rewrite_assigned_to(from, to) - rewrite_responsible(from, to) - rewrite_actor(from, to) - rewrite_owner(from, to) - rewrite_logged_by(from, to) - rewrite_presenter(from, to) + self.class.replacements.each do |class_name, attributes| + klass = class_name.constantize + attributes.each { |attribute| rewrite(klass, attribute, from, to) } + end end def rewrite_custom_value(from, to) @@ -62,7 +76,7 @@ def rewrite_custom_value(from, to) def rewrite_default_journals(from, to) journal_classes.each do |klass| - foreign_keys.each do |foreign_key| + self.class.foreign_keys.each do |foreign_key| if klass.column_names.include? foreign_key rewrite(klass, foreign_key, from, to) end @@ -78,87 +92,10 @@ def rewrite_customizable_journals(from, to) .update_all(value: to.id.to_s) end - def rewrite_author(from, to) - [WorkPackage, - Attachment, - WikiPage, - News, - Comment, - Message, - Budget, - MeetingAgenda, - MeetingMinutes, - MeetingAgendaItem].each do |klass| - rewrite(klass, :author_id, from, to) - end - end - - def rewrite_creator(from, to) - [AuthProvider].each do |klass| - rewrite(klass, :creator_id, from, to) - end - end - - def rewrite_user(from, to) - [TimeEntry, - CostEntry, - ::Query, - Changeset, - CostQuery, - MeetingParticipant].each do |klass| - rewrite(klass, :user_id, from, to) - end - end - - def rewrite_actor(from, to) - [::Notification].each do |klass| - rewrite(klass, :actor_id, from, to) - end - end - - def rewrite_owner(from, to) - [::Doorkeeper::Application].each do |klass| - rewrite(klass, :owner_id, from, to) - end - end - - def rewrite_assigned_to(from, to) - [WorkPackage].each do |klass| - rewrite(klass, :assigned_to_id, from, to) - end - end - - def rewrite_responsible(from, to) - [WorkPackage].each do |klass| - rewrite(klass, :responsible_id, from, to) - end - end - - def rewrite_logged_by(from, to) - [ - TimeEntry, - CostEntry - ].each do |klass| - rewrite(klass, :logged_by_id, from, to) - end - end - - def rewrite_presenter(from, to) - [ - MeetingAgendaItem - ].each do |klass| - rewrite(klass, :presenter_id, from, to) - end - end - def journal_classes [Journal] + Journal::BaseJournal.subclasses end - def foreign_keys - %w[author_id creator_id user_id assigned_to_id responsible_id logged_by_id presenter_id] - end - def rewrite(klass, attribute, from, to) klass.where(attribute => from.id).update_all(attribute => to.id) end diff --git a/app/services/project_life_cycle_steps/preview_attributes_service.rb b/app/services/project_life_cycle_steps/preview_attributes_service.rb new file mode 100644 index 000000000000..0070cabfe246 --- /dev/null +++ b/app/services/project_life_cycle_steps/preview_attributes_service.rb @@ -0,0 +1,47 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module ProjectLifeCycleSteps + class PreviewAttributesService < ::BaseServices::SetAttributes + def perform(*) + super.tap do |service_call| + clear_unchanged_fields(service_call) + end + end + + private + + def clear_unchanged_fields(service_call) + service_call + .result + .available_life_cycle_steps + .select(&:not_set?) + .each { _1.errors.clear } + end + end +end diff --git a/app/services/project_life_cycle_steps/set_attributes_service.rb b/app/services/project_life_cycle_steps/set_attributes_service.rb new file mode 100644 index 000000000000..c1603d42603b --- /dev/null +++ b/app/services/project_life_cycle_steps/set_attributes_service.rb @@ -0,0 +1,32 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module ProjectLifeCycleSteps + class SetAttributesService < ::BaseServices::SetAttributes + end +end diff --git a/app/services/project_life_cycle_steps/update_service.rb b/app/services/project_life_cycle_steps/update_service.rb new file mode 100644 index 000000000000..a8a5a33aa923 --- /dev/null +++ b/app/services/project_life_cycle_steps/update_service.rb @@ -0,0 +1,32 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module ProjectLifeCycleSteps + class UpdateService < ::BaseServices::Update + end +end diff --git a/app/services/reminders/create_service.rb b/app/services/reminders/create_service.rb new file mode 100644 index 000000000000..340f980a289f --- /dev/null +++ b/app/services/reminders/create_service.rb @@ -0,0 +1,39 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Reminders + class CreateService < ::BaseServices::Create + include Reminders::ServiceHelpers + + def after_perform(service_call) + schedule_new_reminder_job(service_call.result) + + service_call + end + end +end diff --git a/app/services/reminders/delete_service.rb b/app/services/reminders/delete_service.rb new file mode 100644 index 000000000000..8250ffe12f8f --- /dev/null +++ b/app/services/reminders/delete_service.rb @@ -0,0 +1,39 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Reminders + class DeleteService < ::BaseServices::Delete + include Reminders::ServiceHelpers + + def destroy(reminder) + destroy_scheduled_reminder_job(reminder) + mark_unread_notifications_as_read_for(reminder) + reminder.update(completed_at: Time.zone.now) + end + end +end diff --git a/app/services/reminders/set_attributes_service.rb b/app/services/reminders/set_attributes_service.rb new file mode 100644 index 000000000000..c1cbff56aa64 --- /dev/null +++ b/app/services/reminders/set_attributes_service.rb @@ -0,0 +1,32 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Reminders + class SetAttributesService < ::BaseServices::SetAttributes + end +end diff --git a/app/services/reminders/update_service.rb b/app/services/reminders/update_service.rb new file mode 100644 index 000000000000..9a1bd002e1cd --- /dev/null +++ b/app/services/reminders/update_service.rb @@ -0,0 +1,47 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Reminders + class UpdateService < ::BaseServices::Update + include Reminders::ServiceHelpers + + def after_perform(service_call) + reschedule_reminder(service_call.result) if remind_at_changed? + + service_call + end + + private + + def remind_at_changed? + # For some reason reminder.remind_at_changed? returns false + # so we assume a change if remind_at is present in the params (would have passed contract validation) + params[:remind_at].present? + end + end +end diff --git a/app/services/sessions/clear_old_sessions_service.rb b/app/services/sessions/clear_old_sessions_service.rb new file mode 100644 index 000000000000..95b7a8846c5d --- /dev/null +++ b/app/services/sessions/clear_old_sessions_service.rb @@ -0,0 +1,39 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +module Sessions + class ClearOldSessionsService + class << self + ## + # Drop all sessions for the given user + def call!(days_ago: 30) + # sessions expire after 30 days of inactivity by default + ActiveRecord::SessionStore::Session.where("updated_at < ?", days_ago.days.ago).delete_all + end + end + end +end diff --git a/app/views/account/lost_password.html.erb b/app/views/account/lost_password.html.erb index 5f2859f265c4..1a5fced1d7c6 100644 --- a/app/views/account/lost_password.html.erb +++ b/app/views/account/lost_password.html.erb @@ -27,7 +27,15 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <% html_title t(:label_password_lost) %> -<%= toolbar title: t(:label_password_lost) %> + +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { t(:label_password_lost) } + header.with_breadcrumbs([{ href: home_path, text: organization_name }, + t(:label_password_lost)]) + end +%> +
    <%= styled_form_tag({action: "lost_password"}) do %>
    diff --git a/app/views/account/password_recovery.html.erb b/app/views/account/password_recovery.html.erb index 47b1f76db29f..3e33e162f621 100644 --- a/app/views/account/password_recovery.html.erb +++ b/app/views/account/password_recovery.html.erb @@ -27,7 +27,15 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <% html_title t(:label_password_lost) %> -<%= toolbar title: t(:label_password_lost) %> + +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { t(:label_password_lost) } + header.with_breadcrumbs([{ href: home_path, text: organization_name }, + t(:label_password_lost)]) + end +%> + <%= error_messages_for 'user' %> <%= styled_form_tag({token: @token.value}, autocomplete: 'off') do %>
    diff --git a/app/views/admin/settings/project_custom_fields/edit.html.erb b/app/views/admin/settings/project_custom_fields/edit.html.erb index 9651e2fb7db1..4b28742f4731 100644 --- a/app/views/admin/settings/project_custom_fields/edit.html.erb +++ b/app/views/admin/settings/project_custom_fields/edit.html.erb @@ -28,7 +28,6 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <% html_title t(:label_administration), t("settings.project_attributes.heading"), @custom_field.name %> -<% local_assigns[:additional_breadcrumb] = @custom_field.name %> <%= render(Settings::ProjectCustomFields::EditFormHeaderComponent.new( diff --git a/app/views/admin/settings/project_custom_fields/new.html.erb b/app/views/admin/settings/project_custom_fields/new.html.erb index f95642bc2048..0384cb23b787 100644 --- a/app/views/admin/settings/project_custom_fields/new.html.erb +++ b/app/views/admin/settings/project_custom_fields/new.html.erb @@ -28,7 +28,6 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <% html_title t(:label_administration), t("settings.project_attributes.heading"), t('settings.project_attributes.new.heading') %> -<% local_assigns[:additional_breadcrumb] = t('settings.project_attributes.new.heading') %> <%= render(Settings::ProjectCustomFields::NewFormHeaderComponent.new) %> diff --git a/app/views/categories/destroy.html.erb b/app/views/categories/destroy.html.erb index ea75334d6a64..430fd0185378 100644 --- a/app/views/categories/destroy.html.erb +++ b/app/views/categories/destroy.html.erb @@ -26,7 +26,16 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. ++#%> -<%= toolbar title: "#{Category.model_name.human} #{@category.name}" %> + +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { @category.name } + header.with_breadcrumbs([{ href: project_overview_path(@project.id), text: @project.name }, + { href: project_settings_general_path(@project.id), text: I18n.t("label_project_settings") }, + { href: project_settings_categories_path(@project.id), text: t(:label_work_package_category_plural) }, + @category.name]) + end %> + <%= form_tag({}, {method: :delete, class: 'form'}) do %>

    <%= t(:text_work_package_category_destroy_question, count: @issue_count) %>

    diff --git a/app/views/categories/edit.html.erb b/app/views/categories/edit.html.erb index cd0a4711ef27..ea7ef00d6d33 100644 --- a/app/views/categories/edit.html.erb +++ b/app/views/categories/edit.html.erb @@ -26,7 +26,17 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. ++#%> -<%= toolbar title: Category.model_name.human %> + +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { @category.name } + header.with_breadcrumbs([{ href: project_overview_path(@project.id), text: @project.name }, + { href: project_settings_general_path(@project.id), text: I18n.t("label_project_settings") }, + { href: project_settings_categories_path(@project.id), text: t(:label_work_package_category_plural) }, + @category.name]) + end +%> + <%= labelled_tabular_form_for @category, as: :category do |f| %> <%= render partial: 'categories/form', locals: { f: f } %> <%= f.button t(:button_save), class: 'button -primary -with-icon icon-checkmark' %> diff --git a/app/views/categories/new.html.erb b/app/views/categories/new.html.erb index f93042e60ecc..bcb04b11371c 100644 --- a/app/views/categories/new.html.erb +++ b/app/views/categories/new.html.erb @@ -26,7 +26,17 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. ++#%> -<%= toolbar title: t(:label_work_package_category_new) %> + +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { t(:label_work_package_category_new) } + header.with_breadcrumbs([{ href: project_overview_path(@project.id), text: @project.name }, + { href: project_settings_general_path(@project.id), text: I18n.t("label_project_settings") }, + { href: project_settings_categories_path(@project.id), text: t(:label_work_package_category_plural) }, + t(:label_work_package_category_new) + ]) + end %> + <%= labelled_tabular_form_for [@project, @category], as: :category do |f| %> <%= render partial: 'categories/form', locals: { f: f } %> <%= f.button t(:button_create), class: 'button -primary -with-icon icon-checkmark' %> diff --git a/app/views/colors/confirm_destroy.html.erb b/app/views/colors/confirm_destroy.html.erb index 878cb53b1785..a8bd724e9195 100644 --- a/app/views/colors/confirm_destroy.html.erb +++ b/app/views/colors/confirm_destroy.html.erb @@ -26,7 +26,16 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. ++#%> -<%= toolbar title: @color.name %> + +<%= + render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title { @color.name } + header.with_breadcrumbs([{ href: admin_index_path, text: t(:label_administration) }, + { href: colors_path, text: t(:label_color_plural) }, + @color.name]) + end +%> + <%= labelled_tabular_form_for @color, url: color_url(@color), html: {method: 'delete'}, diff --git a/app/views/enterprises/_info.html.erb b/app/views/enterprises/_info.html.erb index 14ac77975146..2d83f73f2c05 100644 --- a/app/views/enterprises/_info.html.erb +++ b/app/views/enterprises/_info.html.erb @@ -27,9 +27,8 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <% content_for :header_tags do %> - + <%= nonced_javascript_include_tag OpenProject::Static::Links.links[:chargebee][:href], + "data-cb-site": OpenProject::Configuration.enterprise_chargebee_site %> <% end %>
    diff --git a/app/views/forums/edit.html.erb b/app/views/forums/edit.html.erb index 55eecad24ebf..db71092e464e 100644 --- a/app/views/forums/edit.html.erb +++ b/app/views/forums/edit.html.erb @@ -26,7 +26,13 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. ++#%> -<%= toolbar title: Forum.model_name.human %> +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { @forum.name } + header.with_breadcrumbs( [{ href: project_overview_path(@project.id), text: @project.name }, + { href: project_forums_path(@project.id), text: t(:label_forum_plural) }, + @forum.name + ]) end %> <%= labelled_tabular_form_for [@project, @forum] do |f| %> <%= render partial: 'form', locals: { f: f } %> diff --git a/app/views/forums/index.html.erb b/app/views/forums/index.html.erb index 73a2c166a9a7..b228b5921e34 100644 --- a/app/views/forums/index.html.erb +++ b/app/views/forums/index.html.erb @@ -27,20 +27,28 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <% html_title t(:label_forum_plural) %> -<%= toolbar title: t(:label_forum_plural) do %> - <% if User.current.allowed_in_project?(:manage_forums, @project) %> -
  • - <%= link_to(new_project_forum_path(@project), - { aria: { label: t(:label_forum_new) }, - title: t(:label_forum_new), - class: 'button -primary' }) do %> - <%= op_icon('button--icon icon-add') %> - <%= t('activerecord.models.forum') %> - <% end %> -
  • - <% end %> -<% end %> +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { t(:label_forum_plural) } + header.with_breadcrumbs( [{ href: project_overview_path(@project.id), text: @project.name }, + t(:label_forum_plural) + ]) end %> + +<% if User.current.allowed_in_project?(:manage_forums, @project) %> + <%= + render(Primer::OpenProject::SubHeader.new) do |subheader| + subheader.with_action_button(scheme: :primary, + aria: { label: t(:label_forum_new) }, + title: t(:label_forum_new), + tag: :a, + href: new_project_forum_path(@project)) do |button| + button.with_leading_visual_icon(icon: :plus) + t('activerecord.models.forum') + end + end + %> +<% end %> <% if @forums.empty? %> <%= no_results_box(action_url: new_project_forum_path(@project)) %> <% else %> diff --git a/app/views/forums/new.html.erb b/app/views/forums/new.html.erb index 47c22c0fd526..97f41c0d1be0 100644 --- a/app/views/forums/new.html.erb +++ b/app/views/forums/new.html.erb @@ -27,8 +27,14 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= toolbar title: t(:label_forum_new) %> - +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { t(:label_forum_new) } + header.with_breadcrumbs( [{ href: project_overview_path(@project.id), text: @project.name }, + { href: project_forums_path(@project.id), text: t(:label_forum_plural) }, + t(:label_forum_new) + ]) + end %> <%= labelled_tabular_form_for [@project, @forum] do |f| %> <%= render partial: 'form', locals: {f: f} %> <%= f.button t(:button_create), class: 'button -primary -with-icon icon-checkmark' %> diff --git a/app/views/forums/show.html.erb b/app/views/forums/show.html.erb index 576da3808a90..3f0ddfa73894 100644 --- a/app/views/forums/show.html.erb +++ b/app/views/forums/show.html.erb @@ -49,24 +49,34 @@ See COPYRIGHT and LICENSE files for more details. <% end %>
    -<%= toolbar title: @forum.name, subtitle: format_text(@forum.description) do %> - <% if authorize_for(:messages, :new) %> -
  • - <%= link_to({ controller: '/messages', action: 'new', forum_id: @forum }, - { class: 'add-message-button button -primary', - aria: { label: t(:label_message_new) }, - title: t(:label_message_new) }) do %> - <%= op_icon('button--icon icon-add') %> - <%= t(:label_message) %> - <% end %> - <% csp_onclick('jQuery("#add-message").show(); jQuery("#message_subject").focus();', '.add-message-button') %> -
  • - <% end %> - <% unless User.current.anonymous? %> -
  • - <%= watcher_link(@forum, User.current) %> -
  • - <% end %> + +<%= + render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title { @forum.name } + header.with_description { @forum.description } + header.with_breadcrumbs([{ href: project_overview_path(@project.id), text: @project.name }, + { href: project_forums_path(@project), text: t(:label_forum_plural) }, + @forum.name]) + + watcher_action_button(header, @forum) + end +%> +<% if authorize_for(:messages, :new) %> + <%= + render(Primer::OpenProject::SubHeader.new) do |subheader| + if authorize_for(:messages, :new) + subheader.with_action_button(scheme: :primary, + aria: { label: t(:label_message_new) }, + title: t(:label_message_new), + tag: :a, + class: 'add-message-button', + href: url_for({ controller: '/messages', action: 'new', forum_id: @forum })) do |button| + button.with_leading_visual_icon(icon: :plus) + t(:label_message) + end + end + end + %> <% end %> <% if @topics.any? %> diff --git a/app/views/groups/_memberships.html.erb b/app/views/groups/_memberships.html.erb index a7d554fff612..8426d22ee27b 100644 --- a/app/views/groups/_memberships.html.erb +++ b/app/views/groups/_memberships.html.erb @@ -26,7 +26,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. ++#%> -<% roles = Role.givable %> +<% roles = ProjectRole.givable %> <% projects = Project.active.order(Arel.sql('lft')) %> <% memberships = @group.memberships %> diff --git a/app/views/layouts/_common_head.html.erb b/app/views/layouts/_common_head.html.erb index ff5c33451036..1e895933c5f2 100644 --- a/app/views/layouts/_common_head.html.erb +++ b/app/views/layouts/_common_head.html.erb @@ -4,6 +4,7 @@ <%= appsignal_frontend_tag %> <% relative_url_root = OpenProject::Configuration['rails_relative_url_root'] || '' %> + <% if @project %> diff --git a/app/views/messages/edit.html.erb b/app/views/messages/edit.html.erb index 503d85c27e51..f906ee6ed300 100644 --- a/app/views/messages/edit.html.erb +++ b/app/views/messages/edit.html.erb @@ -27,7 +27,14 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -

    <%= link_to h(@forum.name), controller: '/forums', action: 'show', project_id: @project, id: @forum %> » <%= h @message.subject %>

    +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { @message.subject } + header.with_breadcrumbs( [{ href: project_overview_path(@project.id), text: @project.name }, + { href: project_forums_path(@project), text: t(:label_forum_plural) }, + { href: project_forum_path(@project, @forum), text: @forum.name }, + @message.subject]) + end %> <%= labelled_tabular_form_for @message, url: topic_path(@message), diff --git a/app/views/messages/new.html.erb b/app/views/messages/new.html.erb index 58fed5b40082..3b6e0879f527 100644 --- a/app/views/messages/new.html.erb +++ b/app/views/messages/new.html.erb @@ -27,8 +27,15 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -

    <%= link_to h(@forum.name), controller: '/forums', action: 'show', project_id: @project, id: @forum %> » <%= t(:label_message_new) %>

    - +<%= + render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title { t(:label_message_new) } + header.with_breadcrumbs([{ href: project_overview_path(@project.id), text: @project.name }, + { href: project_forums_path(@project), text: t(:label_forum_plural) }, + { href: url_for(controller: '/forums', action: 'show', project_id: @project, id: @forum), text: @forum.name }, + t(:label_message_new)]) + end +%> <%= labelled_tabular_form_for @message, url: forum_topics_path(@forum), html: { diff --git a/app/views/messages/show.html.erb b/app/views/messages/show.html.erb index f294bd74e145..5ec9cbdda7d5 100644 --- a/app/views/messages/show.html.erb +++ b/app/views/messages/show.html.erb @@ -27,48 +27,12 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<% breadcrumb_paths( - link_to(t(:label_forum_plural), project_forums_path(@project)), - link_to(h(@forum.name), project_forum_path(@project, @forum))) -%> +<%= render Messages::ShowPageHeaderComponent.new(topic: @topic, message: @message, forum: @forum, project:@project) %> <% content_controller 'forum-messages', dynamic: true %> <% title avatar(@topic.author) + h(@topic.subject) %> -<%= toolbar title: title do %> -
  • - <%= watcher_link(@topic, User.current) %> -
  • -
  • - <% if !@topic.locked? && authorize_for('messages', 'reply') %> - <%= link_to( - { action: 'quote', id: @topic }, - data: { 'action': 'forum-messages#quote' }, - class: 'boards--quote-button button' - ) do %> - <%= op_icon('button--icon icon-quote') %> - <%= t(:button_quote) %> - <% end %> - <% end %> -
  • -
  • - <% if @message.editable_by?(User.current) %> - <%= link_to(edit_topic_path(@topic), accesskey: accesskey(:edit), class: 'button') do %> - <%= op_icon('button--icon icon-edit') %> - <%= t(:button_edit) %> - <% end %> - <% end %> -
  • -
  • - <% if @message.destroyable_by?(User.current) %> - <%= link_to(topic_path(@topic), method: :delete, data: { confirm: t(:text_are_you_sure) }, class: 'button') do %> - <%= op_icon('button--icon icon-delete') %> - <%= t(:button_delete) %> - <% end %> - <% end %> -
  • -<% end %>

    <%= authoring @topic.created_at, @topic.author %>

    diff --git a/app/views/news/edit.html.erb b/app/views/news/edit.html.erb index dd8cadc3a921..0509feb45196 100644 --- a/app/views/news/edit.html.erb +++ b/app/views/news/edit.html.erb @@ -28,7 +28,15 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <% html_title t(:label_news_edit) %> -<%= toolbar title: News.model_name.human %> +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { @news.title } + header.with_breadcrumbs([*([ href: home_path, text: organization_name ] unless @project), + *([ href: project_overview_path(@project.id), text: @project.name ] if @project), + *([ href: project_news_index_path(@project.id), text: t(:label_news_plural) ] if @project), + @news.title]) + end +%> <%= labelled_tabular_form_for @news, html: { id: 'news-form' } do |f| %> <%= render partial: 'form', locals: { f: f } %> diff --git a/app/views/news/index.html.erb b/app/views/news/index.html.erb index 968d66ba0867..6b18570a3df2 100644 --- a/app/views/news/index.html.erb +++ b/app/views/news/index.html.erb @@ -26,6 +26,9 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. ++#%> + +<% html_title t(:label_news_plural) %> + <% managable = User.current.allowed_in_project?(:manage_news, @project) %> @@ -58,9 +61,9 @@ See COPYRIGHT and LICENSE files for more details. <% if @newss.any? %> <% @newss.each do |news| %>
    -

    <%= avatar(news.author) %><%= link_to_project(news.project) + ': ' unless news.project == @project %> - <%= link_to h(news.title), news_path(news) %> - <%= "(#{t(:label_x_comments, count: news.comments_count)})" if news.comments_count > 0 %>

    +

    <%= avatar(news.author) %><%= link_to_project(news.project) + ': ' unless news.project == @project %> + <%= link_to h(news.title), news_path(news) %> + <%= "(#{t(:label_x_comments, count: news.comments_count)})" if news.comments_count > 0 %>

    <%= authoring news.created_at, news.author %>

    <%= format_text(news.summary.present? ? news.summary : truncate(news.description, length: 150, escape: false), object: news) %> diff --git a/app/views/news/new.html.erb b/app/views/news/new.html.erb index f031848d2b34..383750570f35 100644 --- a/app/views/news/new.html.erb +++ b/app/views/news/new.html.erb @@ -26,10 +26,16 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. ++#%> - <% html_title t(:label_news_new) %> -<%= toolbar title: t(:label_news_new) %> +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { t(:label_news_new) } + header.with_breadcrumbs([{ href: project_overview_path(@project.id), text: @project.name }, + { href: project_news_index_path(@project), text: t(:label_news_plural)}, + t(:label_news_new)]) + end +%> <%= labelled_tabular_form_for [@project, @news], html: { id: 'news-form' } do |f| %> <%= render partial: 'news/form', locals: { f: f } %> diff --git a/app/views/news/show.html.erb b/app/views/news/show.html.erb index 6e6820b3def3..9b0fd627fb1d 100644 --- a/app/views/news/show.html.erb +++ b/app/views/news/show.html.erb @@ -27,33 +27,44 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= toolbar title: "#{avatar(@news.author)} #{h @news.title}".html_safe do %> - <% if User.current.allowed_in_project?(:manage_news, @project) %> -
  • - <%= link_to(edit_news_path(@news), - accesskey: accesskey(:edit), - class: 'button edit-news-button') do %> - <%= op_icon('button--icon icon-edit') %> - <%= t(:button_edit) %> - <% end %> - <% csp_onclick('jQuery("#edit-news").show()', '.edit-news-button') %> -
  • - <% end %> -
  • - <%= watcher_link(@news, User.current) %> -
  • - <% if User.current.allowed_in_project?(:manage_news, @project) %> -
  • - <%= link_to(news_path(@news), - data: { confirm: t(:text_are_you_sure) }, - method: :delete, - class: 'button') do %> - <%= op_icon('button--icon icon-delete') %> - <%= t(:button_delete) %> - <% end %> -
  • - <% end %> -<% end %> +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { "#{avatar(@news.author)} #{h @news.title}".html_safe } + header.with_breadcrumbs([*([ href: home_path, text: organization_name ] unless @project), + *([ href: project_overview_path(@project.id), text: @project.name ] if @project), + *([ href: project_news_index_path(@project.id), text: t(:label_news_plural) ] if @project), + @news.title]) + if User.current.allowed_in_project?(:manage_news, @project) + header.with_action_button(tag: :a, + mobile_icon: :pencil, + mobile_label: t(:button_edit), + size: :medium, + href: edit_news_path(@news), + aria: { label: I18n.t(:button_edit) }, + title: I18n.t(:button_edit)) do |button| + csp_onclick('jQuery("#edit-news").show()', '.edit-news-button') + button.with_leading_visual_icon(icon: :pencil) + t(:button_edit) + end + end + + watcher_action_button(header, @news) + + if User.current.allowed_in_project?(:manage_news, @project) + header.with_action_button(scheme: :danger, + tag: :a, + href: news_path(@news), + mobile_icon: :trash, + mobile_label: I18n.t("button_delete"), + data: { confirm: t(:text_are_you_sure), method: :delete, }, + aria: { label: I18n.t("button_delete") }, + ) do |button| + button.with_leading_visual_icon(icon: :trash) + I18n.t("button_delete") + end + end + end +%> <% if authorize_for('news', 'edit') %>