diff --git a/.github/workflows/codeql-scan-core.yml b/.github/workflows/codeql-scan-core.yml index 2e1a855121f5..bf3582282911 100644 --- a/.github/workflows/codeql-scan-core.yml +++ b/.github/workflows/codeql-scan-core.yml @@ -5,6 +5,8 @@ on: branches: [ "dev", "release/*", "stable/*" ] pull_request: branches: [ "dev", "release/*", "stable/*" ] + paths-ignore: + - 'docs/**' schedule: - cron: '32 1 * * 2' diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 07c1674cb336..de098ef7b263 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -315,7 +315,7 @@ jobs: needs: [setup, build, merge] permissions: contents: none - if: ${{ github.repository == 'opf/openproject' && inputs.tag != '' }} + if: ${{ github.repository == 'opf/openproject' }} runs-on: ubuntu-latest steps: - name: Trigger Helm charts release diff --git a/.github/workflows/email-notification.yml b/.github/workflows/email-notification.yml index 9e9799ad1ff6..10f8a3cf0222 100644 --- a/.github/workflows/email-notification.yml +++ b/.github/workflows/email-notification.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Send mail - uses: dawidd6/action-send-mail@v3 + uses: dawidd6/action-send-mail@v4 with: subject: ${{ inputs.subject }} body: ${{ inputs.body }} diff --git a/.github/workflows/rubocop-core.yml b/.github/workflows/rubocop-core.yml index ecd47277ae61..32a1b04da2cd 100644 --- a/.github/workflows/rubocop-core.yml +++ b/.github/workflows/rubocop-core.yml @@ -2,6 +2,8 @@ name: rubocop on: pull_request: + paths-ignore: + - 'docs/**' jobs: rubocop: diff --git a/Gemfile b/Gemfile index 428b51e40a8f..366d19ea5233 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.7.0" +gem "activerecord-import", "~> 1.8.0" gem "activerecord-session_store", "~> 2.1.0" gem "ox" gem "rails", "~> 7.1.3" @@ -46,7 +46,7 @@ gem "ffi", "~> 1.15" gem "rdoc", ">= 2.4.2" -gem "doorkeeper", "~> 5.7.0" +gem "doorkeeper", "~> 5.8.0" # Maintain our own omniauth due to relative URL root issues # see upstream PR: https://github.com/omniauth/omniauth/pull/903 gem "omniauth", git: "https://github.com/opf/omniauth", ref: "fe862f986b2e846e291784d2caa3d90a658c67f0" @@ -93,7 +93,7 @@ gem "deckar01-task_list", "~> 2.3.1" # Requires escape-utils for faster escaping gem "escape_utils", "~> 1.3" # Syntax highlighting used in html-pipeline with rouge -gem "rouge", "~> 4.4.0" +gem "rouge", "~> 4.5.1" # HTML sanitization used for html-pipeline gem "sanitize", "~> 6.1.0" # HTML autolinking for mails and urls (replaces autolink) @@ -184,7 +184,7 @@ gem "rails-i18n", "~> 7.0.0" gem "sprockets", "~> 3.7.2" # lock sprockets below 4.0 gem "sprockets-rails", "~> 3.5.1" -gem "puma", "~> 6.4" +gem "puma", "~> 6.5" gem "puma-plugin-statsd", "~> 2.0" gem "rack-timeout", "~> 0.7.0", require: "rack/timeout/base" @@ -222,7 +222,7 @@ gem "appsignal", "~> 3.10.0", require: false gem "view_component" # Lookbook -gem "lookbook", "~> 2.3.3" +gem "lookbook", "~> 2.3.4" # Require factory_bot for usage with openproject plugins testing gem "factory_bot", "~> 6.5.0", require: false @@ -247,7 +247,7 @@ group :test do gem "rack_session_access" gem "rspec", "~> 3.13.0" # also add to development group, so 'spec' rake task gets loaded - gem "rspec-rails", "~> 7.0.0", group: :development + gem "rspec-rails", "~> 7.1.0", group: :development # Retry failures within the same environment gem "retriable", "~> 3.1.1" @@ -400,4 +400,4 @@ end gem "openproject-octicons", "~>19.19.0" gem "openproject-octicons_helper", "~>19.19.0" -gem "openproject-primer_view_components", "~>0.48.2" +gem "openproject-primer_view_components", "~>0.49.2" diff --git a/Gemfile.lock b/Gemfile.lock index 5abe00198084..e42bda9e0386 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/citizensadvice/capybara_accessible_selectors - revision: 347bbe06cb420416855e80bb4e3a3016b2d5872c + revision: aed2860e5b5df7f39284fdc35a5bf57bda7e1ad9 branch: main specs: capybara_accessible_selectors (0.11.0) @@ -206,7 +206,7 @@ PATH remote: modules/two_factor_authentication specs: openproject-two_factor_authentication (1.0.0) - aws-sdk-sns (~> 1.88.0) + aws-sdk-sns (~> 1.90.0) messagebird-rest (~> 1.4.2) rotp (~> 6.1) webauthn (~> 3.0) @@ -225,36 +225,36 @@ PATH GEM remote: https://rubygems.org/ specs: - Ascii85 (1.1.1) - actioncable (7.1.4.1) - actionpack (= 7.1.4.1) - activesupport (= 7.1.4.1) + Ascii85 (2.0.1) + actioncable (7.1.5) + actionpack (= 7.1.5) + activesupport (= 7.1.5) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.4.1) - actionpack (= 7.1.4.1) - activejob (= 7.1.4.1) - activerecord (= 7.1.4.1) - activestorage (= 7.1.4.1) - activesupport (= 7.1.4.1) + actionmailbox (7.1.5) + actionpack (= 7.1.5) + activejob (= 7.1.5) + activerecord (= 7.1.5) + activestorage (= 7.1.5) + activesupport (= 7.1.5) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.1.4.1) - actionpack (= 7.1.4.1) - actionview (= 7.1.4.1) - activejob (= 7.1.4.1) - activesupport (= 7.1.4.1) + actionmailer (7.1.5) + actionpack (= 7.1.5) + actionview (= 7.1.5) + activejob (= 7.1.5) + activesupport (= 7.1.5) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.2) - actionpack (7.1.4.1) - actionview (= 7.1.4.1) - activesupport (= 7.1.4.1) + actionpack (7.1.5) + actionview (= 7.1.5) + activesupport (= 7.1.5) nokogiri (>= 1.8.5) racc rack (>= 2.2.4) @@ -265,33 +265,33 @@ GEM actionpack-xml_parser (2.0.1) actionpack (>= 5.0) railties (>= 5.0) - actiontext (7.1.4.1) - actionpack (= 7.1.4.1) - activerecord (= 7.1.4.1) - activestorage (= 7.1.4.1) - activesupport (= 7.1.4.1) + actiontext (7.1.5) + actionpack (= 7.1.5) + activerecord (= 7.1.5) + activestorage (= 7.1.5) + activesupport (= 7.1.5) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.4.1) - activesupport (= 7.1.4.1) + actionview (7.1.5) + activesupport (= 7.1.5) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.1.4.1) - activesupport (= 7.1.4.1) + activejob (7.1.5) + activesupport (= 7.1.5) globalid (>= 0.3.6) - activemodel (7.1.4.1) - activesupport (= 7.1.4.1) + activemodel (7.1.5) + activesupport (= 7.1.5) activemodel-serializers-xml (1.0.3) activemodel (>= 5.0.0.a) activesupport (>= 5.0.0.a) builder (~> 3.1) - activerecord (7.1.4.1) - activemodel (= 7.1.4.1) - activesupport (= 7.1.4.1) + activerecord (7.1.5) + activemodel (= 7.1.5) + activesupport (= 7.1.5) timeout (>= 0.4.0) - activerecord-import (1.7.0) + activerecord-import (1.8.1) activerecord (>= 4.2) activerecord-nulldb-adapter (1.0.1) activerecord (>= 5.2.0, < 7.2) @@ -302,23 +302,26 @@ GEM multi_json (~> 1.11, >= 1.11.2) rack (>= 2.0.8, < 4) railties (>= 6.1) - activestorage (7.1.4.1) - actionpack (= 7.1.4.1) - activejob (= 7.1.4.1) - activerecord (= 7.1.4.1) - activesupport (= 7.1.4.1) + activestorage (7.1.5) + actionpack (= 7.1.5) + activejob (= 7.1.5) + activerecord (= 7.1.5) + activesupport (= 7.1.5) marcel (~> 1.0) - activesupport (7.1.4.1) + activesupport (7.1.5) base64 + benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) mutex_m + securerandom (>= 0.3) tzinfo (~> 2.0) - acts_as_list (1.2.3) + acts_as_list (1.2.4) activerecord (>= 6.1) activesupport (>= 6.1) acts_as_tree (2.9.1) @@ -340,32 +343,31 @@ GEM activerecord (>= 4.0) awesome_nested_set (3.7.0) activerecord (>= 4.0.0, < 8.0) - awrence (1.2.1) aws-eventstream (1.3.0) - aws-partitions (1.996.0) - aws-sdk-core (3.211.0) + aws-partitions (1.1013.0) + aws-sdk-core (3.214.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.95.0) + aws-sdk-kms (1.96.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.169.0) + aws-sdk-s3 (1.174.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sdk-sns (1.88.0) - aws-sdk-core (~> 3, >= 3.207.0) + aws-sdk-sns (1.90.0) + aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) aws-sigv4 (1.10.1) aws-eventstream (~> 1, >= 1.0.2) - axe-core-api (4.10.1) + axe-core-api (4.10.2) dumb_delegator ostruct virtus - axe-core-rspec (4.10.1) - axe-core-api (= 4.10.1) + axe-core-rspec (4.10.2) + axe-core-api (= 4.10.2) dumb_delegator ostruct virtus @@ -375,6 +377,7 @@ GEM thread_safe (~> 0.3, >= 0.3.1) base64 (0.2.0) bcrypt (3.1.20) + benchmark (0.4.0) better_html (2.1.1) actionview (>= 6.0) activesupport (>= 6.0) @@ -438,7 +441,7 @@ GEM bigdecimal rexml crass (1.0.6) - css_parser (1.19.0) + css_parser (1.19.1) addressable csv (3.3.0) cuprite (0.15.1) @@ -446,7 +449,7 @@ GEM ferrum (~> 0.15.0) daemons (1.4.1) dalli (3.2.8) - date (3.3.4) + date (3.4.0) date_validator (0.12.0) activemodel (>= 3) activesupport (>= 3) @@ -462,7 +465,7 @@ GEM disposable (0.6.3) declarative (>= 0.0.9, < 1.0.0) representable (>= 3.1.1, < 4) - doorkeeper (5.7.1) + doorkeeper (5.8.0) railties (>= 5) dotenv (3.1.4) dotenv-rails (3.1.4) @@ -477,8 +480,9 @@ GEM zeitwerk (~> 2.6) dry-container (0.11.0) concurrent-ruby (~> 1.0) - dry-core (1.0.1) + dry-core (1.0.2) concurrent-ruby (~> 1.0) + logger zeitwerk (~> 2.6) dry-inflector (1.1.0) dry-initializer (3.1.1) @@ -541,7 +545,7 @@ GEM tzinfo eventmachine (1.2.7) eventmachine_httpserver (0.2.1) - excon (1.0.0) + excon (1.2.0) factory_bot (6.5.0) activesupport (>= 5.0.0) factory_bot_rails (6.4.4) @@ -634,7 +638,7 @@ GEM rack gravatar_image_tag (1.2.0) hana (1.3.7) - hashdiff (1.1.1) + hashdiff (1.1.2) hashery (2.1.2) hashie (3.6.0) highline (3.1.1) @@ -645,10 +649,10 @@ GEM htmlbeautifier (1.4.3) htmldiff (0.0.1) htmlentities (4.3.4) - http-2 (1.0.1) + http-2 (1.0.2) http_parser.rb (0.6.0) httpclient (2.8.3) - httpx (1.3.1) + httpx (1.3.3) http-2 (>= 1.0.0) i18n (1.14.6) concurrent-ruby (~> 1.0) @@ -677,7 +681,7 @@ GEM reline (>= 0.4.2) iso8601 (0.13.0) jmespath (1.6.2) - json (2.7.4) + json (2.8.2) json-jwt (1.16.7) activesupport (>= 4.2) aes_key_wrap @@ -703,7 +707,7 @@ GEM launchy (3.0.1) addressable (~> 2.8) childprocess (~> 5.0) - lefthook (1.8.1) + lefthook (1.8.4) letter_opener (1.10.0) launchy (>= 2.2, < 4) letter_opener_web (3.0.0) @@ -724,10 +728,10 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.22.0) + loofah (2.23.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) - lookbook (2.3.3) + lookbook (2.3.4) activemodel css_parser htmlbeautifier (~> 1.3) @@ -754,21 +758,21 @@ GEM mime-types (3.6.0) logger mime-types-data (~> 3.2015) - mime-types-data (3.2024.1001) + mime-types-data (3.2024.1105) mini_magick (5.0.1) mini_mime (1.1.5) - mini_portile2 (2.8.7) + mini_portile2 (2.8.8) minitest (5.25.1) - msgpack (1.7.3) + msgpack (1.7.5) multi_json (1.15.0) mustermann (3.0.3) ruby2_keywords (~> 0.0.1) mustermann-grape (1.1.0) mustermann (>= 1.0.0) - mutex_m (0.2.0) + mutex_m (0.3.0) net-http (0.4.1) uri - net-imap (0.4.17) + net-imap (0.5.1) date net-protocol net-ldap (0.19.0) @@ -778,11 +782,11 @@ GEM timeout net-smtp (0.5.0) net-protocol - nio4r (2.7.3) + nio4r (2.7.4) nokogiri (1.16.7) mini_portile2 (~> 2.8.2) racc (~> 1.4) - oj (3.16.6) + oj (3.16.7) bigdecimal (>= 3.0) ostruct (>= 0.2) okcomputer (1.18.5) @@ -808,7 +812,7 @@ GEM actionview openproject-octicons (= 19.19.0) railties - openproject-primer_view_components (0.48.2) + openproject-primer_view_components (0.49.2) actionview (>= 5.0.0) activesupport (>= 5.0.0) openproject-octicons (>= 19.17.0) @@ -818,9 +822,9 @@ GEM openssl (3.2.0) openssl-signature_algorithm (1.3.0) openssl (> 2.0) - optimist (3.1.0) + optimist (3.2.0) os (1.1.4) - ostruct (0.6.0) + ostruct (0.6.1) ox (2.14.18) paper_trail (15.2.0) activerecord (>= 6.1) @@ -828,14 +832,14 @@ GEM parallel (1.26.3) parallel_tests (4.7.2) parallel - parser (3.3.5.0) + parser (3.3.6.0) ast (~> 2.4.1) racc pdf-core (0.9.0) pdf-inspector (1.3.0) pdf-reader (>= 1.0, < 3.0.a) - pdf-reader (2.12.0) - Ascii85 (~> 1.0) + pdf-reader (2.13.0) + Ascii85 (>= 1.0, < 3.0, != 2.0.0) afm (~> 0.2.1) hashery (~> 2.0) ruby-rc4 @@ -864,7 +868,7 @@ GEM pry-rescue (1.6.0) interception (>= 0.5) pry (>= 0.12.0) - psych (5.1.2) + psych (5.2.0) stringio public_suffix (6.0.1) puffing-billy (4.0.0) @@ -875,7 +879,7 @@ GEM eventmachine_httpserver http_parser.rb (~> 0.6.0) multi_json - puma (6.4.3) + puma (6.5.0) nio4r (~> 2.0) puma-plugin-statsd (2.6.0) puma (>= 5.0, < 7) @@ -906,23 +910,23 @@ GEM rack_session_access (0.2.0) builder (>= 2.0.0) rack (>= 1.0.0) - rackup (1.0.0) + rackup (1.0.1) rack (< 3) webrick - rails (7.1.4.1) - actioncable (= 7.1.4.1) - actionmailbox (= 7.1.4.1) - actionmailer (= 7.1.4.1) - actionpack (= 7.1.4.1) - actiontext (= 7.1.4.1) - actionview (= 7.1.4.1) - activejob (= 7.1.4.1) - activemodel (= 7.1.4.1) - activerecord (= 7.1.4.1) - activestorage (= 7.1.4.1) - activesupport (= 7.1.4.1) + 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) bundler (>= 1.15.0) - railties (= 7.1.4.1) + railties (= 7.1.5) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -934,12 +938,12 @@ GEM rails-html-sanitizer (1.6.0) loofah (~> 2.21) nokogiri (~> 1.14) - rails-i18n (7.0.9) + rails-i18n (7.0.10) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) - railties (7.1.4.1) - actionpack (= 7.1.4.1) - activesupport (= 7.1.4.1) + railties (7.1.5) + actionpack (= 7.1.5) + activesupport (= 7.1.5) irb rackup (>= 1.0.0) rake (>= 12.2) @@ -956,7 +960,7 @@ GEM msgpack (>= 0.4.3) optimist (>= 3.0.0) rbtree3 (0.7.1) - rdoc (6.7.0) + rdoc (6.8.1) psych (>= 4.0.0) recaptcha (5.17.0) redcarpet (3.6.0) @@ -965,7 +969,7 @@ GEM redis-client (0.22.2) connection_pool regexp_parser (2.9.2) - reline (0.5.10) + reline (0.5.11) io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) @@ -982,7 +986,7 @@ GEM roar (1.2.0) representable (~> 3.1) rotp (6.3.0) - rouge (4.4.0) + rouge (4.5.1) rspec (3.13.0) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -995,7 +999,7 @@ GEM rspec-mocks (3.13.2) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (7.0.1) + rspec-rails (7.1.0) actionpack (>= 7.0) activesupport (>= 7.0) railties (>= 7.0) @@ -1008,7 +1012,7 @@ GEM rspec-support (3.13.1) rspec-wait (1.0.1) rspec (>= 3.4) - rubocop (1.67.0) + rubocop (1.68.0) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) @@ -1018,7 +1022,7 @@ GEM rubocop-ast (>= 1.32.2, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.32.3) + rubocop-ast (1.36.1) parser (>= 3.3.1.0) rubocop-capybara (2.21.0) rubocop (~> 1.41) @@ -1026,7 +1030,7 @@ GEM rubocop (~> 1.61) rubocop-openproject (0.2.0) rubocop - rubocop-performance (1.22.1) + rubocop-performance (1.23.0) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) rubocop-rails (2.27.0) @@ -1060,9 +1064,10 @@ GEM crass (~> 1.0.2) nokogiri (>= 1.12.0) secure_headers (7.0.0) - selenium-devtools (0.129.0) + securerandom (0.3.2) + selenium-devtools (0.130.0) selenium-webdriver (~> 4.2) - selenium-webdriver (4.25.0) + selenium-webdriver (4.26.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -1079,7 +1084,7 @@ GEM multi_json (~> 1.10) simpleidn (0.2.3) smart_properties (1.17.0) - spreadsheet (1.3.1) + spreadsheet (1.3.3) bigdecimal ruby-ole spring (4.2.1) @@ -1100,7 +1105,7 @@ GEM store_attribute (1.3.1) activerecord (>= 6.1) stringex (2.8.6) - stringio (3.1.1) + stringio (3.1.2) structured_warnings (0.4.0) svg-graph (2.2.2) swd (2.0.3) @@ -1118,7 +1123,7 @@ GEM thor (1.3.2) thread_safe (0.3.6) timecop (0.9.10) - timeout (0.4.1) + timeout (0.4.2) tpm-key_attestation (0.12.1) bindata (~> 2.4) openssl (> 2.0) @@ -1147,8 +1152,8 @@ GEM public_suffix vcr (6.3.1) base64 - vernier (1.2.1) - view_component (3.19.0) + vernier (1.4.0) + view_component (3.20.0) activesupport (>= 5.2.0, < 8.1) concurrent-ruby (~> 1.0) method_source (~> 1.0) @@ -1160,9 +1165,8 @@ GEM rack (>= 2.0.9) warden-basic_auth (0.2.1) warden (~> 1.2) - webauthn (3.1.0) + webauthn (3.2.2) android_key_attestation (~> 0.3.0) - awrence (~> 1.1) bindata (~> 2.4) cbor (~> 0.5.9) cose (~> 1.1) @@ -1177,7 +1181,7 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webrick (1.8.2) + webrick (1.9.0) websocket (1.2.11) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) @@ -1189,7 +1193,7 @@ GEM xpath (3.2.0) nokogiri (~> 1.8) yard (0.9.37) - zeitwerk (2.7.0) + zeitwerk (2.7.1) PLATFORMS ruby @@ -1197,7 +1201,7 @@ PLATFORMS DEPENDENCIES actionpack-xml_parser (~> 2.0.0) activemodel-serializers-xml (~> 1.0.1) - activerecord-import (~> 1.7.0) + activerecord-import (~> 1.8.0) activerecord-nulldb-adapter (~> 1.0.0) activerecord-session_store (~> 2.1.0) acts_as_list (~> 1.2.0) @@ -1235,7 +1239,7 @@ DEPENDENCIES debug deckar01-task_list (~> 2.3.1) disposable (~> 0.6.2) - doorkeeper (~> 5.7.0) + doorkeeper (~> 5.8.0) dotenv-rails dry-auto_inject dry-container @@ -1273,7 +1277,7 @@ DEPENDENCIES letter_opener_web listen (~> 3.9.0) lograge (~> 0.14.0) - lookbook (~> 2.3.3) + lookbook (~> 2.3.4) mail (= 2.8.1) markly (~> 0.10) matrix (~> 0.4.2) @@ -1308,7 +1312,7 @@ DEPENDENCIES openproject-octicons (~> 19.19.0) openproject-octicons_helper (~> 19.19.0) openproject-openid_connect! - openproject-primer_view_components (~> 0.48.2) + openproject-primer_view_components (~> 0.49.2) openproject-recaptcha! openproject-reporting! openproject-storages! @@ -1330,7 +1334,7 @@ DEPENDENCIES pry-rails (~> 0.3.6) pry-rescue (~> 1.6.0) puffing-billy (~> 4.0.0) - puma (~> 6.4) + puma (~> 6.5) puma-plugin-statsd (~> 2.0) rack-attack (~> 6.7.0) rack-cors (~> 2.0.2) @@ -1350,9 +1354,9 @@ DEPENDENCIES retriable (~> 3.1.1) rinku (~> 2.0.4) roar (~> 1.2.0) - rouge (~> 4.4.0) + rouge (~> 4.5.1) rspec (~> 3.13.0) - rspec-rails (~> 7.0.0) + rspec-rails (~> 7.1.0) rspec-retry (~> 0.6.1) rspec-wait rubocop diff --git a/app/components/_index.sass b/app/components/_index.sass index a14f1dce5db6..45a89e33bc3f 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -17,3 +17,4 @@ @import "projects/row_component" @import "op_primer/border_box_table_component" @import "work_packages/exports/modal_dialog_component" +@import "work_package_relations_tab/index_component" diff --git a/app/components/admin/custom_fields/custom_field_projects/new_custom_field_projects_form_modal_component.html.erb b/app/components/admin/custom_fields/custom_field_projects/new_custom_field_projects_form_modal_component.html.erb index a7596230ea1c..3950d4e83b5b 100644 --- a/app/components/admin/custom_fields/custom_field_projects/new_custom_field_projects_form_modal_component.html.erb +++ b/app/components/admin/custom_fields/custom_field_projects/new_custom_field_projects_form_modal_component.html.erb @@ -37,7 +37,7 @@ See COPYRIGHT and LICENSE files for more details. ) do |form| concat(render(Primer::Alpha::Dialog::Body.new( id: dialog_body_id, test_selector: dialog_body_id, aria: { label: title }, - style: "min-height: 300px" + classes: "Overlay-body_autocomplete_height" )) do render(Projects::CustomFields::CustomFieldMappingForm.new(form, project_mapping: @custom_field_project_mapping)) end) diff --git a/app/components/admin/custom_fields/hierarchy/item_component.html.erb b/app/components/admin/custom_fields/hierarchy/item_component.html.erb index eabc0eea2cca..80f2b831b8f0 100644 --- a/app/components/admin/custom_fields/hierarchy/item_component.html.erb +++ b/app/components/admin/custom_fields/hierarchy/item_component.html.erb @@ -29,15 +29,14 @@ See COPYRIGHT and LICENSE files for more details. <%= component_wrapper(tag: "turbo-frame", refresh: :morph) do - if show_edit_form? - render Admin::CustomFields::Hierarchy::ItemFormComponent.new( - target_item: model, - url: custom_field_item_path(@root.custom_field_id, model), - method: :put - ) + if show_form? + render Admin::CustomFields::Hierarchy::ItemFormComponent.new(model) else flex_layout(align_items: :center, justify_content: :space_between, test_selector: "op-custom-fields--hierarchy-item") do |item_container| item_container.with_column(flex_layout: true) do |item_information| + item_information.with_column(mr: 2) do + render(Primer::OpenProject::DragHandle.new(draggable: true)) + end item_information.with_column(mr: 2) do render(Primer::Beta::Link.new(href: custom_field_item_path(@root.custom_field_id, model), underline: false)) do render(Primer::Beta::Text.new(font_weight: :bold)) { model.label } @@ -62,8 +61,7 @@ See COPYRIGHT and LICENSE files for more details. menu.with_show_button(icon: "kebab-horizontal", scheme: :invisible, "aria-label": I18n.t("custom_fields.admin.items.actions")) - edit_action_item(menu) - deletion_action_item(menu) + menu_items(menu) end end end diff --git a/app/components/admin/custom_fields/hierarchy/item_component.rb b/app/components/admin/custom_fields/hierarchy/item_component.rb index 980fc9307e40..2e25f04aa655 100644 --- a/app/components/admin/custom_fields/hierarchy/item_component.rb +++ b/app/components/admin/custom_fields/hierarchy/item_component.rb @@ -38,7 +38,7 @@ class ItemComponent < ApplicationComponent def initialize(item:, show_edit_form: false) super(item) @show_edit_form = show_edit_form - @root = item.root + @root = item.root || item.parent.root end def wrapper_uniq_by @@ -49,22 +49,41 @@ def short_text "(#{model.short})" end - def show_edit_form? = @show_edit_form + def show_form? = @show_edit_form || model.new_record? def children_count I18n.t("custom_fields.admin.hierarchy.subitems", count: model.children.count) end - def deletion_action_item(menu) - menu.with_item(label: I18n.t(:button_delete), - scheme: :danger, - tag: :a, - href: deletion_dialog_custom_field_item_path(custom_field_id: @root.custom_field_id, id: model.id), - content_arguments: { data: { controller: "async-dialog" } }) do |item| - item.with_leading_visual_icon(icon: :trash) + def first_item? + model.sort_order == 0 + end + + def last_item? + model.sort_order == model.parent.children.length - 1 + end + + def menu_items(menu) + edit_action_item(menu) + menu.with_divider + add_above_action_item(menu) + add_below_action_item(menu) + add_sub_item_action_item(menu) + menu.with_divider + if !first_item? + move_to_top_action_item(menu) + move_up_action_item(menu) + end + if !last_item? + move_down_action_item(menu) + move_to_bottom_action_item(menu) end + menu.with_divider + deletion_action_item(menu) end + private + def edit_action_item(menu) menu.with_item(label: I18n.t(:button_edit), tag: :a, @@ -72,6 +91,91 @@ def edit_action_item(menu) item.with_leading_visual_icon(icon: :pencil) end end + + def add_above_action_item(menu) + menu.with_item( + label: I18n.t(:button_add_item_above), + tag: :a, + content_arguments: { data: { turbo_frame: ItemsComponent.wrapper_key } }, + href: new_child_custom_field_item_path(@root.custom_field_id, model.parent, position: model.sort_order) + ) { _1.with_leading_visual_icon(icon: "fold-up") } + end + + def add_below_action_item(menu) + menu.with_item( + label: I18n.t(:button_add_item_below), + tag: :a, + content_arguments: { data: { turbo_frame: ItemsComponent.wrapper_key } }, + href: new_child_custom_field_item_path(@root.custom_field_id, model.parent, position: model.sort_order + 1) + ) { _1.with_leading_visual_icon(icon: "fold-down") } + end + + def add_sub_item_action_item(menu) + menu.with_item( + label: I18n.t(:button_add_sub_item), + tag: :a, + content_arguments: { data: { turbo_frame: ItemsComponent.wrapper_key } }, + href: new_child_custom_field_item_path(@root.custom_field_id, model) + ) { _1.with_leading_visual_icon(icon: "op-arrow-in") } + end + + def move_to_top_action_item(menu) + form_inputs = [{ name: "new_sort_order", value: 0 }] + + menu.with_item(label: I18n.t(:label_sort_highest), + tag: :button, + href: move_custom_field_item_path(@root.custom_field_id, model), + content_arguments: { data: { turbo_frame: ItemsComponent.wrapper_key } }, + form_arguments: { method: :post, inputs: form_inputs }) do |item| + item.with_leading_visual_icon(icon: "move-to-top") + end + end + + def move_up_action_item(menu) + form_inputs = [{ name: "new_sort_order", value: model.sort_order - 1 }] + + menu.with_item(label: I18n.t(:label_sort_higher), + tag: :button, + href: move_custom_field_item_path(@root.custom_field_id, model), + content_arguments: { data: { turbo_frame: ItemsComponent.wrapper_key } }, + form_arguments: { method: :post, inputs: form_inputs }) do |item| + item.with_leading_visual_icon(icon: "chevron-up") + end + end + + def move_down_action_item(menu) + form_inputs = [{ name: "new_sort_order", value: model.sort_order + 2 }] + + menu.with_item(label: I18n.t(:label_sort_lower), + tag: :button, + href: move_custom_field_item_path(@root.custom_field_id, model), + content_arguments: { data: { turbo_frame: ItemsComponent.wrapper_key } }, + form_arguments: { method: :post, inputs: form_inputs }) do |item| + item.with_leading_visual_icon(icon: "chevron-down") + end + end + + def move_to_bottom_action_item(menu) + form_inputs = [{ name: "new_sort_order", value: model.parent.children.length + 1 }] + + menu.with_item(label: I18n.t(:label_sort_lowest), + tag: :button, + href: move_custom_field_item_path(@root.custom_field_id, model), + content_arguments: { data: { turbo_frame: ItemsComponent.wrapper_key } }, + form_arguments: { method: :post, inputs: form_inputs }) do |item| + item.with_leading_visual_icon(icon: "move-to-bottom") + end + end + + def deletion_action_item(menu) + menu.with_item(label: I18n.t(:button_delete), + scheme: :danger, + tag: :a, + href: deletion_dialog_custom_field_item_path(custom_field_id: @root.custom_field_id, id: model.id), + content_arguments: { data: { controller: "async-dialog" } }) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end end end end diff --git a/app/components/admin/custom_fields/hierarchy/item_form_component.html.erb b/app/components/admin/custom_fields/hierarchy/item_form_component.html.erb index 19e98ea72e8a..7b60fdf76593 100644 --- a/app/components/admin/custom_fields/hierarchy/item_form_component.html.erb +++ b/app/components/admin/custom_fields/hierarchy/item_form_component.html.erb @@ -28,7 +28,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= - primer_form_with(url: @url, method: @method, test_selector: "op-custom-fields--new-item-form") do |f| + primer_form_with(**item_options) do |f| render(CustomFields::Hierarchy::ItemForm.new(f, target_item: model)) end %> diff --git a/app/components/admin/custom_fields/hierarchy/item_form_component.rb b/app/components/admin/custom_fields/hierarchy/item_form_component.rb index 779b15079c16..816ff30c7951 100644 --- a/app/components/admin/custom_fields/hierarchy/item_form_component.rb +++ b/app/components/admin/custom_fields/hierarchy/item_form_component.rb @@ -34,10 +34,29 @@ module Hierarchy class ItemFormComponent < ApplicationComponent include OpTurbo::Streamable - def initialize(target_item:, url:, method:) - super(target_item) - @url = url - @method = method + def item_options + options = { url:, method: http_verb, data: { test_selector: "op-custom-fields--new-item-form" } } + options[:data][:turbo_frame] = ItemsComponent.wrapper_key if model.new_record? + + options + end + + def http_verb + model.new_record? ? :post : :put + end + + def url + if model.new_record? + new_child_custom_field_item_path(root.custom_field_id, model.parent, position: model.sort_order) + else + custom_field_item_path(root.custom_field_id, model) + end + end + + private + + def root + @root ||= model.new_record? ? model.parent.root : model.root end end end diff --git a/app/components/admin/custom_fields/hierarchy/items_component.html.erb b/app/components/admin/custom_fields/hierarchy/items_component.html.erb index ad31202add3c..84b71f90a75f 100644 --- a/app/components/admin/custom_fields/hierarchy/items_component.html.erb +++ b/app/components/admin/custom_fields/hierarchy/items_component.html.erb @@ -27,12 +27,14 @@ See COPYRIGHT and LICENSE files for more details. ++#%> +<% helpers.content_controller "admin--hierarchy-item", dynamic: true %> + <%= component_wrapper(tag: "turbo-frame", refresh: :morph, data: { turbo_action: :advance }) do flex_layout do |container| container.with_row do render(Primer::OpenProject::SubHeader.new) do |subheader| - subheader.with_action_button(tag: :a, scheme: :primary, href: new_item_path) do |button| + subheader.with_action_button(tag: :a, scheme: :primary, href: new_item_path) do |button| button.with_leading_visual_icon(icon: :plus) I18n.t(:label_item) end @@ -42,28 +44,41 @@ See COPYRIGHT and LICENSE files for more details. render(Primer::Beta::BorderBox.new) do |box| box.with_header { item_header } - if children.empty? && !show_new_item_form? + if children.empty? box.with_row do render(Primer::Beta::Blankslate.new(test_selector: "op-custom-fields--hierarchy-items-blankslate")) do |component| - component.with_visual_icon(icon: "list-ordered") - component.with_heading(tag: :h3).with_content(I18n.t("custom_fields.admin.items.blankslate.title")) - component.with_description { I18n.t("custom_fields.admin.items.blankslate.description") } + component.with_visual_icon(icon: blank_icon) + + component.with_heading(tag: :h3).with_content(I18n.t(blank_header_text)) + component.with_description { I18n.t(blank_description_text) } + component.with_primary_action(tag: :a, href: new_item_path) do |button| + button.with_leading_visual_icon(icon: :plus) + I18n.t(:label_item) + end end end else children.each do |item| - box.with_row do - render Admin::CustomFields::Hierarchy::ItemComponent.new(item: item) - end - end + drag_controls = if item.persisted? + { + "data-controller": "admin--hierarchy-item", + "data-action": "dragstart->admin--hierarchy-item#dragstart + dragenter->admin--hierarchy-item#dragenter + dragleave->admin--hierarchy-item#dragleave + dragend->admin--hierarchy-item#dragend + drop->admin--hierarchy-item#drop", + "data-hierarchy-item-id": item.id, + "data-sort-order": item.sort_order, + "data-frame-id": Admin::CustomFields::Hierarchy::ItemsComponent.wrapper_key, + "data-move-url": move_custom_field_item_url(root.custom_field_id, item), + "data-index-url": custom_field_item_url(root.custom_field_id, item.parent) + } + else + {} + end - if show_new_item_form? - box.with_footer(test_selector: "op-custom-fields--new-item-form") do - render Admin::CustomFields::Hierarchy::ItemFormComponent.new( - target_item: @new_item, - url: new_child_custom_field_item_path(root.custom_field_id, model), - method: :post - ) + box.with_row(**drag_controls) do + render Admin::CustomFields::Hierarchy::ItemComponent.new(item: item) end end end diff --git a/app/components/admin/custom_fields/hierarchy/items_component.rb b/app/components/admin/custom_fields/hierarchy/items_component.rb index 94c31d83aef8..9c83089684a2 100644 --- a/app/components/admin/custom_fields/hierarchy/items_component.rb +++ b/app/components/admin/custom_fields/hierarchy/items_component.rb @@ -35,21 +35,32 @@ class ItemsComponent < ApplicationComponent include OpTurbo::Streamable include OpPrimer::ComponentHelpers - property :children - def initialize(item:, new_item: nil) super(item) @new_item = new_item end - def show_new_item_form? = @new_item - def root @root ||= model.root? ? model : model.root end def new_item_path - new_child_custom_field_item_path(root.custom_field_id, model) + position = model.children.any? ? model.children.last.sort_order + 1 : 0 + + new_child_custom_field_item_path(root.custom_field_id, model, position:) + end + + def children + list = model.children + return list unless @new_item + + position = @new_item.sort_order&.to_i + + if position + list[0...position] + [@new_item] + list[position..] + else + list + [@new_item] + end end def item_header @@ -60,6 +71,26 @@ def item_header end end + def blank_icon + model.root? ? "list-ordered" : "op-arrow-in" + end + + def blank_header_text + if model.root? + "custom_fields.admin.items.blankslate.root.title" + else + "custom_fields.admin.items.blankslate.item.title" + end + end + + def blank_description_text + if model.root? + "custom_fields.admin.items.blankslate.root.description" + else + "custom_fields.admin.items.blankslate.item.description" + end + end + private def slices diff --git a/app/components/op_primer/border_box_row_component.html.erb b/app/components/op_primer/border_box_row_component.html.erb index f2ffcf6ad838..0b350142c64f 100644 --- a/app/components/op_primer/border_box_row_component.html.erb +++ b/app/components/op_primer/border_box_row_component.html.erb @@ -34,11 +34,17 @@ See COPYRIGHT and LICENSE files for more details. classes: "#{table.grid_class} #{row_css_class}", ) do %> <% columns.each do |column| %> - <%= render(Primer::BaseComponent.new(tag: :div, classes: column_css_class(column), **column_args(column))) { column_value(column) } %> + <%= render(Primer::BaseComponent.new(tag: :div, classes: grid_column_classes(column), **column_args(column))) do %> + <% label = mobile_label(column) %> + <% if label.present? %> + <%= render(Primer::Beta::Text.new(classes: "op-border-box-grid--row-label")) { "#{label}: " } %> + <% end %> + <%= column_value(column) %> + <% end %> <% end %> <% if table.has_actions? %> - <%= flex_layout(align_items: :center, justify_content: :flex_end) do |flex| %> + <%= flex_layout(classes: "op-border-box-grid--row-action") do |flex| %> <% button_links.each_with_index do |link, i| %> <% args = i == (button_links.count - 1) ? {} : { mr: 1 } %> <% flex.with_column(**args) { link } %> diff --git a/app/components/op_primer/border_box_row_component.rb b/app/components/op_primer/border_box_row_component.rb index 5c3b21120158..4da530abcc46 100644 --- a/app/components/op_primer/border_box_row_component.rb +++ b/app/components/op_primer/border_box_row_component.rb @@ -32,6 +32,26 @@ module OpPrimer class BorderBoxRowComponent < RowComponent # rubocop:disable OpenProject/AddPreviewForViewComponent include ComponentHelpers + def mobile_label(column) + return unless table.mobile_labels.include?(column) + + table.column_title(column) + end + + def visible_on_mobile?(column) + table.mobile_columns.include?(column) + end + + def grid_column_classes(column) + classes = ["op-border-box-grid--row-item"] + classes << column_css_class(column) + classes << "op-border-box-grid--main-column" if table.main_column?(column) + classes << "ellipsis" unless table.main_column?(column) + classes << "op-border-box-grid--no-mobile" unless visible_on_mobile?(column) + + classes.compact.join(" ") + end + def column_args(_column) {} end 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 01cbc672c596..4c96232aa95f 100644 --- a/app/components/op_primer/border_box_table_component.html.erb +++ b/app/components/op_primer/border_box_table_component.html.erb @@ -35,12 +35,17 @@ See COPYRIGHT and LICENSE files for more details. )) do |component| component.with_header(classes: grid_class, color: :muted) do headers.each do |name, args| - caption = args.delete(:caption) - concat render(Primer::Beta::Text.new(font_weight: :semibold, **header_args(name))) { caption } + concat render(Primer::Beta::Text.new(classes: header_classes(name), + font_weight: :semibold, + **header_args(name))) { args[:caption] } end + concat render(Primer::Beta::Text.new(classes: "op-border-box-grid--mobile-heading", + font_weight: :semibold)) { mobile_title } + if has_actions? - concat render(Primer::BaseComponent.new(tag: :div)) + concat render(Primer::BaseComponent.new(classes: heading_class, + tag: :div)) end end @@ -55,3 +60,7 @@ See COPYRIGHT and LICENSE files for more details. end end %> + +<% if paginated? %> + <%= helpers.pagination_links_full rows %> +<% end %> diff --git a/app/components/op_primer/border_box_table_component.rb b/app/components/op_primer/border_box_table_component.rb index f0a464c8c6f3..b005fe4be6d0 100644 --- a/app/components/op_primer/border_box_table_component.rb +++ b/app/components/op_primer/border_box_table_component.rb @@ -32,16 +32,71 @@ module OpPrimer class BorderBoxTableComponent < TableComponent include ComponentHelpers - def before_render - raise ArgumentError, "BorderBoxTableComponent cannot be #sortable?" if sortable? + class << self + # Declares columns to be shown in the mobile table + # + # Use it in subclasses like so: + # + # columns :name, :description + # + # mobile_columns :name + # + # This results in the description columns to be hidden on mobile + def mobile_columns(*names) + return @mobile_columns || columns if names.empty? - super + @mobile_columns = names.map(&:to_sym) + end + + # Declares which columns to be rendered with a label + # + # mobile_labels :name + # + # This results in the description columns to be hidden on mobile + def mobile_labels(*names) + return @mobile_labels if names.empty? + + @mobile_labels = names.map(&:to_sym) + end + + # Declare main columns, that will result in a grid column span of 2 and not truncate text + # + # column_grid_span :title + # + def main_column(*names) + return Array(@main_columns) if names.empty? + + @main_columns = names.map(&:to_sym) + end + end + + delegate :mobile_columns, :mobile_labels, + to: :class + + def main_column?(column) + self.class.main_column.include?(column) end def header_args(_column) {} end + def column_title(name) + header = headers.find { |h| h[0] == name } + header ? header[1][:caption] : nil + end + + def header_classes(column) + classes = [heading_class] + classes << "op-border-box-grid--main-column" if main_column?(column) + + classes.join(" ") + end + + def heading_class + "op-border-box-grid--heading" + end + # Default grid class with equal weights def grid_class "op-border-box-grid" @@ -63,6 +118,10 @@ def render_blank_slate end end + def mobile_title + raise ArgumentError, "Need to provide a mobile table title" + end + def blank_title I18n.t(:label_nothing_display) end diff --git a/app/components/op_primer/border_box_table_component.sass b/app/components/op_primer/border_box_table_component.sass index 254c212dffc7..34902845700b 100644 --- a/app/components/op_primer/border_box_table_component.sass +++ b/app/components/op_primer/border_box_table_component.sass @@ -1,7 +1,47 @@ +@import "helpers" + .op-border-box-grid display: grid - justify-content: flex-start - align-items: center - // Distribute columns evenly by default - grid-auto-columns: minmax(0, 1fr) - grid-auto-flow: column + + &--row-action + align-items: center + justify-content: flex-end + +@media screen and (min-width: $breakpoint-md) + .op-border-box-grid + // Distribute columns evenly on desktop + grid-auto-columns: minmax(0, 1fr) + grid-auto-flow: column + justify-content: flex-start + align-items: center + + &--heading, + &--row-item + &:not(:first-child) + padding-left: 6px + + &:not(:last-child) + padding-right: 6px + + &--mobile-heading, + &--row-label + display: none + + &--main-column + grid-column: span 2 + +@media screen and (max-width: $breakpoint-md) + .op-border-box-grid + grid-template-columns: 1fr auto + grid-auto-flow: row + + &--heading, + &--no-mobile + display: none + + &--row-item + grid-column: 1 + + &--row-action + grid-column: 2 + grid-row: 1 diff --git a/app/components/projects/index_page_header_component.html.erb b/app/components/projects/index_page_header_component.html.erb index 2d4bdb4e1667..50579e86c016 100644 --- a/app/components/projects/index_page_header_component.html.erb +++ b/app/components/projects/index_page_header_component.html.erb @@ -152,6 +152,14 @@ item.with_leading_visual_icon(icon: 'trash') end end + menu.with_item( + tag: :button, + label: t('label_zen_mode'), + content_arguments: { data: { controller: "projects-zen-mode", target: "projects-zen-mode.button", action: "click->projects-zen-mode#performAction" } + } + ) do |item| + item.with_leading_visual_icon(icon: 'screen-full') + end end end %> diff --git a/app/components/work_package_relations_tab/add_work_package_child_dialog_component.html.erb b/app/components/work_package_relations_tab/add_work_package_child_dialog_component.html.erb new file mode 100644 index 000000000000..1aedf05ea037 --- /dev/null +++ b/app/components/work_package_relations_tab/add_work_package_child_dialog_component.html.erb @@ -0,0 +1,30 @@ +<%= + render(Primer::Alpha::Dialog.new(title: dialog_title, + size: :large, + id: DIALOG_ID)) do |d| + d.with_header(variant: :large) + d.with_body(classes: body_classes) do + render(WorkPackageRelationsTab::AddWorkPackageChildFormComponent.new( + work_package: @work_package + )) + end + d.with_footer do + component_collection do |buttons| + buttons.with_component(Primer::ButtonComponent.new( + data: { + 'close-dialog-id': DIALOG_ID + } + )) do + t("button_cancel") + end + buttons.with_component(Primer::ButtonComponent.new( + scheme: :primary, + form: FORM_ID, + data: { turbo: true }, + type: :submit)) do + t("button_save") + end + end + end + end +%> diff --git a/app/components/work_package_relations_tab/add_work_package_child_dialog_component.rb b/app/components/work_package_relations_tab/add_work_package_child_dialog_component.rb new file mode 100644 index 000000000000..fc7e09dee8df --- /dev/null +++ b/app/components/work_package_relations_tab/add_work_package_child_dialog_component.rb @@ -0,0 +1,58 @@ +# 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 WorkPackageRelationsTab::AddWorkPackageChildDialogComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + I18N_NAMESPACE = "work_package_relations_tab" + DIALOG_ID = "add-work-package-child-dialog" + FORM_ID = "add-work-package-child-form" + + attr_reader :work_package + + def initialize(work_package:) + super() + + @work_package = work_package + end + + private + + def dialog_title + child_label = t("#{I18N_NAMESPACE}.relations.label_child_singular") + t("#{I18N_NAMESPACE}.label_add_x", x: child_label) + end + + def body_classes + "Overlay-body_autocomplete_height" + end +end diff --git a/app/components/work_package_relations_tab/add_work_package_child_form_component.html.erb b/app/components/work_package_relations_tab/add_work_package_child_form_component.html.erb new file mode 100644 index 000000000000..cd3345e9a12a --- /dev/null +++ b/app/components/work_package_relations_tab/add_work_package_child_form_component.html.erb @@ -0,0 +1,43 @@ +<%= component_wrapper do %> + <%= primer_form_with( + id: FORM_ID, + model: WorkPackage.new, + **submit_url_options, + data: { + turbo: true, + update_work_package: true + } + ) do |f| %> + <%# Form fields section %> + <%= flex_layout(my: 3) do |flex| + flex.with_row do + if @base_errors&.any? + render(Primer::Alpha::Banner.new(mb: 3, icon: :stop, scheme: :danger)) { @base_errors.join("\n") } + end + end + flex.with_row do + # @workpackage is not available inside the render_inline_form block + # so we need to re-define them here. Figure out solution for this. + url = ::API::V3::Utilities::PathHelper::ApiV3Path.work_package_available_relation_candidates(@work_package.id, type: Relation::TYPE_CHILD) + render_inline_form(f) do |my_form| + my_form.work_package_autocompleter( + name: :id, + label: WorkPackage.model_name.human, + visually_hide_label: false, + autocomplete_options: { + resource: 'work_packages', + searchKey: 'subjectOrId', + url:, + relations: true, # Activates relations fetch mode in the autocomplete + openDirectly: false, + focusDirectly: true, + dropdownPosition: 'bottom', + appendTo: "##{DIALOG_ID}", + data: { test_selector: ID_FIELD_TEST_SELECTOR } + } + ) + end + end + end %> + <% end %> +<% end %> diff --git a/app/components/work_package_relations_tab/add_work_package_child_form_component.rb b/app/components/work_package_relations_tab/add_work_package_child_form_component.rb new file mode 100644 index 000000000000..f9f4d6863b11 --- /dev/null +++ b/app/components/work_package_relations_tab/add_work_package_child_form_component.rb @@ -0,0 +1,52 @@ +# 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 WorkPackageRelationsTab::AddWorkPackageChildFormComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + DIALOG_ID = "add-work-package-child-dialog" + FORM_ID = "add-work-package-child-form" + ID_FIELD_TEST_SELECTOR = "work-package-child-form-id" + I18N_NAMESPACE = "work_package_relations_tab" + + def initialize(work_package:, base_errors: nil) + super() + + @work_package = work_package + @base_errors = base_errors + end + + def submit_url_options + { method: :post, + url: work_package_children_path(@work_package) } + end +end diff --git a/app/components/work_package_relations_tab/index_component.html.erb b/app/components/work_package_relations_tab/index_component.html.erb new file mode 100644 index 000000000000..e78e21510bbf --- /dev/null +++ b/app/components/work_package_relations_tab/index_component.html.erb @@ -0,0 +1,103 @@ +<%= component_wrapper(tag: "turbo-frame") do %> + <%= + if should_render_create_button? + flex_layout(justify_content: :space_between, align_items: :center, mb: 4) do |action_bar| + action_bar.with_column(pr: 1) do + render(Primer::Beta::Text.new(color: :muted)) do + t("#{I18N_NAMESPACE}.index.action_bar_title") + end + end + + # Prevent the menu from overflowing on Safari + action_bar.with_column(flex_shrink: 0, ml: 2) do + render(Primer::Alpha::ActionMenu.new(test_selector: NEW_RELATION_ACTION_MENU, + menu_id: NEW_RELATION_ACTION_MENU)) do |menu| + menu.with_show_button do |button| + button.with_leading_visual_icon(icon: :"plus") + button.with_trailing_action_icon(icon: :"triangle-down") + t(:label_relation) + end + + if should_render_add_relations? + 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, + href: new_relation_path(relation_type:), + test_selector: new_button_test_selector(relation_type:), + content_arguments: { + data: { turbo_stream: true } + } + ) do |item| + item.with_description.with_content(t("#{I18N_NAMESPACE}.relations.#{relation_type}_description")) + end + end + end + + if should_render_add_child? + menu.with_item( + label: t("#{I18N_NAMESPACE}.relations.label_child_singular").capitalize, + href: new_work_package_child_path(@work_package), + test_selector: new_button_test_selector(relation_type: :child), + content_arguments: { + data: { turbo_stream: true } + } + ) do |item| + item.with_description.with_content(t("#{I18N_NAMESPACE}.relations.child_description")) + end + end + end + end + end + end + %> + + <%= + flex_layout(mb: 3) do |flex| + if any_relations? + key_namespace = "#{I18N_NAMESPACE}.relations" + + # Relations + directionally_aware_grouped_relations.each do |relation_type, relations_of_type| + base_key = "#{key_namespace}.label_#{relation_type}" + + flex.with_row(mb: 4) do + render_relation_group( + title: t("#{base_key}_plural").capitalize, + relation_type:, + items: relations_of_type + ) do |relation| + render(WorkPackageRelationsTab::RelationComponent.new(work_package:, + relation:)) + end + end + end + + # Children + if children.any? + base_key = "#{key_namespace}.label_child" + + flex.with_row do + render_relation_group( + title: t("#{base_key}_plural").capitalize, + relation_type: :children, + items: children + ) do |child| + render(WorkPackageRelationsTab::RelationComponent.new(work_package:, + relation: nil, + child:)) + end + end + end + else + flex.with_row do + render Primer::Beta::Blankslate.new(border: true) do |component| + component.with_visual_icon(icon: "package-dependents") + component.with_heading(tag: :h4).with_content(t("#{I18N_NAMESPACE}.index.blankslate_heading")) + component.with_description { t("#{I18N_NAMESPACE}.index.blankslate_description") } + end + end + end + end + %> +<% end %> diff --git a/app/components/work_package_relations_tab/index_component.rb b/app/components/work_package_relations_tab/index_component.rb new file mode 100644 index 000000000000..083455043601 --- /dev/null +++ b/app/components/work_package_relations_tab/index_component.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +class WorkPackageRelationsTab::IndexComponent < ApplicationComponent + FRAME_ID = "work-package-relations-tab-content" + NEW_RELATION_ACTION_MENU = "new-relation-action-menu" + I18N_NAMESPACE = "work_package_relations_tab" + include ApplicationHelper + include OpPrimer::ComponentHelpers + include Turbo::FramesHelper + include OpTurbo::Streamable + + attr_reader :work_package, :relations, :children, :directionally_aware_grouped_relations + + def initialize(work_package:, relations:, children:) + super() + + @work_package = work_package + @relations = relations + @children = children + @directionally_aware_grouped_relations = group_relations_by_directional_context + end + + def self.wrapper_key + FRAME_ID + end + + private + + def should_render_add_child? + helpers.current_user.allowed_in_project?(:manage_subtasks, @work_package.project) + end + + def should_render_add_relations? + helpers.current_user.allowed_in_project?(:manage_work_package_relations, @work_package.project) + end + + def should_render_create_button? + should_render_add_child? || should_render_add_relations? + end + + def group_relations_by_directional_context + relations.group_by do |relation| + relation.relation_type_for(work_package) + end + end + + 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 + end + end + + items.each do |item| + border_box.with_row(test_selector: row_test_selector(item)) do + yield(item) + end + end + end + end + + def new_relation_path(relation_type:) + raise ArgumentError, "Invalid relation type: #{relation_type}" unless Relation::TYPES.key?(relation_type) + + if relation_type == Relation::TYPE_CHILD + raise NotImplementedError, "Child relations are not supported yet" + else + new_work_package_relation_path(work_package, relation_type:) + end + end + + def new_button_test_selector(relation_type:) + "op-new-relation-button-#{relation_type}" + end + + def row_test_selector(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}" + end + end +end diff --git a/app/components/work_package_relations_tab/index_component.sass b/app/components/work_package_relations_tab/index_component.sass new file mode 100644 index 000000000000..32a1e670cced --- /dev/null +++ b/app/components/work_package_relations_tab/index_component.sass @@ -0,0 +1,5 @@ +// We reference an ID as one is required to be specified for the action menu list. +// It can't be nested inside the BEM model as it's placed as a #top-layer element. +#new-relation-action-menu-list + max-height: 450px + max-width: 280px diff --git a/app/components/work_package_relations_tab/relation_component.html.erb b/app/components/work_package_relations_tab/relation_component.html.erb new file mode 100644 index 000000000000..12788bdb76d0 --- /dev/null +++ b/app/components/work_package_relations_tab/relation_component.html.erb @@ -0,0 +1,80 @@ +<%= +flex_layout do |flex| + flex.with_row(flex_layout: true, justify_content: :space_between, align_items: :center) do |row| + row.with_column do + render(WorkPackages::InfoLineComponent.new(work_package: related_work_package)) + end + + if should_render_action_menu? + row.with_column do + render(Primer::Alpha::ActionMenu.new(test_selector: action_menu_test_selector)) do |menu| + menu.with_show_button(icon: "kebab-horizontal", + "aria-label": I18n.t(:label_relation_actions), + scheme: :invisible, + ml: 2) + + if should_render_edit_option? + menu.with_item(label: "Edit relation", + href: edit_path, + test_selector: edit_button_test_selector, + content_arguments: { + data: { turbo_stream: true } + }) do |item| + item.with_leading_visual_icon(icon: :pencil) + end + end + + menu.with_item(label: "Delete relation", + scheme: :danger, + href: destroy_path, + form_arguments: { + method: :delete, + data: { + confirm: t("text_are_you_sure"), + turbo_stream: true, + update_work_package: true + } + }, + test_selector: delete_button_test_selector) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + end + end + end + + flex.with_row(mb: 2) do + render(Primer::Beta::Link.new(href: work_package_path(related_work_package), + color: :default, + underline: false, + font_size: :normal, + font_weight: :bold, + target: "_blank")) { related_work_package.subject } + end + + if should_display_description? + flex.with_row(mb: 2) do + render(Primer::Beta::Text.new(font_size: :small, color: :muted)) { format_text(relation, :description) } + 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 + + render(Primer::Beta::Octicon.new(icon:, color: :muted)) + 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)}" + end + 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 new file mode 100644 index 000000000000..efed86f893f4 --- /dev/null +++ b/app/components/work_package_relations_tab/relation_component.rb @@ -0,0 +1,105 @@ +class WorkPackageRelationsTab::RelationComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + + attr_reader :work_package, :relation, :child + + def initialize(work_package:, + relation:, + child: nil) + super() + + @work_package = work_package + @relation = relation + @child = child + end + + def related_work_package + @related_work_package ||= if parent_child_relationship? + @child + else + relation.from == work_package ? relation.to : relation.from + end + end + + private + + def parent_child_relationship? = @child.present? + + def should_render_edit_option? + # Children have nothing to edit as it's not a relation. + !parent_child_relationship? && allowed_to_manage_relations? + end + + def should_render_action_menu? + if parent_child_relationship? + allowed_to_manage_subtasks? + else + allowed_to_manage_relations? + end + end + + def allowed_to_manage_subtasks? + helpers.current_user.allowed_in_project?(:manage_subtasks, @work_package.project) + end + + def allowed_to_manage_relations? + helpers.current_user.allowed_in_project?(:manage_work_package_relations, @work_package.project) + end + + def underlying_resource_id + @underlying_resource_id ||= if parent_child_relationship? + @child.id + else + @relation.other_work_package(work_package).id + end + end + + def should_display_description? + return false if parent_child_relationship? + + relation.description.present? + end + + def should_display_start_and_end_dates? + return false if parent_child_relationship? + + relation.follows? || relation.precedes? + end + + def follows? + relation.relation_type_for(work_package) == Relation::TYPE_FOLLOWS + end + + def precedes? + relation.relation_type_for(work_package) == Relation::TYPE_PRECEDES + end + + def edit_path + if parent_child_relationship? + raise NotImplementedError, "Children relationships are not editable" + else + edit_work_package_relation_path(@work_package, @relation) + end + end + + def destroy_path + if parent_child_relationship? + work_package_child_path(@work_package, @child) + else + work_package_relation_path(@work_package, @relation) + end + end + + def action_menu_test_selector + "op-relation-row-#{underlying_resource_id}-action-menu" + end + + def edit_button_test_selector + "op-relation-row-#{underlying_resource_id}-edit-button" + end + + def delete_button_test_selector + "op-relation-row-#{underlying_resource_id}-delete-button" + end +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 new file mode 100644 index 000000000000..2a804ff50d0b --- /dev/null +++ b/app/components/work_package_relations_tab/work_package_relation_dialog_component.html.erb @@ -0,0 +1,29 @@ +<%= + render(Primer::Alpha::Dialog.new(title: dialog_title, + size: :large, + id: DIALOG_ID)) do |d| + d.with_header(variant: :large) + d.with_body(classes: body_classes) do + render(WorkPackageRelationsTab::WorkPackageRelationFormComponent.new( + work_package: @work_package, + relation: @relation + )) + end + d.with_footer do + component_collection do |buttons| + buttons.with_component(Primer::ButtonComponent.new( + data: { 'close-dialog-id': DIALOG_ID } + )) do + t(:button_cancel) + end + buttons.with_component(Primer::ButtonComponent.new( + scheme: :primary, + form: FORM_ID, + data: { turbo: true }, + type: :submit)) do + t(:button_save) + end + end + end + end +%> diff --git a/app/components/work_package_relations_tab/work_package_relation_dialog_component.rb b/app/components/work_package_relations_tab/work_package_relation_dialog_component.rb new file mode 100644 index 000000000000..02fd5f33dd76 --- /dev/null +++ b/app/components/work_package_relations_tab/work_package_relation_dialog_component.rb @@ -0,0 +1,65 @@ +# 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 WorkPackageRelationsTab::WorkPackageRelationDialogComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + I18N_NAMESPACE = "work_package_relations_tab" + DIALOG_ID = "work-package-relation-dialog" + FORM_ID = "work-package-relation-form" + + attr_reader :relation, :work_package + + def initialize(work_package:, relation:) + super() + + @relation = relation + @work_package = work_package + end + + private + + def dialog_title + relative_label = relation.label_for(work_package) + relation_label = t("#{I18N_NAMESPACE}.relations.#{relative_label}_singular") + + if relation.persisted? + t("#{I18N_NAMESPACE}.label_edit_x", x: relation_label) + else + t("#{I18N_NAMESPACE}.label_add_x", x: relation_label) + end + end + + def body_classes + @relation.persisted? ? nil : "Overlay-body_autocomplete_height" + 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 new file mode 100644 index 000000000000..0a6f63688a42 --- /dev/null +++ b/app/components/work_package_relations_tab/work_package_relation_form_component.html.erb @@ -0,0 +1,65 @@ +<%= component_wrapper do %> + <%= primer_form_with( + id: FORM_ID, + model: @relation, + **submit_url_options, + data: { + turbo: true, + update_work_package: true + } + ) do |f| %> + <%# Form fields section %> + <%= flex_layout(my: 2) do |flex| + flex.with_row do + if @base_errors&.any? + render(Primer::Alpha::Banner.new(mb: 3, icon: :stop, scheme: :danger)) { @base_errors.join("\n") } + end + end + flex.with_row do + # 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 + 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| + if relation.persisted? + my_form.text_field( + name: :to_id, + label: WorkPackage.model_name.human, + visually_hide_label: false, + value: to_id_field_value, + readonly: true + ) + else + my_form.hidden( + name: :relation_type, + value: relation.relation_type + ) + + my_form.autocompleter( + name: :to_id, + label: WorkPackage.model_name.human, + visually_hide_label: false, + autocomplete_options: { + resource: 'work_packages', + url:, + relations: true, # Activates relations fetch mode in the autocomplete + openDirectly: false, + focusDirectly: true, + dropdownPosition: 'bottom', + appendTo: "##{DIALOG_ID}", + data: { test_selector: TO_ID_FIELD_TEST_SELECTOR} + } + ) + end + + my_form.text_field( + name: :description, + label: Relation.human_attribute_name(:description), + autofocus: relation.persisted? + ) + end + 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 new file mode 100644 index 000000000000..938ce175ddf6 --- /dev/null +++ b/app/components/work_package_relations_tab/work_package_relation_form_component.rb @@ -0,0 +1,67 @@ +# 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 WorkPackageRelationsTab::WorkPackageRelationFormComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + DIALOG_ID = "work-package-relation-dialog" + FORM_ID = "work-package-relation-form" + TO_ID_FIELD_TEST_SELECTOR = "work-package-relation-form-to-id" + I18N_NAMESPACE = "work_package_relations_tab" + + def initialize(work_package:, relation:, base_errors: nil) + super() + + @work_package = work_package + @relation = relation + @base_errors = base_errors + end + + def related_work_package + @related_work_package ||= begin + related = @relation.to + # We cannot rely on the related WorkPackage being the "to", + # depending on the relation it can also be "from" + related.id == @work_package.id ? @relation.from : related + end + end + + def submit_url_options + if @relation.persisted? + { method: :patch, + url: work_package_relation_path(@work_package, @relation) } + else + { method: :post, + url: work_package_relations_path(@work_package) } + end + end +end diff --git a/app/components/work_packages/activities_tab/index_component.html.erb b/app/components/work_packages/activities_tab/index_component.html.erb index 6ff2baeee91c..6b4492a05afe 100644 --- a/app/components/work_packages/activities_tab/index_component.html.erb +++ b/app/components/work_packages/activities_tab/index_component.html.erb @@ -1,53 +1,59 @@ <%= - content_tag("turbo-frame", id: "work-package-activities-tab-content") do - flex_layout(classes: "work-packages-activities-tab-index-component", mb: [5, 5, 5, 5, 0]) do |activties_tab_wrapper_container| - activties_tab_wrapper_container.with_row(classes: "work-packages-activities-tab-index-component--errors") do - render( - WorkPackages::ActivitiesTab::ErrorStreamComponent.new - ) - end - activties_tab_wrapper_container.with_row do - component_wrapper(data: wrapper_data_attributes) do - flex_layout do |activties_tab_container| - activties_tab_container.with_row(mb: 2) do - render( - WorkPackages::ActivitiesTab::Journals::FilterAndSortingComponent.new( - work_package:, - filter: - ) - ) - end - activties_tab_container.with_row(flex_layout: true, mt: 3) do |journals_wrapper_container| - journals_wrapper_container.with_row( - classes: "work-packages-activities-tab-index-component--journals-container work-packages-activities-tab-index-component--journals-container_with-initial-input-compensation", - data: { "work-packages--activities-tab--index-target": "journalsContainer" } - ) do + unless deferred + content_tag("turbo-frame", id: "work-package-activities-tab-content") do + flex_layout(classes: "work-packages-activities-tab-index-component", mb: [5, 5, 5, 5, 0]) do |activties_tab_wrapper_container| + activties_tab_wrapper_container.with_row(classes: "work-packages-activities-tab-index-component--errors") do + render( + WorkPackages::ActivitiesTab::ErrorStreamComponent.new + ) + end + activties_tab_wrapper_container.with_row do + component_wrapper(data: wrapper_data_attributes) do + flex_layout do |activties_tab_container| + activties_tab_container.with_row(mb: 2) do render( - WorkPackages::ActivitiesTab::Journals::IndexComponent.new(work_package:, filter:) + WorkPackages::ActivitiesTab::Journals::FilterAndSortingComponent.new( + work_package:, + filter: + ) ) end - if adding_comment_allowed? + activties_tab_container.with_row(flex_layout: true, mt: 3) do |journals_wrapper_container| journals_wrapper_container.with_row( - classes: "work-packages-activities-tab-index-component--input-container work-packages-activities-tab-index-component--input-container_sort-#{journal_sorting}", - mt: 3, - mb: [3, nil, nil, nil, 0], - pt: 2, - pb: 2, - pl: 3, - pr: [3, nil, nil, nil, 2], - border: [nil, nil, nil, nil, :top], - border_radius: [2, nil, nil, nil, 0], - bg: :subtle + classes: "work-packages-activities-tab-index-component--journals-container work-packages-activities-tab-index-component--journals-container_with-initial-input-compensation", + data: { "work-packages--activities-tab--index-target": "journalsContainer" } ) do render( - WorkPackages::ActivitiesTab::Journals::NewComponent.new(work_package:) + WorkPackages::ActivitiesTab::Journals::IndexComponent.new(work_package:, filter:) ) end + if adding_comment_allowed? + journals_wrapper_container.with_row( + classes: "work-packages-activities-tab-index-component--input-container work-packages-activities-tab-index-component--input-container_sort-#{journal_sorting}", + mt: 3, + mb: [3, nil, nil, nil, 0], + pt: 2, + pb: 2, + pl: 3, + pr: [3, nil, nil, nil, 2], + border: [nil, nil, nil, nil, :top], + border_radius: [2, nil, nil, nil, 0], + bg: :subtle + ) do + render( + WorkPackages::ActivitiesTab::Journals::NewComponent.new(work_package:) + ) + end + end end end end end end end + else + render( + WorkPackages::ActivitiesTab::Journals::IndexComponent.new(work_package:, filter:, deferred:) + ) end %> diff --git a/app/components/work_packages/activities_tab/index_component.rb b/app/components/work_packages/activities_tab/index_component.rb index 65ed1463d37c..3be97f3d5cd0 100644 --- a/app/components/work_packages/activities_tab/index_component.rb +++ b/app/components/work_packages/activities_tab/index_component.rb @@ -36,17 +36,18 @@ class IndexComponent < ApplicationComponent include OpTurbo::Streamable include WorkPackages::ActivitiesTab::SharedHelpers - def initialize(work_package:, last_server_timestamp:, filter: :all) + def initialize(work_package:, last_server_timestamp:, filter: :all, deferred: false) super @work_package = work_package @filter = filter @last_server_timestamp = last_server_timestamp + @deferred = deferred end private - attr_reader :work_package, :filter, :last_server_timestamp + attr_reader :work_package, :filter, :last_server_timestamp, :deferred def wrapper_data_attributes stimulus_controller = "work-packages--activities-tab--index" @@ -77,7 +78,7 @@ def polling_interval end def adding_comment_allowed? - User.current.allowed_in_project?(:add_work_package_notes, @work_package.project) + User.current.allowed_in_work_package?(:add_work_package_notes, @work_package) end end end diff --git a/app/components/work_packages/activities_tab/journals/index_component.html.erb b/app/components/work_packages/activities_tab/journals/index_component.html.erb index aa761592d114..c541372a20a6 100644 --- a/app/components/work_packages/activities_tab/journals/index_component.html.erb +++ b/app/components/work_packages/activities_tab/journals/index_component.html.erb @@ -1,34 +1,61 @@ <%= - component_wrapper(class: "work-packages-activities-tab-journals-index-component") do - flex_layout(data: { test_selector: "op-wp-journals-#{filter}-#{journal_sorting}" }) do |journals_index_wrapper_container| - journals_index_wrapper_container.with_row( - classes: "work-packages-activities-tab-journals-index-component--journals-inner-container", - mb: inner_container_margin_bottom - ) do - flex_layout(id: insert_target_modifier_id, - data: { test_selector: "op-wp-journals-container" }) do |journals_index_container| - if empty_state? - journals_index_container.with_row(mt: 2, mb: 3) do - render( - WorkPackages::ActivitiesTab::Journals::EmptyComponent.new - ) + unless deferred + component_wrapper(class: "work-packages-activities-tab-journals-index-component") do + flex_layout(data: { test_selector: "op-wp-journals-#{filter}-#{journal_sorting}" }) do |journals_index_wrapper_container| + journals_index_wrapper_container.with_row( + classes: "work-packages-activities-tab-journals-index-component--journals-inner-container", + mb: inner_container_margin_bottom + ) do + flex_layout(id: insert_target_modifier_id, + data: { test_selector: "op-wp-journals-container" }) do |journals_index_container| + if empty_state? + journals_index_container.with_row(mt: 2, mb: 3) do + render( + WorkPackages::ActivitiesTab::Journals::EmptyComponent.new + ) + end + end + + if !journal_sorting_desc? && journals.count > MAX_RECENT_JOURNALS + journals_index_container.with_row do + helpers.turbo_frame_tag("work-package-activities-tab-content-older-journals", src: work_package_activities_path(work_package, filter:, deferred: true)) + end end - end - journals.each do |journal| - journals_index_container.with_row do - render(WorkPackages::ActivitiesTab::Journals::ItemComponent.new( - journal:, filter:, - grouped_emoji_reactions: wp_journals_grouped_emoji_reactions[journal.id] - )) + recent_journals.each do |journal| + journals_index_container.with_row do + render(WorkPackages::ActivitiesTab::Journals::ItemComponent.new( + journal:, filter:, + grouped_emoji_reactions: wp_journals_grouped_emoji_reactions[journal.id] + )) + end + end + + if journal_sorting_desc? && journals.count > MAX_RECENT_JOURNALS + journals_index_container.with_row do + helpers.turbo_frame_tag("work-package-activities-tab-content-older-journals", src: work_package_activities_path(work_package, filter:, deferred: true)) + end end end end - end - unless empty_state? || journal_sorting_desc? - journals_index_wrapper_container - .with_row(classes: "work-packages-activities-tab-journals-index-component--stem-connection") + unless empty_state? || journal_sorting_desc? + journals_index_wrapper_container + .with_row(classes: "work-packages-activities-tab-journals-index-component--stem-connection") + end + end + end + else + helpers.turbo_frame_tag("work-package-activities-tab-content-older-journals") do + flex_layout do |older_journals_container| + older_journals.each do |journal| + older_journals_container.with_row do + render(WorkPackages::ActivitiesTab::Journals::ItemComponent.new( + journal:, filter:, + grouped_emoji_reactions: wp_journals_grouped_emoji_reactions[journal.id] + )) + end + end end end end diff --git a/app/components/work_packages/activities_tab/journals/index_component.rb b/app/components/work_packages/activities_tab/journals/index_component.rb index a2120884a006..469f0e3d4e11 100644 --- a/app/components/work_packages/activities_tab/journals/index_component.rb +++ b/app/components/work_packages/activities_tab/journals/index_component.rb @@ -32,21 +32,24 @@ module WorkPackages module ActivitiesTab module Journals class IndexComponent < ApplicationComponent + MAX_RECENT_JOURNALS = 30 + include ApplicationHelper include OpPrimer::ComponentHelpers include OpTurbo::Streamable include WorkPackages::ActivitiesTab::SharedHelpers - def initialize(work_package:, filter: :all) + def initialize(work_package:, filter: :all, deferred: false) super @work_package = work_package @filter = filter + @deferred = deferred end private - attr_reader :work_package, :filter + attr_reader :work_package, :filter, :deferred def insert_target_modified? true @@ -60,16 +63,48 @@ def journal_sorting_desc? journal_sorting == "desc" end - def journals + def base_journals work_package .journals - .includes(:user, :notifications) + .includes( + :user, + :customizable_journals, + :attachable_journals, + :storable_journals, + :notifications + ) .reorder(version: journal_sorting) .with_sequence_version end + def journals + API::V3::Activities::ActivityEagerLoadingWrapper.wrap(base_journals) + end + + def recent_journals + recent_ones = if journal_sorting_desc? + base_journals.first(MAX_RECENT_JOURNALS) + else + base_journals.last(MAX_RECENT_JOURNALS) + end + + API::V3::Activities::ActivityEagerLoadingWrapper.wrap(recent_ones) + end + + def older_journals + older_ones = if journal_sorting_desc? + base_journals.offset(MAX_RECENT_JOURNALS) + else + total = base_journals.count + limit = [total - MAX_RECENT_JOURNALS, 0].max + base_journals.limit(limit) + end + + API::V3::Activities::ActivityEagerLoadingWrapper.wrap(older_ones) + end + def journal_with_notes - journals.where.not(notes: "") + base_journals.where.not(notes: "") end def wp_journals_grouped_emoji_reactions diff --git a/app/components/work_packages/activities_tab/journals/item_component.rb b/app/components/work_packages/activities_tab/journals/item_component.rb index 0030013b1fdd..af733a7474c4 100644 --- a/app/components/work_packages/activities_tab/journals/item_component.rb +++ b/app/components/work_packages/activities_tab/journals/item_component.rb @@ -75,7 +75,7 @@ def updated? end def has_unread_notifications? - journal.notifications.where(read_ian: false, recipient_id: User.current.id).any? + journal.has_unread_notifications_for_user?(User.current) end def notification_on_details? diff --git a/app/components/work_packages/activities_tab/journals/item_component/details.html.erb b/app/components/work_packages/activities_tab/journals/item_component/details.html.erb index 1398253b079c..e2ca551782ed 100644 --- a/app/components/work_packages/activities_tab/journals/item_component/details.html.erb +++ b/app/components/work_packages/activities_tab/journals/item_component/details.html.erb @@ -14,23 +14,20 @@ when :only_comments render_empty_line(details_container) unless journal.notes.blank? && !journal.noop? when :only_changes - if journal.details.any? + if has_details? render_details_header(details_container) render_details(details_container) end else - if journal.details.any? + if has_details? if journal.notes.present? render_details(details_container) else render_details_header(details_container) render_details(details_container) end - elsif journal.notes.present? - render_details(details_container) else - # empty row to render the flex layout with its minimal height - render_empty_line(details_container) + render_details(details_container) end end end diff --git a/app/components/work_packages/activities_tab/journals/item_component/details.rb b/app/components/work_packages/activities_tab/journals/item_component/details.rb index 59f0c5ed4fbf..529fbcd6ab95 100644 --- a/app/components/work_packages/activities_tab/journals/item_component/details.rb +++ b/app/components/work_packages/activities_tab/journals/item_component/details.rb @@ -53,6 +53,14 @@ def wrapper_uniq_by journal.id end + def journal_details + @journal_details ||= journal.details + end + + def has_details? + @has_details ||= journal_details.any? + end + def render_details_header(details_container) details_container.with_row( flex_layout: true, @@ -223,7 +231,7 @@ def skip_rendering_details? end def render_journal_details(details_container_inner) - journal.details.each do |detail| + journal_details.each do |detail| rendered_detail = journal.render_detail(detail) render_single_detail(details_container_inner, rendered_detail) if rendered_detail.present? 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 new file mode 100644 index 000000000000..2ca7ef37b7e4 --- /dev/null +++ b/app/components/work_packages/exports/generate/modal_dialog_component.html.erb @@ -0,0 +1,57 @@ +<%= render(Primer::Alpha::Dialog.new( + title: I18n.t("pdf_generator.dialog.title"), + id: MODAL_ID, + size: :large +)) do |dialog| + dialog.with_header(variant: :large) + dialog.with_body do + primer_form_with( + url: generate_pdf_work_package_path(work_package), + data: { turbo: false }, + id: GENERATE_PDF_FORM_ID + ) do |form| + flex_layout do |modal_body| + generate_selects.each_with_index do |entry, index| + modal_body.with_row(mt: index == 0 ? 0 : 3) do + render(Primer::Alpha::Select.new( + name: entry[:name], + label: entry[:label], + caption: entry[:caption], + size: :medium, + input_width: :small, + value: entry[:options].find { |e| e[:default] }[:value]) + ) do |component| + entry[:options].each do |entry| + component.option(label: entry[:label], value: entry[:value]) + end + end + end + end + modal_body.with_row(mt: 3) do + render Primer::Alpha::TextField.new( + name: :header_text_right, + label: I18n.t("pdf_generator.dialog.header_right.label"), + caption: I18n.t("pdf_generator.dialog.header_right.caption"), + visually_hide_label: false, + value: default_header_text_right + ) + end + modal_body.with_row(mt: 3) do + render Primer::Alpha::TextField.new( + name: :footer_text_center, + label: I18n.t("pdf_generator.dialog.footer_center.label"), + caption: I18n.t("pdf_generator.dialog.footer_center.caption"), + visually_hide_label: false, + value: default_footer_text_center + ) + end + end + end + end + dialog.with_footer do + render(Primer::ButtonComponent.new(data: { "close-dialog-id": MODAL_ID })) { I18n.t(:button_cancel) } + render(Primer::ButtonComponent.new( + scheme: :primary, type: :submit, form: GENERATE_PDF_FORM_ID, + data: { "close-dialog-id": MODAL_ID })) { I18n.t("pdf_generator.dialog.submit") } + end +end %> diff --git a/app/components/work_packages/exports/generate/modal_dialog_component.rb b/app/components/work_packages/exports/generate/modal_dialog_component.rb new file mode 100644 index 000000000000..bf64944cabb6 --- /dev/null +++ b/app/components/work_packages/exports/generate/modal_dialog_component.rb @@ -0,0 +1,128 @@ +# 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. +# ++ +require "text/hyphen" + +module WorkPackages + module Exports + module Generate + class ModalDialogComponent < ApplicationComponent + MODAL_ID = "op-work-package-generate-pdf-dialog" + GENERATE_PDF_FORM_ID = "op-work-packages-generate-pdf-dialog-form" + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + attr_reader :work_package, :params + + def initialize(work_package:, params:) + super + + @work_package = work_package + @params = params + end + + def default_header_text_right + "#{work_package.type} ##{work_package.id}" + end + + def default_footer_text_center + work_package.subject + end + + def generate_selects + [ + { + name: "hyphenation", + label: I18n.t("pdf_generator.dialog.hyphenation.label"), + caption: I18n.t("pdf_generator.dialog.hyphenation.caption"), + options: hyphenation_options + }, + { + name: "paper_size", + label: I18n.t("pdf_generator.dialog.paper_size.label"), + caption: I18n.t("pdf_generator.dialog.paper_size.caption"), + options: paper_size_options + } + ] + end + + def hyphenation_options + # This is a list of languages that are supported by the hyphenation library + # https://rubygems.org/gems/text-hyphen + # The labels are the language names in the language itself (NOT to be put I18n) + supported_languages = [ + { label: "Català", value: "ca" }, + { label: "Dansk", value: "da" }, + { label: "Deutsch", value: "de" }, + { label: "Eesti", value: "et" }, + { label: "English (UK)", value: "en_uk" }, + { label: "English (USA)", value: "en_us" }, + { label: "Español", value: "es" }, + { label: "Euskara", value: "eu" }, + { label: "Français", value: "fr" }, + { label: "Gaeilge", value: "ga" }, + { label: "Hrvatski", value: "hr" }, + { label: "Indonesia", value: "id" }, + { label: "Interlingua", value: "ia" }, + { label: "Italiano", value: "it" }, + { label: "Magyar", value: "hu" }, + { label: "Melayu", value: "ms" }, + { label: "Nederlands", value: "nl" }, + { label: "Norsk", value: "no" }, + { label: "Polski", value: "pl" }, + { label: "Português", value: "pt" }, + { label: "Slovenčina", value: "sk" }, + { label: "Suomi", value: "fi" }, + { label: "Svenska", value: "sv" }, + { label: "Ísland", value: "is" }, + { label: "Čeština", value: "cs" }, + { label: "Монгол", value: "mn" }, + { label: "Русский", value: "ru" } + ] + + [{ value: "", label: "Off", default: true }].concat(supported_languages) + end + + def paper_size_options + [ + { label: "A4", value: "A4", default: true }, + { label: "A3", value: "A3" }, + { label: "A2", value: "A2" }, + { label: "A1", value: "A1" }, + { label: "A0", value: "A0" }, + { label: "Executive", value: "EXECUTIVE" }, + { label: "Folio", value: "FOLIO" }, + { label: "Letter", value: "LETTER" }, + { label: "Tabloid", value: "TABLOID" } + ] + end + end + end + end +end diff --git a/app/components/work_packages/hover_card_component.html.erb b/app/components/work_packages/hover_card_component.html.erb index a9f99822eb9d..5b11c6009870 100644 --- a/app/components/work_packages/hover_card_component.html.erb +++ b/app/components/work_packages/hover_card_component.html.erb @@ -1,16 +1,8 @@ <%= if @work_package.present? grid_layout('op-wp-hover-card', tag: :div) do |grid| - grid.with_area(:type, tag: :div) do - render(WorkPackages::HighlightedTypeComponent.new(work_package: @work_package, font_size: :small)) - end - - grid.with_area(:status, tag: :div) do - render WorkPackages::StatusBadgeComponent.new(status: @work_package.status) - end - - grid.with_area(:id, tag: :div) do - render(Primer::Beta::Text.new(font_size: :small,color: :muted)) { "##{@work_package.id}" } + grid.with_area(:info, tag: :div) do + render(WorkPackages::InfoLineComponent.new(work_package: @work_package)) end grid.with_area(:project, tag: :div) do diff --git a/app/components/work_packages/hover_card_component.sass b/app/components/work_packages/hover_card_component.sass index c28d444389a0..c44bc23ce480 100644 --- a/app/components/work_packages/hover_card_component.sass +++ b/app/components/work_packages/hover_card_component.sass @@ -1,11 +1,11 @@ .op-wp-hover-card display: grid align-items: center - grid-template-columns: auto auto auto auto 1fr + grid-template-columns: auto auto 1fr grid-template-rows: max-content 1fr auto grid-row-gap: calc(var(--stack-gap-condensed) / 2) grid-column-gap: var(--stack-gap-condensed) - grid-template-areas: "type id status project project project" "subject subject subject subject subject subject" "assignee assignee assignee assignee dates dates" + grid-template-areas: "info project project project" "subject subject subject subject" "assignee assignee dates dates" overflow: hidden &--project diff --git a/app/components/work_packages/info_line_component.html.erb b/app/components/work_packages/info_line_component.html.erb new file mode 100644 index 000000000000..a62250927f19 --- /dev/null +++ b/app/components/work_packages/info_line_component.html.erb @@ -0,0 +1,19 @@ +<%= + flex_layout do |flex| + flex.with_column(mr: 2) do + render(WorkPackages::HighlightedTypeComponent.new(work_package: @work_package, font_size: :small)) + end + flex.with_column(mr: 2) do + render(Primer::Beta::Link.new( + href: url_for(controller: "/work_packages", action: "show", id: @work_package), + title: @work_package.subject, + target: :_blank, + font_size: @font_size, + color: :muted + )) { "##{@work_package.id}" } + end + flex.with_column do + render WorkPackages::StatusBadgeComponent.new(status: @work_package.status) + end + end +%> diff --git a/app/components/work_packages/info_line_component.rb b/app/components/work_packages/info_line_component.rb new file mode 100644 index 000000000000..e4ea984ad648 --- /dev/null +++ b/app/components/work_packages/info_line_component.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class WorkPackages::InfoLineComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + def initialize(work_package:, font_size: :small) + super + + @work_package = work_package + @font_size = font_size + end +end diff --git a/app/contracts/custom_fields/hierarchy/insert_item_contract.rb b/app/contracts/custom_fields/hierarchy/insert_item_contract.rb index d633761738c8..043e1d90225b 100644 --- a/app/contracts/custom_fields/hierarchy/insert_item_contract.rb +++ b/app/contracts/custom_fields/hierarchy/insert_item_contract.rb @@ -40,13 +40,22 @@ class InsertItemContract < Dry::Validation::Contract end rule(:parent) do + next if schema_error?(:parent) + key.failure("must exist") unless value.persisted? end rule(:label) do - if CustomField::Hierarchy::Item.exists?(parent_id: values[:parent], label: value) - key.failure(:not_unique) - end + next if schema_error?(:parent) + + key.failure(:not_unique) if values[:parent].children.exists?(label: value) + end + + rule(:short) do + next if schema_error?(:parent) + next unless key? + + key.failure(:not_unique) if values[:parent].children.exists?(short: value) end end end diff --git a/app/contracts/custom_fields/hierarchy/update_item_contract.rb b/app/contracts/custom_fields/hierarchy/update_item_contract.rb index 8899cfb8a44e..614cd121ab7e 100644 --- a/app/contracts/custom_fields/hierarchy/update_item_contract.rb +++ b/app/contracts/custom_fields/hierarchy/update_item_contract.rb @@ -31,6 +31,8 @@ module CustomFields module Hierarchy class UpdateItemContract < Dry::Validation::Contract + config.messages.backend = :i18n + params do required(:item).filled(type?: CustomField::Hierarchy::Item) optional(:label).filled(:string) @@ -38,16 +40,21 @@ class UpdateItemContract < Dry::Validation::Contract end rule(:item) do - key.failure("must exist") if value.new_record? - key.failure("must not be a root item") if value.root? + key.failure(:not_persisted) if value.new_record? + key.failure(:root_item) if value.root? end rule(:label) do + next if schema_error?(:item) + + key.failure(:not_unique) if values[:item].siblings.exists?(label: value) + end + + rule(:short) do + next if schema_error?(:item) next unless key? - if values[:item].siblings.where(label: value).any? - key.failure("must be unique at the same hierarchical level") - end + key.failure(:not_unique) if values[:item].siblings.exists?(short: value) end end end diff --git a/app/controllers/account_controller.rb b/app/controllers/account_controller.rb index 3d7f75cceebb..15d2c0e1e1ae 100644 --- a/app/controllers/account_controller.rb +++ b/app/controllers/account_controller.rb @@ -377,7 +377,7 @@ def authenticate_user end end - def password_authentication(username, password) + def password_authentication(username, password) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity user = User.try_to_login(username, password, session) if user.nil? # login failed, now try to find out why and do the appropriate thing @@ -386,6 +386,7 @@ def password_authentication(username, password) # correct password if not user.active? account_inactive(user, flash_now: true) + render status: :unprocessable_entity elsif user.force_password_change return if redirect_if_password_change_not_allowed(user) @@ -397,12 +398,15 @@ def password_authentication(username, password) show_user_name: true) else flash_and_log_invalid_credentials + render status: :unprocessable_entity end elsif user and user.invited? invited_account_not_activated(user) + render status: :unprocessable_entity else # incorrect password flash_and_log_invalid_credentials + render status: :unprocessable_entity end elsif user.new_record? onthefly_creation_failed(user, login: user.login, ldap_auth_source_id: user.ldap_auth_source_id) diff --git a/app/controllers/admin/custom_fields/hierarchy/items_controller.rb b/app/controllers/admin/custom_fields/hierarchy/items_controller.rb index b40a44c57067..2b27fba7eafd 100644 --- a/app/controllers/admin/custom_fields/hierarchy/items_controller.rb +++ b/app/controllers/admin/custom_fields/hierarchy/items_controller.rb @@ -56,7 +56,7 @@ def show end def new - @new_item = ::CustomField::Hierarchy::Item.new(parent: @active_item) + @new_item = ::CustomField::Hierarchy::Item.new(parent: @active_item, sort_order: params[:position]) end def edit; end @@ -65,10 +65,15 @@ def create item_service .insert_item(**item_input) .either( - ->(_) { redirect_to(new_child_custom_field_item_path(@custom_field, @active_item), status: :see_other) }, - ->(validation_result) do + lambda do |item| + redirect_to( + new_child_custom_field_item_path(@custom_field, @active_item, position: item.sort_order + 1), + status: :see_other + ) + end, + lambda do |validation_result| add_errors_to_form(validation_result) - render action: :new + render :new end ) end @@ -77,16 +82,23 @@ def update item_service .update_item(item: @active_item, label: item_input[:label], short: item_input[:short]) .either( - ->(_) do + lambda do |_| redirect_to(custom_field_item_path(@custom_field, @active_item.parent), status: :see_other) end, - ->(validation_result) do + lambda do |validation_result| add_errors_to_edit_form(validation_result) render action: :edit end ) end + def move + item_service + .reorder_item(item: @active_item, new_sort_order: params.require(:new_sort_order)) + + redirect_to(custom_field_item_path(@custom_field, @active_item.parent), status: :see_other) + end + def destroy item_service .delete_branch(item: @active_item) @@ -111,12 +123,13 @@ def item_service def item_input input = { parent: @active_item, label: params[:label] } input[:short] = params[:short] if params[:short].present? + input[:sort_order] = params[:sort_order].to_i if params[:sort_order].present? input end def add_errors_to_form(validation_result) - @new_item = ::CustomField::Hierarchy::Item.new(parent: @active_item, **validation_result.to_h) + @new_item = ::CustomField::Hierarchy::Item.new(**item_input) validation_result.errors(full: true).to_h.each do |attribute, errors| @new_item.errors.add(attribute, errors.join(", ")) end diff --git a/app/controllers/angular_controller.rb b/app/controllers/angular_controller.rb index 15c0d4bcf29e..6d9f02807901 100644 --- a/app/controllers/angular_controller.rb +++ b/app/controllers/angular_controller.rb @@ -34,7 +34,11 @@ class AngularController < ApplicationController def empty_layout # Frontend will handle rendering # but we will need to render with layout - render html: "", layout: "angular/angular" + respond_to do |format| + format.html do + render html: "", layout: "angular/angular" + end + end end def login_back_url_params diff --git a/app/controllers/attribute_help_texts_controller.rb b/app/controllers/attribute_help_texts_controller.rb index 44aa15e6085f..062a2733782f 100644 --- a/app/controllers/attribute_help_texts_controller.rb +++ b/app/controllers/attribute_help_texts_controller.rb @@ -44,7 +44,7 @@ def new def edit; end - def create + def create # rubocop:disable Metrics/AbcSize call = ::AttributeHelpTexts::CreateService .new(user: current_user) .call(permitted_params_with_attachments) @@ -54,12 +54,12 @@ def create redirect_to attribute_help_texts_path(tab: call.result.attribute_scope) else @attribute_help_text = call.result - flash[:error] = call.message || I18n.t("notice_internal_server_error") - render action: "new" + flash.now[:error] = call.message || I18n.t("notice_internal_server_error") + render action: "new", status: :unprocessable_entity end end - def update + def update # rubocop:disable Metrics/AbcSize call = ::AttributeHelpTexts::UpdateService .new(user: current_user, model: @attribute_help_text) .call(permitted_params_with_attachments) @@ -68,8 +68,8 @@ def update flash[:notice] = t(:notice_successful_update) redirect_to attribute_help_texts_path(tab: @attribute_help_text.attribute_scope) else - flash[:error] = call.message || I18n.t("notice_internal_server_error") - render action: "edit" + flash.now[:error] = call.message || I18n.t("notice_internal_server_error") + render action: :edit, status: :unprocessable_entity end end diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index b41f73c47598..286f766e469b 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -38,7 +38,7 @@ def new @category = @project.categories.build end - def create + def create # rubocop:disable Metrics/AbcSize @category = @project.categories.build @category.attributes = permitted_params.category @@ -55,7 +55,7 @@ def create else respond_to do |format| format.html do - render action: :new + render action: :new, status: :unprocessable_entity end format.js do render(:update) { |page| page.alert(@category.errors.full_messages.join('\n')) } @@ -70,7 +70,7 @@ def update flash[:notice] = I18n.t(:notice_successful_update) redirect_to project_settings_categories_path(@project) else - render action: "edit" + render action: :edit, status: :unprocessable_entity end end diff --git a/app/controllers/colors_controller.rb b/app/controllers/colors_controller.rb index ed553c1ce1cf..9aba619dff5c 100644 --- a/app/controllers/colors_controller.rb +++ b/app/controllers/colors_controller.rb @@ -69,7 +69,7 @@ def create redirect_to colors_path else flash.now[:error] = I18n.t(:error_color_could_not_be_saved) - render action: "new" + render action: :new, status: :unprocessable_entity end end @@ -81,7 +81,7 @@ def update redirect_to colors_path else flash.now[:error] = I18n.t(:error_color_could_not_be_saved) - render action: "edit" + render action: :edit, status: :unprocessable_entity end end diff --git a/app/controllers/concerns/accounts/registration.rb b/app/controllers/concerns/accounts/registration.rb index ca11d39edfc5..655b185151f4 100644 --- a/app/controllers/concerns/accounts/registration.rb +++ b/app/controllers/concerns/accounts/registration.rb @@ -118,7 +118,7 @@ def respond_for_registered_user(user) def onthefly_creation_failed(user, auth_source_options = {}) @user = user session[:auth_source_registration] = auth_source_options unless auth_source_options.empty? - render action: "register" + render action: "register", status: :unprocessable_entity end def self_registration_disabled diff --git a/app/controllers/concerns/accounts/user_password_change.rb b/app/controllers/concerns/accounts/user_password_change.rb index c4b602d7c956..e222950ef566 100644 --- a/app/controllers/concerns/accounts/user_password_change.rb +++ b/app/controllers/concerns/accounts/user_password_change.rb @@ -65,7 +65,7 @@ def render_password_change(user, message, show_user_name: false) flash[:error] = message unless message.nil? @user = user @username = user.login - render "my/password", locals: { show_user_name: } + render "my/password", locals: { show_user_name: }, status: :unprocessable_entity end ## diff --git a/app/controllers/concerns/custom_fields/shared_actions.rb b/app/controllers/concerns/custom_fields/shared_actions.rb index 44928935ff06..3fab2fca5724 100644 --- a/app/controllers/concerns/custom_fields/shared_actions.rb +++ b/app/controllers/concerns/custom_fields/shared_actions.rb @@ -47,7 +47,7 @@ def edit_path(custom_field, params = {}) end end - def create + def create # rubocop:disable Metrics/AbcSize call = ::CustomFields::CreateService .new(user: current_user) .call(get_custom_field_params.merge(type: permitted_params.custom_field_type)) @@ -58,7 +58,7 @@ def create redirect_to index_path(call.result, tab: call.result.class.name) else @custom_field = call.result || new_custom_field - render action: "new" + render action: :new, status: :unprocessable_entity end end @@ -76,7 +76,7 @@ def perform_update(custom_field_params) call_hook(:controller_custom_fields_edit_after_save, custom_field: @custom_field) redirect_back_or_default(edit_path(@custom_field, id: @custom_field.id)) else - render action: "edit" + render action: :edit, status: :unprocessable_entity end end diff --git a/app/controllers/custom_actions_controller.rb b/app/controllers/custom_actions_controller.rb index a7296b639590..a2ec48df759f 100644 --- a/app/controllers/custom_actions_controller.rb +++ b/app/controllers/custom_actions_controller.rb @@ -77,7 +77,7 @@ def index_or_render(render_action) call.on_failure do @custom_action = call.result - render action: render_action + render action: render_action, status: :unprocessable_entity end } end diff --git a/app/controllers/enterprises_controller.rb b/app/controllers/enterprises_controller.rb index a011ec00375c..4f07aa400121 100644 --- a/app/controllers/enterprises_controller.rb +++ b/app/controllers/enterprises_controller.rb @@ -47,7 +47,7 @@ def show end end - def create + def create # rubocop:disable Metrics/AbcSize @token = EnterpriseToken.current || EnterpriseToken.new saved_encoded_token = @token.encoded_token @token.encoded_token = params[:enterprise_token][:encoded_token] @@ -64,7 +64,7 @@ def create @current_token = @token || EnterpriseToken.new end respond_to do |format| - format.html { render action: :show } + format.html { render action: :show, status: :unprocessable_entity } format.json { render json: { description: @token.errors.full_messages.join(", ") }, status: :bad_request } end end diff --git a/app/controllers/enumerations_controller.rb b/app/controllers/enumerations_controller.rb index e9dfc3587347..c1b2be5ec7d0 100644 --- a/app/controllers/enumerations_controller.rb +++ b/app/controllers/enumerations_controller.rb @@ -58,7 +58,7 @@ def create flash[:notice] = I18n.t(:notice_successful_create) redirect_to action: "index", type: @enumeration.type else - render action: "new" + render action: :new, status: :unprocessable_entity end end @@ -70,7 +70,7 @@ def update flash[:notice] = I18n.t(:notice_successful_update) redirect_to enumerations_path(type: @enumeration.type) else - render action: "edit" + render action: :edit, status: :unprocessable_entity end end @@ -96,7 +96,7 @@ def move redirect_to enumerations_path else flash.now[:error] = I18n.t(:error_type_could_not_be_saved) - render action: "edit" + render action: :edit, status: :unprocessable_entity end end diff --git a/app/controllers/forums_controller.rb b/app/controllers/forums_controller.rb index 1def837243cc..434b7686ffd9 100644 --- a/app/controllers/forums_controller.rb +++ b/app/controllers/forums_controller.rb @@ -111,7 +111,7 @@ def move flash[:notice] = t(:notice_successful_update) else flash.now[:error] = t("forum_could_not_be_saved") - render action: "edit" + render action: :edit, status: :unprocessable_entity end redirect_to action: "index" end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 1694553a9fc3..82de14c39b87 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -64,7 +64,7 @@ def create flash[:notice] = I18n.t(:notice_successful_create) redirect_to(groups_path) else - render action: :new + render action: :new, status: :unprocessable_entity end end @@ -77,7 +77,7 @@ def update flash[:notice] = I18n.t(:notice_successful_update) redirect_to(groups_path) else - render action: "edit" + render action: :edit, status: :unprocessable_entity end end diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb index d9e44f4aa302..ee25591af6a2 100644 --- a/app/controllers/messages_controller.rb +++ b/app/controllers/messages_controller.rb @@ -89,7 +89,7 @@ def create redirect_to topic_path(@message) else - render action: "new" + render action: :new, status: :unprocessable_entity end end @@ -118,7 +118,7 @@ def update @message.reload redirect_to topic_path(@message.root, r: @message.parent_id && @message.id) else - render action: "edit" + render action: :edit, status: :unprocessable_entity end end diff --git a/app/controllers/news_controller.rb b/app/controllers/news_controller.rb index 1a36644447a0..4e851f18bb96 100644 --- a/app/controllers/news_controller.rb +++ b/app/controllers/news_controller.rb @@ -82,7 +82,7 @@ def create redirect_to controller: "/news", action: "index", project_id: @project else @news = call.result - render action: "new" + render action: :new, status: :unprocessable_entity end end @@ -96,7 +96,7 @@ def update redirect_to action: "show", id: @news else @news = call.result - render action: "edit" + render action: :edit, status: :unprocessable_entity end end diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index 0d32225d584b..04686f9e908b 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -61,7 +61,7 @@ def create redirect_to action: :show, id: result.id else @application = result - render action: :new + render action: :new, status: :unprocessable_entity end end @@ -79,7 +79,7 @@ def update redirect_to action: :index else flash[:error] = call.errors.full_messages.join('\n') - render action: :edit + render action: :edit, status: :unprocessable_entity end end diff --git a/app/controllers/placeholder_users_controller.rb b/app/controllers/placeholder_users_controller.rb index 82d6660e274c..65ce10e47e47 100644 --- a/app/controllers/placeholder_users_controller.rb +++ b/app/controllers/placeholder_users_controller.rb @@ -79,7 +79,7 @@ def edit @individual_principal = @placeholder_user end - def create + def create # rubocop:disable Metrics/AbcSize service = PlaceholderUsers::CreateService.new(user: User.current) service_result = service.call(permitted_params.placeholder_user) @placeholder_user = service_result.result @@ -94,13 +94,13 @@ def create else respond_to do |format| format.html do - render action: :new + render action: :new, status: :unprocessable_entity end end end end - def update + def update # rubocop:disable Metrics/AbcSize service_result = PlaceholderUsers::UpdateService .new(user: User.current, model: @placeholder_user) @@ -118,7 +118,7 @@ def update respond_to do |format| format.html do - render action: :edit + render action: :edit, status: :unprocessable_entity end end end diff --git a/app/controllers/projects/identifier_controller.rb b/app/controllers/projects/identifier_controller.rb index 57462a5a5314..f8527c7d4ba9 100644 --- a/app/controllers/projects/identifier_controller.rb +++ b/app/controllers/projects/identifier_controller.rb @@ -42,7 +42,7 @@ def update flash[:notice] = I18n.t(:notice_successful_update) redirect_to project_settings_general_path(@project) else - render action: "show" + render action: "show", status: :unprocessable_entity end end end diff --git a/app/controllers/projects/settings/custom_fields_controller.rb b/app/controllers/projects/settings/custom_fields_controller.rb index 8c8009fc8983..ffd9d0b6244d 100644 --- a/app/controllers/projects/settings/custom_fields_controller.rb +++ b/app/controllers/projects/settings/custom_fields_controller.rb @@ -30,9 +30,7 @@ class Projects::Settings::CustomFieldsController < Projects::SettingsController menu_item :settings_custom_fields def show - @wp_custom_fields = WorkPackageCustomField - .order("lower(name)") - .where.not(field_format: "hierarchy") # TODO: Remove after enabling hierarchy fields + @wp_custom_fields = WorkPackageCustomField.order("lower(name)") end def update diff --git a/app/controllers/roles_controller.rb b/app/controllers/roles_controller.rb index d4c2e0d08fbb..33d2bcbd23f4 100644 --- a/app/controllers/roles_controller.rb +++ b/app/controllers/roles_controller.rb @@ -65,7 +65,7 @@ def create else @roles = roles_scope - render action: "new" + render action: :new, status: :unprocessable_entity end end @@ -77,7 +77,7 @@ def update flash[:notice] = I18n.t(:notice_successful_update) redirect_to action: "index" else - render action: "edit" + render action: :edit, status: :unprocessable_entity end end @@ -111,7 +111,7 @@ def bulk_update else @calls = calls @permissions = visible_permissions - render action: "report" + render action: "report", status: :unprocessable_entity end end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index fa15c4ef341a..cd097715a846 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -54,7 +54,7 @@ def create flash[:notice] = I18n.t(:notice_successful_create) redirect_to action: "index" else - render action: "new" + render action: :new, status: :unprocessable_entity end end @@ -65,7 +65,7 @@ def update flash[:notice] = I18n.t(:notice_successful_update) redirect_to action: "index" else - render action: "edit" + render action: :edit, status: :unprocessable_entity end end diff --git a/app/controllers/sys_controller.rb b/app/controllers/sys_controller.rb index b12087027fee..95e9132540a5 100644 --- a/app/controllers/sys_controller.rb +++ b/app/controllers/sys_controller.rb @@ -43,6 +43,24 @@ def repo_auth end end + def fetch_changesets + projects = [] + if params[:id] + projects << Project.active.has_module(:repository).find_by!(identifier: params[:id]) + else + projects = Project.active.has_module(:repository) + .includes(:repository).references(:repositories) + end + projects.each do |project| + if project.repository + project.repository.fetch_changesets + end + end + head :ok + rescue ActiveRecord::RecordNotFound + head :not_found + end + private def authorized?(project, user) diff --git a/app/controllers/types_controller.rb b/app/controllers/types_controller.rb index 7deb8391fcd2..c46d8cb09eee 100644 --- a/app/controllers/types_controller.rb +++ b/app/controllers/types_controller.rb @@ -60,7 +60,7 @@ def edit end end - def create + def create # rubocop:disable Metrics/AbcSize CreateTypeService .new(current_user) .call(permitted_type_params, copy_workflow_from: params[:copy_workflow_from]) do |call| @@ -73,7 +73,7 @@ def create call.on_failure do |result| flash[:error] = result.errors.full_messages.join("\n") load_projects_and_types - render action: "new" + render action: :new, status: :unprocessable_entity end end end @@ -88,7 +88,7 @@ def update call.on_failure do |result| flash[:error] = result.errors.full_messages.join("\n") - render_edit_tab(@type) + render_edit_tab(@type, status: :unprocessable_entity) end end end @@ -99,7 +99,7 @@ def move redirect_to types_path else flash.now[:error] = I18n.t(:error_type_could_not_be_saved) - render action: "edit" + render action: :edit, status: :unprocessable_entity end end @@ -139,12 +139,12 @@ def redirect_to_type_tab_path(type, notice) notice:) end - def render_edit_tab(type) + def render_edit_tab(type, status: :ok) @tab = params[:tab] @projects = Project.all @type = type - render action: "edit" + render action: :edit, status: end def show_local_breadcrumb diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 006335e2d3cc..d667d29e9c42 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -93,11 +93,11 @@ def create flash[:notice] = I18n.t(:notice_successful_create) redirect_to(params[:continue] ? new_user_path : helpers.allowed_management_user_profile_path(@user)) else - render action: "new" + render action: :new, status: :unprocessable_entity end end - def update + def update # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity update_params = build_user_update_params call = ::Users::UpdateService.new(model: @user, user: current_user).call(update_params) @@ -125,7 +125,7 @@ def update respond_to do |format| format.html do flash[:notice] = I18n.t(:notice_successful_update) - render action: :edit + redirect_to action: :edit end end else @@ -136,7 +136,7 @@ def update respond_to do |format| format.html do - render action: :edit + render action: :edit, status: :unprocessable_entity end end end diff --git a/app/controllers/versions_controller.rb b/app/controllers/versions_controller.rb index 87d25fe223d8..15d5ea200f56 100644 --- a/app/controllers/versions_controller.rb +++ b/app/controllers/versions_controller.rb @@ -159,7 +159,7 @@ def render_cu(call, success_message, failure_action) flash[:notice] = t(success_message) redirect_back_or_version_settings else - render action: failure_action + render action: failure_action, status: :unprocessable_entity end end diff --git a/app/controllers/wiki_controller.rb b/app/controllers/wiki_controller.rb index 95538c1a28ca..f3c8853a9445 100644 --- a/app/controllers/wiki_controller.rb +++ b/app/controllers/wiki_controller.rb @@ -144,12 +144,12 @@ def create flash[:notice] = I18n.t(:notice_successful_create) redirect_to_show else - render action: "new" + render action: :new, status: :unprocessable_entity end end # Creates a new page or updates an existing one - def update + def update # rubocop:disable Metrics/AbcSize @old_title = params[:id] @page = @wiki.find_or_new_page(@old_title) if @page.nil? @@ -170,12 +170,12 @@ def update flash[:notice] = I18n.t(:notice_successful_update) redirect_to_show else - render action: "edit" + render action: :edit, status: :unprocessable_entity end rescue ActiveRecord::StaleObjectError # Optimistic locking exception flash.now[:error] = I18n.t(:notice_locking_conflict) - render action: "edit" + render action: :edit, status: :unprocessable_entity end # rename a page diff --git a/app/controllers/wiki_menu_items_controller.rb b/app/controllers/wiki_menu_items_controller.rb index 1a035cc1da69..51ad5b23af87 100644 --- a/app/controllers/wiki_menu_items_controller.rb +++ b/app/controllers/wiki_menu_items_controller.rb @@ -62,7 +62,7 @@ def edit get_data_from_params(params) end - def update + def update # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity wiki_menu_setting = wiki_menu_item_params[:setting] parent_wiki_menu_item = params[:parent_wiki_menu_item] @@ -105,7 +105,7 @@ def update else respond_to do |format| format.html do - render action: "edit", id: @page + render action: :edit, id: @page, status: :unprocessable_entity end end end diff --git a/app/controllers/work_package_children_controller.rb b/app/controllers/work_package_children_controller.rb new file mode 100644 index 000000000000..2c95e3cc8a39 --- /dev/null +++ b/app/controllers/work_package_children_controller.rb @@ -0,0 +1,105 @@ +# 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 WorkPackageChildrenController < ApplicationController + include OpTurbo::ComponentStream + include OpTurbo::DialogStreamHelper + + before_action :set_work_package + + before_action :authorize # Short-circuit early if not authorized + + before_action :set_child, except: %i[new create] + before_action :set_relations, except: %i[new create] + + def new + component = WorkPackageRelationsTab::AddWorkPackageChildDialogComponent + .new(work_package: @work_package) + respond_with_dialog(component) + end + + def create + target_work_package_id = params[:work_package][:id] + target_child_work_package = WorkPackage.find(target_work_package_id) + + target_child_work_package.parent = @work_package + + if target_child_work_package.save + @children = @work_package.children.visible + @relations = @work_package.relations.visible + + component = WorkPackageRelationsTab::IndexComponent.new( + work_package: @work_package, + relations: @relations, + children: @children + ) + replace_via_turbo_stream(component:) + update_flash_message_via_turbo_stream( + message: I18n.t(:notice_successful_update), scheme: :success + ) + respond_with_turbo_streams + end + end + + def destroy + @child.parent = nil + + if @child.save + @work_package.reload + @children = @work_package.children.visible + component = WorkPackageRelationsTab::IndexComponent.new( + work_package: @work_package, + relations: @relations, + children: @children + ) + replace_via_turbo_stream(component:) + update_flash_message_via_turbo_stream( + message: I18n.t(:notice_successful_update), scheme: :success + ) + + respond_with_turbo_streams + end + end + + private + + def set_work_package + @work_package = WorkPackage.find(params[:work_package_id]) + @project = @work_package.project + end + + def set_child + @child = WorkPackage.find(params[:id]) + end + + def set_relations + @relations = @work_package.relations.visible + end +end diff --git a/app/controllers/work_package_relations_controller.rb b/app/controllers/work_package_relations_controller.rb new file mode 100644 index 000000000000..837e4e10a190 --- /dev/null +++ b/app/controllers/work_package_relations_controller.rb @@ -0,0 +1,132 @@ +# 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 WorkPackageRelationsController < ApplicationController + include OpTurbo::ComponentStream + include OpTurbo::DialogStreamHelper + + before_action :set_work_package + before_action :set_relation, except: %i[new create] + before_action :authorize + + def new + @relation = @work_package.relations.build( + from: @work_package, + relation_type: params[:relation_type] + ) + + respond_with_dialog( + WorkPackageRelationsTab::WorkPackageRelationDialogComponent + .new(work_package: @work_package, relation: @relation) + ) + end + + def edit + respond_with_dialog( + WorkPackageRelationsTab::WorkPackageRelationDialogComponent + .new(work_package: @work_package, relation: @relation) + ) + end + + def create + service_result = Relations::CreateService.new(user: current_user) + .call(create_relation_params) + + if service_result.success? + @work_package.reload + component = WorkPackageRelationsTab::IndexComponent.new(work_package: @work_package, + relations: @work_package.relations, + children: @work_package.children) + replace_via_turbo_stream(component:) + respond_with_turbo_streams + else + respond_with_turbo_streams(status: :unprocessable_entity) + end + end + + def update + service_result = Relations::UpdateService + .new(user: current_user, + model: @relation) + .call(update_relation_params) + + if service_result.success? + @work_package.reload + component = WorkPackageRelationsTab::IndexComponent.new(work_package: @work_package, + relations: @work_package.relations, + children: @work_package.children) + replace_via_turbo_stream(component:) + respond_with_turbo_streams + else + respond_with_turbo_streams(status: :unprocessable_entity) + end + end + + def destroy + service_result = Relations::DeleteService.new(user: current_user, model: @relation).call + + if service_result.success? + @children = WorkPackage.where(parent_id: @work_package.id) + @relations = @work_package + .relations + .reload + .includes(:to, :from) + + component = WorkPackageRelationsTab::IndexComponent.new(work_package: @work_package, + relations: @relations, + children: @children) + replace_via_turbo_stream(component:) + respond_with_turbo_streams + else + respond_with_turbo_streams(status: :unprocessable_entity) + end + end + + private + + def set_work_package + @work_package = WorkPackage.find(params[:work_package_id]) + end + + def set_relation + @relation = @work_package.relations.find(params[:id]) + end + + def create_relation_params + params.require(:relation) + .permit(:relation_type, :to_id, :description) + .merge(from_id: @work_package.id) + end + + def update_relation_params + params.require(:relation) + .permit(:description) + end +end diff --git a/app/controllers/work_package_relations_tab_controller.rb b/app/controllers/work_package_relations_tab_controller.rb new file mode 100644 index 000000000000..c8d9ab9ad75b --- /dev/null +++ b/app/controllers/work_package_relations_tab_controller.rb @@ -0,0 +1,66 @@ +#-- 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 WorkPackageRelationsTabController < ApplicationController + include OpTurbo::ComponentStream + + before_action :set_work_package + before_action :authorize_global + + def index + @children = WorkPackage.where(parent_id: @work_package.id) + @relations = @work_package + .relations + .includes(:to, :from) + + component = WorkPackageRelationsTab::IndexComponent.new( + work_package: @work_package, + relations: @relations, + children: @children + ) + + respond_to do |format| + format.html do + render(component, layout: false) + end + format.turbo_stream do + replace_via_turbo_stream(component:) + render turbo_stream: turbo_streams + end + end + end + + private + + def set_work_package + @work_package = WorkPackage.find(params[:work_package_id]) + @project = @work_package.project # required for authorization via before_action + rescue ActiveRecord::RecordNotFound + render_404 + end +end diff --git a/app/controllers/work_packages/activities_tab_controller.rb b/app/controllers/work_packages/activities_tab_controller.rb index 93ac7ba6cde9..2b824dc6b5e6 100644 --- a/app/controllers/work_packages/activities_tab_controller.rb +++ b/app/controllers/work_packages/activities_tab_controller.rb @@ -43,7 +43,8 @@ def index WorkPackages::ActivitiesTab::IndexComponent.new( work_package: @work_package, filter: @filter, - last_server_timestamp: get_current_server_timestamp + last_server_timestamp: get_current_server_timestamp, + deferred: ActiveRecord::Type::Boolean.new.cast(params[:deferred]) ), layout: false ) @@ -374,6 +375,7 @@ def rerender_journals_with_updated_notification(journals, last_update_timestamp, # alternative approach in order to bypass the notification join issue in relation with the sequence_version query Notification .where(journal_id: journals.pluck(:id)) + .where(recipient_id: User.current.id) .where("notifications.updated_at > ?", last_update_timestamp) .find_each do |notification| update_item_show_component( diff --git a/app/controllers/work_packages/bulk_controller.rb b/app/controllers/work_packages/bulk_controller.rb index 8030b41f7ce5..f2938d63a950 100644 --- a/app/controllers/work_packages/bulk_controller.rb +++ b/app/controllers/work_packages/bulk_controller.rb @@ -52,7 +52,7 @@ def update else flash[:error] = bulk_error_message(@work_packages, @call) setup_edit - render action: :edit + render action: :edit, status: :unprocessable_entity end end diff --git a/app/controllers/work_packages_controller.rb b/app/controllers/work_packages_controller.rb index c17fec995245..81e3613a662e 100644 --- a/app/controllers/work_packages_controller.rb +++ b/app/controllers/work_packages_controller.rb @@ -37,13 +37,13 @@ class WorkPackagesController < ApplicationController accept_key_auth :index, :show before_action :authorize_on_work_package, - :project, only: :show + :project, only: %i[show generate_pdf_dialog generate_pdf] before_action :load_and_authorize_in_optional_project, :check_allowed_export, :protect_from_unauthorized_export, only: %i[index export_dialog] before_action :authorize, only: :show_conflict_flash_message - authorization_checked! :index, :show, :export_dialog + authorization_checked! :index, :show, :export_dialog, :generate_pdf_dialog, :generate_pdf before_action :load_and_validate_query, only: :index, unless: -> { request.format.html? } before_action :load_work_packages, only: :index, if: -> { request.format.atom? } @@ -93,6 +93,19 @@ def export_dialog respond_with_dialog WorkPackages::Exports::ModalDialogComponent.new(query: @query, project: @project, title: params[:title]) end + def generate_pdf_dialog + respond_with_dialog WorkPackages::Exports::Generate::ModalDialogComponent.new(work_package: work_package, params: params) + end + + def generate_pdf + exporter = WorkPackage::PDFExport::DocumentGenerator.new(work_package, params) + export = exporter.export! + send_data(export.content, type: export.mime_type, filename: export.title) + rescue ::Exports::ExportError => e + flash[:error] = e.message + redirect_back(fallback_location: work_package_path(work_package)) + end + def show_conflict_flash_message scheme = params[:scheme]&.to_sym || :danger diff --git a/app/forms/custom_fields/details_form.rb b/app/forms/custom_fields/details_form.rb index 096d55ced7dd..a73381e8cd33 100644 --- a/app/forms/custom_fields/details_form.rb +++ b/app/forms/custom_fields/details_form.rb @@ -61,12 +61,6 @@ class DetailsForm < ApplicationForm caption: I18n.t("custom_fields.instructions.is_filter") ) - details_form.check_box( - name: :searchable, - label: I18n.t("activerecord.attributes.custom_field.searchable"), - caption: I18n.t("custom_fields.instructions.searchable") - ) - details_form.submit(name: :submit, label: I18n.t(:button_save), scheme: :default) end end diff --git a/app/forms/custom_fields/hierarchy/item_form.rb b/app/forms/custom_fields/hierarchy/item_form.rb index 988c3a3b0a37..be2e5185feb8 100644 --- a/app/forms/custom_fields/hierarchy/item_form.rb +++ b/app/forms/custom_fields/hierarchy/item_form.rb @@ -30,6 +30,8 @@ module CustomFields module Hierarchy class ItemForm < ApplicationForm form do |item_form| + item_form.hidden name: :sort_order, value: @target_item.sort_order + item_form.group(layout: :horizontal) do |input_group| input_group.text_field( name: :label, @@ -37,6 +39,7 @@ class ItemForm < ApplicationForm value: @target_item.label, visually_hide_label: true, required: true, + autofocus: true, placeholder: I18n.t("custom_fields.admin.items.placeholder.label"), validation_message: validation_message_for(:label) ) @@ -48,11 +51,12 @@ class ItemForm < ApplicationForm visually_hide_label: true, full_width: false, required: false, - placeholder: I18n.t("custom_fields.admin.items.placeholder.short") + placeholder: I18n.t("custom_fields.admin.items.placeholder.short"), + validation_message: validation_message_for(:short) ) end - item_form.group(layout: :horizontal) do |button_group| + item_form.group(layout: :horizontal, align_self: :end) do |button_group| button_group.button(name: :cancel, tag: :a, label: I18n.t(:button_cancel), diff --git a/app/forms/projects/custom_fields/custom_field_mapping_form.rb b/app/forms/projects/custom_fields/custom_field_mapping_form.rb index f637ea059934..1a3f71f3dbe6 100644 --- a/app/forms/projects/custom_fields/custom_field_mapping_form.rb +++ b/app/forms/projects/custom_fields/custom_field_mapping_form.rb @@ -31,8 +31,7 @@ class CustomFieldMappingForm < ApplicationForm include OpPrimer::ComponentHelpers form do |form| - form.group(layout: :vertical) do |group| - group.project_autocompleter( + form.project_autocompleter( name: :id, label: Project.model_name.human, visually_hide_label: true, @@ -48,13 +47,12 @@ class CustomFieldMappingForm < ApplicationForm } ) - group.check_box( + form.check_box( name: :include_sub_projects, label: I18n.t(:label_include_sub_projects), checked: false, label_arguments: { class: "no-wrap" } ) - end end def initialize(project_mapping:) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 079034343892..50e94f057851 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -291,9 +291,23 @@ def theme_options_for_select ] end + def body_data_attributes(local_assigns) + { + controller: "application", + relative_url_root: root_path, + overflowing_identifier: ".__overflowing_body", + rendered_at: Time.zone.now.iso8601, + turbo: local_assigns[:turbo_opt_out] ? "false" : nil + }.merge(user_theme_data_attributes) + .compact + end + def user_theme_data_attributes mode, _theme_suffix = User.current.pref.theme.split("_", 2) - "data-color-mode=#{mode} data-#{mode}-theme=#{User.current.pref.theme}" + { + color_mode: mode, + "#{mode}_theme": User.current.pref.theme + } end def highlight_default_language(lang_options) @@ -451,7 +465,7 @@ def translate_language(lang_code) end def link_to_content_update(text, url_params = {}, html_options = {}) - link_to(text, url_params, html_options) + link_to(text, url_params, html_options.reverse_merge(target: "_top")) end def password_complexity_requirements diff --git a/app/helpers/colors_helper.rb b/app/helpers/colors_helper.rb index ac61eaa323ff..a049b8a5abc0 100644 --- a/app/helpers/colors_helper.rb +++ b/app/helpers/colors_helper.rb @@ -49,33 +49,41 @@ def selected_color(colored_thing) ## def color_css Color.find_each do |color| - set_background_colors_for class_name: ".__hl_inline_color_#{color.id}_dot::before", hexcode: color.hexcode - set_foreground_colors_for class_name: ".__hl_inline_color_#{color.id}_text", hexcode: color.hexcode + set_background_colors_for(class_name: ".#{hl_inline_class('color', color)}_dot::before", color:) + set_foreground_colors_for(class_name: ".#{hl_inline_class('color', color)}_text", color:) end end # # Styles to display the color of attributes (type, status etc.) for example in the WP view ## - def resource_color_css(name, scope) + def resource_color_css(name, scope, inline_foreground: false) scope.includes(:color).find_each do |entry| color = entry.color if color.nil? - concat ".__hl_inline_#{name}_#{entry.id}::before { display: none }\n" + concat ".#{hl_inline_class(name, entry)}::before { display: none }\n" next end - if name === "type" - set_foreground_colors_for class_name: ".__hl_inline_#{name}_#{entry.id}", hexcode: color.hexcode + if inline_foreground + set_foreground_colors_for(class_name: ".#{hl_inline_class(name, entry)}", color:) else - set_background_colors_for class_name: ".__hl_inline_#{name}_#{entry.id}::before", hexcode: color.hexcode + set_background_colors_for(class_name: ".#{hl_inline_class(name, entry)}::before", color:) end - set_background_colors_for class_name: ".__hl_background_#{name}_#{entry.id}", hexcode: color.hexcode + set_background_colors_for(class_name: ".#{hl_background_class(name, entry)}", color:) end end + def hl_inline_class(name, model) + "__hl_inline_#{name}_#{model.id}" + end + + def hl_background_class(name, model) + "__hl_background_#{name}_#{model.id}" + end + def icon_for_color(color, options = {}) return unless color @@ -90,10 +98,10 @@ def color_by_variable(variable) DesignColor.find_by(variable:)&.hexcode end - def set_background_colors_for(class_name:, hexcode:) + def set_background_colors_for(class_name:, color:) mode = User.current.pref.theme.split("_", 2)[0] - concat "#{class_name} { #{default_color_styles(hexcode)} }" + concat "#{class_name} { #{default_color_styles(color.hexcode)} }" if mode == "dark" concat "#{class_name} { #{default_variables_dark} }" concat "#{class_name} { #{highlighted_background_dark} }" @@ -103,10 +111,10 @@ def set_background_colors_for(class_name:, hexcode:) end end - def set_foreground_colors_for(class_name:, hexcode:) + def set_foreground_colors_for(class_name:, color:) mode = User.current.pref.theme.split("_", 2)[0] - concat "#{class_name} { #{default_color_styles(hexcode)} }" + concat "#{class_name} { #{default_color_styles(color.hexcode)} }" if mode == "dark" concat "#{class_name} { #{default_variables_dark} }" concat "#{class_name} { #{highlighted_foreground_dark} }" diff --git a/app/helpers/custom_fields_helper.rb b/app/helpers/custom_fields_helper.rb index 229e80f835aa..f9880b5d14e6 100644 --- a/app/helpers/custom_fields_helper.rb +++ b/app/helpers/custom_fields_helper.rb @@ -68,7 +68,7 @@ def custom_field_tag(name, custom_value) # rubocop:disable Metrics/AbcSize,Metri field_name = "#{name}[custom_field_values][#{custom_field.id}]" field_id = "#{name}_custom_field_values_#{custom_field.id}" - field_format = OpenProject::CustomFieldFormat.find_by_name(custom_field.field_format) + field_format = OpenProject::CustomFieldFormat.find_by(name: custom_field.field_format) tag = case field_format.try(:edit_as) when "date" @@ -146,7 +146,7 @@ def custom_field_tag_with_label(name, custom_value) def custom_field_tag_for_bulk_edit(name, custom_field, project = nil) # rubocop:disable Metrics/AbcSize field_name = "#{name}[custom_field_values][#{custom_field.id}]" field_id = "#{name}_custom_field_values_#{custom_field.id}" - field_format = OpenProject::CustomFieldFormat.find_by_name(custom_field.field_format) + field_format = OpenProject::CustomFieldFormat.find_by(name: custom_field.field_format) case field_format.try(:edit_as) when "date" @@ -172,6 +172,19 @@ def custom_field_tag_for_bulk_edit(name, custom_field, project = nil) # rubocop: options_for_select(base_options + custom_field.possible_values_options(project)), id: field_id, multiple: custom_field.multi_value?) + when "hierarchy" + base_options = [[I18n.t(:label_no_change_option), ""]] + result = CustomFields::Hierarchy::HierarchicalItemService.new + .get_descendants(item: custom_field.hierarchy_root, include_self: false) + .either( + ->(items) { items }, + ->(_) { [] } + ) + options = base_options + result.map do |item| + label = item.short.present? ? "#{item.label} (#{item.short})" : item.label + [label, item.id] + end + styled_select_tag(field_name, options_for_select(options), id: field_id, multiple: custom_field.multi_value?) else styled_text_field_tag(field_name, "", id: field_id) end @@ -186,33 +199,28 @@ def show_value(custom_value) # Return a string used to display a custom value def format_value(value, custom_field) - custom_value = CustomValue.new(custom_field:, - value:) - - custom_value.formatted_value + CustomValue.new(custom_field:, value:).formatted_value end # Return an array of custom field formats which can be used in select_tag def custom_field_formats_for_select(custom_field) - hierarchy_if_deactivated = lambda do |format| - format.name == "hierarchy" && !OpenProject::FeatureDecisions.custom_field_of_type_hierarchy_active? + OpenProject::CustomFieldFormat.all_for_field(custom_field) + .sort_by(&:order) + .reject { |format| format.label.nil? } + .map do |custom_field_format| + [label_for_custom_field_format(custom_field_format.name), custom_field_format.name] end - - OpenProject::CustomFieldFormat - .all_for_field(custom_field) - .sort_by(&:order) - .reject { |format| format.label.nil? } - .reject(&hierarchy_if_deactivated) - .map do |custom_field_format| - [label_for_custom_field_format(custom_field_format.name), custom_field_format.name] - end end def label_for_custom_field_format(format_string) - format = OpenProject::CustomFieldFormat.find_by_name(format_string) + format = OpenProject::CustomFieldFormat.find_by(name: format_string) + return "" if format.nil? - if format - format.label.is_a?(Proc) ? format.label.call : I18n.t(format.label) - end + 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)})" : "" + + "#{label}#{suffix}" end end diff --git a/app/helpers/journals_helper.rb b/app/helpers/journals_helper.rb index 85217057380e..821798a5cec2 100644 --- a/app/helpers/journals_helper.rb +++ b/app/helpers/journals_helper.rb @@ -38,7 +38,8 @@ def back_to_activity_page_url(activity_page) in ["users", user_id] user_url(user_id) in ["work_packages", work_package_id] - work_package_url(work_package_id) + # Sometimes the parameter provided is erroneous (having an extra ') for unknown reasons. + work_package_url(work_package_id.chomp("'")) else nil end diff --git a/app/helpers/password_helper.rb b/app/helpers/password_helper.rb index 8f4419a583c5..398a824aac49 100644 --- a/app/helpers/password_helper.rb +++ b/app/helpers/password_helper.rb @@ -47,16 +47,18 @@ def password_confirmation_form_for(record, options = {}, &) # when the user is internally authenticated. def password_confirmation_form_tag(url_for_options = {}, options = {}, &) if password_confirmation_required? - data = options.fetch(:data, {}) - options[:data] = password_confirmation_data_attribute(data) + options[:data] ||= {} + options[:data] = password_confirmation_data_attribute(options[:data]) end form_tag(url_for_options, options, &) end def password_confirmation_data_attribute(with_data = {}) + controller = with_data.fetch(:controller, "") + if password_confirmation_required? - with_data.merge("request-for-confirmation": true) + with_data.merge(controller: "#{controller} password-confirmation-dialog".strip) else with_data end diff --git a/app/helpers/secure_headers_helper.rb b/app/helpers/secure_headers_helper.rb new file mode 100644 index 000000000000..714c20a476f3 --- /dev/null +++ b/app/helpers/secure_headers_helper.rb @@ -0,0 +1,38 @@ +#-- 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 SecureHeadersHelper + ## + # Output a rails +csp_meta_tag+ compatible tag + # while we're still using the +secure_headers+ gem. + def secure_header_csp_meta_tag + tag :meta, + name: "csp-nonce", + content: content_security_policy_script_nonce + end +end diff --git a/app/models/attachment.rb b/app/models/attachment.rb index b1d7ae1942c3..f1eb8ad9cae3 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -163,8 +163,12 @@ def is_pdf? content_type == "application/pdf" end + def is_html? + content_type == "text/html" + end + def is_text? - content_type.match?(/\Atext\/.+/) + content_type.match?(/\Atext\/.+/) && !is_html? end def is_diff? diff --git a/app/models/custom_actions/actions/custom_field.rb b/app/models/custom_actions/actions/custom_field.rb index c1b0a82eba89..374b3406218a 100644 --- a/app/models/custom_actions/actions/custom_field.rb +++ b/app/models/custom_actions/actions/custom_field.rb @@ -49,7 +49,7 @@ def apply(work_package) def self.all WorkPackageCustomField - .order(:name) + .usable_as_custom_action .map do |cf| create_subclass(cf) end diff --git a/app/models/custom_actions/conditions/role.rb b/app/models/custom_actions/conditions/role.rb index d6c7756b03f1..12b09dc82671 100644 --- a/app/models/custom_actions/conditions/role.rb +++ b/app/models/custom_actions/conditions/role.rb @@ -39,7 +39,7 @@ def key def roles_in_project(work_packages, user) with_request_store(projects_of(work_packages)) do |projects| - projects.map do |project| + projects.filter_map do |project| user.roles_for_project(project) end.flatten end diff --git a/app/models/custom_field/hierarchy/item.rb b/app/models/custom_field/hierarchy/item.rb index acdbaaf96062..dcb12cc27597 100644 --- a/app/models/custom_field/hierarchy/item.rb +++ b/app/models/custom_field/hierarchy/item.rb @@ -32,7 +32,7 @@ class CustomField::Hierarchy::Item < ApplicationRecord self.table_name = "hierarchical_items" belongs_to :custom_field - has_closure_tree order: "sort_order", numeric_order: true, dependent: :destroy + has_closure_tree order: "sort_order", numeric_order: true, dont_order_roots: true, dependent: :destroy scope :including_children, -> { includes(children: :children) } end diff --git a/app/models/custom_field/order_statements.rb b/app/models/custom_field/order_statements.rb index 98c46c8c8167..86984954feea 100644 --- a/app/models/custom_field/order_statements.rb +++ b/app/models/custom_field/order_statements.rb @@ -27,152 +27,144 @@ #++ module CustomField::OrderStatements - # Returns a ORDER BY clause that can used to sort customized - # objects by their value of the custom field. - # Returns false, if the custom field can not be used for sorting. - def order_statements + # Returns the expression to use in ORDER BY clause to sort objects by their + # value of the custom field. + def order_statement + case field_format + when "string", "date", "bool", "link", "int", "float", "list", "user", "version" + "cf_order_#{id}.value" + end + end + + # Returns the join statement that is required to sort objects by their value + # of the custom field. + def order_join_statement case field_format - when "list" - [order_by_list_sql] when "string", "date", "bool", "link" - [coalesce_select_custom_value_as_string] - when "int", "float" - # Make the database cast values into numeric - # Postgresql will raise an error if a value can not be casted! - # CustomValue validations should ensure that it doesn't occur - [select_custom_value_as_decimal] + join_for_order_by_string_sql + when "int" + join_for_order_by_int_sql + when "float" + join_for_order_by_float_sql + when "list" + join_for_order_by_list_sql when "user" - [order_by_user_sql] + join_for_order_by_user_sql when "version" - [order_by_version_sql] + join_for_order_by_version_sql end end - ## - # Returns the null handling for the given direction - def null_handling(asc) - return unless %w[int float].include?(field_format) - + # Returns the ORDER BY option defining order of objects without value for the + # custom field. + def order_null_handling(asc) null_direction = asc ? "FIRST" : "LAST" Arel.sql("NULLS #{null_direction}") end - # Returns the grouping result - # which differ for multi-value select fields, - # because in this case we do want the primary CV values + # Returns the expression to use in GROUP BY (and ORDER BY) clause to group + # objects by their value of the custom field. def group_by_statement - return order_statements unless field_format == "list" + return unless can_be_used_for_grouping? - if multi_value? - # We want to return the internal IDs in the case of grouping - select_custom_values_as_group - else - coalesce_select_custom_value_as_string - end + order_statement end - private - - def coalesce_select_custom_value_as_string - # COALESCE is here to make sure that blank and NULL values are sorted equally - <<-SQL.squish - COALESCE(#{select_custom_value_as_string}, '') - SQL - end + # 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" - def select_custom_value_as_string - <<-SQL.squish - ( - SELECT cv_sort.value - FROM #{CustomValue.quoted_table_name} cv_sort - WHERE #{cv_sort_only_custom_field_condition_sql} - LIMIT 1 - ) - SQL + # MIN needed to not add this column to group by, ANY_VALUE can be used when + # minimum required PostgreSQL becomes 16 + "MIN(cf_order_#{id}.ids)" end - def select_custom_values_as_group - <<-SQL.squish - COALESCE( - ( - SELECT string_agg(cv_sort.value, '.') - FROM #{CustomValue.quoted_table_name} cv_sort - WHERE #{cv_sort_only_custom_field_condition_sql} - AND cv_sort.value IS NOT NULL - ), - '' - ) - SQL - end + # Returns the join statement that is required to group objects by their value + # of the custom field. + def group_by_join_statement + return unless can_be_used_for_grouping? - def select_custom_value_as_decimal - <<-SQL.squish - ( - SELECT CAST(cv_sort.value AS decimal(60,3)) - FROM #{CustomValue.quoted_table_name} cv_sort - WHERE #{cv_sort_only_custom_field_condition_sql} - AND cv_sort.value <> '' - AND cv_sort.value IS NOT NULL - LIMIT 1 - ) - SQL + order_join_statement end - def order_by_list_sql - columns = multi_value? ? "array_agg(co_sort.position ORDER BY co_sort.position)" : "co_sort.position" - limit = multi_value? ? "" : "LIMIT 1" + private + def can_be_used_for_grouping? = field_format.in?(%w[list date bool int float string link]) + + # Template for all the join statements. + # + # For single value custom fields the join ensures single value for every + # customized object using DISTINCT ON and selecting first value by id of + # custom value: + # + # LEFT OUTER JOIN ( + # SELECT DISTINCT ON (cv.customized_id), cv.customized_id, xxx "value" + # FROM custom_values cv + # WHERE … + # ORDER BY cv.customized_id, cv.id + # ) cf_order_NNN ON cf_order_NNN.customized_id = … + # + # For multi value custom fields the GROUP BY and value aggregate function + # ensure single value for every customized object: + # + # LEFT OUTER JOIN ( + # SELECT cv.customized_id, ARRAY_AGG(xxx ORDERY BY yyy) "value" + # FROM custom_values cv + # WHERE … + # GROUP BY cv.customized_id, cv.id + # ) cf_order_NNN ON cf_order_NNN.customized_id = … + # + def join_for_order_sql(value:, add_select: nil, join: nil, multi_value: false) <<-SQL.squish - ( - SELECT #{columns} - FROM #{CustomOption.quoted_table_name} co_sort - INNER JOIN #{CustomValue.quoted_table_name} cv_sort - ON cv_sort.value IS NOT NULL AND cv_sort.value != '' AND co_sort.id = cv_sort.value::bigint - WHERE #{cv_sort_only_custom_field_condition_sql} - #{limit} - ) + LEFT OUTER JOIN ( + SELECT + #{multi_value ? '' : 'DISTINCT ON (cv.customized_id)'} + cv.customized_id + , #{value} "value" + #{", #{add_select}" if add_select} + FROM #{CustomValue.quoted_table_name} cv + #{join} + WHERE cv.customized_type = #{CustomValue.connection.quote(self.class.customized_class.name)} + AND cv.custom_field_id = #{id} + AND cv.value IS NOT NULL + AND cv.value != '' + #{multi_value ? 'GROUP BY cv.customized_id' : 'ORDER BY cv.customized_id, cv.id'} + ) cf_order_#{id} + ON cf_order_#{id}.customized_id = #{self.class.customized_class.quoted_table_name}.id SQL end - def order_by_user_sql - columns_array = "ARRAY[cv_user.lastname, cv_user.firstname, cv_user.mail]" + def join_for_order_by_string_sql = join_for_order_sql(value: "cv.value") - columns = multi_value? ? "array_agg(#{columns_array} ORDER BY #{columns_array})" : columns_array - limit = multi_value? ? "" : "LIMIT 1" + def join_for_order_by_int_sql = join_for_order_sql(value: "cv.value::decimal(60)") - <<-SQL.squish - ( - SELECT #{columns} - FROM #{User.quoted_table_name} cv_user - INNER JOIN #{CustomValue.quoted_table_name} cv_sort - ON cv_sort.value IS NOT NULL AND cv_sort.value != '' AND cv_user.id = cv_sort.value::bigint - WHERE #{cv_sort_only_custom_field_condition_sql} - #{limit} - ) - SQL + def join_for_order_by_float_sql = join_for_order_sql(value: "cv.value::double precision") + + def join_for_order_by_list_sql + join_for_order_sql( + value: multi_value? ? "ARRAY_AGG(co.position ORDER BY co.position)" : "co.position", + add_select: "#{multi_value? ? "ARRAY_TO_STRING(ARRAY_AGG(cv.value ORDER BY co.position), '.')" : 'cv.value'} ids", + join: "INNER JOIN #{CustomOption.quoted_table_name} co ON co.id = cv.value::bigint", + multi_value: + ) end - def order_by_version_sql - columns = multi_value? ? "array_agg(cv_version.name ORDER BY cv_version.name)" : "cv_version.name" - limit = multi_value? ? "" : "LIMIT 1" + def join_for_order_by_user_sql + columns_array = "ARRAY[users.lastname, users.firstname, users.mail]" - <<-SQL.squish - ( - SELECT #{columns} - FROM #{Version.quoted_table_name} cv_version - INNER JOIN #{CustomValue.quoted_table_name} cv_sort - ON cv_sort.value IS NOT NULL AND cv_sort.value != '' AND cv_version.id = cv_sort.value::bigint - WHERE #{cv_sort_only_custom_field_condition_sql} - #{limit} - ) - SQL + join_for_order_sql( + value: multi_value? ? "ARRAY_AGG(#{columns_array} ORDER BY #{columns_array})" : columns_array, + join: "INNER JOIN #{User.quoted_table_name} users ON users.id = cv.value::bigint", + multi_value: + ) end - def cv_sort_only_custom_field_condition_sql - <<-SQL.squish - cv_sort.customized_type='#{self.class.customized_class.name}' - AND cv_sort.customized_id=#{self.class.customized_class.quoted_table_name}.id - AND cv_sort.custom_field_id=#{id} - SQL + def join_for_order_by_version_sql + join_for_order_sql( + value: multi_value? ? "array_agg(versions.name ORDER BY versions.name)" : "versions.name", + join: "INNER JOIN #{Version.quoted_table_name} versions ON versions.id = cv.value::bigint", + multi_value: + ) end end diff --git a/app/models/custom_value.rb b/app/models/custom_value.rb index 5208e36cc605..60e6f317e019 100644 --- a/app/models/custom_value.rb +++ b/app/models/custom_value.rb @@ -59,7 +59,7 @@ def value=(val) def strategy @strategy ||= begin format = custom_field&.field_format || "empty" - OpenProject::CustomFieldFormat.find_by_name(format).formatter.new(self) # rubocop:disable Rails/DynamicFindBy + OpenProject::CustomFieldFormat.find_by(name: format).formatter.new(self) end end diff --git a/app/models/custom_value/hierarchy_strategy.rb b/app/models/custom_value/hierarchy_strategy.rb index e87133e0fab1..2ce05be7336b 100644 --- a/app/models/custom_value/hierarchy_strategy.rb +++ b/app/models/custom_value/hierarchy_strategy.rb @@ -28,11 +28,14 @@ class CustomValue::HierarchyStrategy < CustomValue::ARObjectStrategy def validate_type_of_value - raise NotImplementedError - end + item = CustomField::Hierarchy::Item.find_by(id: value) + return :invalid if item.nil? + + parent = custom_field.hierarchy_root - def typed_value - raise NotImplementedError + if persistence_service.descendant_of?(item:, parent:).failure? + :inclusion + end end private @@ -42,11 +45,17 @@ def ar_class end def ar_object(value) - option = CustomField::Hierarchy::Item.find_by(id: value.to_s) - if option.nil? + item = CustomField::Hierarchy::Item.find_by(id: value.to_s) + if item.nil? "#{value} #{I18n.t(:label_not_found)}" + elsif item.short.present? + "#{item.label} (#{item.short})" else - option.value + item.label end end + + def persistence_service + @persistence_service ||= CustomFields::Hierarchy::HierarchicalItemService.new + end end diff --git a/app/models/custom_value/link_strategy.rb b/app/models/custom_value/link_strategy.rb index 3e6951d6dec7..091c01de5d63 100644 --- a/app/models/custom_value/link_strategy.rb +++ b/app/models/custom_value/link_strategy.rb @@ -28,7 +28,7 @@ class CustomValue::LinkStrategy < CustomValue::FormatStrategy def typed_value - formatted_value + formatted_value if value.present? end def parse_value(val) diff --git a/app/models/journal.rb b/app/models/journal.rb index 04f66834916a..95da1c3ed52a 100644 --- a/app/models/journal.rb +++ b/app/models/journal.rb @@ -177,6 +177,16 @@ def has_cause? cause_type.present? end + def has_unread_notifications_for_user?(user) + # we optionally set the instance variable @unread_notifications in the ActivityEagerLoadingWrapper + # in order to avoid N+1 queries + if instance_variable_defined?(:@unread_notifications) + @unread_notifications&.any? { |notification| notification.recipient_id == user.id } + else + notifications.where(read_ian: false, recipient_id: user.id).any? + end + end + private def has_file_links? diff --git a/app/models/journal/base_journal.rb b/app/models/journal/base_journal.rb index ea9cd0fa0941..826b7c627671 100644 --- a/app/models/journal/base_journal.rb +++ b/app/models/journal/base_journal.rb @@ -37,7 +37,7 @@ def journaled_attributes end def self.journaled_attributes - @journaled_attributes ||= column_names.map(&:to_sym) - excluded_attributes + @journaled_attributes ||= columns.reject(&:virtual?).map { |col| col.name.to_sym } - excluded_attributes end def self.excluded_attributes diff --git a/app/models/permitted_params.rb b/app/models/permitted_params.rb index 34d36be2cfc4..ba22823578ba 100644 --- a/app/models/permitted_params.rb +++ b/app/models/permitted_params.rb @@ -474,7 +474,6 @@ def self.permitted_attributes :searchable, :admin_only, :default_value, - :possible_values, :multi_value, :content_right_to_left, :custom_field_section_id, diff --git a/app/models/project.rb b/app/models/project.rb index cb377c6b5ad2..89191c1eba8b 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -86,6 +86,7 @@ class Project < ApplicationRecord has_many :notification_settings, dependent: :destroy 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 store_attribute :settings, :deactivate_work_package_attachments, :boolean @@ -174,7 +175,7 @@ class Project < ApplicationRecord scopes :activated_time_activity, :visible_with_activated_time_activity - enum status_code: { + enum :status_code, { on_track: 0, at_risk: 1, off_track: 2, diff --git a/app/models/project/gate.rb b/app/models/project/gate.rb new file mode 100644 index 000000000000..46ee6fffdb74 --- /dev/null +++ b/app/models/project/gate.rb @@ -0,0 +1,42 @@ +#-- 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 Project::Gate < Project::LifeCycleStep + alias_attribute :date, :start_date + + # 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 + if end_date.present? + errors.add(:base, :end_date_not_allowed) + end + end +end diff --git a/app/models/project/gate_definition.rb b/app/models/project/gate_definition.rb new file mode 100644 index 000000000000..a51561075728 --- /dev/null +++ b/app/models/project/gate_definition.rb @@ -0,0 +1,35 @@ +#-- 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 Project::GateDefinition < Project::LifeCycleStepDefinition + has_many :gates, # Alias for life_cycle_steps + class_name: "Project::Gate", + foreign_key: :definition_id, + inverse_of: :definition, + dependent: :destroy +end diff --git a/app/models/project/life_cycle_step.rb b/app/models/project/life_cycle_step.rb new file mode 100644 index 000000000000..dfa4b11efda9 --- /dev/null +++ b/app/models/project/life_cycle_step.rb @@ -0,0 +1,49 @@ +#-- 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 Project::LifeCycleStep < ApplicationRecord + belongs_to :project, optional: false + belongs_to :definition, + optional: false, + class_name: "Project::LifeCycleStepDefinition" + has_many :work_packages, inverse_of: :project_life_cycle_step, dependent: :nullify + + attr_readonly :definition_id, :type + + validates :type, inclusion: { in: %w[Project::Stage Project::Gate], message: :must_be_a_stage_or_gate } + + 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." + end + + super + end +end diff --git a/app/models/project/life_cycle_step_definition.rb b/app/models/project/life_cycle_step_definition.rb new file mode 100644 index 000000000000..d511cc284cc4 --- /dev/null +++ b/app/models/project/life_cycle_step_definition.rb @@ -0,0 +1,54 @@ +#-- 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 Project::LifeCycleStepDefinition < ApplicationRecord + has_many :life_cycle_steps, + class_name: "Project::LifeCycleStep", + foreign_key: :definition_id, + inverse_of: :definition, + dependent: :destroy + has_many :projects, through: :life_cycle_steps + belongs_to :color, optional: false + + validates :name, presence: true + validates :type, inclusion: { in: %w[Project::StageDefinition Project::GateDefinition], message: :must_be_a_stage_or_gate } + + 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 + + super + end +end diff --git a/app/models/project/stage.rb b/app/models/project/stage.rb new file mode 100644 index 000000000000..b49ca7787b71 --- /dev/null +++ b/app/models/project/stage.rb @@ -0,0 +1,33 @@ +#-- 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 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 +end diff --git a/app/models/project/stage_definition.rb b/app/models/project/stage_definition.rb new file mode 100644 index 000000000000..91ae98584def --- /dev/null +++ b/app/models/project/stage_definition.rb @@ -0,0 +1,35 @@ +#-- 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 Project::StageDefinition < Project::LifeCycleStepDefinition + has_many :stages, # Alias for life_cycle_steps + class_name: "Project::Stage", + foreign_key: :definition_id, + inverse_of: :definition, + dependent: :destroy +end diff --git a/app/models/projects.rb b/app/models/projects.rb new file mode 100644 index 000000000000..27b2504bd994 --- /dev/null +++ b/app/models/projects.rb @@ -0,0 +1,5 @@ +module Projects + def self.table_name_prefix + "projects_" + end +end diff --git a/app/models/queries/custom_fields/hierarchy/item_query.rb b/app/models/queries/custom_fields/hierarchy/item_query.rb new file mode 100644 index 000000000000..a1bd47a6eceb --- /dev/null +++ b/app/models/queries/custom_fields/hierarchy/item_query.rb @@ -0,0 +1,42 @@ +#-- 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 Queries + module CustomFields + module Hierarchy + class ItemQuery + include ::Queries::BaseQuery + include ::Queries::UnpersistedQuery + + def self.model + CustomField::Hierarchy::Item + end + end + end + end +end diff --git a/app/models/queries/filters.rb b/app/models/queries/filters.rb index 293f2469f206..503c69862dba 100644 --- a/app/models/queries/filters.rb +++ b/app/models/queries/filters.rb @@ -40,7 +40,8 @@ module Queries::Filters search: Queries::Filters::Strategies::Search, float: Queries::Filters::Strategies::Float, inexistent: Queries::Filters::Strategies::Inexistent, - empty_value: Queries::Filters::Strategies::EmptyValue + empty_value: Queries::Filters::Strategies::EmptyValue, + hierarchy: Queries::Filters::Strategies::Hierarchy }.freeze ## diff --git a/app/models/queries/filters/shared/custom_field_filter.rb b/app/models/queries/filters/shared/custom_field_filter.rb index 1e3cea7c7d58..45a9c3831e5f 100644 --- a/app/models/queries/filters/shared/custom_field_filter.rb +++ b/app/models/queries/filters/shared/custom_field_filter.rb @@ -70,29 +70,12 @@ def create!(name:, **) ## # Create a filter instance for the given custom field def from_custom_field!(custom_field:, **) - constant_name = subfilter_module(custom_field) - clazz = "::Queries::Filters::Shared::CustomFields::#{constant_name}".constantize - clazz.create!(custom_field:, custom_field_context:, **) + subfilter_class(custom_field).create!(custom_field:, custom_field_context:, **) rescue NameError => e Rails.logger.error "Failed to constantize custom field filter for #{name}. #{e}" raise ::Queries::Filters::InvalidError end - ## - # Get the subfilter class name for the given custom field - def subfilter_module(custom_field) - case custom_field.field_format - when "user" - :User - when "list", "version" - :ListOptional - when "bool" - :Bool - else - :Base - end - end - def all_custom_fields key = ["Queries::Filters::Shared::CustomFieldFilter", custom_field_context.custom_field_class, @@ -102,5 +85,22 @@ def all_custom_fields custom_field_context.custom_field_class.all.to_a end end + + private + + def subfilter_class(custom_field) + case custom_field.field_format + when "user" + ::Queries::Filters::Shared::CustomFields::User + when "list", "version" + ::Queries::Filters::Shared::CustomFields::ListOptional + when "hierarchy" + ::Queries::Filters::Shared::CustomFields::Hierarchy + when "bool" + ::Queries::Filters::Shared::CustomFields::Bool + else + ::Queries::Filters::Shared::CustomFields::Base + end + end end end diff --git a/app/models/queries/filters/shared/custom_fields/base.rb b/app/models/queries/filters/shared/custom_fields/base.rb index 1a6bc559cf71..07cf1b0cb30f 100644 --- a/app/models/queries/filters/shared/custom_fields/base.rb +++ b/app/models/queries/filters/shared/custom_fields/base.rb @@ -89,6 +89,8 @@ def type :text when "date" :date + when "hierarchy" + :hierarchy else :string end diff --git a/app/models/queries/filters/shared/custom_fields/hierarchy.rb b/app/models/queries/filters/shared/custom_fields/hierarchy.rb new file mode 100644 index 000000000000..81538bc4bdb4 --- /dev/null +++ b/app/models/queries/filters/shared/custom_fields/hierarchy.rb @@ -0,0 +1,64 @@ +# 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 Queries + module Filters + module Shared + module CustomFields + class Hierarchy < Base + def ar_object_filter? + true + end + + def value_objects + CustomField::Hierarchy::Item + .where(id: @values) + .map { |item| HierarchyItemFilterAdapter.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 +end diff --git a/app/models/queries/filters/strategies/hierarchy.rb b/app/models/queries/filters/strategies/hierarchy.rb new file mode 100644 index 000000000000..158ff5ab0c63 --- /dev/null +++ b/app/models/queries/filters/strategies/hierarchy.rb @@ -0,0 +1,46 @@ +# 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 Queries + module Filters + module Strategies + class Hierarchy < BaseStrategy + self.supported_operators = %w[= ! eq_with_descendants] + self.default_operator = "=" + + def operator_map + super.dup.tap do |super_value| + super_value["eq_with_descendants"] = ::Queries::Operators::CustomFields::Hierarchies::EqualsWithDescendants + end + end + end + end + end +end diff --git a/app/models/queries/operators.rb b/app/models/queries/operators.rb index 7581f53237b3..fa60d90d36f8 100644 --- a/app/models/queries/operators.rb +++ b/app/models/queries/operators.rb @@ -27,7 +27,7 @@ #++ module Queries::Operators - operators = [ + OPERATORS = [ Queries::Operators::GreaterOrEqual, Queries::Operators::LessOrEqual, Queries::Operators::Equals, @@ -61,7 +61,5 @@ module Queries::Operators Queries::Operators::Parent, Queries::Operators::Children, Queries::Operators::Child - ] - - OPERATORS = Hash[*(operators.map { |o| [o.symbol.to_s, o] }).flatten].freeze + ].index_by { |o| o.symbol.to_s }.freeze end diff --git a/app/models/queries/operators/ago.rb b/app/models/queries/operators/ago.rb index 0ad737920c50..0bf7bb71ac93 100644 --- a/app/models/queries/operators/ago.rb +++ b/app/models/queries/operators/ago.rb @@ -34,10 +34,9 @@ class Ago < Base extend DateRangeClauses def self.sql_for_field(values, db_table, db_field) - relative_date_range_clause(db_table, - db_field, - - values.first.to_i, - - values.first.to_i) + days = values.first.to_i + + relative_date_range_clause(db_table, db_field, -days, -days) end end end diff --git a/app/models/queries/operators/between_date.rb b/app/models/queries/operators/between_date.rb index 0f9351668851..42b6a2e0d5f9 100644 --- a/app/models/queries/operators/between_date.rb +++ b/app/models/queries/operators/between_date.rb @@ -34,7 +34,7 @@ class BetweenDate < Base extend DateRangeClauses def self.sql_for_field(values, db_table, db_field) - lower_boundary, upper_boundary = values.map { |v| v.blank? ? nil : Date.parse(v) } + lower_boundary, upper_boundary = values.map { |v| Date.parse(v) if v.present? } date_range_clause(db_table, db_field, lower_boundary, upper_boundary) end diff --git a/app/models/queries/operators/between_date_time.rb b/app/models/queries/operators/between_date_time.rb index 62ff8b47401e..8be8084a7747 100644 --- a/app/models/queries/operators/between_date_time.rb +++ b/app/models/queries/operators/between_date_time.rb @@ -34,12 +34,9 @@ class BetweenDateTime < Base extend DatetimeRangeClauses def self.sql_for_field(values, db_table, db_field) - lower_boundary, upper_boundary = values.map { |v| v.blank? ? nil : DateTime.parse(v) } + lower_boundary, upper_boundary = values.map { |v| DateTime.parse(v) if v.present? } - datetime_range_clause(db_table, - db_field, - lower_boundary, - upper_boundary) + datetime_range_clause(db_table, db_field, lower_boundary, upper_boundary) end end end diff --git a/app/models/queries/operators/custom_fields/hierarchies/equals_with_descendants.rb b/app/models/queries/operators/custom_fields/hierarchies/equals_with_descendants.rb new file mode 100644 index 000000000000..941df671456f --- /dev/null +++ b/app/models/queries/operators/custom_fields/hierarchies/equals_with_descendants.rb @@ -0,0 +1,49 @@ +#-- 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 Queries + module Operators + module CustomFields + module Hierarchies + class EqualsWithDescendants < Base + label "equals_with_descendants" + set_symbol "eq_with_descendants" + + def self.sql_for_field(values, db_table, db_field) + items = CustomField::Hierarchy::Item.find(values) + service = ::CustomFields::Hierarchy::HierarchicalItemService.new + + actual_values = items.map { |item| service.get_descendants(item:).value! }.flatten.map(&:id).uniq + + Equals.sql_for_field(actual_values, db_table, db_field) + end + end + end + end + end +end diff --git a/app/models/queries/operators/date_limits.rb b/app/models/queries/operators/date_limits.rb new file mode 100644 index 000000000000..9a0864ec37bc --- /dev/null +++ b/app/models/queries/operators/date_limits.rb @@ -0,0 +1,48 @@ +#-- 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 Queries::Operators + module DateLimits + # Technically dates in PostgreSQL can be up to 5874897 AD, but limit to + # timestamp range, as dates are used to query for timestamps too. + # Date.new BCE years are counted astronomically, so 0 is 1 BC. + # Minimal allowed date is Date.new(-4713, 11, 24), but specification says 4713 BC (-4712). + # + # https://www.postgresql.org/docs/current/datatype-datetime.html + PG_DATE_FROM = Date.new(-4712, 1, 1) + PG_DATE_TO_EXCLUSIVE = Date.new(294276 + 1, 1, 1) + + def date_too_small?(date) + date < PG_DATE_FROM + end + + def date_too_big?(date) + date >= PG_DATE_TO_EXCLUSIVE + end + end +end diff --git a/app/models/queries/operators/date_range_clauses.rb b/app/models/queries/operators/date_range_clauses.rb index 3b41a14105f3..0bc9d30d6406 100644 --- a/app/models/queries/operators/date_range_clauses.rb +++ b/app/models/queries/operators/date_range_clauses.rb @@ -28,15 +28,16 @@ module Queries::Operators module DateRangeClauses + include DateLimits + # Returns a SQL clause for a date or datetime field for a relative range from # the end of the day of yesterday + from until the end of today + to. def relative_date_range_clause(table, field, from, to) - if from - from_date = Date.today + from - end - if to - to_date = Date.today + to - end + today = Time.zone.today + + from_date = today + from if from + to_date = today + to if to + date_range_clause(table, field, from_date, to_date) end @@ -44,15 +45,28 @@ def relative_date_range_clause(table, field, from, to) # at the beginning of the day of from until the end of the day of to def date_range_clause(table, field, from, to) s = [] + if from - s << ("#{table}.#{field} > '%s'" % [quoted_date_from_utc(from.yesterday)]) + return "1 <> 1" if date_too_big?(from) + + unless date_too_small?(from) + s << "#{table}.#{field} > '#{quoted_date_from_utc(from.yesterday)}'" + end end + if to - s << ("#{table}.#{field} <= '%s'" % [quoted_date_from_utc(to)]) + return "1 <> 1" if date_too_small?(to) + + unless date_too_big?(to) + s << "#{table}.#{field} <= '#{quoted_date_from_utc(to)}'" + end end - s.join(" AND ") + + s.join(" AND ").presence || "1 = 1" end + private + def quoted_date_from_utc(value) connection.quoted_date(value.to_time(:utc).end_of_day) end diff --git a/app/models/queries/operators/datetime_range_clauses.rb b/app/models/queries/operators/datetime_range_clauses.rb index c40c33cc48ed..9c278138a5b7 100644 --- a/app/models/queries/operators/datetime_range_clauses.rb +++ b/app/models/queries/operators/datetime_range_clauses.rb @@ -28,15 +28,28 @@ module Queries::Operators module DatetimeRangeClauses + include DateLimits + def datetime_range_clause(table, field, from, to) s = [] + if from - s << ("#{table}.#{field} >= '%s'" % [connection.quoted_date(from)]) + return "1 <> 1" if date_too_big?(from) + + unless date_too_small?(from) + s << "#{table}.#{field} >= '#{connection.quoted_date(from)}'" + end end + if to - s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date(to)]) + return "1 <> 1" if date_too_small?(to) + + unless date_too_big?(to) + s << "#{table}.#{field} <= '#{connection.quoted_date(to)}'" + end end - s.join(" AND ") + + s.join(" AND ").presence || "1 = 1" end end end diff --git a/app/models/queries/operators/in.rb b/app/models/queries/operators/in.rb index 3151bf37c8e9..729fc7b6fae3 100644 --- a/app/models/queries/operators/in.rb +++ b/app/models/queries/operators/in.rb @@ -34,7 +34,9 @@ class In < Base extend DateRangeClauses def self.sql_for_field(values, db_table, db_field) - relative_date_range_clause(db_table, db_field, values.first.to_i, values.first.to_i) + days = values.first.to_i + + relative_date_range_clause(db_table, db_field, days, days) end end end diff --git a/app/models/queries/operators/in_less_than.rb b/app/models/queries/operators/in_less_than.rb index b803f54fb84b..240f56e3bb72 100644 --- a/app/models/queries/operators/in_less_than.rb +++ b/app/models/queries/operators/in_less_than.rb @@ -34,7 +34,9 @@ class InLessThan < Base extend DateRangeClauses def self.sql_for_field(values, db_table, db_field) - relative_date_range_clause(db_table, db_field, 0, values.first.to_i) + days = values.first.to_i + + relative_date_range_clause(db_table, db_field, 0, days) end end end diff --git a/app/models/queries/operators/in_more_than.rb b/app/models/queries/operators/in_more_than.rb index 22717dc20672..e5fdbeccb8d7 100644 --- a/app/models/queries/operators/in_more_than.rb +++ b/app/models/queries/operators/in_more_than.rb @@ -34,7 +34,9 @@ class InMoreThan < Base extend DateRangeClauses def self.sql_for_field(values, db_table, db_field) - relative_date_range_clause(db_table, db_field, values.first.to_i, nil) + days = values.first.to_i + + relative_date_range_clause(db_table, db_field, days, nil) end end end diff --git a/app/models/queries/operators/less_than_ago.rb b/app/models/queries/operators/less_than_ago.rb index c27619714259..d98b38c8d30e 100644 --- a/app/models/queries/operators/less_than_ago.rb +++ b/app/models/queries/operators/less_than_ago.rb @@ -34,7 +34,9 @@ class LessThanAgo < Base extend DateRangeClauses def self.sql_for_field(values, db_table, db_field) - relative_date_range_clause(db_table, db_field, - values.first.to_i, 0) + days = values.first.to_i + + relative_date_range_clause(db_table, db_field, -days, 0) end end end diff --git a/app/models/queries/operators/more_than_ago.rb b/app/models/queries/operators/more_than_ago.rb index bd8f836de768..fe817a949365 100644 --- a/app/models/queries/operators/more_than_ago.rb +++ b/app/models/queries/operators/more_than_ago.rb @@ -34,7 +34,9 @@ class MoreThanAgo < Base extend DateRangeClauses def self.sql_for_field(values, db_table, db_field) - relative_date_range_clause(db_table, db_field, nil, - values.first.to_i) + days = values.first.to_i + + relative_date_range_clause(db_table, db_field, nil, -days) end end end diff --git a/app/models/queries/operators/on_date.rb b/app/models/queries/operators/on_date.rb index 4987c3dfa205..0d2d072775d2 100644 --- a/app/models/queries/operators/on_date.rb +++ b/app/models/queries/operators/on_date.rb @@ -34,10 +34,9 @@ class OnDate < Base extend DateRangeClauses def self.sql_for_field(values, db_table, db_field) - date_range_clause(db_table, - db_field, - Date.parse(values.first), - Date.parse(values.first)) + date = Date.parse(values.first) + + date_range_clause(db_table, db_field, date, date) end end end diff --git a/app/models/queries/operators/on_date_time.rb b/app/models/queries/operators/on_date_time.rb index b4f034298e31..56d7b5fa11ea 100644 --- a/app/models/queries/operators/on_date_time.rb +++ b/app/models/queries/operators/on_date_time.rb @@ -39,10 +39,7 @@ def self.sql_for_field(values, db_table, db_field) lower_boundary = datetime upper_boundary = datetime + 24.hours - datetime_range_clause(db_table, - db_field, - lower_boundary, - upper_boundary) + datetime_range_clause(db_table, db_field, lower_boundary, upper_boundary) end end end diff --git a/app/models/queries/projects/orders/custom_field_order.rb b/app/models/queries/projects/orders/custom_field_order.rb index 30aa9e3b4558..b7ad4e3cf9f7 100644 --- a/app/models/queries/projects/orders/custom_field_order.rb +++ b/app/models/queries/projects/orders/custom_field_order.rb @@ -58,10 +58,16 @@ def available? private def order(scope) - joined_statement = custom_field.order_statements.map do |statement| - Arel.sql("#{statement} #{direction}") + if (join_statement = custom_field.order_join_statement) + scope = scope.joins(join_statement) end - scope.order(joined_statement) + order_statement = "#{custom_field.order_statement} #{direction}" + + if (null_handling = custom_field.order_null_handling(direction == :asc)) + order_statement = "#{order_statement} #{null_handling}" + end + + scope.order(Arel.sql(order_statement)) end end diff --git a/app/models/queries/work_packages/selects/custom_field_select.rb b/app/models/queries/work_packages/selects/custom_field_select.rb index c6e2a107d5da..6120e1111035 100644 --- a/app/models/queries/work_packages/selects/custom_field_select.rb +++ b/app/models/queries/work_packages/selects/custom_field_select.rb @@ -32,21 +32,20 @@ def initialize(custom_field) @cf = custom_field - set_name! - set_sortable! - set_groupable! - set_summable! - end - - def groupable_custom_field?(custom_field) - %w(list date bool int).include?(custom_field.field_format) + @name = custom_field.column_name.to_sym + @sortable = custom_field.order_statement + @sortable_join = custom_field.order_join_statement + @groupable = custom_field.group_by_statement + @groupable_join = custom_field.group_by_join_statement + @groupable_select = custom_field.group_by_select_statement + @summable = summable_statement end def caption @cf.name end - delegate :null_handling, to: :custom_field + def null_handling(...) = custom_field.order_null_handling(...) def custom_field @cf @@ -68,32 +67,6 @@ def self.instances(context = nil) private - def set_name! - self.name = custom_field.column_name.to_sym - end - - def set_sortable! - self.sortable = custom_field.order_statements || false - end - - def set_groupable! - self.groupable = custom_field.group_by_statement if groupable_custom_field?(custom_field) - self.groupable ||= false - end - - def set_summable! - self.summable = if %w(float int).include?(custom_field.field_format) - select = summable_select_statement - - ->(query, grouped) { - Queries::WorkPackages::Selects::WorkPackageSelect - .scoped_column_sum(summable_scope(query), select, grouped && query.group_by_statement) - } - else - false - end - end - def summable_scope(query) WorkPackage .where(id: query.results.work_packages) @@ -105,9 +78,22 @@ def summable_scope(query) def summable_select_statement if custom_field.field_format == "int" - "COALESCE(SUM(value::BIGINT)::BIGINT, 0) #{name}" + "COALESCE(SUM(#{CustomValue.quoted_table_name}.value::BIGINT)::BIGINT, 0) #{name}" + else + "COALESCE(ROUND(SUM(#{CustomValue.quoted_table_name}.value::NUMERIC), 2)::FLOAT, 0.0) #{name}" + end + end + + def summable_statement + if %w[float int].include?(custom_field.field_format) + select = summable_select_statement + + ->(query, grouped) { + Queries::WorkPackages::Selects::WorkPackageSelect + .scoped_column_sum(summable_scope(query), select, grouped:, query:) + } else - "COALESCE(ROUND(SUM(value::NUMERIC), 2)::FLOAT, 0.0) #{name}" + false end end end diff --git a/app/models/queries/work_packages/selects/property_select.rb b/app/models/queries/work_packages/selects/property_select.rb index c73db63aaeff..0a7da338fe3b 100644 --- a/app/models/queries/work_packages/selects/property_select.rb +++ b/app/models/queries/work_packages/selects/property_select.rb @@ -130,15 +130,12 @@ def caption shared_with_users: { sortable: false, groupable: false - } } def self.instances(_context = nil) - property_selects.filter_map do |name, options| - next unless !options[:if] || options[:if].call - - new(name, options.except(:if)) + property_selects.map do |name, options| + new(name, options) end end end diff --git a/app/models/queries/work_packages/selects/relation_of_type_select.rb b/app/models/queries/work_packages/selects/relation_of_type_select.rb index eb6809cec69c..f7dd9d4d668e 100644 --- a/app/models/queries/work_packages/selects/relation_of_type_select.rb +++ b/app/models/queries/work_packages/selects/relation_of_type_select.rb @@ -28,12 +28,9 @@ class Queries::WorkPackages::Selects::RelationOfTypeSelect < Queries::WorkPackages::Selects::RelationSelect def initialize(type) - self.type = type - super(name) - end + super(:"relations_of_type_#{type[:sym]}") - def name - :"relations_of_type_#{type[:sym]}" + @type = type end def sym diff --git a/app/models/queries/work_packages/selects/relation_select.rb b/app/models/queries/work_packages/selects/relation_select.rb index c12f7663eb2e..224b1e58f743 100644 --- a/app/models/queries/work_packages/selects/relation_select.rb +++ b/app/models/queries/work_packages/selects/relation_select.rb @@ -27,7 +27,7 @@ #++ class Queries::WorkPackages::Selects::RelationSelect < Queries::WorkPackages::Selects::WorkPackageSelect - attr_accessor :type + attr_reader :type def self.granted_by_enterprise_token EnterpriseToken.allows_to?(:work_package_query_relation_columns) diff --git a/app/models/queries/work_packages/selects/relation_to_type_select.rb b/app/models/queries/work_packages/selects/relation_to_type_select.rb index cd6ddcd432fc..7744ed717f10 100644 --- a/app/models/queries/work_packages/selects/relation_to_type_select.rb +++ b/app/models/queries/work_packages/selects/relation_to_type_select.rb @@ -28,14 +28,9 @@ class Queries::WorkPackages::Selects::RelationToTypeSelect < Queries::WorkPackages::Selects::RelationSelect def initialize(type) - super + super(:"relations_to_type_#{type.id}") - set_name! type - self.type = type - end - - def set_name!(type) - self.name = :"relations_to_type_#{type.id}" + @type = type end def caption diff --git a/app/models/queries/work_packages/selects/work_package_select.rb b/app/models/queries/work_packages/selects/work_package_select.rb index a3ffe42aa3b6..9b9603518367 100644 --- a/app/models/queries/work_packages/selects/work_package_select.rb +++ b/app/models/queries/work_packages/selects/work_package_select.rb @@ -27,19 +27,15 @@ #++ class Queries::WorkPackages::Selects::WorkPackageSelect - attr_accessor :highlightable, - :name, - :sortable_join, - :summable, - :default_order, - :association - alias_method :highlightable?, :highlightable - - attr_reader :groupable, - :sortable, - :displayable - - attr_writer :null_handling, + attr_reader :highlightable, + :name, + :sortable_join, + :groupable_join, + :groupable_select, + :summable, + :default_order, + :association, + :null_handling, :summable_select, :summable_work_packages_select @@ -51,17 +47,17 @@ def self.select_group_by(group_by_statement) group_by = group_by_statement group_by = group_by.first if group_by.is_a?(Array) - "#{group_by} id" + "#{group_by} group_id" end - def self.scoped_column_sum(scope, select, group_by) - scope = scope - .except(:order, :select) + def self.scoped_column_sum(scope, select, grouped:, query:) + scope = scope.except(:order, :select) - if group_by + if grouped scope - .group(group_by) - .select(select_group_by(group_by), select) + .joins(query.group_by_join_statement) + .group(query.group_by_statement) + .select(select_group_by(query.group_by_select), select) else scope .select(select) @@ -76,31 +72,25 @@ def null_handling(_asc) @null_handling end - def groupable=(value) - @groupable = name_or_value_or_false(value) + def displayable + @displayable.nil? ? true : @displayable end - def sortable=(value) - @sortable = name_or_value_or_false(value) + def sortable + name_or_value_or_false(@sortable) end - def displayable=(value) - @displayable = value.nil? ? true : value + def groupable + name_or_value_or_false(@groupable) end - def displayable? - displayable - end + def displayable? = !!displayable - # Returns true if the column is sortable, otherwise false - def sortable? - !!sortable - end + def sortable? = !!sortable - # Returns true if the column is groupable, otherwise false - def groupable? - !!groupable - end + def groupable? = !!groupable + + def highlightable? = !!highlightable def summable? summable || @summable_select || @summable_work_packages_select @@ -127,22 +117,25 @@ def value(model) end def initialize(name, options = {}) - self.name = name - - %i(sortable - sortable_join - displayable - groupable - summable - summable_select - summable_work_packages_select - association - null_handling - default_order).each do |attribute| - send(:"#{attribute}=", options[attribute]) + @name = name + + %i[ + sortable + sortable_join + displayable + groupable + groupable_join + summable + summable_select + summable_work_packages_select + association + null_handling + default_order + ].each do |attribute| + instance_variable_set(:"@#{attribute}", options[attribute]) end - self.highlightable = !!options.fetch(:highlightable, false) + @highlightable = !!options.fetch(:highlightable, false) end def caption diff --git a/app/models/query.rb b/app/models/query.rb index d8051b6b1d06..47eabd5b19d6 100644 --- a/app/models/query.rb +++ b/app/models/query.rb @@ -359,7 +359,15 @@ def group_by_column end def group_by_statement - group_by_column.try(:groupable) + group_by_column&.groupable + end + + def group_by_select + group_by_column&.groupable_select || group_by_statement + end + + def group_by_join_statement + group_by_column&.groupable_join end def statement diff --git a/app/models/query/results.rb b/app/models/query/results.rb index dbf58dcbfa8c..8bffcccd8ad0 100644 --- a/app/models/query/results.rb +++ b/app/models/query/results.rb @@ -82,6 +82,7 @@ def filtered_work_packages def sorted_work_packages work_package_scope .joins(sort_criteria_joins) + .joins(query.group_by_join_statement) .order(order_option) .order(sort_criteria_array) end @@ -195,10 +196,8 @@ def columns_hash_for(association = nil) ## # Return the case insensitive version for columns with a string type def case_insensitive_condition(column_key, condition, columns_hash) - if columns_hash[column_key]&.type == :string + if columns_hash[column_key]&.type == :string || custom_field_type(column_key) == "string" "LOWER(#{condition})" - elsif custom_field_type(column_key) == "string" - condition.map { |c| "LOWER(#{c})" } else condition end diff --git a/app/models/query/results/group_by.rb b/app/models/query/results/group_by.rb index b5609d28e876..c3097fdb03e7 100644 --- a/app/models/query/results/group_by.rb +++ b/app/models/query/results/group_by.rb @@ -46,6 +46,7 @@ def work_package_count_for(group) def group_counts_by_group work_packages_with_includes_for_count + .joins(query.group_by_join_statement) .group(group_by_for_count) .visible .references(:statuses, :projects) @@ -67,7 +68,7 @@ def group_by_for_count end def pluck_for_count - Array(query.group_by_statement).map { |statement| Arel.sql(statement) } + + Array(query.group_by_select).map { |statement| Arel.sql(statement) } + [Arel.sql('COUNT(DISTINCT "work_packages"."id")')] end @@ -98,7 +99,7 @@ def transform_list_custom_field_keys(custom_field, groups) groups.transform_keys do |key| if custom_field.multi_value? - key.split(".").map do |subkey| + (key ? key.split(".") : []).map do |subkey| options[subkey].first end else @@ -108,7 +109,7 @@ def transform_list_custom_field_keys(custom_field, groups) end def custom_options_for_keys(custom_field, groups) - keys = groups.keys.map { |k| k.split(".") } + 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). # This can result in us having ids in the keys array multiple times (e.g. ["1", "1", "3", "3"]). diff --git a/app/models/query/results/sums.rb b/app/models/query/results/sums.rb index 23509ef6656f..3eaf4dec7cca 100644 --- a/app/models/query/results/sums.rb +++ b/app/models/query/results/sums.rb @@ -42,24 +42,26 @@ def all_total_sums def all_group_sums return nil unless query.grouped? - sums_by_id = sums_select(true).inject({}) do |result, group_sum| - result[group_sum["id"]] = {} + transform_group_keys(sums_by_group_id) + end + + private + + def sums_by_group_id + sums_select(true).inject({}) do |result, group_sum| + result[group_sum["group_id"]] = {} query.summed_up_columns.each do |column| - result[group_sum["id"]][column] = group_sum[column.name.to_s] + result[group_sum["group_id"]][column] = group_sum[column.name.to_s] end result end - - transform_group_keys(sums_by_id) end - private - def sums_select(grouped = false) select = if grouped - ["work_packages.id"] + ["work_packages.group_id"] else [] end @@ -86,7 +88,7 @@ def sums_work_package_scope(grouped) .select(sums_work_package_scope_selects(grouped)) if grouped - scope.group(query.group_by_statement) + scope.joins(query.group_by_join_statement).group(query.group_by_statement) else scope end @@ -96,7 +98,8 @@ def sums_callable_joins(grouped) callable_summed_up_columns .map do |c| join_condition = if grouped - "#{c.name}.id = work_packages.id OR #{c.name}.id IS NULL AND work_packages.id IS NULL" + "#{c.name}.group_id = work_packages.group_id OR " \ + "#{c.name}.group_id IS NULL AND work_packages.group_id IS NULL" else "TRUE" end @@ -109,7 +112,7 @@ def sums_callable_joins(grouped) def sums_work_package_scope_selects(grouped) group_statement = if grouped - [Queries::WorkPackages::Selects::WorkPackageSelect.select_group_by(query.group_by_statement)] + [Queries::WorkPackages::Selects::WorkPackageSelect.select_group_by(query.group_by_select)] else [] end diff --git a/app/models/relation.rb b/app/models/relation.rb index f57f195fb1f7..947312b1c213 100644 --- a/app/models/relation.rb +++ b/app/models/relation.rb @@ -31,12 +31,12 @@ class Relation < ApplicationRecord belongs_to :to, class_name: "WorkPackage" TYPE_RELATES = "relates".freeze - TYPE_DUPLICATES = "duplicates".freeze - TYPE_DUPLICATED = "duplicated".freeze - TYPE_BLOCKS = "blocks".freeze - TYPE_BLOCKED = "blocked".freeze TYPE_PRECEDES = "precedes".freeze TYPE_FOLLOWS = "follows".freeze + TYPE_BLOCKS = "blocks".freeze + TYPE_BLOCKED = "blocked".freeze + TYPE_DUPLICATES = "duplicates".freeze + TYPE_DUPLICATED = "duplicated".freeze TYPE_INCLUDES = "includes".freeze TYPE_PARTOF = "partof".freeze TYPE_REQUIRES = "requires".freeze @@ -51,12 +51,13 @@ class Relation < ApplicationRecord TYPE_RELATES => { name: :label_relates_to, sym_name: :label_relates_to, order: 1, sym: TYPE_RELATES }, - TYPE_DUPLICATES => { - name: :label_duplicates, sym_name: :label_duplicated_by, order: 2, sym: TYPE_DUPLICATED + TYPE_PRECEDES => { + name: :label_precedes, sym_name: :label_follows, order: 6, + sym: TYPE_FOLLOWS, reverse: TYPE_FOLLOWS }, - TYPE_DUPLICATED => { - name: :label_duplicated_by, sym_name: :label_duplicates, order: 3, - sym: TYPE_DUPLICATES, reverse: TYPE_DUPLICATES + TYPE_FOLLOWS => { + name: :label_follows, sym_name: :label_precedes, order: 7, + sym: TYPE_PRECEDES }, TYPE_BLOCKS => { name: :label_blocks, sym_name: :label_blocked_by, order: 4, sym: TYPE_BLOCKED @@ -65,13 +66,12 @@ class Relation < ApplicationRecord name: :label_blocked_by, sym_name: :label_blocks, order: 5, sym: TYPE_BLOCKS, reverse: TYPE_BLOCKS }, - TYPE_PRECEDES => { - name: :label_precedes, sym_name: :label_follows, order: 6, - sym: TYPE_FOLLOWS, reverse: TYPE_FOLLOWS + TYPE_DUPLICATES => { + name: :label_duplicates, sym_name: :label_duplicated_by, order: 6, sym: TYPE_DUPLICATED }, - TYPE_FOLLOWS => { - name: :label_follows, sym_name: :label_precedes, order: 7, - sym: TYPE_PRECEDES + TYPE_DUPLICATED => { + name: :label_duplicated_by, sym_name: :label_duplicates, order: 7, + sym: TYPE_DUPLICATES, reverse: TYPE_DUPLICATES }, TYPE_INCLUDES => { name: :label_includes, sym_name: :label_part_of, order: 8, diff --git a/app/models/type/attributes.rb b/app/models/type/attributes.rb index f8383f7d533f..d05fefc24080 100644 --- a/app/models/type/attributes.rb +++ b/app/models/type/attributes.rb @@ -146,9 +146,7 @@ def merge_date_for_form_attributes(attributes) end def add_custom_fields_to_form_attributes(attributes) - WorkPackageCustomField.includes(:custom_options) - .where.not(field_format: "hierarchy") # TODO: Remove after enabling hierarchy fields - .find_each do |field| + WorkPackageCustomField.includes(:custom_options).find_each do |field| attributes[field.attribute_name] = { required: field.is_required, has_default: field.default_value.present?, diff --git a/app/models/work_package.rb b/app/models/work_package.rb index e02d14aa81dd..a1ec03892668 100644 --- a/app/models/work_package.rb +++ b/app/models/work_package.rb @@ -54,6 +54,7 @@ class WorkPackage < ApplicationRecord belongs_to :assigned_to, class_name: "Principal", optional: true belongs_to :responsible, class_name: "Principal", optional: true belongs_to :version, optional: true + belongs_to :project_life_cycle_step, class_name: "Project::LifeCycleStep", optional: true belongs_to :priority, class_name: "IssuePriority" belongs_to :category, class_name: "Category", optional: true @@ -302,7 +303,7 @@ def included_in_totals_calculation? end def done_ratio - if WorkPackage.status_based_mode? && status && status.default_done_ratio + if WorkPackage.status_based_mode? && status&.default_done_ratio status.default_done_ratio else read_attribute(:done_ratio) @@ -377,7 +378,7 @@ def attributes=(new_attributes) # Set the done_ratio using the status if that setting is set. This will keep the done_ratios # even if the user turns off the setting later def update_done_ratio_from_status - if WorkPackage.status_based_mode? && status && status.default_done_ratio + if WorkPackage.status_based_mode? && status&.default_done_ratio self.done_ratio = status.default_done_ratio end end @@ -631,7 +632,7 @@ def self.update_versions(conditions = nil) # Default assignment based on category def default_assign - if assigned_to.nil? && category && category.assigned_to + if assigned_to.nil? && category&.assigned_to self.assigned_to = category.assigned_to end end diff --git a/app/models/work_package/exports/formatters/costs.rb b/app/models/work_package/exports/formatters/costs.rb index 9133f3cfe02e..310f864aff4b 100644 --- a/app/models/work_package/exports/formatters/costs.rb +++ b/app/models/work_package/exports/formatters/costs.rb @@ -38,8 +38,8 @@ def format_options def number_format_string # [$CUR] makes sure we have an actually working currency format with arbitrary currencies - curr = "[$CUR]".gsub "CUR", ERB::Util.h(Setting.plugin_costs["costs_currency"]) - format = ERB::Util.h Setting.plugin_costs["costs_currency_format"] + curr = "[$CUR]".gsub "CUR", ERB::Util.h(Setting.costs_currency) + format = ERB::Util.h Setting.costs_currency_format number = "#,##0.00" format.gsub("%n", number).gsub("%u", curr) diff --git a/app/models/work_package/pdf_export/common/attachments.rb b/app/models/work_package/pdf_export/common/attachments.rb new file mode 100644 index 000000000000..2a1574f8618d --- /dev/null +++ b/app/models/work_package/pdf_export/common/attachments.rb @@ -0,0 +1,77 @@ +#-- 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. +#++ + +require "mini_magick" + +module WorkPackage::PDFExport::Common::Attachments + def resize_image(file_path) + tmp_file = Tempfile.new(["temp_image", File.extname(file_path)]) + @resized_images = [] if @resized_images.nil? + + @resized_images << tmp_file + resized_file_path = tmp_file.path + + image = MiniMagick::Image.open(file_path) + image.resize("x800>") + image.write(resized_file_path) + + resized_file_path + end + + def pdf_embeddable?(content_type) + %w[image/jpeg image/png].include?(content_type) + end + + def delete_all_resized_images + @resized_images&.each(&:close!) + @resized_images = [] + end + + def attachment_image_local_file(attachment) + attachment.file.local_file + rescue StandardError => e + Rails.logger.error "Failed to access attachment #{attachment.id} file: #{e}" + nil # return nil as if the id was wrong and the attachment obj has not been found + end + + def attachment_image_filepath(work_package, src) + # images are embedded into markup with the api-path as img.src + attachment = attachment_by_api_content_src(work_package, src) + return nil if attachment.nil? || !pdf_embeddable?(attachment.content_type) + + local_file = attachment_image_local_file(attachment) + return nil if local_file.nil? + + resize_image(local_file.path) + end + + def attachment_by_api_content_src(work_package, src) + # find attachment by api-path + work_package.attachments.find { |a| api_url_helpers.attachment_content(a.id) == src } + end +end diff --git a/app/models/work_package/pdf_export/common.rb b/app/models/work_package/pdf_export/common/common.rb similarity index 89% rename from app/models/work_package/pdf_export/common.rb rename to app/models/work_package/pdf_export/common/common.rb index e9ccb01a6a1c..c91d44836ab5 100644 --- a/app/models/work_package/pdf_export/common.rb +++ b/app/models/work_package/pdf_export/common/common.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module WorkPackage::PDFExport::Common +module WorkPackage::PDFExport::Common::Common include Redmine::I18n include ActionView::Helpers::TextHelper include ActionView::Helpers::NumberHelper @@ -36,7 +36,7 @@ module WorkPackage::PDFExport::Common private def get_pdf(_language) - ::WorkPackage::PDFExport::View.new(current_language) + ::WorkPackage::PDFExport::Common::View.new(current_language) end def field_value(work_package, attribute) @@ -76,26 +76,11 @@ def with_vertical_margin(opts) pdf.move_down(opts[:bottom_margin]) if opts.key?(:bottom_margin) end - def write_optional_page_break - space_from_bottom = pdf.y - pdf.bounds.bottom - if space_from_bottom < styles.page_break_threshold - pdf.start_new_page - end - end - def get_column_value(work_package, column_name) formatter = formatter_for(column_name, :pdf) formatter.format(work_package) end - def get_column_value_cell(work_package, column_name) - value = get_column_value(work_package, column_name) - return get_id_column_cell(work_package, value) if column_name == :id - return get_subject_column_cell(work_package, value) if wants_report? && column_name == :subject - - escape_tags(value) - end - def get_formatted_value(value, column_name) return "" if value.nil? @@ -108,19 +93,6 @@ def escape_tags(value) value.to_s.gsub("<", "<").gsub(">", ">") end - def get_id_column_cell(work_package, value) - href = url_helpers.work_package_url(work_package) - make_link_href_cell(href, value) - end - - def get_subject_column_cell(work_package, value) - make_link_anchor(work_package.id, escape_tags(value)) - end - - def make_link_href_cell(href, caption) - "#{caption}" - end - def make_link_anchor(anchor, caption) "#{caption}" end @@ -306,6 +278,10 @@ def title_datetime DateTime.now.strftime("%Y-%m-%d_%H-%M") end + def footer_date + format_time(Time.zone.now) + end + def current_page_nr pdf.page_number + @page_count - (with_cover? ? 1 : 0) end diff --git a/app/models/work_package/pdf_export/common/logo.rb b/app/models/work_package/pdf_export/common/logo.rb new file mode 100644 index 000000000000..fe4cc81b6f28 --- /dev/null +++ b/app/models/work_package/pdf_export/common/logo.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 WorkPackage::PDFExport::Common::Logo + def logo_image + image_obj, image_info = pdf.build_image_object(logo_image_filename) + [image_obj, image_info] + end + + def logo_image_filename + custom_logo_image_filename || Rails.root.join("app/assets/images/logo_openproject.png") + end + + def custom_logo_image_filename + return unless CustomStyle.current.present? && + CustomStyle.current.export_logo.present? && CustomStyle.current.export_logo.local_file.present? + + image_file = CustomStyle.current.export_logo.local_file.path + content_type = OpenProject::ContentTypeDetector.new(image_file).detect + return unless pdf_embeddable?(content_type) + + image_file + end +end diff --git a/app/models/work_package/pdf_export/markdown_field.rb b/app/models/work_package/pdf_export/common/macro.rb similarity index 84% rename from app/models/work_package/pdf_export/markdown_field.rb rename to app/models/work_package/pdf_export/common/macro.rb index 53ccd93dc8b6..bb2a73b914db 100644 --- a/app/models/work_package/pdf_export/markdown_field.rb +++ b/app/models/work_package/pdf_export/common/macro.rb @@ -26,28 +26,15 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module WorkPackage::PDFExport::MarkdownField - include WorkPackage::PDFExport::Markdown +module WorkPackage::PDFExport::Common::Macro PREFORMATTED_BLOCKS = %w(pre code).freeze - def write_markdown_field!(work_package, markdown, label) - return if markdown.blank? - - write_optional_page_break - with_margin(styles.wp_markdown_label_margins) do - pdf.formatted_text([styles.wp_markdown_label.merge({ text: label })]) - end - with_margin(styles.wp_markdown_margins) do - write_markdown! work_package, apply_markdown_field_macros(markdown, work_package) - end - end - - private - def apply_markdown_field_macros(markdown, work_package) apply_macros(markdown, work_package, WorkPackage::Exports::Macros::Attributes) end + private + def apply_macros(markdown, work_package, formatter) return markdown unless formatter.applicable?(markdown) diff --git a/app/models/work_package/pdf_export/view.rb b/app/models/work_package/pdf_export/common/view.rb similarity index 98% rename from app/models/work_package/pdf_export/view.rb rename to app/models/work_package/pdf_export/common/view.rb index 86f9c4418c61..8adc723fc043 100644 --- a/app/models/work_package/pdf_export/view.rb +++ b/app/models/work_package/pdf_export/common/view.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class WorkPackage::PDFExport::View +class WorkPackage::PDFExport::Common::View include Prawn::View include Redmine::I18n diff --git a/app/models/work_package/pdf_export/document_generator.rb b/app/models/work_package/pdf_export/document_generator.rb new file mode 100644 index 000000000000..37b69c6b4ea0 --- /dev/null +++ b/app/models/work_package/pdf_export/document_generator.rb @@ -0,0 +1,94 @@ +# 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 WorkPackage::PDFExport::DocumentGenerator < Exports::Exporter + include WorkPackage::PDFExport::Common::Common + include WorkPackage::PDFExport::Common::Attachments + include WorkPackage::PDFExport::Common::Logo + include WorkPackage::PDFExport::Common::Macro + include WorkPackage::PDFExport::Generator::Generator + + attr_accessor :pdf + + self.model = WorkPackage + + alias :work_package :object + + def self.key + :generate_pdf + end + + def initialize(work_package, _options = {}) + super + + setup_page! + end + + def setup_page! + self.pdf = get_pdf(current_language) + end + + def export! + render_doc + success(pdf.render) + rescue StandardError => e + Rails.logger.error { "Failed to generate PDF: #{e} #{e.message}}." } + error(I18n.t(:error_pdf_failed_to_export, error: e.message)) + end + + def render_doc + generate_doc!( + apply_markdown_field_macros(work_package.description || "", work_package), + "contracts.yml" + ) + end + + def hyphenation_language + options[:hyphenation] + end + + def heading + options[:header_text_right] + end + + def footer_title + options[:footer_text_center] + end + + def title + # ____.pdf + build_pdf_filename([work_package.project, work_package.type, + "##{work_package.id}", work_package.subject].join("_")) + end + + def with_images? + true + end +end diff --git a/app/models/work_package/pdf_export/cover.rb b/app/models/work_package/pdf_export/export/cover.rb similarity index 98% rename from app/models/work_package/pdf_export/cover.rb rename to app/models/work_package/pdf_export/export/cover.rb index ebac1df6b501..e50d0c101c30 100644 --- a/app/models/work_package/pdf_export/cover.rb +++ b/app/models/work_package/pdf_export/export/cover.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -26,7 +28,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module WorkPackage::PDFExport::Cover +module WorkPackage::PDFExport::Export::Cover def write_cover_page! write_cover_logo write_cover_hr diff --git a/app/models/work_package/pdf_export/export/export_common.rb b/app/models/work_package/pdf_export/export/export_common.rb new file mode 100644 index 000000000000..ace15b8f08c7 --- /dev/null +++ b/app/models/work_package/pdf_export/export/export_common.rb @@ -0,0 +1,59 @@ +# 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 WorkPackage::PDFExport::Export::ExportCommon + def write_optional_page_break + space_from_bottom = pdf.y - pdf.bounds.bottom + if space_from_bottom < styles.page_break_threshold + pdf.start_new_page + end + end + + def make_link_href_cell(href, caption) + "#{caption}" + end + + def get_column_value_cell(work_package, column_name) + value = get_column_value(work_package, column_name) + return get_id_column_cell(work_package, value) if column_name == :id + return get_subject_column_cell(work_package, value) if wants_report? && column_name == :subject + + escape_tags(value) + end + + def get_id_column_cell(work_package, value) + href = url_helpers.work_package_url(work_package) + make_link_href_cell(href, value) + end + + def get_subject_column_cell(work_package, value) + make_link_anchor(work_package.id, escape_tags(value)) + end +end diff --git a/app/models/work_package/pdf_export/gantt.rb b/app/models/work_package/pdf_export/export/gantt.rb similarity index 98% rename from app/models/work_package/pdf_export/gantt.rb rename to app/models/work_package/pdf_export/export/gantt.rb index 778f286168f6..a395b4ce0710 100644 --- a/app/models/work_package/pdf_export/gantt.rb +++ b/app/models/work_package/pdf_export/export/gantt.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -43,7 +45,7 @@ # 1. Build the data classes, do the layout, measuring, etc. # 2. Paint the Gantt chart into the PDF -module WorkPackage::PDFExport::Gantt +module WorkPackage::PDFExport::Export::Gantt GANTT_DAY_COLUMN_WIDTHS = [64, 32, 24, 18].freeze GANTT_COLUMN_WIDTHS = [128, 64, 32, 24].freeze GANTT_COLUMN_WIDTHS_NAMES = %w[very_wide wide medium narrow].freeze diff --git a/app/models/work_package/pdf_export/gantt/gantt_builder.rb b/app/models/work_package/pdf_export/export/gantt/gantt_builder.rb similarity index 99% rename from app/models/work_package/pdf_export/gantt/gantt_builder.rb rename to app/models/work_package/pdf_export/export/gantt/gantt_builder.rb index 334697cfbe75..1af1b7ee16a5 100644 --- a/app/models/work_package/pdf_export/gantt/gantt_builder.rb +++ b/app/models/work_package/pdf_export/export/gantt/gantt_builder.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module WorkPackage::PDFExport::Gantt +module WorkPackage::PDFExport::Export::Gantt class GanttBuilder include Redmine::I18n BAR_CELL_PADDING = 5.to_f diff --git a/app/models/work_package/pdf_export/gantt/gantt_builder_days.rb b/app/models/work_package/pdf_export/export/gantt/gantt_builder_days.rb similarity index 97% rename from app/models/work_package/pdf_export/gantt/gantt_builder_days.rb rename to app/models/work_package/pdf_export/export/gantt/gantt_builder_days.rb index 829e2f8bb41f..465a4f9515bc 100644 --- a/app/models/work_package/pdf_export/gantt/gantt_builder_days.rb +++ b/app/models/work_package/pdf_export/export/gantt/gantt_builder_days.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module WorkPackage::PDFExport::Gantt +module WorkPackage::PDFExport::Export::Gantt class GanttBuilderDays < GanttBuilder def build_column_dates_range(range) range.to_a diff --git a/app/models/work_package/pdf_export/gantt/gantt_builder_months.rb b/app/models/work_package/pdf_export/export/gantt/gantt_builder_months.rb similarity index 98% rename from app/models/work_package/pdf_export/gantt/gantt_builder_months.rb rename to app/models/work_package/pdf_export/export/gantt/gantt_builder_months.rb index 2505a1d2de52..fe8b8f252d8a 100644 --- a/app/models/work_package/pdf_export/gantt/gantt_builder_months.rb +++ b/app/models/work_package/pdf_export/export/gantt/gantt_builder_months.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module WorkPackage::PDFExport::Gantt +module WorkPackage::PDFExport::Export::Gantt class GanttBuilderMonths < GanttBuilder def build_column_dates_range(range) range diff --git a/app/models/work_package/pdf_export/gantt/gantt_builder_quarters.rb b/app/models/work_package/pdf_export/export/gantt/gantt_builder_quarters.rb similarity index 98% rename from app/models/work_package/pdf_export/gantt/gantt_builder_quarters.rb rename to app/models/work_package/pdf_export/export/gantt/gantt_builder_quarters.rb index c6767403b3fe..664f02d2048d 100644 --- a/app/models/work_package/pdf_export/gantt/gantt_builder_quarters.rb +++ b/app/models/work_package/pdf_export/export/gantt/gantt_builder_quarters.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module WorkPackage::PDFExport::Gantt +module WorkPackage::PDFExport::Export::Gantt class GanttBuilderQuarters < GanttBuilder def build_column_dates_range(range) range diff --git a/app/models/work_package/pdf_export/gantt/gantt_builder_weeks.rb b/app/models/work_package/pdf_export/export/gantt/gantt_builder_weeks.rb similarity index 98% rename from app/models/work_package/pdf_export/gantt/gantt_builder_weeks.rb rename to app/models/work_package/pdf_export/export/gantt/gantt_builder_weeks.rb index f0cc8750a423..337f04977d61 100644 --- a/app/models/work_package/pdf_export/gantt/gantt_builder_weeks.rb +++ b/app/models/work_package/pdf_export/export/gantt/gantt_builder_weeks.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module WorkPackage::PDFExport::Gantt +module WorkPackage::PDFExport::Export::Gantt class GanttBuilderWeeks < GanttBuilder def build_column_dates_range(range) range diff --git a/app/models/work_package/pdf_export/gantt/gantt_painter.rb b/app/models/work_package/pdf_export/export/gantt/gantt_painter.rb similarity index 99% rename from app/models/work_package/pdf_export/gantt/gantt_painter.rb rename to app/models/work_package/pdf_export/export/gantt/gantt_painter.rb index 952b08b505b8..4247900974b1 100644 --- a/app/models/work_package/pdf_export/gantt/gantt_painter.rb +++ b/app/models/work_package/pdf_export/export/gantt/gantt_painter.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module WorkPackage::PDFExport::Gantt +module WorkPackage::PDFExport::Export::Gantt class GanttPainter GANTT_GRID_COLOR = "9b9ea3".freeze GANTT_LINE_COLOR = "2b8bd5".freeze diff --git a/app/models/work_package/pdf_export/markdown.rb b/app/models/work_package/pdf_export/export/markdown.rb similarity index 77% rename from app/models/work_package/pdf_export/markdown.rb rename to app/models/work_package/pdf_export/export/markdown.rb index a875f800bf89..eab8c54370a9 100644 --- a/app/models/work_package/pdf_export/markdown.rb +++ b/app/models/work_package/pdf_export/export/markdown.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -28,8 +30,8 @@ require "md_to_pdf/core" -module WorkPackage::PDFExport::Markdown - class MD2PDF +module WorkPackage::PDFExport::Export::Markdown + class MD2PDFExport include MarkdownToPDF::Core include MarkdownToPDF::Parser @@ -96,35 +98,10 @@ def warn(text, element, node) end end - def write_markdown!(work_package, markdown) - md2pdf = MD2PDF.new(styles.wp_markdown_styling_yml, pdf) + def write_markdown!(work_package, markdown, styling_yml) + md2pdf = MD2PDFExport.new(styling_yml, pdf) md2pdf.draw_markdown(markdown, pdf, ->(src) { with_images? ? attachment_image_filepath(work_package, src) : nil }) end - - private - - def attachment_image_local_file(attachment) - attachment.file.local_file - rescue StandardError => e - Rails.logger.error "Failed to access attachment #{attachment.id} file: #{e}" - nil # return nil as if the id was wrong and the attachment obj has not been found - end - - def attachment_image_filepath(work_package, src) - # images are embedded into markup with the api-path as img.src - attachment = attachment_by_api_content_src(work_package, src) - return nil if attachment.nil? || !pdf_embeddable?(attachment.content_type) - - local_file = attachment_image_local_file(attachment) - return nil if local_file.nil? - - resize_image(local_file.path) - end - - def attachment_by_api_content_src(work_package, src) - # find attachment by api-path - work_package.attachments.detect { |a| api_url_helpers.attachment_content(a.id) == src } - end end diff --git a/app/models/work_package/pdf_export/export/markdown_field.rb b/app/models/work_package/pdf_export/export/markdown_field.rb new file mode 100644 index 000000000000..02c097089ac1 --- /dev/null +++ b/app/models/work_package/pdf_export/export/markdown_field.rb @@ -0,0 +1,60 @@ +# 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 WorkPackage::PDFExport::Export::MarkdownField + include WorkPackage::PDFExport::Export::Markdown + include WorkPackage::PDFExport::Common::Macro + + def write_markdown_field!(work_package, markdown, label) + return if markdown.blank? + + write_optional_page_break + write_markdown_field_label(label) + write_markdown_field_value(work_package, markdown) + end + + private + + def write_markdown_field_label(label) + with_margin(styles.wp_markdown_label_margins) do + pdf.formatted_text([styles.wp_markdown_label.merge({ text: label })]) + end + end + + def write_markdown_field_value(work_package, markdown) + with_margin(styles.wp_markdown_margins) do + write_markdown!( + work_package, + apply_markdown_field_macros(markdown, work_package), + styles.wp_markdown_styling_yml + ) + end + end +end diff --git a/app/models/work_package/pdf_export/overview_table.rb b/app/models/work_package/pdf_export/export/overview_table.rb similarity index 98% rename from app/models/work_package/pdf_export/overview_table.rb rename to app/models/work_package/pdf_export/export/overview_table.rb index c44684665114..10405fb87c6b 100644 --- a/app/models/work_package/pdf_export/overview_table.rb +++ b/app/models/work_package/pdf_export/export/overview_table.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -26,7 +28,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module WorkPackage::PDFExport::OverviewTable +module WorkPackage::PDFExport::Export::OverviewTable def write_work_packages_overview!(work_packages) if query.grouped? write_grouped!(work_packages) diff --git a/app/models/work_package/pdf_export/page.rb b/app/models/work_package/pdf_export/export/page.rb similarity index 83% rename from app/models/work_package/pdf_export/page.rb rename to app/models/work_package/pdf_export/export/page.rb index 571efcfaf76f..c63001e5060f 100644 --- a/app/models/work_package/pdf_export/page.rb +++ b/app/models/work_package/pdf_export/export/page.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -26,7 +28,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module WorkPackage::PDFExport::Page +module WorkPackage::PDFExport::Export::Page MAX_NR_OF_PDF_FOOTER_LINES = 3 def configure_page_size!(layout) @@ -68,24 +70,6 @@ def logo_pdf_image [image_obj, image_info, scale] end - def logo_image - image_file = custom_logo_image - image_file = Rails.root.join("app/assets/images/logo_openproject.png") if image_file.nil? - image_obj, image_info = pdf.build_image_object(image_file) - [image_obj, image_info] - end - - def custom_logo_image - return unless CustomStyle.current.present? && - CustomStyle.current.export_logo.present? && CustomStyle.current.export_logo.local_file.present? - - image_file = CustomStyle.current.export_logo.local_file.path - content_type = OpenProject::ContentTypeDetector.new(image_file).detect - return unless pdf_embeddable?(content_type) - - image_file - end - def write_title! pdf.title = heading with_margin(styles.page_heading_margins) do @@ -126,10 +110,6 @@ def footer_page_nr current_page_nr.to_s + total_page_nr_text end - def footer_date - format_time(Time.zone.now) - end - def total_page_nr_text if @total_page_nr "/#{@total_page_nr - (with_cover? ? 1 : 0)}" diff --git a/app/models/work_package/pdf_export/schema.json b/app/models/work_package/pdf_export/export/schema.json similarity index 100% rename from app/models/work_package/pdf_export/schema.json rename to app/models/work_package/pdf_export/export/schema.json diff --git a/app/models/work_package/pdf_export/standard.yml b/app/models/work_package/pdf_export/export/standard.yml similarity index 100% rename from app/models/work_package/pdf_export/standard.yml rename to app/models/work_package/pdf_export/export/standard.yml diff --git a/app/models/work_package/pdf_export/style.rb b/app/models/work_package/pdf_export/export/style.rb similarity index 98% rename from app/models/work_package/pdf_export/style.rb rename to app/models/work_package/pdf_export/export/style.rb index 349bc977d67f..4a1a152adf1f 100644 --- a/app/models/work_package/pdf_export/style.rb +++ b/app/models/work_package/pdf_export/export/style.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -26,7 +28,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module WorkPackage::PDFExport::Style +module WorkPackage::PDFExport::Export::Style include MarkdownToPDF::StyleValidation class PDFStyles @@ -308,7 +310,6 @@ def load_style end def styles_asset_path - # TODO: where to put & load yml & json file File.dirname(File.expand_path(__FILE__)) end end diff --git a/app/models/work_package/pdf_export/sums_table.rb b/app/models/work_package/pdf_export/export/sums_table.rb similarity index 97% rename from app/models/work_package/pdf_export/sums_table.rb rename to app/models/work_package/pdf_export/export/sums_table.rb index c34c08ad4eb0..59c22e467c9e 100644 --- a/app/models/work_package/pdf_export/sums_table.rb +++ b/app/models/work_package/pdf_export/export/sums_table.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -26,7 +28,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module WorkPackage::PDFExport::SumsTable +module WorkPackage::PDFExport::Export::SumsTable def write_work_packages_sums!(_work_packages) return unless has_summable_column? diff --git a/app/models/work_package/pdf_export/table_of_contents.rb b/app/models/work_package/pdf_export/export/table_of_contents.rb similarity index 98% rename from app/models/work_package/pdf_export/table_of_contents.rb rename to app/models/work_package/pdf_export/export/table_of_contents.rb index 8d763bb21d09..2e6094626a4f 100644 --- a/app/models/work_package/pdf_export/table_of_contents.rb +++ b/app/models/work_package/pdf_export/export/table_of_contents.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -26,7 +28,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module WorkPackage::PDFExport::TableOfContents +module WorkPackage::PDFExport::Export::TableOfContents def write_work_packages_toc!(work_packages, id_wp_meta_map) toc_list = build_toc_data_list work_packages, id_wp_meta_map with_margin(styles.toc_margins) do diff --git a/app/models/work_package/pdf_export/work_package_detail.rb b/app/models/work_package/pdf_export/export/work_package_detail.rb similarity index 98% rename from app/models/work_package/pdf_export/work_package_detail.rb rename to app/models/work_package/pdf_export/export/work_package_detail.rb index 24ad8fa359b7..f54b179a08c3 100644 --- a/app/models/work_package/pdf_export/work_package_detail.rb +++ b/app/models/work_package/pdf_export/export/work_package_detail.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -26,8 +28,8 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module WorkPackage::PDFExport::WorkPackageDetail - include WorkPackage::PDFExport::MarkdownField +module WorkPackage::PDFExport::Export::WorkPackageDetail + include WorkPackage::PDFExport::Export::MarkdownField def write_work_packages_details!(work_packages, id_wp_meta_map) work_packages.each do |work_package| diff --git a/app/models/work_package/pdf_export/generator/contracts.yml b/app/models/work_package/pdf_export/generator/contracts.yml new file mode 100644 index 000000000000..97930db5db6b --- /dev/null +++ b/app/models/work_package/pdf_export/generator/contracts.yml @@ -0,0 +1,244 @@ +page: + font: 'NotoSans' + size: 11 + character-spacing: 0 + styles: [] + color: '000000' + page-size: 'A4' + page-layout: 'portrait' + margin-left: 20mm + margin-right: 20mm + margin-top: 40mm + margin-bottom: 35mm + leading: 6 + +page-footer: + filter-pages: [ ] + align: "left" + offset: -51 + size: 8 + +page-footer-2: + filter-pages: [ ] + align: "center" + offset: -51 + size: 8 + +page-footer-3: + filter-pages: [ ] + align: "right" + offset: -51 + size: 8 + +page-header: + filter-pages: [ ] + align: "right" + size: 8 + leading: 4 + offset: -61 + +page-logo: + filter-pages: [ ] + align: "left" + max-width: 40mm + offset: -70 + +paragraph: + align: 'justify' + padding-bottom: 3mm + +link: + color: '1b69b6' + +unordered-list: + spacing: 3mm + padding-bottom: 3mm + +unordered-list-point: + sign: "•" + spacing: 0.75mm + +ordered-list: + spacing: 3mm + padding-bottom: 3mm + +ordered-list-point: + spanning: true + +ordered-list-point-1: + template: '()' + +ordered-list-point-2: + template: ')' + alphabetical: true + +hrule: + margin-top: 2mm + margin-bottom: 4mm + line-width: 1 + +header: + styles: [ 'bold' ] + padding-top: 2mm + padding-bottom: 2mm + +header-1: + size: 18 + padding-top: 6mm +header-2: + size: 14 + padding-top: 5mm +header-3: + size: 12 + padding-top: 4mm +header-4: + size: 11 + padding-top: 3mm +header-5: + size: 10 +header-6: + size: 10 +header-7: + size: 10 +header-8: + size: 10 + +table: + margin-top: 2mm + margin-bottom: 4mm + header: + styles: [ 'bold' ] + background-color: 'F0F0F0' + size: 10 + cell: + no-border: true + padding: 1mm + size: 9 + +headless-table: + margin-top: 2mm + margin-bottom: 4mm + cell: + no-border: true + padding: 1mm + size: 9 + +blockquote: + background-color: 'f4f9ff' + size: 11 + styles: [ 'italic' ] + color: '0f3b66' + border-color: 'b8d6f4' + border-width: 1 + no-border-right: true + no-border-left: false + no-border-bottom: true + no-border-top: true + padding: 8mm + padding-left: 4mm + padding-right: 4mm + margin-top: 2mm + margin-bottom: 2mm + +alerts: + NOTE: + border_color: '0969da' + alert_color: '0969da' + padding: '4mm' + size: 10 + styles: [] + border_width: 2 + no_border_right: true + no_border_left: false + no_border_bottom: true + no_border_top: true + TIP: + border_color: '1a7f37' + alert_color: '1a7f37' + padding: '4mm' + size: 10 + styles: [ ] + border_width: 2 + no_border_right: true + no_border_left: false + no_border_bottom: true + no_border_top: true + IMPORTANT: + border_color: '8250df' + alert_color: '8250df' + padding: '4mm' + size: 10 + styles: [ ] + border_width: 2 + no_border_right: true + no_border_left: false + no_border_bottom: true + no_border_top: true + WARNING: + border_color: 'bf8700' + alert_color: 'bf8700' + padding: '4mm' + size: 10 + styles: [ ] + border_width: 2 + no_border_right: true + no_border_left: false + no_border_bottom: true + no_border_top: true + CAUTION: + border_color: 'd1242f' + alert_color: 'd1242f' + size: 10 + styles: [ ] + padding: '4mm' + border_width: 2 + no_border_right: true + no_border_left: false + no_border_bottom: true + no_border_top: true + +code: + font: 'SpaceMono' + color: '880000' + +codeblock: + background-color: 'F5F5F5' + font: 'SpaceMono' + size: 8 + color: '880000' + padding: 3mm + margin-top: 2mm + margin-bottom: 2mm + +image: + max-width: 50mm + margin: 2mm + margin-bottom: 3mm + align: "center" + +image-classes: + small: + max-width: 10mm + left: + margin: 0 + align: "left" + center: + margin: 0 + align: "center" + right: + margin: 0 + align: "right" + +footnote-definition: + point: + size: 13 + styles: [ 'superscript' ] + color: '000000' + +footnote-reference: + size: 13 + styles: [ 'superscript' ] + color: '000088' + +fields-default: + pdf_hyphenation: true diff --git a/app/models/work_package/pdf_export/generator/generator.rb b/app/models/work_package/pdf_export/generator/generator.rb new file mode 100644 index 000000000000..2e152e561526 --- /dev/null +++ b/app/models/work_package/pdf_export/generator/generator.rb @@ -0,0 +1,167 @@ +# 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. +#++ + +require "md_to_pdf/core" + +module WorkPackage::PDFExport::Generator::Generator + class MD2PDFGenerator + include MarkdownToPDF::Core + include MarkdownToPDF::Parser + include MarkdownToPDF::StyleSchema + + def initialize(styling_yml) + symbol_yml = symbolize(styling_yml) + validate_schema!(symbol_yml, styles_schema) + @styles = MarkdownToPDF::Styles.new(symbol_yml) + init_options({ auto_generate_header_ids: false }) + end + + def init_pdf(pdf) + @pdf = pdf + init_pdf_page_styles(pdf) + pdf_init_md2pdf_fonts(pdf) + end + + def init_pdf_page_styles(pdf) + page_style = @styles.page + page_margins = opts_margin(page_style) + pdf.options[:page_layout] = (page_style[:page_layout] || "portrait").to_sym + pdf.options[:page_size] = page_style[:page_size] + %i[top_margin left_margin bottom_margin right_margin].each do |margin| + pdf.options[margin] = page_margins[margin] + end + end + + def generate!(markdown, options, image_loader) + @image_loader = image_loader + fields = {} + .merge(@styles.default_fields) + .merge(options) + doc = parse_frontmatter_markdown(markdown, fields) + @hyphens = Text::Hyphen.new(language: options[:language], left: 2, right: 2) if options[:language].present? + render_doc(doc) + end + + def render_doc(doc) + style = @styles.page + opts = pdf_root_options(style) + root = doc[:root] + draw_node(root, opts, true) + draw_footnotes(opts) + repeating_page_footer(doc, opts) + repeating_page_header(doc, opts) + repeating_page_logo(doc[:logo], root, opts) + end + + def image_url_to_local_file(url, _node = nil) + return nil if url.blank? || @image_loader.nil? + + @image_loader.call(url) + end + + def hyphenate(text) + return text if @hyphens.nil? + + @hyphens.visualize(text, Prawn::Text::SHY) + end + + def handle_mention_html_tag(tag, node, opts) + if tag.text.blank? + # + # + text = tag.attr("data-text") + if text.present? && !node.next.respond_to?(:string_content) && node.next.string_content != text + return [text_hash(text, opts)] + end + end + # @Some User + [] + end + + def handle_unknown_inline_html_tag(tag, node, opts) + result = if tag.name == "mention" + handle_mention_html_tag(tag, node, opts) + else + # unknown/unsupported html tags eg. hi are ignored + # but scanned for supported or text children + data_inlinehtml_tag(tag, node, opts) + end + [result, opts] + end + + def handle_unknown_html_tag(_tag, _node, opts) + # unknown/unsupported html tags eg. hi are ignored + # but scanned for supported or text children [true, ...] + [true, opts] + end + + def warn(text, element, node) + Rails.logger.warn "PDF-Export: #{text}\nGot #{element} at #{node.source_position.inspect}\n\n" + end + end + + def generate_doc!(markdown, styling_file) + md2pdf = MD2PDFGenerator.new(md_to_pdf_styling(styling_file)) + md2pdf.init_pdf(pdf) + md2pdf.generate!(markdown, md_to_pdf_options, ->(src) { + if src == logo_image_filename + logo_image_filename + else + attachment_image_filepath(work_package, src) + end + }) + end + + private + + def md_to_pdf_styling(styling_file) + styling = YAML::load_file(File.join(styling_asset_path, styling_file)) + # overwrite the paper size if it is set in the options + styling["page"]["page-size"] = options[:paper_size] if options[:paper_size].present? + styling + end + + def md_to_pdf_options + # rubocop:disable Naming/VariableNumber + { + language: hyphenation_language, + pdf_footer: footer_date, + pdf_footer_2: footer_title, + pdf_footer_3: I18n.t("pdf_generator.page_nr_footer", page: "", total: ""), + pdf_header_logo: logo_image_filename, + pdf_header: heading + } + # rubocop:enable Naming/VariableNumber + end + + def styling_asset_path + File.dirname(File.expand_path(__FILE__)) + end +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 cf346d0f91c7..9a1ca49c949e 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 @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -40,16 +42,20 @@ require "open3" class WorkPackage::PDFExport::WorkPackageListToPdf < WorkPackage::Exports::QueryExporter - include WorkPackage::PDFExport::Common - include WorkPackage::PDFExport::Attachments - include WorkPackage::PDFExport::OverviewTable - include WorkPackage::PDFExport::SumsTable - include WorkPackage::PDFExport::WorkPackageDetail - include WorkPackage::PDFExport::TableOfContents - include WorkPackage::PDFExport::Page - include WorkPackage::PDFExport::Gantt - include WorkPackage::PDFExport::Style - include WorkPackage::PDFExport::Cover + include WorkPackage::PDFExport::Common::Common + include WorkPackage::PDFExport::Common::Logo + include WorkPackage::PDFExport::Common::Attachments + include WorkPackage::PDFExport::Export::ExportCommon + include WorkPackage::PDFExport::Export::WorkPackageDetail + include WorkPackage::PDFExport::Export::Page + include WorkPackage::PDFExport::Export::Style + include WorkPackage::PDFExport::Export::OverviewTable + include WorkPackage::PDFExport::Export::SumsTable + include WorkPackage::PDFExport::Export::WorkPackageDetail + include WorkPackage::PDFExport::Export::TableOfContents + include WorkPackage::PDFExport::Export::Style + include WorkPackage::PDFExport::Export::Cover + include WorkPackage::PDFExport::Export::Gantt attr_accessor :pdf, :options 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 9f85c9d9572b..5dcfa09dd8c8 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 @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -27,11 +29,13 @@ #++ class WorkPackage::PDFExport::WorkPackageToPdf < Exports::Exporter - include WorkPackage::PDFExport::Common - include WorkPackage::PDFExport::Attachments - include WorkPackage::PDFExport::WorkPackageDetail - include WorkPackage::PDFExport::Page - include WorkPackage::PDFExport::Style + include WorkPackage::PDFExport::Common::Common + include WorkPackage::PDFExport::Common::Logo + include WorkPackage::PDFExport::Common::Attachments + include WorkPackage::PDFExport::Export::ExportCommon + include WorkPackage::PDFExport::Export::WorkPackageDetail + include WorkPackage::PDFExport::Export::Page + include WorkPackage::PDFExport::Export::Style attr_accessor :pdf, :columns @@ -54,7 +58,7 @@ def export! render_work_package success(pdf.render) rescue StandardError => e - Rails.logger.error { "Failed to generated PDF export: #{e} #{e.message}}." } + Rails.logger.error { "Failed to generate PDF export: #{e} #{e.message}}." } error(I18n.t(:error_pdf_failed_to_export, error: e.message)) end diff --git a/app/models/work_package_custom_field.rb b/app/models/work_package_custom_field.rb index 1b1a68fe5bc8..5af119827e68 100644 --- a/app/models/work_package_custom_field.rb +++ b/app/models/work_package_custom_field.rb @@ -49,6 +49,11 @@ class WorkPackageCustomField < CustomField end } + scope :usable_as_custom_action, -> { + where.not(field_format: %w[hierarchy]) + .order(:name) + } + def self.summable where(field_format: %w[int float]) end diff --git a/app/seeders/basic_data/color_seeder.rb b/app/seeders/basic_data/color_seeder.rb index 225c372b704d..760ec3ed6cf5 100644 --- a/app/seeders/basic_data/color_seeder.rb +++ b/app/seeders/basic_data/color_seeder.rb @@ -29,6 +29,7 @@ module BasicData class ColorSeeder < ModelSeeder self.model_class = Color self.seed_data_model_key = "colors" + self.attribute_names_for_lookups = %i[name] def model_attributes(color_data) { diff --git a/app/models/queries/filters/strategies/validations.rb b/app/seeders/basic_data/life_cycle_color_seeder.rb similarity index 75% rename from app/models/queries/filters/strategies/validations.rb rename to app/seeders/basic_data/life_cycle_color_seeder.rb index bd17a263a057..835dd00cb2d8 100644 --- a/app/models/queries/filters/strategies/validations.rb +++ b/app/seeders/basic_data/life_cycle_color_seeder.rb @@ -26,26 +26,19 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module Queries::Filters::Strategies - module Validations - private +module BasicData + class LifeCycleColorSeeder < ColorSeeder + self.seed_data_model_key = "life_cycle_colors" - def date?(str) - true if Date.parse(str) - rescue StandardError - false + def applicable? + missing_color_names.any? end - def validate - unless values.all? { |value| value.blank? || date?(value) } - errors.add(:values, I18n.t("activerecord.errors.messages.not_a_date")) - end - end + private - def integer?(str) - true if Integer(str) - rescue StandardError - false + def missing_color_names + color_names = models_data.pluck("name") + color_names - Color.where(name: color_names).pluck(:name) end end end diff --git a/app/seeders/basic_data/life_cycle_step_definition_seeder.rb b/app/seeders/basic_data/life_cycle_step_definition_seeder.rb new file mode 100644 index 000000000000..54166f731019 --- /dev/null +++ b/app/seeders/basic_data/life_cycle_step_definition_seeder.rb @@ -0,0 +1,46 @@ +#-- 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 BasicData + class LifeCycleStepDefinitionSeeder < ModelSeeder + self.model_class = Project::LifeCycleStepDefinition + self.seed_data_model_key = "life_cycles" + self.needs = [ + BasicData::LifeCycleColorSeeder + ] + + self.attribute_names_for_lookups = %i[name type] + + def model_attributes(life_cyle_data) + { + name: life_cyle_data["name"], + type: life_cyle_data["type"], + color_id: color_id(life_cyle_data["color_name"]) + } + end + end +end diff --git a/app/seeders/common.yml b/app/seeders/common.yml index bd7e4a71f8b0..ee89fb7b1376 100644 --- a/app/seeders/common.yml +++ b/app/seeders/common.yml @@ -70,6 +70,23 @@ colors: t_name: Black hexcode: "#000000" +life_cycle_colors: + - reference: :default_color_pm2_orange + t_name: PM2 Orange + hexcode: "#F7983A" + - reference: :default_color_pm2_purple + t_name: PM2 Purple + hexcode: "#682D91" + - reference: :default_color_pm2_red + t_name: PM2 Red + hexcode: "#F05823" + - reference: :default_color_pm2_magenta + t_name: PM2 Magenta + hexcode: "#EC038A" + - reference: :default_color_pm2_green_yellow + t_name: PM2 Green Yellow + hexcode: "#B1D13A" + document_categories: - t_name: Documentation position: 1 diff --git a/app/seeders/root_seeder.rb b/app/seeders/root_seeder.rb index f1a150e4723f..0958652ca40c 100644 --- a/app/seeders/root_seeder.rb +++ b/app/seeders/root_seeder.rb @@ -75,6 +75,11 @@ def do_seed! seed_development_data if seed_development_data? seed_plugins_data seed_env_data + cleanup_seed_data + end + + def cleanup_seed_data + admin_user.lock! if Setting.seed_admin_user_locked? end def seed_development_data? diff --git a/app/seeders/source/seed_data.rb b/app/seeders/source/seed_data.rb index f3813a98e8c2..13f9726bfe39 100644 --- a/app/seeders/source/seed_data.rb +++ b/app/seeders/source/seed_data.rb @@ -60,7 +60,10 @@ def find_reference(reference, *fallbacks, default: :__unset__) default else references = [reference, *fallbacks].map(&:inspect) - message = "Nothing registered with #{'reference'.pluralize(references.count)} #{references.to_sentence(locale: false)}" + message = <<~STRING + Nothing registered with #{'reference'.pluralize(references.count)} #{references.to_sentence(locale: false)} + Perhaps you forgot to add the `attribute_names_for_lookups` for your seeder? + STRING raise ArgumentError, message end end diff --git a/app/seeders/standard.yml b/app/seeders/standard.yml index a177de929704..0e00dea26de8 100644 --- a/app/seeders/standard.yml +++ b/app/seeders/standard.yml @@ -26,6 +26,36 @@ # See COPYRIGHT and LICENSE files for more details. #++ +life_cycles: + - reference: :default_life_cycle_initiating + t_name: Initiating + type: Project::StageDefinition + color_name: :default_color_pm2_orange + - reference: :default_life_cycle_ready_for_planning + t_name: Ready for Planning + type: Project::GateDefinition + color_name: :default_color_pm2_purple + - reference: :default_life_cycle_planning + t_name: Planning + type: Project::StageDefinition + color_name: :default_color_pm2_red + - reference: :default_life_cycle_ready_for_executing + t_name: Ready for Executing + type: Project::GateDefinition + color_name: :default_color_pm2_purple + - reference: :default_life_cycle_executing + t_name: Executing + type: Project::StageDefinition + color_name: :default_color_pm2_magenta + - reference: :default_life_cycle_ready_for_closing + t_name: Ready for Closing + type: Project::GateDefinition + color_name: :default_color_pm2_purple + - reference: :default_life_cycle_closing + t_name: Closing + type: Project::StageDefinition + color_name: :default_color_pm2_green_yellow + priorities: - reference: :default_priority_low t_name: Low diff --git a/app/seeders/standard/basic_data_seeder.rb b/app/seeders/standard/basic_data_seeder.rb index 5c6c06c8ee29..d77525b74a16 100644 --- a/app/seeders/standard/basic_data_seeder.rb +++ b/app/seeders/standard/basic_data_seeder.rb @@ -37,6 +37,8 @@ def data_seeder_classes ::BasicData::TimeEntryActivitySeeder, ::BasicData::ColorSeeder, ::BasicData::ColorSchemeSeeder, + ::BasicData::LifeCycleColorSeeder, + ::BasicData::LifeCycleStepDefinitionSeeder, ::BasicData::WorkflowSeeder, ::BasicData::PrioritySeeder, ::BasicData::SettingSeeder, diff --git a/app/services/authorization/enterprise_service.rb b/app/services/authorization/enterprise_service.rb index 7ddd9730e29f..4e419b03d20e 100644 --- a/app/services/authorization/enterprise_service.rb +++ b/app/services/authorization/enterprise_service.rb @@ -34,6 +34,7 @@ class Authorization::EnterpriseService board_view conditional_highlighting custom_actions + custom_field_hierarchies date_alerts define_custom_style edit_attribute_groups diff --git a/app/services/custom_fields/hierarchy/hierarchical_item_service.rb b/app/services/custom_fields/hierarchy/hierarchical_item_service.rb index 72e682a23324..8d9421054652 100644 --- a/app/services/custom_fields/hierarchy/hierarchical_item_service.rb +++ b/app/services/custom_fields/hierarchy/hierarchical_item_service.rb @@ -44,17 +44,18 @@ def generate_root(custom_field) .bind { |validation| create_root_item(validation[:custom_field]) } end - # Insert a new node on the hierarchy tree. + # Insert a new node on the hierarchy tree at a desired position or at the end if no sort_order is passed. # @param parent [CustomField::Hierarchy::Item] the parent of the node # @param label [String] the node label/name that must be unique at the same tree level # @param short [String] an alias for the node + # @param sort_order [Integer] the position into which insert the item. # @return [Success(CustomField::Hierarchy::Item), Failure(Dry::Validation::Result), Failure(ActiveModel::Errors)] - def insert_item(parent:, label:, short: nil) + def insert_item(parent:, label:, short: nil, sort_order: nil) CustomFields::Hierarchy::InsertItemContract .new .call({ parent:, label:, short: }.compact) .to_monad - .bind { |validation| create_child_item(validation:) } + .bind { |validation| create_child_item(validation:, sort_order:) } end # Updates an item/node @@ -87,6 +88,18 @@ def get_branch(item:) Success(item.self_and_ancestors.reverse) end + # Gets all descendant nodes in a tree starting from the item/node. + # @param item [CustomField::Hierarchy::Item] the node + # @param include_self [Boolean] flag + # @return [Success(Array)] + def get_descendants(item:, include_self: true) + if include_self + Success(item.self_and_descendants) + else + Success(item.descendants) + end + end + # Move an item/node to a new parent item/node # @param item [CustomField::Hierarchy::Item] the parent of the node # @param new_parent [CustomField::Hierarchy::Item] the new parent of the node @@ -97,18 +110,37 @@ def move_item(item:, new_parent:) # Reorder the item along its siblings. # @param item [CustomField::Hierarchy::Item] the parent of the node - # @param new_sort_order [Integer] the new parent of the node - # @return [Success(CustomField::Hierarchy::Item)] + # @param new_sort_order [Integer] the new position of the node + # @return [Success] def reorder_item(item:, new_sort_order:) - old_item = item.siblings.where(sort_order: new_sort_order).first - Success(old_item.prepend_sibling(item)) + 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:) + + Success() end - def soft_delete_item(item) + def soft_delete_item(item:) # Soft delete the item and children raise NotImplementedError end + def hashed_subtree(item:, depth:) + if depth >= 0 + Success(item.hash_tree(limit_depth: depth + 1)) + else + Success(item.hash_tree) + end + end + + def descendant_of?(item:, parent:) + item.descendant_of?(parent) ? Success() : Failure() + end + private def create_root_item(custom_field) @@ -118,8 +150,11 @@ def create_root_item(custom_field) Success(item) end - def create_child_item(validation:) - item = validation[:parent].children.create(label: validation[:label], short: validation[:short]) + def create_child_item(validation:, sort_order: nil) + attributes = validation.to_h + attributes[:sort_order] = sort_order - 1 if sort_order + + item = validation[:parent].children.create(**attributes) return Failure(item.errors) if item.new_record? Success(item) @@ -132,6 +167,16 @@ def update_item_attributes(item:, attributes:) Failure(item.errors) end end + + def update_item_order(item:, new_sort_order:) + target_item = item.siblings.find_by(sort_order: new_sort_order) + if target_item.present? + target_item.prepend_sibling(item) + else + target_item = item.siblings.last + target_item.append_sibling(item) + end + end end end end diff --git a/app/services/journals/update_service.rb b/app/services/journals/update_service.rb index 229838dd8df2..db3ba619ede4 100644 --- a/app/services/journals/update_service.rb +++ b/app/services/journals/update_service.rb @@ -27,5 +27,15 @@ #++ module Journals - class UpdateService < ::BaseServices::Update; end + class UpdateService < ::BaseServices::Update + protected + + def after_perform(call) + OpenProject::Notifications.send(OpenProject::Events::JOURNAL_UPDATED, + journal: call.result, + send_notification: Journal::NotificationConfiguration.active?) + + call + end + end end diff --git a/app/views/account/_password_login_form.html.erb b/app/views/account/_password_login_form.html.erb index 04b2e0d9d507..7ae03893f00d 100644 --- a/app/views/account/_password_login_form.html.erb +++ b/app/views/account/_password_login_form.html.erb @@ -27,7 +27,12 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= styled_form_tag({action: "login"}, autocomplete: 'off', class: '-wide-labels user-login--form') do %> +<%= styled_form_tag( + {action: "login"}, + autocomplete: 'off', + class: '-wide-labels user-login--form', + data: { turbo: false } # allow redirects without turbo + ) do %> <%= back_url_hidden_field_tag %>
diff --git a/app/views/account/_register.html.erb b/app/views/account/_register.html.erb index f76fc104dfa5..554f8be6fa02 100644 --- a/app/views/account/_register.html.erb +++ b/app/views/account/_register.html.erb @@ -27,7 +27,11 @@ See COPYRIGHT and LICENSE files for more details. ++#%>