diff --git a/.erb-lint.yml b/.erb-lint.yml index 7dc513d45ec3..e812b208f7bb 100644 --- a/.erb-lint.yml +++ b/.erb-lint.yml @@ -32,4 +32,4 @@ linters: Lint/UselessAssignment: Enabled: false Rails/OutputSafety: - Enabled: false + Enabled: true diff --git a/.erb-linters/erblint-github.rb b/.erb_linters/erblint-github.rb similarity index 100% rename from .erb-linters/erblint-github.rb rename to .erb_linters/erblint-github.rb diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index eab13d3a371e..be8948af8a72 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: - ruby-version: '3.3.4' + ruby-version: '3.4.1' - uses: MeilCli/danger-action@v6 with: danger_file: 'Dangerfile' diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d730336dadf5..8332dcfd6d83 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -53,9 +53,13 @@ jobs: VERSION=${TAG_REF#v} echo "Version: $VERSION" + echo "Checkout REF: $CHECKOUT_REF" echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "checkout_ref=$CHECKOUT_REF" >> "$GITHUB_OUTPUT" - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ steps.extract_version.outputs.checkout_ref }} - name: Cache NPM uses: runs-on/cache@v4 with: @@ -136,9 +140,9 @@ jobs: runner: runner=4cpu-linux-x64 steps: - name: Checkout + uses: actions/checkout@v4 with: ref: ${{ needs.setup.outputs.checkout_ref }} - uses: actions/checkout@v4 - name: Prepare docker files run: | cp ./docker/prod/Dockerfile ./Dockerfile diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 85171d8123a8..ae765ec999bd 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -7,6 +7,7 @@ on: - release/* paths: - 'docs/**' + - 'config/static_links.yml' permissions: contents: read diff --git a/.github/workflows/eslint-core.yml b/.github/workflows/eslint-core.yml index 9c24db416359..9d02166efc80 100644 --- a/.github/workflows/eslint-core.yml +++ b/.github/workflows/eslint-core.yml @@ -19,7 +19,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-node@v4 with: - node-version: '18.13' + node-version: '20.9' cache: npm cache-dependency-path: frontend/package-lock.json - uses: reviewdog/action-eslint@v1 diff --git a/.github/workflows/pullpreview.yml b/.github/workflows/pullpreview.yml index 23364897f05f..aba0a76f525f 100644 --- a/.github/workflows/pullpreview.yml +++ b/.github/workflows/pullpreview.yml @@ -33,7 +33,6 @@ jobs: echo "OPENPROJECT_FEATURE__SHOW__CHANGES__ACTIVE=true" >> .env.pullpreview echo "OPENPROJECT_LOOKBOOK__ENABLED=true" >> .env.pullpreview echo "OPENPROJECT_HSTS=false" >> .env.pullpreview - echo "OPENPROJECT_FEATURE_PRIMERIZED_WORK_PACKAGE_ACTIVITIES_ACTIVE=true" >> .env.pullpreview echo "OPENPROJECT_NOTIFICATIONS_POLLING_INTERVAL=10000" >> .env.pullpreview - name: Boot as BIM edition if: contains(github.ref, 'bim/') || contains(github.head_ref, 'bim/') diff --git a/.github/workflows/rubocop-core.yml b/.github/workflows/rubocop-core.yml index 32a1b04da2cd..dc827243e436 100644 --- a/.github/workflows/rubocop-core.yml +++ b/.github/workflows/rubocop-core.yml @@ -10,9 +10,12 @@ jobs: name: rubocop runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: ruby/setup-ruby@v1 - - uses: reviewdog/action-rubocop@v2 + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + - name: Run Rubocop + uses: reviewdog/action-rubocop@v2 with: github_token: ${{ secrets.github_token }} rubocop_version: gemfile @@ -26,3 +29,11 @@ jobs: rubocop-rspec_rails:gemfile reporter: github-pr-check only_changed: true + - name: Install erb_lint + run: gem install -N erb_lint erblint-github + - name: Run erb-lint + uses: tk0miya/action-erblint@v1 + with: + github_token: ${{ secrets.github_token }} + reporter: github-pr-check + fail_on_error: true diff --git a/.github/workflows/test-frontend-unit.yml b/.github/workflows/test-frontend-unit.yml new file mode 100644 index 000000000000..a8002d46aa31 --- /dev/null +++ b/.github/workflows/test-frontend-unit.yml @@ -0,0 +1,54 @@ +name: "Frontend test suite" + +on: + workflow_dispatch: + push: + branches: + - dev + - release/* + paths: + - '**/frontend/**/*.ts' + - '**/frontend/**/*.js' + - '**/frontend/**/*.json' + + pull_request: + types: [opened, reopened, synchronize] + paths: + - '**/frontend/**/*.ts' + - '**/frontend/**/*.js' + - '**/frontend/**/*.json' + +permissions: + contents: read + +defaults: + run: + working-directory: ./frontend + +jobs: + units: + name: Units + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: '20.9' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install Dependencies + id: npm-i + run: npm i + + - name: Register plugins + id: npm-run-ci-plugins-register-frontend + run: npm run ci:plugins:register_frontend + + - name: Test (Angular) + id: npm-test + run: npm test -- --code-coverage diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index a29d660332e0..65cf0186a54a 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -2,7 +2,7 @@ name: Check work package version on: pull_request: - types: [labeled, synchronize] + types: [labeled, synchronize, ready_for_review] permissions: contents: read # to fetch code (actions/checkout) @@ -10,7 +10,7 @@ permissions: jobs: version-check: - if: contains(github.event.pull_request.labels.*.name, 'needs review') + if: github.event.pull_request.draft == false runs-on: ubuntu-latest steps: diff --git a/.pkgr.yml b/.pkgr.yml index 62354658845f..9c2820aaed0d 100644 --- a/.pkgr.yml +++ b/.pkgr.yml @@ -21,7 +21,7 @@ targets: <<: *debian ubuntu-22.04: <<: *debian - centos-8: ¢os8 + centos-9: env: - NODE_ENV=production - NPM_CONFIG_PRODUCTION=false @@ -30,11 +30,6 @@ targets: - ImageMagick - unzip - poppler-utils - centos-9: - <<: *centos8 - env: - - NODE_ENV=production - - NPM_CONFIG_PRODUCTION=false sles-15: build_dependencies: - sqlite3-devel diff --git a/.rubocop.yml b/.rubocop.yml index d45f3c613d7b..367f25ae1742 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -19,12 +19,18 @@ inherit_mode: - Exclude AllCops: - TargetRubyVersion: 3.3 + TargetRubyVersion: 3.4 # Enable any new cops in new versions by default NewCops: enable Exclude: - '**/node_modules/**/*' +# Disable it as it is deprecated +# From https://docs.rubocop.org/rubocop-capybara/cops_capybara.html#capybaraclicklinkorbuttonstyle +# "This cop is deprecated. We plan to remove this in the next major version update to 3.0." +Capybara/ClickLinkOrButtonStyle: + Enabled: false + FactoryBot/ConsistentParenthesesStyle: Enabled: false @@ -326,9 +332,6 @@ Style/ColonMethodCall: Style/CommentAnnotation: Enabled: false -Style/PreferredHashMethods: - Enabled: false - Style/Documentation: Enabled: false @@ -341,6 +344,9 @@ Style/EachWithObject: Style/EmptyLiteral: Enabled: false +Style/EndlessMethod: + Enabled: true + Style/EvenOdd: Enabled: false @@ -401,6 +407,9 @@ Style/PercentLiteralDelimiters: Style/PerlBackrefs: Enabled: false +Style/PreferredHashMethods: + Enabled: false + Style/Proc: Enabled: false @@ -453,7 +462,8 @@ Style/WordArray: Enabled: false Style/FrozenStringLiteralComment: - Enabled: false + Enabled: true + EnforcedStyle: always_true Style/NumericLiterals: Enabled: false diff --git a/.ruby-version b/.ruby-version index a0891f563f38..47b322c971c3 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.4 +3.4.1 diff --git a/Gemfile b/Gemfile index b604d3d3c9f8..73259809213f 100644 --- a/Gemfile +++ b/Gemfile @@ -74,7 +74,7 @@ gem "addressable", "~> 2.8.0" gem "auto_strip_attributes", "~> 2.5" # Provide timezone info for TZInfo used by AR -gem "tzinfo-data", "~> 1.2024.1" +gem "tzinfo-data", "~> 1.2025.1" # to generate html-diffs (e.g. for wiki comparison) gem "htmldiff" @@ -83,7 +83,7 @@ gem "htmldiff" gem "stringex", "~> 2.8.5" # CommonMark markdown parser with GFM extension -gem "commonmarker", "~> 1.1.3" +gem "commonmarker", "~> 2.0.2" # HTML pipeline for transformations on text formatter output # such as sanitization or additional features @@ -95,7 +95,7 @@ gem "escape_utils", "~> 1.3" # Syntax highlighting used in html-pipeline with rouge gem "rouge", "~> 4.5.1" # HTML sanitization used for html-pipeline -gem "sanitize", "~> 6.1.0" +gem "sanitize", "~> 7.0.0" # HTML autolinking for mails and urls (replaces autolink) gem "rinku", "~> 2.0.4", require: %w[rinku rails_rinku] # Version parsing with semver @@ -126,7 +126,7 @@ gem "multi_json", "~> 1.15.0" gem "oj", "~> 3.16.0" gem "daemons" -gem "good_job", "= 3.26.2" # update should be done manually in sync with saas-openproject version. +gem "good_job", "= 3.99.1" # update should be done manually in sync with saas-openproject version. gem "rack-protection", "~> 3.2.0" @@ -137,7 +137,7 @@ gem "rack-protection", "~> 3.2.0" gem "rack-attack", "~> 6.7.0" # CSP headers -gem "secure_headers", "~> 7.0.0" +gem "secure_headers", "~> 7.1.0" # Browser detection for incompatibility checks gem "browser", "~> 6.2.0" @@ -158,7 +158,7 @@ gem "structured_warnings", "~> 0.4.0" gem "airbrake", "~> 13.0.0", require: false gem "markly", "~> 0.10" # another markdown parser like commonmarker, but with AST support used in PDF export -gem "md_to_pdf", git: "https://github.com/opf/md-to-pdf", ref: "fe05b4f8bae8fd46f4fa93b8e0adee6295ef7388" +gem "md_to_pdf", git: "https://github.com/opf/md-to-pdf", ref: "965034bdd4b119c7233ea4ecd62d3964d3dec11d" gem "prawn", "~> 2.4" gem "ttfunk", "~> 1.7.0" # remove after https://github.com/prawnpdf/prawn/issues/1346 resolved. @@ -191,7 +191,7 @@ gem "puma", "~> 6.5" gem "puma-plugin-statsd", "~> 2.0" gem "rack-timeout", "~> 0.7.0", require: "rack/timeout/base" -gem "nokogiri", "~> 1.17.0" +gem "nokogiri", "~> 1.18.1" gem "carrierwave", "~> 1.3.4" gem "carrierwave_direct", "~> 2.1.0" @@ -218,7 +218,7 @@ gem "dry-monads" gem "dry-validation" # ActiveRecord extension which adds typecasting to store accessors -gem "store_attribute", "~> 1.0" +gem "store_attribute", "~> 2.0" # Appsignal integration gem "appsignal", "~> 3.10.0", require: false @@ -237,9 +237,12 @@ gem "turbo-rails", "~> 2.0.0" gem "httpx" +# Brings actual deep freezing to most ruby objects +gem "ice_nine" + group :test do - gem "launchy", "~> 3.0.0" - gem "rack-test", "~> 2.1.0" + gem "launchy", "~> 3.1.0" + gem "rack-test", "~> 2.2.0" gem "shoulda-context", "~> 2.0" # Test prof provides factories from code @@ -275,6 +278,7 @@ group :test do gem "capybara_accessible_selectors", git: "https://github.com/citizensadvice/capybara_accessible_selectors", tag: "v0.12.0" gem "capybara-screenshot", "~> 1.0.17" gem "cuprite", "~> 0.15.0" + gem "ferrum", github: "toy/ferrum", ref: "mouse-events-buttons-property-0.15" gem "rspec-wait" gem "selenium-devtools" gem "selenium-webdriver", "~> 4.20" @@ -330,11 +334,11 @@ group :development, :test do # Output a stack trace anytime, useful when a process is stuck gem "rbtrace" - # REPL with debug commands - gem "debug" + # REPL with debug commands, Debug changed to byebug due to the issue below + # https://github.com/puma/puma/issues/2835#issuecomment-2302133927 + gem "byebug" gem "pry-byebug", "~> 3.10.0", platforms: [:mri] - gem "pry-doc" gem "pry-rails", "~> 0.3.6" gem "pry-rescue", "~> 1.6.0" @@ -353,7 +357,7 @@ group :development, :test do gem "erblint-github", require: false # Brakeman scanner - gem "brakeman", "~> 6.2.0" + gem "brakeman", "~> 7.0.0" # i18n-tasks helps find and manage missing and unused translations. gem "i18n-tasks", "~> 1.0.13", require: false @@ -403,4 +407,4 @@ end gem "openproject-octicons", "~>19.20.0 " gem "openproject-octicons_helper", "~>19.20.0 " -gem "openproject-primer_view_components", "~>0.52.0" +gem "openproject-primer_view_components", "~>0.54.0" diff --git a/Gemfile.lock b/Gemfile.lock index 2ff8ccffa25d..f9c47bccf644 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,16 +8,16 @@ GIT GIT remote: https://github.com/opf/md-to-pdf - revision: fe05b4f8bae8fd46f4fa93b8e0adee6295ef7388 - ref: fe05b4f8bae8fd46f4fa93b8e0adee6295ef7388 + revision: 965034bdd4b119c7233ea4ecd62d3964d3dec11d + ref: 965034bdd4b119c7233ea4ecd62d3964d3dec11d specs: - md_to_pdf (0.1.2) + md_to_pdf (0.1.5) color_conversion (~> 0.1) front_matter_parser (~> 1.0) json-schema (~> 4.3) markly (~> 0.10) matrix (~> 0.4) - nokogiri (~> 1.16) + nokogiri (~> 1.18) prawn (~> 2.4) prawn-table (~> 0.2) text-hyphen (~> 1.5) @@ -33,10 +33,10 @@ GIT GIT remote: https://github.com/opf/omniauth-openid-connect.git - revision: d63f5967514d10db9ddece798dadfa2ac532cbe0 - ref: d63f5967514d10db9ddece798dadfa2ac532cbe0 + revision: 3d5fec65072fb4566fb975a9cbe401d758d22317 + ref: 3d5fec65072fb4566fb975a9cbe401d758d22317 specs: - omniauth-openid-connect (0.4.0) + omniauth-openid-connect (0.4.1) addressable (~> 2.5) omniauth (~> 1.6) openid_connect (~> 2.2.0) @@ -58,6 +58,17 @@ GIT parallel_tests (>= 3.3.0, < 5) rspec (>= 3.10) +GIT + remote: https://github.com/toy/ferrum.git + revision: 889b29926f017530a0e087b1db0bc02747e437b1 + ref: mouse-events-buttons-property-0.15 + specs: + ferrum (0.15) + addressable (~> 2.5) + concurrent-ruby (~> 1.1) + webrick (~> 1.7) + websocket-driver (~> 0.7) + PATH remote: modules/auth_plugins specs: @@ -206,7 +217,7 @@ PATH remote: modules/two_factor_authentication specs: openproject-two_factor_authentication (1.0.0) - aws-sdk-sns (~> 1.92.0) + aws-sdk-sns (~> 1.94.0) messagebird-rest (~> 1.4.2) rotp (~> 6.1) webauthn (~> 3.0) @@ -344,23 +355,23 @@ GEM awesome_nested_set (3.8.0) activerecord (>= 4.0.0, < 8.1) aws-eventstream (1.3.0) - aws-partitions (1.1023.0) - aws-sdk-core (3.214.0) + aws-partitions (1.1044.0) + aws-sdk-core (3.217.1) 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.96.0) - aws-sdk-core (~> 3, >= 3.210.0) + aws-sdk-kms (1.97.0) + aws-sdk-core (~> 3, >= 3.216.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.176.1) - aws-sdk-core (~> 3, >= 3.210.0) + aws-sdk-s3 (1.179.0) + aws-sdk-core (~> 3, >= 3.216.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sdk-sns (1.92.0) - aws-sdk-core (~> 3, >= 3.210.0) + aws-sdk-sns (1.94.0) + aws-sdk-core (~> 3, >= 3.216.0) aws-sigv4 (~> 1.5) - aws-sigv4 (1.10.1) + aws-sigv4 (1.11.0) aws-eventstream (~> 1, >= 1.0.2) axe-core-api (4.10.2) dumb_delegator @@ -385,11 +396,11 @@ GEM erubi (~> 1.4) parser (>= 2.4) smart_properties - bigdecimal (3.1.8) + bigdecimal (3.1.9) bindata (2.5.0) bootsnap (1.18.4) msgpack (~> 1.2) - brakeman (6.2.2) + brakeman (7.0.0) racc browser (6.2.0) builder (3.3.0) @@ -426,13 +437,13 @@ GEM coercible (1.0.0) descendants_tracker (~> 0.0.1) color_conversion (0.1.2) - colored2 (4.0.0) - commonmarker (1.1.5) + colored2 (4.0.3) + commonmarker (2.0.4) rb_sys (~> 0.9) compare-xml (0.66) nokogiri (~> 1.8) - concurrent-ruby (1.3.4) - connection_pool (2.4.1) + concurrent-ruby (1.3.5) + connection_pool (2.5.0) cookiejar (0.3.4) cose (1.3.1) cbor (~> 0.5.9) @@ -443,7 +454,7 @@ GEM crass (1.0.6) css_parser (1.21.0) addressable - csv (3.3.1) + csv (3.3.2) cuprite (0.15.1) capybara (~> 3.0) ferrum (~> 0.15.0) @@ -453,9 +464,6 @@ GEM date_validator (0.12.0) activemodel (>= 3) activesupport (>= 3) - debug (1.9.2) - irb (~> 1.10) - reline (>= 0.3.8) deckar01-task_list (2.3.4) html-pipeline (~> 2.0) declarative (0.0.20) @@ -472,50 +480,51 @@ GEM dotenv (= 3.1.7) railties (>= 6.1) drb (2.2.1) - dry-auto_inject (1.0.1) - dry-core (~> 1.0) + dry-auto_inject (1.1.0) + dry-core (~> 1.1) zeitwerk (~> 2.6) - dry-configurable (1.2.0) - dry-core (~> 1.0, < 2) + dry-configurable (1.3.0) + dry-core (~> 1.1) zeitwerk (~> 2.6) dry-container (0.11.0) concurrent-ruby (~> 1.0) - dry-core (1.0.2) + dry-core (1.1.0) concurrent-ruby (~> 1.0) logger zeitwerk (~> 2.6) - dry-inflector (1.1.0) - dry-initializer (3.1.1) - dry-logic (1.5.0) + dry-inflector (1.2.0) + dry-initializer (3.2.0) + dry-logic (1.6.0) + bigdecimal concurrent-ruby (~> 1.0) - dry-core (~> 1.0, < 2) + dry-core (~> 1.1) zeitwerk (~> 2.6) - dry-monads (1.6.0) + dry-monads (1.7.1) concurrent-ruby (~> 1.0) - dry-core (~> 1.0, < 2) + dry-core (~> 1.1) zeitwerk (~> 2.6) - dry-schema (1.13.4) + dry-schema (1.14.0) concurrent-ruby (~> 1.0) dry-configurable (~> 1.0, >= 1.0.1) - dry-core (~> 1.0, < 2) - dry-initializer (~> 3.0) - dry-logic (>= 1.4, < 2) - dry-types (>= 1.7, < 2) + dry-core (~> 1.1) + dry-initializer (~> 3.2) + dry-logic (~> 1.5) + dry-types (~> 1.8) zeitwerk (~> 2.6) - dry-types (1.7.2) + dry-types (1.8.1) bigdecimal (~> 3.0) concurrent-ruby (~> 1.0) dry-core (~> 1.0) dry-inflector (~> 1.0) dry-logic (~> 1.4) zeitwerk (~> 2.6) - dry-validation (1.10.0) + dry-validation (1.11.1) concurrent-ruby (~> 1.0) - dry-core (~> 1.0, < 2) - dry-initializer (~> 3.0) - dry-schema (>= 1.12, < 2) + dry-core (~> 1.1) + dry-initializer (~> 3.2) + dry-schema (~> 1.14) zeitwerk (~> 2.6) - dumb_delegator (1.0.0) + dumb_delegator (1.1.0) em-http-request (1.1.7) addressable (>= 2.3.4) cookiejar (!= 0.3.1) @@ -531,7 +540,7 @@ GEM activemodel equivalent-xml (0.6.0) nokogiri (>= 1.4.3) - erb_lint (0.7.0) + erb_lint (0.9.0) activesupport better_html (>= 2.0.1) parser (>= 2.7.1.4) @@ -539,15 +548,15 @@ GEM rubocop (>= 1) smart_properties erblint-github (1.0.1) - erubi (1.13.0) + erubi (1.13.1) escape_utils (1.3.0) et-orbi (1.2.11) tzinfo eventmachine (1.2.7) eventmachine_httpserver (0.2.1) - excon (1.2.2) - factory_bot (6.5.0) - activesupport (>= 5.0.0) + excon (1.2.3) + factory_bot (6.5.1) + activesupport (>= 6.1.0) factory_bot_rails (6.4.4) factory_bot (~> 6.5) railties (>= 5.0.0) @@ -560,12 +569,7 @@ GEM faraday-net_http (3.4.0) net-http (>= 0.5.0) fastimage (2.3.1) - ferrum (0.15) - addressable (~> 2.5) - concurrent-ruby (~> 1.1) - webrick (~> 1.7) - websocket-driver (~> 0.7) - ffi (1.17.0) + ffi (1.17.1) flamegraph (0.9.5) fog-aws (3.30.0) base64 (~> 0.2.0) @@ -580,7 +584,7 @@ GEM fog-json (1.2.0) fog-core multi_json (~> 1.10) - fog-xml (0.1.4) + fog-xml (0.1.5) fog-core nokogiri (>= 1.5.11, < 2.0.0) formatador (1.1.0) @@ -601,14 +605,14 @@ GEM i18n (>= 0.7) multi_json request_store (>= 1.0) - good_job (3.26.2) + good_job (3.99.1) activejob (>= 6.0.0) activerecord (>= 6.0.0) concurrent-ruby (>= 1.0.2) fugit (>= 1.1) railties (>= 6.0.0) thor (>= 0.14.1) - google-apis-core (0.15.1) + google-apis-core (0.16.0) addressable (~> 2.5, >= 2.5.1) googleauth (~> 1.9) httpclient (>= 2.8.3, < 3.a) @@ -621,7 +625,7 @@ GEM google-cloud-env (2.2.1) faraday (>= 1.0, < 3.a) google-logging-utils (0.1.0) - googleauth (1.12.1) + googleauth (1.13.1) faraday (>= 1.0, < 3.a) google-cloud-env (~> 2.2) google-logging-utils (~> 0.1) @@ -643,7 +647,7 @@ GEM hashdiff (1.1.2) hashery (2.1.2) hashie (3.6.0) - highline (3.1.1) + highline (3.1.2) reline html-pipeline (2.14.3) activesupport (>= 2) @@ -656,7 +660,7 @@ GEM httpclient (2.8.3) httpx (1.4.0) http-2 (>= 1.0.0) - i18n (1.14.6) + i18n (1.14.7) concurrent-ruby (~> 1.0) i18n-js (4.2.3) glob (>= 0.4.0) @@ -678,7 +682,8 @@ GEM ice_nine (0.11.2) interception (0.5) io-console (0.8.0) - irb (1.14.2) + irb (1.15.1) + pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) iso8601 (0.13.0) @@ -701,15 +706,16 @@ GEM json_spec (1.1.5) multi_json (~> 1.0) rspec (>= 2.0, < 4.0) - jwt (2.9.3) + jwt (2.10.1) base64 ladle (1.0.1) open4 (~> 1.0) - language_server-protocol (3.17.0.3) - launchy (3.0.1) + language_server-protocol (3.17.0.4) + launchy (3.1.0) addressable (~> 2.8) childprocess (~> 5.0) - lefthook (1.10.0) + logger (~> 1.6) + lefthook (1.10.10) letter_opener (1.10.0) launchy (>= 2.2, < 4) letter_opener_web (3.0.0) @@ -724,13 +730,13 @@ GEM omniauth (~> 1.1) omniauth-openid-connect (>= 0.2.1) rails (>= 3.2.21) - logger (1.6.4) + logger (1.6.5) lograge (0.14.0) actionpack (>= 4) activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.23.1) + loofah (2.24.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) lookbook (2.3.4) @@ -760,7 +766,7 @@ GEM mime-types (3.6.0) logger mime-types-data (~> 3.2015) - mime-types-data (3.2024.1203) + mime-types-data (3.2025.0107) mini_magick (5.0.1) mini_mime (1.1.5) mini_portile2 (2.8.8) @@ -774,7 +780,7 @@ GEM mutex_m (0.3.0) net-http (0.6.0) uri - net-imap (0.5.1) + net-imap (0.5.5) date net-protocol net-ldap (0.19.0) @@ -785,13 +791,13 @@ GEM net-smtp (0.5.0) net-protocol nio4r (2.7.4) - nokogiri (1.17.2) + nokogiri (1.18.2) mini_portile2 (~> 2.8.2) racc (~> 1.4) - oj (3.16.8) + oj (3.16.9) bigdecimal (>= 3.0) ostruct (>= 0.2) - okcomputer (1.18.5) + okcomputer (1.18.6) omniauth-saml (1.10.5) omniauth (~> 1.3, >= 1.3.2) ruby-saml (~> 1.17) @@ -814,27 +820,28 @@ GEM actionview openproject-octicons (= 19.20.0) railties - openproject-primer_view_components (0.52.0) + openproject-primer_view_components (0.54.0) actionview (>= 5.0.0) activesupport (>= 5.0.0) openproject-octicons (>= 19.20.0) view_component (>= 3.1, < 4.0) openproject-token (4.0.0) activemodel - openssl (3.2.0) + openssl (3.3.0) openssl-signature_algorithm (1.3.0) openssl (> 2.0) optimist (3.2.0) os (1.1.4) ostruct (0.6.1) - ox (2.14.18) + ox (2.14.21) + bigdecimal (>= 3.0) paper_trail (15.2.0) activerecord (>= 6.1) request_store (~> 1.4) parallel (1.26.3) - parallel_tests (4.7.2) + parallel_tests (4.9.0) parallel - parser (3.3.6.0) + parser (3.3.7.0) ast (~> 2.4.1) racc pdf-core (0.9.0) @@ -851,26 +858,26 @@ GEM activesupport (> 2.2.1) nokogiri (~> 1.10, >= 1.10.4) rubyzip (>= 1.2.0) + pp (0.6.2) + prettyprint prawn (2.4.0) pdf-core (~> 0.9.0) ttfunk (~> 1.7) prawn-table (0.2.2) prawn (>= 1.3.0, < 3.0.0) + prettyprint (0.2.0) pry (0.14.2) coderay (~> 1.1) method_source (~> 1.0) pry-byebug (3.10.1) byebug (~> 11.0) pry (>= 0.13, < 0.15) - pry-doc (1.5.0) - pry (~> 0.11) - yard (~> 0.9.11) pry-rails (0.3.11) pry (>= 0.13.0) pry-rescue (1.6.0) interception (>= 0.5) pry (>= 0.12.0) - psych (5.2.2) + psych (5.2.3) date stringio public_suffix (6.0.1) @@ -882,7 +889,7 @@ GEM eventmachine_httpserver http_parser.rb (~> 0.6.0) multi_json - puma (6.5.0) + puma (6.6.0) nio4r (~> 2.0) puma-plugin-statsd (2.6.0) puma (>= 5.0, < 7) @@ -907,7 +914,7 @@ GEM rack (~> 2.2, >= 2.2.4) rack-session (1.0.2) rack (< 3) - rack-test (2.1.0) + rack-test (2.2.0) rack (>= 1.3) rack-timeout (0.7.0) rack_session_access (0.2.0) @@ -957,21 +964,21 @@ GEM rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) - rb_sys (0.9.103) + rb_sys (0.9.108) rbtrace (0.5.1) ffi (>= 1.0.6) msgpack (>= 0.4.3) optimist (>= 3.0.0) rbtree3 (0.7.1) - rdoc (6.9.1) + rdoc (6.11.0) psych (>= 4.0.0) - recaptcha (5.18.0) + recaptcha (5.19.0) redcarpet (3.6.0) redis (5.3.0) redis-client (>= 0.22.0) - redis-client (0.23.0) + redis-client (0.23.2) connection_pool - regexp_parser (2.9.3) + regexp_parser (2.10.0) reline (0.6.0) io-console (~> 0.5) representable (3.2.0) @@ -1015,17 +1022,17 @@ GEM rspec-support (3.13.2) rspec-wait (1.0.1) rspec (>= 3.4) - rubocop (1.69.2) + rubocop (1.71.2) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.36.2, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.37.0) + rubocop-ast (1.38.0) parser (>= 3.3.1.0) rubocop-capybara (2.21.0) rubocop (~> 1.41) @@ -1033,15 +1040,15 @@ GEM rubocop (~> 1.61) rubocop-openproject (0.2.0) rubocop - rubocop-performance (1.23.0) + rubocop-performance (1.23.1) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails (2.27.0) + rubocop-rails (2.29.1) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.52.0, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rspec (3.3.0) + rubocop-rspec (3.4.0) rubocop (~> 1.61) rubocop-rspec_rails (2.30.0) rubocop (~> 1.61) @@ -1063,14 +1070,14 @@ GEM rubyzip (2.3.2) safety_net_attestation (0.4.0) jwt (~> 2.0) - sanitize (6.1.3) + sanitize (7.0.0) crass (~> 1.0.2) - nokogiri (>= 1.12.0) - secure_headers (7.0.0) + nokogiri (>= 1.16.8) + secure_headers (7.1.0) securerandom (0.4.1) - selenium-devtools (0.131.0) + selenium-devtools (0.132.0) selenium-webdriver (~> 4.2) - selenium-webdriver (4.27.0) + selenium-webdriver (4.28.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -1104,8 +1111,8 @@ GEM activesupport (>= 6.1) sprockets (>= 3.0.0) ssrf_filter (1.0.8) - stackprof (0.2.26) - store_attribute (1.3.1) + stackprof (0.2.27) + store_attribute (2.0.0) activerecord (>= 6.1) stringex (2.8.6) stringio (3.1.2) @@ -1121,7 +1128,7 @@ GEM table_print (1.5.7) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) - test-prof (1.4.3) + test-prof (1.4.4) text-hyphen (1.5.0) thor (1.3.2) thread_safe (0.3.6) @@ -1142,7 +1149,7 @@ GEM rails (>= 5.0.4) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - tzinfo-data (1.2024.2) + tzinfo-data (1.2025.1) tzinfo (>= 1.0.0) uber (0.1.0) unicode-display_width (2.6.0) @@ -1156,7 +1163,7 @@ GEM vcr (6.3.1) base64 vernier (1.5.0) - view_component (3.20.0) + view_component (3.21.0) activesupport (>= 5.2.0, < 8.1) concurrent-ruby (~> 1.0) method_source (~> 1.0) @@ -1180,13 +1187,14 @@ GEM activesupport faraday (~> 2.0) faraday-follow_redirects - webmock (3.24.0) + webmock (3.25.0) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) webrick (1.9.1) websocket (1.2.11) - websocket-driver (0.7.6) + websocket-driver (0.7.7) + base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) will_paginate (4.0.1) @@ -1219,9 +1227,10 @@ DEPENDENCIES axe-core-rspec bcrypt (~> 3.1.6) bootsnap (~> 1.18.0) - brakeman (~> 6.2.0) + brakeman (~> 7.0.0) browser (~> 6.2.0) budgets! + byebug capybara (~> 3.40.0) capybara-screenshot (~> 1.0.17) capybara_accessible_selectors! @@ -1230,7 +1239,7 @@ DEPENDENCIES climate_control closure_tree (~> 7.4.0) colored2 - commonmarker (~> 1.1.3) + commonmarker (~> 2.0.2) compare-xml (~> 0.66) costs! csv (~> 3.3) @@ -1239,7 +1248,6 @@ DEPENDENCIES dalli (~> 3.2.0) dashboards! date_validator (~> 0.12.0) - debug deckar01-task_list (~> 2.3.1) disposable (~> 0.6.2) doorkeeper (~> 5.8.0) @@ -1255,13 +1263,14 @@ DEPENDENCIES escape_utils (~> 1.3) factory_bot (~> 6.5.0) factory_bot_rails (~> 6.4.4) + ferrum! ffi (~> 1.15) flamegraph fog-aws friendly_id (~> 5.5.0) fuubar (~> 2.5.0) gon (~> 6.4.0) - good_job (= 3.26.2) + good_job (= 3.99.1) google-apis-gmail_v1 googleauth grape (~> 2.2.0) @@ -1273,10 +1282,11 @@ DEPENDENCIES i18n-js (~> 4.2.3) i18n-tasks (~> 1.0.13) ice_cube (~> 0.17.0) + ice_nine json_schemer (~> 2.3.0) json_spec (~> 1.1.4) ladle - launchy (~> 3.0.0) + launchy (~> 3.1.0) lefthook letter_opener_web listen (~> 3.9.0) @@ -1291,7 +1301,7 @@ DEPENDENCIES multi_json (~> 1.15.0) my_page! net-ldap (~> 0.19.0) - nokogiri (~> 1.17.0) + nokogiri (~> 1.18.1) oj (~> 3.16.0) okcomputer (~> 1.18.1) omniauth! @@ -1316,7 +1326,7 @@ DEPENDENCIES openproject-octicons (~> 19.20.0) openproject-octicons_helper (~> 19.20.0) openproject-openid_connect! - openproject-primer_view_components (~> 0.52.0) + openproject-primer_view_components (~> 0.54.0) openproject-recaptcha! openproject-reporting! openproject-storages! @@ -1334,7 +1344,6 @@ DEPENDENCIES plaintext (~> 0.3.2) prawn (~> 2.4) pry-byebug (~> 3.10.0) - pry-doc pry-rails (~> 0.3.6) pry-rescue (~> 1.6.0) puffing-billy (~> 4.0.0) @@ -1344,7 +1353,7 @@ DEPENDENCIES rack-cors (~> 2.0.2) rack-mini-profiler rack-protection (~> 3.2.0) - rack-test (~> 2.1.0) + rack-test (~> 2.2.0) rack-timeout (~> 0.7.0) rack_session_access rails (~> 7.1.3) @@ -1375,8 +1384,8 @@ DEPENDENCIES ruby-prof ruby-progressbar (~> 1.13.0) rubytree (~> 2.1.0) - sanitize (~> 6.1.0) - secure_headers (~> 7.0.0) + sanitize (~> 7.0.0) + secure_headers (~> 7.1.0) selenium-devtools selenium-webdriver (~> 4.20) semantic (~> 1.6.1) @@ -1388,7 +1397,7 @@ DEPENDENCIES sprockets (~> 3.7.2) sprockets-rails (~> 3.5.1) stackprof - store_attribute (~> 1.0) + store_attribute (~> 2.0) stringex (~> 2.8.5) structured_warnings (~> 0.4.0) svg-graph (~> 2.2.0) @@ -1401,7 +1410,7 @@ DEPENDENCIES turbo_power (~> 0.7.0) turbo_tests! typed_dag (~> 2.0.2) - tzinfo-data (~> 1.2024.1) + tzinfo-data (~> 1.2025.1) validate_url vcr vernier @@ -1413,7 +1422,7 @@ DEPENDENCIES with_advisory_lock (~> 5.1.0) RUBY VERSION - ruby 3.3.4p94 + ruby 3.4.1p0 BUNDLED WITH - 2.5.13 + 2.6.3 diff --git a/Gemfile.modules b/Gemfile.modules index 1689af163585..dbea2ec4b178 100644 --- a/Gemfile.modules +++ b/Gemfile.modules @@ -14,7 +14,7 @@ gem 'omniauth-openid_connect-providers', gem 'omniauth-openid-connect', git: 'https://github.com/opf/omniauth-openid-connect.git', - ref: 'd63f5967514d10db9ddece798dadfa2ac532cbe0' + ref: '3d5fec65072fb4566fb975a9cbe401d758d22317' group :opf_plugins do # included so that engines can reference OpenProject::Version diff --git a/app/components/_index.sass b/app/components/_index.sass index 59f6c3cdfa43..42597cf0ed83 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -2,6 +2,7 @@ @import "work_packages/activities_tab/journals/new_component" @import "work_packages/activities_tab/journals/index_component" @import "work_packages/activities_tab/journals/item_component" +@import "work_packages/activities_tab/journals/revision_component" @import "work_packages/activities_tab/journals/item_component/details" @import "work_packages/activities_tab/journals/item_component/add_reactions" @import "work_packages/activities_tab/journals/item_component/reactions" @@ -19,5 +20,7 @@ @import "op_primer/border_box_table_component" @import "work_packages/exports/modal_dialog_component" @import "work_package_relations_tab/index_component" +@import "work_package_relations_tab/relation_component" @import "users/hover_card_component" @import "enterprise_edition/banner_component" +@import "work_packages/types/pattern_input" diff --git a/app/components/admin/custom_fields/hierarchy/delete_item_dialog_component.html.erb b/app/components/admin/custom_fields/hierarchy/delete_item_dialog_component.html.erb index 6c2d6b0e2b29..f9735c0fe9ec 100644 --- a/app/components/admin/custom_fields/hierarchy/delete_item_dialog_component.html.erb +++ b/app/components/admin/custom_fields/hierarchy/delete_item_dialog_component.html.erb @@ -28,27 +28,16 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= - render(Primer::Alpha::Dialog.new(id: DIALOG_ID, title: "Delete item", test_selector: TEST_SELECTOR)) do |dialog| - dialog.with_header(variant: :large) - dialog.with_body do - "Are you sure you want to delete this item from the current hierarchy level?" - end - - dialog.with_footer do - concat(render(Primer::ButtonComponent.new(data: { "close-dialog-id": DIALOG_ID })) do - I18n.t(:button_cancel) - end) - - concat(primer_form_with( - model: @custom_field, - url: custom_field_item_path(custom_field_id: @custom_field.id, id: @hierarchy_item.id), - method: :delete, - data: { turbo: true } - ) do - render(Primer::ButtonComponent.new(scheme: :danger, type: :submit, data: { "close-dialog-id": DIALOG_ID })) do - I18n.t(:button_delete) - end - end) + render(Primer::OpenProject::DangerDialog.new( + title: I18n.t("custom_fields.admin.items.delete_dialog.title"), + form_arguments:, + size: :large, + test_selector: TEST_SELECTOR + )) do |dialog| + dialog.with_confirmation_message do |message| + message.with_heading(tag: :h2) { I18n.t("custom_fields.admin.items.delete_dialog.heading") } + message.with_description_content(I18n.t("custom_fields.admin.items.delete_dialog.description")) end + dialog.with_confirmation_check_box_content(I18n.t(:text_permanent_delete_confirmation_checkbox_label)) end %> diff --git a/app/components/admin/custom_fields/hierarchy/delete_item_dialog_component.rb b/app/components/admin/custom_fields/hierarchy/delete_item_dialog_component.rb index 851116a4ee48..ce3a9ac0b739 100644 --- a/app/components/admin/custom_fields/hierarchy/delete_item_dialog_component.rb +++ b/app/components/admin/custom_fields/hierarchy/delete_item_dialog_component.rb @@ -34,7 +34,6 @@ module Hierarchy class DeleteItemDialogComponent < ApplicationComponent include OpTurbo::Streamable - DIALOG_ID = "op-hierarchy-item--deletion-confirmation" TEST_SELECTOR = "op-custom-fields--delete-item-dialog" def initialize(custom_field:, hierarchy_item:) @@ -42,6 +41,13 @@ def initialize(custom_field:, hierarchy_item:) @custom_field = custom_field @hierarchy_item = hierarchy_item end + + def form_arguments + { + action: custom_field_item_path(custom_field_id: @custom_field.id, id: @hierarchy_item.id), + method: :delete + } + end end end end diff --git a/app/components/enterprise_edition/banner_component.rb b/app/components/enterprise_edition/banner_component.rb index f7ac7ed39bab..713116aed544 100644 --- a/app/components/enterprise_edition/banner_component.rb +++ b/app/components/enterprise_edition/banner_component.rb @@ -29,9 +29,9 @@ # ++ module EnterpriseEdition - # Add a general description of component here - # Add additional usage considerations or best practices that may aid the user to use the component correctly. - # @accessibility Add any accessibility considerations + # A banner indicating that a given feature requires the enterprise edition of OpenProject. + # This component uses conventional names for translation keys or URL look-ups based on the feature_key passed in. + # It will only be rendered if necessary. class BannerComponent < ApplicationComponent include OpPrimer::ComponentHelpers @@ -50,6 +50,7 @@ def initialize(feature_key, **system_arguments) @system_arguments = system_arguments @system_arguments[:tag] = "div" + @system_arguments[:test_selector] = "op-ee-banner-#{feature_key.to_s.tr('_', '-')}" super @feature_key = feature_key diff --git a/app/components/enumerations/table_component.rb b/app/components/enumerations/table_component.rb index 177f8510186c..ffde9e733b78 100644 --- a/app/components/enumerations/table_component.rb +++ b/app/components/enumerations/table_component.rb @@ -30,12 +30,15 @@ module Enumerations class TableComponent < ::TableComponent + attr_reader :enumeration + + def initialize(enumeration:, rows: [], **) + super(rows: rows, **) + @enumeration = enumeration + end + def columns - %i[name is_default active sort].tap do |default| - if with_colors - default.insert 3, :color - end - end + headers.map(&:first) end def sortable? @@ -43,16 +46,13 @@ def sortable? end def headers - [ + @headers ||= [ ["name", { caption: Enumeration.human_attribute_name(:name) }], - ["is_default", { caption: Enumeration.human_attribute_name(:is_default) }], + enumeration.can_have_default_value? ? ["is_default", { caption: Enumeration.human_attribute_name(:is_default) }] : nil, ["active", { caption: Enumeration.human_attribute_name(:active) }], + with_colors ? ["color", { caption: Enumeration.human_attribute_name(:color) }] : nil, ["sort", { caption: I18n.t(:label_sort) }] - ].tap do |default| - if with_colors - default.insert 3, ["color", { caption: Enumeration.human_attribute_name(:color) }] - end - end + ].compact end def with_colors diff --git a/app/components/filter/filter_component.rb b/app/components/filter/filter_component.rb index 6b625627b2d3..ac75e571bf68 100644 --- a/app/components/filter/filter_component.rb +++ b/app/components/filter/filter_component.rb @@ -67,18 +67,40 @@ def additional_filter_attributes(filter) case filter when Queries::Filters::Shared::ProjectFilter::Required, Queries::Filters::Shared::ProjectFilter::Optional - { - autocomplete_options: { - component: "opce-project-autocompleter", - resource: "projects", - filters: [ - { name: "active", operator: "=", values: ["t"] } - ] - } - } + { autocomplete_options: project_autocomplete_options } + when Queries::Filters::Shared::CustomFields::User + { autocomplete_options: user_autocomplete_options } else {} end end + + def project_autocomplete_options + { + component: "opce-project-autocompleter", + resource: "projects", + filters: [ + { name: "active", operator: "=", values: ["t"] } + ] + } + end + + def user_autocomplete_options + { + component: "opce-user-autocompleter", + hideSelected: true, + defaultData: false, + placeholder: I18n.t(:label_user_search), + resource: "principals", + url: ::API::V3::Utilities::PathHelper::ApiV3Path.principals, + filters: [ + { name: "type", operator: "=", values: ["User"] }, + { name: "status", operator: "!", values: [Principal.statuses["locked"].to_s] }, + { name: "member", operator: "=", values: Project.visible.pluck(:id) } + ], + searchKey: "any_name_attribute", + focusDirectly: false + } + end end end diff --git a/app/components/my/access_token/access_token_created_dialog_component.html.erb b/app/components/my/access_token/access_token_created_dialog_component.html.erb index 395e20e6b180..ad570b7a3def 100644 --- a/app/components/my/access_token/access_token_created_dialog_component.html.erb +++ b/app/components/my/access_token/access_token_created_dialog_component.html.erb @@ -29,10 +29,10 @@ See COPYRIGHT and LICENSE files for more details. <%= render(Primer::OpenProject::FeedbackDialog.new( - id:, - title: nil, - size: :large - )) do |dialog| + id:, + title: I18n.t("my.access_token.create_dialog.title"), + size: :large + )) do |dialog| dialog.with_feedback_message do |message| message.with_heading(tag: :h2) { I18n.t("my.access_token.create_dialog.header", type: "API") } end diff --git a/app/components/my/access_token/new_access_token_form_component.html.erb b/app/components/my/access_token/new_access_token_form_component.html.erb index e179d484ec29..0e190a811394 100644 --- a/app/components/my/access_token/new_access_token_form_component.html.erb +++ b/app/components/my/access_token/new_access_token_form_component.html.erb @@ -39,7 +39,7 @@ See COPYRIGHT and LICENSE files for more details. collection.with_component(Primer::Alpha::Dialog::Body.new( aria: { label: I18n.t("my.access_token.new_access_token_dialog_title") } )) do - flex_layout(my: 3) do |body| + flex_layout(mb: 3) do |body| body.with_row do render(Primer::Alpha::Banner.new(scheme: :warning)) do I18n.t("my.access_token.new_access_token_dialog_attention_text") diff --git a/app/components/open_project/common/attribute_component.html.erb b/app/components/open_project/common/attribute_component.html.erb index 5fa17c497919..b3a9cde40089 100644 --- a/app/components/open_project/common/attribute_component.html.erb +++ b/app/components/open_project/common/attribute_component.html.erb @@ -41,7 +41,7 @@ }, title: name, size: :large)) do |component| - component.with_body(mt: 2) { full_text } + component.with_body { full_text } component.with_header(variant: :large) end %> diff --git a/app/components/projects/configure_view_modal_component.html.erb b/app/components/projects/configure_view_modal_component.html.erb index bc7b0a84c24e..e1a868d1e37a 100644 --- a/app/components/projects/configure_view_modal_component.html.erb +++ b/app/components/projects/configure_view_modal_component.html.erb @@ -5,7 +5,7 @@ # enough height to display all options. # This is necessary as long as ng-select does not support popovers. style: "min-height: 480px")) do |d| %> - <% d.with_header(variant: :large, mb: 3) %> + <% d.with_header(variant: :large) %> <%= render(Primer::Alpha::Dialog::Body.new) do %> <%= primer_form_with( url: projects_path, diff --git a/app/components/projects/delete_list_modal_component.html.erb b/app/components/projects/delete_list_modal_component.html.erb index c57d5e8ee1d0..3d850d1f68fb 100644 --- a/app/components/projects/delete_list_modal_component.html.erb +++ b/app/components/projects/delete_list_modal_component.html.erb @@ -2,7 +2,7 @@ size: :large, id: MODAL_ID, data: { 'test-selector': MODAL_ID })) do |d| %> - <% d.with_header(variant: :large, mb: 2) %> + <% d.with_header(variant: :large) %> <% d.with_body { t(:'projects.lists.delete_modal.text') } %> <% d.with_footer do %> <%= render(Primer::Beta::Button.new(data: { "close-dialog-id": MODAL_ID })) { I18n.t(:button_cancel) } %> diff --git a/app/components/projects/settings/life_cycle_steps/index_component.html.erb b/app/components/projects/settings/life_cycle_steps/index_component.html.erb index 8ab58ef17910..1f6ee9ecb67b 100644 --- a/app/components/projects/settings/life_cycle_steps/index_component.html.erb +++ b/app/components/projects/settings/life_cycle_steps/index_component.html.erb @@ -31,7 +31,7 @@ end end header_container.with_column(flex_layout: true, justify_content: :flex_end) do |actions_container| - actions_container.with_column(data: { 'projects--settings--border-box-filter-target': 'bulkActionContainer' }) do + actions_container.with_column(data: { 'projects--settings--border-box-filter-target': 'hideWhenFiltering' }) do render(Primer::Beta::Button.new( tag: :a, href: enable_all_project_settings_life_cycle_steps_path(project_id: project), @@ -45,7 +45,7 @@ t('projects.settings.actions.label_enable_all') end end - actions_container.with_column(data: { 'projects--settings--border-box-filter-target': 'bulkActionContainer' }) do + actions_container.with_column(data: { 'projects--settings--border-box-filter-target': 'hideWhenFiltering' }) do render(Primer::Beta::Button.new( tag: :a, href: disable_all_project_settings_life_cycle_steps_path(project_id: project), diff --git a/app/components/projects/settings/life_cycle_steps/step_component.html.erb b/app/components/projects/settings/life_cycle_steps/step_component.html.erb index 9eda07459e3c..6da353ef3d7b 100644 --- a/app/components/projects/settings/life_cycle_steps/step_component.html.erb +++ b/app/components/projects/settings/life_cycle_steps/step_component.html.erb @@ -1,11 +1,11 @@ <%= flex_layout(align_items: :center, justify_content: :space_between) do |step_container| - step_container.with_column(flex_layout: true) do |title_container| - title_container.with_column(pt: 1, mr: 3) do + step_container.with_column(flex_layout: true, mr: 2, classes: "min-width-0") do |title_container| + title_container.with_column(pt: 1, mr: 3, classes: "ellipsis") do render(Primer::Beta::Text.new(classes: 'filter-target-visible-text')) { definition.name } end - title_container.with_column(pt: 1) do + title_container.with_column(pt: 1, classes: "no-wrap") do render(Projects::LifeCycleTypeComponent.new(definition)) end end diff --git a/app/components/projects/settings/project_custom_field_sections/show_component.html.erb b/app/components/projects/settings/project_custom_field_sections/show_component.html.erb index f2c5ee4e3a7f..d2164ada4f9f 100644 --- a/app/components/projects/settings/project_custom_field_sections/show_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/show_component.html.erb @@ -13,7 +13,7 @@ end end section_header_container.with_column(flex_layout: true, justify_content: :flex_end) do |actions_container| - actions_container.with_column(data: { 'projects--settings--border-box-filter-target': 'bulkActionContainer' }) do + actions_container.with_column(data: { 'projects--settings--border-box-filter-target': 'hideWhenFiltering' }) do render(Primer::Beta::Button.new( tag: :a, href: enable_all_of_section_project_settings_project_custom_fields_path( @@ -32,7 +32,7 @@ t('projects.settings.actions.label_enable_all') end end - actions_container.with_column(data: { 'projects--settings--border-box-filter-target': 'bulkActionContainer' }) do + actions_container.with_column(data: { 'projects--settings--border-box-filter-target': 'hideWhenFiltering' }) do render(Primer::Beta::Button.new( tag: :a, href: disable_all_of_section_project_settings_project_custom_fields_path( diff --git a/app/components/settings/project_custom_field_sections/dialog_body_form_component.html.erb b/app/components/settings/project_custom_field_sections/dialog_body_form_component.html.erb index a45d6a52cb20..f6f9ed27f753 100644 --- a/app/components/settings/project_custom_field_sections/dialog_body_form_component.html.erb +++ b/app/components/settings/project_custom_field_sections/dialog_body_form_component.html.erb @@ -3,7 +3,7 @@ primer_form_with(**form_config) do |f| component_collection do |collection| collection.with_component(Primer::BaseComponent.new(tag: :div)) do - flex_layout(my: 3) do |modal_body| + flex_layout(mb: 3) do |modal_body| modal_body.with_row do render(ProjectCustomFieldSections::NameForm.new(f)) end diff --git a/app/components/settings/project_life_cycle_step_definitions/form_header_component.html.erb b/app/components/settings/project_life_cycle_step_definitions/form_header_component.html.erb new file mode 100644 index 000000000000..4e2a4029b5de --- /dev/null +++ b/app/components/settings/project_life_cycle_step_definitions/form_header_component.html.erb @@ -0,0 +1,34 @@ +<%#-- 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. + +++#%> + +<%= render Primer::OpenProject::PageHeader.new do |header| + header.with_title { t("settings.project_life_cycle_step_definitions.#{heading_scope}.heading") } + header.with_description { t("settings.project_life_cycle_step_definitions.new.description") } + header.with_breadcrumbs(breadcrumbs_items) +end %> diff --git a/app/components/settings/project_life_cycle_step_definitions/form_header_component.rb b/app/components/settings/project_life_cycle_step_definitions/form_header_component.rb new file mode 100644 index 000000000000..8fa4442ce8d6 --- /dev/null +++ b/app/components/settings/project_life_cycle_step_definitions/form_header_component.rb @@ -0,0 +1,55 @@ +# 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 Settings + module ProjectLifeCycleStepDefinitions + class FormHeaderComponent < ApplicationComponent + options :heading_scope + + def breadcrumbs_items + [ + { + href: admin_index_path, + text: t("label_administration") + }, + { + href: admin_settings_project_custom_fields_path, + text: t("label_project_plural") + }, + { + href: admin_settings_project_life_cycle_step_definitions_path, + text: t("settings.project_life_cycle_step_definitions.heading") + }, + t("settings.project_life_cycle_step_definitions.#{heading_scope}.heading") + ] + end + end + end +end diff --git a/app/components/settings/project_life_cycle_step_definitions/index_component.html.erb b/app/components/settings/project_life_cycle_step_definitions/index_component.html.erb new file mode 100644 index 000000000000..05f8a68de371 --- /dev/null +++ b/app/components/settings/project_life_cycle_step_definitions/index_component.html.erb @@ -0,0 +1,128 @@ +<%#-- 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. + +++#%> + +<%= + component_wrapper do + flex_layout(data: wrapper_data_attributes) do |flex| + flex.with_row do + if allowed_to_customize_life_cycle? + render(Primer::OpenProject::SubHeader.new) do |subheader| + subheader.with_filter_input( + name: "border-box-filter", + label: t("settings.project_life_cycle_step_definitions.filter.label"), + visually_hide_label: true, + placeholder: t("settings.project_life_cycle_step_definitions.filter.label"), + leading_visual: { + icon: :search, + size: :small + }, + show_clear_button: true, + data: { + action: "input->projects--settings--border-box-filter#filterLists", + "projects--settings--border-box-filter-target": "filter" + } + ) + subheader.with_action_component do + render(Primer::Alpha::ActionMenu.new( + anchor_align: :end) + ) do |menu| + menu.with_show_button( + scheme: :primary, + aria: { label: I18n.t("settings.project_life_cycle_step_definitions.label_add_description") }, + ) do |button| + button.with_leading_visual_icon(icon: :plus) + button.with_trailing_action_icon(icon: :"triangle-down") + I18n.t("settings.project_life_cycle_step_definitions.label_add") + end + + menu.with_item( + label: I18n.t("settings.project_life_cycle_step_definitions.label_add_stage"), + href: new_stage_admin_settings_project_life_cycle_step_definitions_path + ) do |item| + item.with_leading_visual_icon(icon: "git-commit") + end + + menu.with_item( + label: I18n.t("settings.project_life_cycle_step_definitions.label_add_gate"), + href: new_gate_admin_settings_project_life_cycle_step_definitions_path + ) do |item| + item.with_leading_visual_icon(icon: "diamond") + end + end + end + end + else + render EnterpriseEdition::BannerComponent.new(:customize_life_cycle, mb: 3) + end + end + + flex.with_row do + render(border_box_container(mb: 3, data: drop_target_config)) do |component| + component.with_header(font_weight: :bold, py: 2) do + flex_layout(justify_content: :space_between, align_items: :center) do |header_container| + header_container.with_column do + render(Primer::Beta::Text.new(font_weight: :bold)) do + I18n.t("settings.project_life_cycle_step_definitions.section_header") + end + end + end + end + if definitions.empty? + component.with_row do + render(Primer::Beta::Text.new(color: :subtle)) do + t("settings.project_life_cycle_step_definitions.non_defined") + end + end + else + definitions.each do |definition| + component.with_row( + data: { + "projects--settings--border-box-filter-target": "searchItem", + test_selector: "project-life-cycle-step-definition", + **draggable_item_config(definition) + } + ) do + render(Settings::ProjectLifeCycleStepDefinitions::RowComponent.new( + definition, + first?: definition == definitions.first, + last?: definition == definitions.last, + )) + end + end + end + end + end + flex.with_row(display: :none, data: { "projects--settings--border-box-filter-target": "noResultsText" }) do + render Primer::Beta::Text.new do + I18n.t("js.autocompleter.notFoundText") + end + end + end + end +%> diff --git a/app/components/settings/project_life_cycle_step_definitions/index_component.rb b/app/components/settings/project_life_cycle_step_definitions/index_component.rb new file mode 100644 index 000000000000..f2e6d2b064bd --- /dev/null +++ b/app/components/settings/project_life_cycle_step_definitions/index_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. +#++ + +module Settings + module ProjectLifeCycleStepDefinitions + class IndexComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + include Projects::LifeCycleDefinitionHelper + + options :definitions + + private + + def wrapper_data_attributes + { + controller: "projects--settings--border-box-filter generic-drag-and-drop", + "application-target": "dynamic" + } + end + + def drop_target_config + { + "is-drag-and-drop-target": true, + "target-container-accessor": "& > ul", + "target-allowed-drag-type": "life-cycle-step-definition" + } + end + + def draggable_item_config(definition) + { + "draggable-type": "life-cycle-step-definition", + "drop-url": drop_admin_settings_project_life_cycle_step_definition_path(definition) + } + end + end + end +end diff --git a/app/components/settings/project_life_cycle_step_definitions/index_header_component.html.erb b/app/components/settings/project_life_cycle_step_definitions/index_header_component.html.erb new file mode 100644 index 000000000000..5d9996081ec8 --- /dev/null +++ b/app/components/settings/project_life_cycle_step_definitions/index_header_component.html.erb @@ -0,0 +1,36 @@ +<%#-- 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. + +++#%> + +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { t("settings.project_life_cycle_step_definitions.heading") } + header.with_description { t("settings.project_life_cycle_step_definitions.heading_description") } + header.with_breadcrumbs(breadcrumbs_items) + end +%> diff --git a/app/components/settings/project_life_cycle_step_definitions/index_header_component.rb b/app/components/settings/project_life_cycle_step_definitions/index_header_component.rb new file mode 100644 index 000000000000..df21b4805b98 --- /dev/null +++ b/app/components/settings/project_life_cycle_step_definitions/index_header_component.rb @@ -0,0 +1,43 @@ +# 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 Settings + module ProjectLifeCycleStepDefinitions + class IndexHeaderComponent < ApplicationComponent + def breadcrumbs_items + [ + { href: admin_index_path, text: t("label_administration") }, + { href: admin_settings_project_custom_fields_path, text: t("label_project_plural") }, + t("settings.project_life_cycle_step_definitions.heading") + ] + end + end + end +end diff --git a/app/components/settings/project_life_cycle_step_definitions/row_component.html.erb b/app/components/settings/project_life_cycle_step_definitions/row_component.html.erb new file mode 100644 index 000000000000..645d282ed373 --- /dev/null +++ b/app/components/settings/project_life_cycle_step_definitions/row_component.html.erb @@ -0,0 +1,103 @@ +<%#-- 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. + +++#%> + +<%= + flex_layout(align_items: :center, justify_content: :space_between) do |row_container| + row_container.with_column(flex_layout: true, classes: "gap-2 min-width-0") do |title_container| + if allowed_to_customize_life_cycle? + title_container.with_column do + render(Primer::OpenProject::DragHandle.new( + data: { "projects--settings--border-box-filter-target": "hideWhenFiltering" } + )) + end + end + title_container.with_column(classes: "ellipsis", test_selector: "project-life-cycle-step-definition-name") do + render( + if allowed_to_customize_life_cycle? + Primer::Beta::Link.new( + classes: "filter-target-visible-text", + href: edit_admin_settings_project_life_cycle_step_definition_path(definition), + font_weight: :bold + ) + else + Primer::Beta::Text.new( + font_weight: :bold + ) + end + ) do + definition.name + end + end + title_container.with_column do + render(Projects::LifeCycleTypeComponent.new(definition)) + end + title_container.with_column(classes: "no-wrap") do + render(Primer::Beta::Text.new) { t("project.count", count: definition.project_count) } + end + end + + if allowed_to_customize_life_cycle? + row_container.with_column do + render(Primer::Alpha::ActionMenu.new) do |menu| + menu.with_show_button(icon: "kebab-horizontal", "aria-label": t(:button_actions), scheme: :invisible) + + menu.with_item( + label: t(:label_edit), + href: edit_admin_settings_project_life_cycle_step_definition_path(definition) + ) do |item| + item.with_leading_visual_icon(icon: :pencil) + end + + unless first? + move_action(menu:, move_to: :highest, label: t("label_agenda_item_move_to_top"), icon: "move-to-top") + move_action(menu:, move_to: :higher, label: t("label_agenda_item_move_up"), icon: "chevron-up") + end + unless last? + move_action(menu:, move_to: :lower, label: t("label_agenda_item_move_down"), icon: "chevron-down") + move_action(menu:, move_to: :lowest, label: t("label_agenda_item_move_to_bottom"), icon: "move-to-bottom") + end + + menu.with_item( + label: t(:text_destroy), + scheme: :danger, + href: admin_settings_project_life_cycle_step_definition_path(definition), + form_arguments: { + method: :delete, + data: { + confirm: t("text_are_you_sure_with_project_life_cycle_step") + } + } + ) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + end + end + end +%> diff --git a/app/components/settings/project_life_cycle_step_definitions/row_component.rb b/app/components/settings/project_life_cycle_step_definitions/row_component.rb new file mode 100644 index 000000000000..1e6de58ad6ab --- /dev/null +++ b/app/components/settings/project_life_cycle_step_definitions/row_component.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 Settings + module ProjectLifeCycleStepDefinitions + class RowComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + include Projects::LifeCycleDefinitionHelper + + alias_method :definition, :model + + options :first?, + :last? + + private + + def move_action(menu:, move_to:, label:, icon:) + menu.with_item( + label:, + href: move_admin_settings_project_life_cycle_step_definition_path(definition, move_to:), + form_arguments: { + method: :patch + }, + data: { + "projects--settings--border-box-filter-target": "hideWhenFiltering" + } + ) do |item| + item.with_leading_visual_icon(icon:) + end + end + end + end +end diff --git a/app/components/shares/invite_user_form_component.html.erb b/app/components/shares/invite_user_form_component.html.erb index f1bc280c58b9..dea952c59aa6 100644 --- a/app/components/shares/invite_user_form_component.html.erb +++ b/app/components/shares/invite_user_form_component.html.erb @@ -10,7 +10,7 @@ ) do |form| grid_layout('invite-user-form', tag: :div) do |invite_form| invite_form.with_area('invitee') do - render(Shares::Invitee.new(form, allow_hover_cards:)) + render(Shares::Invitee.new(form)) end invite_form.with_area('permission') do diff --git a/app/components/shares/invite_user_form_component.rb b/app/components/shares/invite_user_form_component.rb index 07f5559506f2..f8d40024bae9 100644 --- a/app/components/shares/invite_user_form_component.rb +++ b/app/components/shares/invite_user_form_component.rb @@ -32,15 +32,14 @@ class InviteUserFormComponent < ApplicationComponent # rubocop:disable OpenProje include OpTurbo::Streamable include OpPrimer::ComponentHelpers - attr_reader :entity, :strategy, :errors, :allow_hover_cards + attr_reader :entity, :strategy, :errors - def initialize(strategy:, errors: nil, allow_hover_cards: false) + def initialize(strategy:, errors: nil) super @strategy = strategy @entity = strategy.entity @errors = errors - @allow_hover_cards = allow_hover_cards end def new_share diff --git a/app/components/shares/manage_shares_component.html.erb b/app/components/shares/manage_shares_component.html.erb index af18bb5f4229..4412dbce2ae8 100644 --- a/app/components/shares/manage_shares_component.html.erb +++ b/app/components/shares/manage_shares_component.html.erb @@ -1,7 +1,7 @@ <%= if strategy.manageable? modal_content.with_row do - render(Shares::InviteUserFormComponent.new(strategy:, errors:, allow_hover_cards:)) + render(Shares::InviteUserFormComponent.new(strategy:, errors:)) end end @@ -100,15 +100,9 @@ end else strategy.shares.each do |share| - render(Shares::ShareRowComponent.new(share:, strategy:, container: border_box, allow_hover_cards:)) + render(Shares::ShareRowComponent.new(share:, strategy:, container: border_box)) end end end end - - if allow_hover_cards - modal_content.with_row do - helpers.angular_component_tag 'opce-custom-modal-overlay', class: 'op-user-share-modal-overlay' - end - end %> diff --git a/app/components/shares/manage_shares_component.rb b/app/components/shares/manage_shares_component.rb index 22246aed01a3..fa61c6afdd57 100644 --- a/app/components/shares/manage_shares_component.rb +++ b/app/components/shares/manage_shares_component.rb @@ -36,7 +36,6 @@ class ManageSharesComponent < ApplicationComponent # rubocop:disable OpenProject attr_reader :strategy, :entity, :errors, - :allow_hover_cards, :modal_content def initialize(strategy:, modal_content:, errors: nil) @@ -46,7 +45,6 @@ def initialize(strategy:, modal_content:, errors: nil) @entity = strategy.entity @errors = errors @modal_content = modal_content - @allow_hover_cards = strategy.allow_hover_cards? end def self.wrapper_key diff --git a/app/components/shares/share_dialog_component.html.erb b/app/components/shares/share_dialog_component.html.erb index d3c7f2732650..d976bc8ac7eb 100644 --- a/app/components/shares/share_dialog_component.html.erb +++ b/app/components/shares/share_dialog_component.html.erb @@ -1,6 +1,6 @@ <%= render(Primer::Alpha::Dialog.new(title: strategy.title, id: 'sharing-modal', data: { 'keep-open-on-submit': true }, size: :xlarge, open: open)) do |d| - d.with_header(variant: :large, mb: 3) + d.with_header(variant: :large) d.with_body do render(strategy.modal_body_component(errors)) end diff --git a/app/components/shares/share_row_component.html.erb b/app/components/shares/share_row_component.html.erb index 47c931db3e76..b0a929c9d3ce 100644 --- a/app/components/shares/share_row_component.html.erb +++ b/app/components/shares/share_row_component.html.erb @@ -14,7 +14,7 @@ user_row_grid.with_area(:avatar, tag: :div) do render(Users::AvatarComponent.new(user: principal, show_name: false, size: :medium, - hover_card: { active: allow_hover_cards, target: :custom })) + hover_card: { active: true })) end user_row_grid.with_area(:user_details, tag: :div, classes: 'ellipsis') do diff --git a/app/components/shares/share_row_component.rb b/app/components/shares/share_row_component.rb index ae3b98457683..45df3ab20e0b 100644 --- a/app/components/shares/share_row_component.rb +++ b/app/components/shares/share_row_component.rb @@ -36,7 +36,7 @@ class ShareRowComponent < ApplicationComponent # rubocop:disable OpenProject/Add include OpTurbo::Streamable include OpPrimer::ComponentHelpers - def initialize(share:, strategy:, container: nil, allow_hover_cards: false) + def initialize(share:, strategy:, container: nil) super @share = share @@ -45,7 +45,6 @@ def initialize(share:, strategy:, container: nil, allow_hover_cards: false) @principal = share.principal @available_roles = strategy.available_roles @container = container - @allow_hover_cards = allow_hover_cards end def wrapper_uniq_by @@ -54,7 +53,7 @@ def wrapper_uniq_by private - attr_reader :share, :entity, :principal, :container, :available_roles, :strategy, :allow_hover_cards + attr_reader :share, :entity, :principal, :container, :available_roles, :strategy def share_editable? @share_editable ||= User.current != share.principal && sharing_manageable? diff --git a/app/components/shares/user_details_component.html.erb b/app/components/shares/user_details_component.html.erb index 4a42f623ee81..bc829adc120e 100644 --- a/app/components/shares/user_details_component.html.erb +++ b/app/components/shares/user_details_component.html.erb @@ -1,12 +1,8 @@ -<%= component_wrapper do +<%= +component_wrapper do flex_layout do |flex| flex.with_row do - render( - Primer::Beta::Link.new( - font_weight: :semibold, - href: principal_show_path, - ) - ) { user.name } + helpers.primer_link_to_user(user, font_weight: :semibold, href: principal_show_path) end flex.with_row(classes: "ellipsis") do @@ -24,7 +20,8 @@ concat( form_with(url: resend_invite_path, method: :post) do render(Primer::Beta::Button.new(type: :submit, px: 0, scheme: :link)) { I18n.t("sharing.user_details.resend_invite") } - end) + end + ) end end else @@ -35,4 +32,5 @@ end end end -end %> +end +%> diff --git a/app/components/table_component.rb b/app/components/table_component.rb index 5a3c186661ab..fd5d0ba28d0b 100644 --- a/app/components/table_component.rb +++ b/app/components/table_component.rb @@ -146,7 +146,7 @@ def paginate_collection(query) end def test_selector - self.class.name.dasherize + options.fetch(:test_selector) { self.class.name.dasherize } end def rows diff --git a/app/components/users/avatar_component.rb b/app/components/users/avatar_component.rb index c0c84ed78e65..8ea7218065f5 100644 --- a/app/components/users/avatar_component.rb +++ b/app/components/users/avatar_component.rb @@ -33,7 +33,7 @@ class AvatarComponent < ApplicationComponent include OpPrimer::ComponentHelpers def initialize(user:, show_name: true, link: true, size: "default", classes: "", title: nil, name_classes: "", - hover_card: { active: true, target: :default }) + hover_card: { active: true }) super @user = user diff --git a/app/components/users/row_component.rb b/app/components/users/row_component.rb index 5b88c1c61960..6b5d34c8d6fe 100644 --- a/app/components/users/row_component.rb +++ b/app/components/users/row_component.rb @@ -46,7 +46,10 @@ def row_css_class def login icon = helpers.avatar user, size: :mini - link = link_to h(user.login), helpers.allowed_management_user_profile_path(user), class: "op-principal--name" + link = helpers.link_to_user(user, + class: "op-principal--name", + name: user.login, + href: helpers.allowed_management_user_profile_path(user)) icon + link 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 index cd3345e9a12a..737701162f4a 100644 --- 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 @@ -1,7 +1,7 @@ <%= component_wrapper do %> <%= primer_form_with( id: FORM_ID, - model: WorkPackage.new, + model: @child, **submit_url_options, data: { turbo: true, @@ -9,7 +9,7 @@ } ) do |f| %> <%# Form fields section %> - <%= flex_layout(my: 3) do |flex| + <%= flex_layout(mb: 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") } @@ -23,6 +23,7 @@ my_form.work_package_autocompleter( name: :id, label: WorkPackage.model_name.human, + required: true, visually_hide_label: false, autocomplete_options: { resource: 'work_packages', 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 index 8ad85664a9c8..b6d2a7cef414 100644 --- 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 @@ -38,10 +38,11 @@ class WorkPackageRelationsTab::AddWorkPackageChildFormComponent < ApplicationCom ID_FIELD_TEST_SELECTOR = "work-package-child-form-id" I18N_NAMESPACE = "work_package_relations_tab" - def initialize(work_package:, base_errors: nil) + def initialize(work_package:, child: nil, base_errors: nil) super() @work_package = work_package + @child = child.presence || WorkPackage.new @base_errors = base_errors 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 index 15d71639e656..a114b1be1ead 100644 --- a/app/components/work_package_relations_tab/index_component.html.erb +++ b/app/components/work_package_relations_tab/index_component.html.erb @@ -18,6 +18,8 @@ t(:label_relation) end + render_child_menu_items(menu) + if should_render_add_relations? Relation::TYPES.each do |relation_type, type_configuration_hash| label_key = "#{I18N_NAMESPACE}.relations.#{type_configuration_hash[:name]}_singular" @@ -33,19 +35,6 @@ end end end - - if should_render_add_child? - menu.with_item( - label: t("#{I18N_NAMESPACE}.relations.label_child_singular").upcase_first, - href: new_work_package_children_relation_path(@work_package), - test_selector: new_button_test_selector(relation_type: :child), - content_arguments: { - data: { turbo_stream: true } - } - ) do |item| - item.with_description.with_content(t("#{I18N_NAMESPACE}.relations.child_description")) - end - end end end end @@ -58,34 +47,47 @@ key_namespace = "#{I18N_NAMESPACE}.relations" # Relations - directionally_aware_grouped_relations.each do |relation_type, relations_of_type| - base_key = "#{key_namespace}.label_#{relation_type}" + directionally_aware_grouped_relations.each do |relation_group| + base_key = "#{I18N_NAMESPACE}.relations.label_#{relation_group.type}" + + # Combine visible and invisible relations into a single list + all_relations = relation_group.visible_relations.map { |r| [r, :visible] } + + relation_group.ghost_relations.map { |r| [r, :ghost] } flex.with_row(mb: 4) do render_relation_group( title: t("#{base_key}_plural").upcase_first, - relation_type:, - items: relations_of_type - ) do |relation| - render(WorkPackageRelationsTab::RelationComponent.new(work_package:, - relation:)) + relation_type: relation_group.type, + items: all_relations + ) do |relation, visibility| + # Render each relation with its visibility + render(WorkPackageRelationsTab::RelationComponent.new( + work_package: work_package, + relation: relation, + visibility: visibility + )) end end end # Children - if children.any? + if any_children? base_key = "#{key_namespace}.label_child" + # Combine visible and invisible children into a single list + all_children = visible_children.map { |r| [r, :visible] } + + ghost_children.map { |r| [r, :ghost] } + flex.with_row do render_relation_group( title: t("#{base_key}_plural").upcase_first, relation_type: :children, - items: children - ) do |child| + items: all_children + ) do |child, visibility| render(WorkPackageRelationsTab::RelationComponent.new(work_package:, relation: nil, - child:)) + child:, + visibility: visibility)) 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 index 247cc5195945..2187bf677556 100644 --- a/app/components/work_package_relations_tab/index_component.rb +++ b/app/components/work_package_relations_tab/index_component.rb @@ -9,27 +9,31 @@ class WorkPackageRelationsTab::IndexComponent < ApplicationComponent FRAME_ID = "work-package-relations-tab-content" NEW_RELATION_ACTION_MENU = "new-relation-action-menu" + NEW_CHILD_ACTION_MENU = "new-child-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, :relation_to_scroll_to + attr_reader :relations_mediator, :relation_to_scroll_to + + delegate :work_package, + :visible_children, + :ghost_children, + :directionally_aware_grouped_relations, + :any_relations?, + :any_children?, + to: :relations_mediator # Initialize the component with required data # # @param work_package [WorkPackage] The work package whose relations are being displayed - # @param relations [Array] The relations associated with this work package - # @param children [Array] Child work packages # @param relation_to_scroll_to [Relation, WorkPackage, nil] Optional relation or child to scroll to when rendering - def initialize(work_package:, relations:, children:, relation_to_scroll_to: nil) + def initialize(work_package: nil, relation_to_scroll_to: nil) super() - @work_package = work_package - @relations = relations - @children = children - @directionally_aware_grouped_relations = group_relations_by_directional_context + @relations_mediator = WorkPackageRelationsTab::RelationsMediator.new(work_package:) @relation_to_scroll_to = relation_to_scroll_to end @@ -40,57 +44,96 @@ def self.wrapper_key private def should_render_add_child? - return false if @work_package.milestone? + return false if work_package.milestone? - helpers.current_user.allowed_in_project?(:manage_subtasks, @work_package.project) + 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) + 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| - render_header(border_box, title, items) + if relation_type == :children && should_render_add_child? + render_children_header(border_box, title, items) + else + render_header(border_box, title, items) + end + render_items(border_box, items, &_block) end end def render_header(border_box, title, items) border_box.with_header(py: 3) do - flex_layout(align_items: :center) do |flex| - flex.with_column(mr: 2) do - render(Primer::Beta::Text.new(font_size: :normal, font_weight: :bold)) { title } + concat render(Primer::Beta::Text.new(mr: 2, font_size: :normal, font_weight: :bold)) { title } + concat render(Primer::Beta::Counter.new(count: items.size, round: true, scheme: :primary)) + end + end + + def render_children_header(border_box, title, items) # rubocop:disable Metrics/AbcSize + border_box.with_header(py: 3) do + flex_layout(justify_content: :space_between, align_items: :center) do |header| + header.with_column(mr: 2) do + concat render(Primer::Beta::Text.new(mr: 2, font_size: :normal, font_weight: :bold)) { title } + concat render(Primer::Beta::Counter.new(count: items.size, round: true, scheme: :primary)) end - flex.with_column do - render(Primer::Beta::Counter.new(count: items.size, round: true, scheme: :primary)) + header.with_column do + render(Primer::Alpha::ActionMenu.new(menu_id: NEW_CHILD_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("work_package_relations_tab.label_add_child_button") + end + + render_child_menu_items(menu) + end end end end end + def render_child_menu_items(menu) # rubocop:disable Metrics/AbcSize + return unless should_render_add_child? + + if helpers.current_user.allowed_in_project?(:add_work_packages, work_package.project) + menu.with_item( + label: t("work_package_relations_tab.relations.new_child"), + href: new_project_work_packages_dialog_path(work_package.project, parent_id: work_package.id), + content_arguments: { + data: { turbo_stream: true } + } + ) do |item| + item.with_description.with_content(t("work_package_relations_tab.relations.new_child_text")) + end + end + + menu.with_item( + label: t("work_package_relations_tab.relations.existing_child"), + href: new_work_package_children_relation_path(work_package), + content_arguments: { + data: { turbo_stream: true } + } + ) do |item| + item.with_description.with_content(t("work_package_relations_tab.relations.child_description")) + end + end + def render_items(border_box, items) - items.each do |item| + items.each do |relation, visibility| border_box.with_row( - test_selector: row_test_selector(item), - data: data_attribute(item) + test_selector: row_test_selector(relation, visibility), + data: data_attribute(relation) ) do - yield(item) + yield(relation, visibility) end end end @@ -125,9 +168,9 @@ def new_button_test_selector(relation_type:) "op-new-relation-button-#{relation_type}" end - def row_test_selector(item) + def row_test_selector(item, visibility) related_work_package_id = find_related_work_package_id(item) - "op-relation-row-#{related_work_package_id}" + "op-relation-row-#{visibility}-#{related_work_package_id}" end def find_related_work_package_id(item) diff --git a/app/components/work_package_relations_tab/index_component.sass b/app/components/work_package_relations_tab/index_component.sass index 32a1e670cced..ae2778fdeb95 100644 --- a/app/components/work_package_relations_tab/index_component.sass +++ b/app/components/work_package_relations_tab/index_component.sass @@ -1,5 +1,6 @@ // 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 +#new-relation-action-menu-list, +#new-child-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 index b4cdd78822b4..8abb42f131e6 100644 --- a/app/components/work_package_relations_tab/relation_component.html.erb +++ b/app/components/work_package_relations_tab/relation_component.html.erb @@ -1,104 +1,97 @@ <%= -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 + flex_layout do |flex| + if visible? + 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_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 + if should_render_edit_option? + menu.with_item(label: I18n.t(:label_relation_edit), + 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) + menu.with_item(label: I18n.t(:label_relation_delete), + 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 - 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 + flex.with_row(mb: 2, classes: "relation-row--subject") 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_dates_row? - flex.with_row(flex_layout: true, align_items: :center, color: :muted, mb: 2) do |dates_row| - if precedes? && lag_present? - dates_row.with_column(mr: 1) do - render(Primer::Beta::Octicon.new(icon: "arrow-both")) + 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 - dates_row.with_column(mr: 3) do - render(Primer::Beta::Text.new) do - lag_as_text(relation.lag) - end + end + else + flex.with_row(mb: 2) do + render(Primer::Beta::Text.new(font_weight: :bold, color: :muted)) { I18n.t("work_package_relations_tab.relations.ghost_relation_title") } + end + flex.with_row(flex_layout: true, align_items: :center, color: :muted, mb: 2) do |ghost_description_row| + ghost_description_row.with_column(mr: 1) do + render(Primer::Beta::Octicon.new(icon: "alert")) + end + ghost_description_row.with_column do + render(Primer::Beta::Text.new) { I18n.t("work_package_relations_tab.relations.ghost_relation_description") } end end + end - if related_work_package.start_date.present? || related_work_package.due_date.present? - dates_row.with_column(mr: 1) do - icon = if follows? - :calendar - elsif precedes? - :pin - end + # Show dates for both visible and ghost relation + if should_display_dates_row? + flex.with_row(flex_layout: true, align_items: :center, color: :muted, mb: 2) do |dates_row| + if precedes? && lag_present? + dates_row.with_column(mr: 1) { render(Primer::Beta::Octicon.new(icon: "arrow-both")) } + dates_row.with_column(mr: 2) { render(Primer::Beta::Text.new) { lag_as_text(relation.lag) } } + end - render(Primer::Beta::Octicon.new(icon:)) + dates_row.with_column(mr: 1) do + render(Primer::Beta::Octicon.new(icon: helpers.work_package_dates_icon(related_work_package))) end dates_row.with_column do - render(Primer::Beta::Text.new) do - "#{format_date(related_work_package.start_date)} - #{format_date(related_work_package.due_date)}" - end + render(Primer::Beta::Text.new) { helpers.work_package_formatted_dates(related_work_package) } end - end - if follows? && lag_present? - dates_row.with_column(ml: 3, mr: 1) do - render(Primer::Beta::Octicon.new(icon: "arrow-both")) - end - dates_row.with_column(mr: 1) do - render(Primer::Beta::Text.new) do - lag_as_text(relation.lag) - end + if follows? && lag_present? + dates_row.with_column(ml: 2, mr: 1) { render(Primer::Beta::Octicon.new(icon: "arrow-both")) } + dates_row.with_column(mr: 1) { render(Primer::Beta::Text.new) { lag_as_text(relation.lag) } } 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 index 5df345651d22..ee85a705031f 100644 --- a/app/components/work_package_relations_tab/relation_component.rb +++ b/app/components/work_package_relations_tab/relation_component.rb @@ -2,15 +2,17 @@ class WorkPackageRelationsTab::RelationComponent < ApplicationComponent include ApplicationHelper include OpPrimer::ComponentHelpers - attr_reader :work_package, :relation, :child + attr_reader :work_package, :relation, :child, :visibility def initialize(work_package:, relation:, + visibility:, child: nil) super() @work_package = work_package @relation = relation + @visibility = visibility @child = child end @@ -47,6 +49,10 @@ def allowed_to_manage_relations? helpers.current_user.allowed_in_project?(:manage_work_package_relations, @work_package.project) end + def visible? + @visibility == :visible + end + def underlying_resource_id @underlying_resource_id ||= if parent_child_relationship? @child.id @@ -66,16 +72,18 @@ def lag_present? end def should_display_dates_row? - return false if parent_child_relationship? - - relation.follows? || relation.precedes? + parent_child_relationship? || relation.follows? || relation.precedes? end def follows? + return false if parent_child_relationship? + relation.relation_type_for(work_package) == Relation::TYPE_FOLLOWS end def precedes? + return false if parent_child_relationship? + relation.relation_type_for(work_package) == Relation::TYPE_PRECEDES end diff --git a/app/components/work_package_relations_tab/relation_component.sass b/app/components/work_package_relations_tab/relation_component.sass new file mode 100644 index 000000000000..ffb228116e57 --- /dev/null +++ b/app/components/work_package_relations_tab/relation_component.sass @@ -0,0 +1,2 @@ +.relation-row--subject + @include text-shortener(false) diff --git a/app/components/work_package_relations_tab/relations_mediator.rb b/app/components/work_package_relations_tab/relations_mediator.rb new file mode 100644 index 000000000000..358c82f2540e --- /dev/null +++ b/app/components/work_package_relations_tab/relations_mediator.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class WorkPackageRelationsTab::RelationsMediator + RelationGroup = Data.define(:type, :visible_relations, :ghost_relations) + + attr_reader :work_package + + def initialize(work_package:) + @work_package = work_package + end + + def visible_relations + @visible_relations ||= work_package.relations.visible.includes(:to, :from) + end + + def visible_children + @visible_children ||= work_package.children.visible + end + + def ghost_relations + @ghost_relations = work_package.relations.includes(:to, :from).where.not(id: visible_relations.select(:id)) + end + + def ghost_children + @ghost_children ||= work_package.children.where.not(id: visible_children.select(:id)) + end + + def directionally_aware_grouped_relations + # Collect all unique relation types + all_relation_types = collect_all_relation_types + + # Group visible and invisible relations by type + all_relation_types.map do |type| + RelationGroup.new( + type: type, + visible_relations: filter_relations_by_type(visible_relations, type), + ghost_relations: filter_relations_by_type(ghost_relations, type) + ) + end + end + + def any_relations? + visible_relations.any? || ghost_relations.any? || visible_children.any? || ghost_children.any? + end + + def all_relations_count + visible_relations.count + ghost_relations.count + visible_children.count + ghost_children.count + end + + def any_children? + visible_children.any? || ghost_children.any? + end + + private + + def collect_all_relation_types + (visible_relations + ghost_relations).map do |relation| + relation.relation_type_for(work_package) + end.uniq + end + + def filter_relations_by_type(relations, type) + relations.select do |relation| + relation.relation_type_for(work_package) == type + end + end +end diff --git a/app/components/work_package_relations_tab/work_package_relation_form_component.html.erb b/app/components/work_package_relations_tab/work_package_relation_form_component.html.erb index 83879e8a97c6..bfd9090c7e09 100644 --- a/app/components/work_package_relations_tab/work_package_relation_form_component.html.erb +++ b/app/components/work_package_relations_tab/work_package_relation_form_component.html.erb @@ -9,7 +9,7 @@ } ) do |f| %> <%# Form fields section %> - <%= flex_layout(my: 2) do |flex| + <%= flex_layout(mb: 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") } @@ -20,15 +20,16 @@ # so we need to re-define them here. Figure out solution for this. relation = @relation lag_shown = show_lag? - to_id_field_value = relation.to.present? ? "#{related_work_package.type.name.upcase} ##{related_work_package.id} - #{related_work_package.subject}" : nil + field_value = displayable_field_value + relation_direction = direction 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, + name: relation_direction, label: WorkPackage.model_name.human, visually_hide_label: false, - value: to_id_field_value, + value: field_value, readonly: true ) else @@ -38,8 +39,9 @@ ) my_form.autocompleter( - name: :to_id, + name: relation_direction, label: WorkPackage.model_name.human, + required: true, visually_hide_label: false, autocomplete_options: { resource: 'work_packages', @@ -65,9 +67,10 @@ name: :lag, type: :number, min: 0, - label: I18n.t("work_package_relations_tab.lag.title"), + label: I18n.t("work_package_relations_tab.lag.subject"), caption: I18n.t("work_package_relations_tab.lag.caption"), input_width: :small, + trailing_visual: { text: { text: I18n.t("datetime.units.day.other") } } ) end end diff --git a/app/components/work_package_relations_tab/work_package_relation_form_component.rb b/app/components/work_package_relations_tab/work_package_relation_form_component.rb index 264d08770fab..0f4509cc23aa 100644 --- a/app/components/work_package_relations_tab/work_package_relation_form_component.rb +++ b/app/components/work_package_relations_tab/work_package_relation_form_component.rb @@ -48,13 +48,28 @@ def initialize(work_package:, relation:, base_errors: nil) 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 + relation_to_matches_wp? ? @relation.from : @relation.to end end + def displayable_field_value + return nil if related_work_package.nil? + + if relation_to_matches_wp? + "#{related_work_package.type.name.upcase} ##{related_work_package.id} - #{related_work_package.subject}" + end + end + + def direction + relation_to_matches_wp? ? :from_id : :to_id + end + + def relation_to_matches_wp? + @relation.to == @work_package + end + def submit_url_options if @relation.persisted? { method: :patch, @@ -66,6 +81,6 @@ def submit_url_options end def show_lag? - @relation.relation_type == Relation::TYPE_PRECEDES || @relation.relation_type == Relation::TYPE_FOLLOWS + [Relation::TYPE_PRECEDES, Relation::TYPE_FOLLOWS].include?(@relation.relation_type) 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 6b4492a05afe..5961bf893244 100644 --- a/app/components/work_packages/activities_tab/index_component.html.erb +++ b/app/components/work_packages/activities_tab/index_component.html.erb @@ -31,13 +31,9 @@ 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( diff --git a/app/components/work_packages/activities_tab/index_component.rb b/app/components/work_packages/activities_tab/index_component.rb index 3be97f3d5cd0..6efc7f98e219 100644 --- a/app/components/work_packages/activities_tab/index_component.rb +++ b/app/components/work_packages/activities_tab/index_component.rb @@ -56,7 +56,7 @@ def wrapper_data_attributes test_selector: "op-wp-activity-tab", controller: stimulus_controller, "application-target": "dynamic", - "#{stimulus_controller}-update-streams-url-value": update_streams_work_package_activities_url(work_package), + "#{stimulus_controller}-update-streams-path-value": update_streams_work_package_activities_path(work_package), "#{stimulus_controller}-sorting-value": journal_sorting, "#{stimulus_controller}-filter-value": filter, "#{stimulus_controller}-user-id-value": User.current.id, diff --git a/app/components/work_packages/activities_tab/index_component.sass b/app/components/work_packages/activities_tab/index_component.sass index 34fe6c22fb33..ef3eed0b772a 100644 --- a/app/components/work_packages/activities_tab/index_component.sass +++ b/app/components/work_packages/activities_tab/index_component.sass @@ -1,10 +1,10 @@ -.work-packages-activities-tab-index-component +@mixin activities-tab-component($breakpoint, $input-container-padding-right: 10px, $input-container-margin-bottom: 20px) overflow-y: hidden &--errors position: absolute width: calc(100% - 22px) z-index: 11 - @media screen and (max-width: $breakpoint-xl) + @media screen and (max-width: $breakpoint) position: fixed bottom: 20px width: calc(100% - 30px) @@ -15,23 +15,42 @@ &_with-initial-input-compensation margin-bottom: 65px // initial margin-bottom, will be increased by stimulus when opening ckeditor - @media screen and (max-width: $breakpoint-xl) + @media screen and (max-width: $breakpoint) margin-bottom: -16px &_with-input-compensation margin-bottom: 180px - @media screen and (max-width: $breakpoint-xl) + @media screen and (max-width: $breakpoint) margin-bottom: -16px &--input-container z-index: 10 - @media screen and (min-width: $breakpoint-xl) + border-radius: var(--borderRadius-medium) + border: none + padding-right: 16px + margin-bottom: $input-container-margin-bottom + @media screen and (min-width: $breakpoint) position: absolute min-height: 60px bottom: 0 left: 0 right: 0 + border-radius: 0px + border-top: var(--borderWidth-thin, 1px) solid var(--borderColor-default) + padding-right: $input-container-padding-right + margin-bottom: 0px &_sort-desc - @media screen and (max-width: $breakpoint-xl) + @media screen and (max-width: $breakpoint) order: -1 + +.work-packages-activities-tab-index-component + @include activities-tab-component($breakpoint-xl) + +.work-packages-activities-tab-index-component--within-notification-center + .work-packages-activities-tab-index-component + @include activities-tab-component($breakpoint-lg, 15px) + +.work-packages-activities-tab-index-component--within-split-screen + .work-packages-activities-tab-index-component + @include activities-tab-component($breakpoint-sm, 10px, 10px) \ No newline at end of file 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 c541372a20a6..9ef8ded4e52b 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 @@ -7,7 +7,7 @@ mb: inner_container_margin_bottom ) do flex_layout(id: insert_target_modifier_id, - data: { test_selector: "op-wp-journals-container" }) do |journals_index_container| + 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( @@ -22,12 +22,16 @@ end end - recent_journals.each do |journal| + recent_journals.each do |record| journals_index_container.with_row do - render(WorkPackages::ActivitiesTab::Journals::ItemComponent.new( - journal:, filter:, - grouped_emoji_reactions: wp_journals_grouped_emoji_reactions[journal.id] - )) + if record.is_a?(Changeset) + render(WorkPackages::ActivitiesTab::Journals::RevisionComponent.new(changeset: record, filter:)) + else + render(WorkPackages::ActivitiesTab::Journals::ItemComponent.new( + journal: record, filter:, + grouped_emoji_reactions: wp_journals_grouped_emoji_reactions[record.id] + )) + end end end @@ -48,12 +52,16 @@ 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.each do |record| older_journals_container.with_row do - render(WorkPackages::ActivitiesTab::Journals::ItemComponent.new( - journal:, filter:, - grouped_emoji_reactions: wp_journals_grouped_emoji_reactions[journal.id] - )) + if record.is_a?(Changeset) + render(WorkPackages::ActivitiesTab::Journals::RevisionComponent.new(changeset: record, filter:)) + else + render(WorkPackages::ActivitiesTab::Journals::ItemComponent.new( + journal: record, filter:, + grouped_emoji_reactions: wp_journals_grouped_emoji_reactions[record.id] + )) + 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 469f0e3d4e11..35535014b0a1 100644 --- a/app/components/work_packages/activities_tab/journals/index_component.rb +++ b/app/components/work_packages/activities_tab/journals/index_component.rb @@ -64,47 +64,62 @@ def journal_sorting_desc? end def base_journals - work_package - .journals - .includes( - :user, - :customizable_journals, - :attachable_journals, - :storable_journals, - :notifications - ) - .reorder(version: journal_sorting) - .with_sequence_version + combine_and_sort_records(fetch_journals, fetch_revisions) + end + + def fetch_journals + API::V3::Activities::ActivityEagerLoadingWrapper.wrap( + work_package + .journals + .includes(:user, :customizable_journals, :attachable_journals, :storable_journals, :notifications) + .reorder(version: journal_sorting) + .with_sequence_version + ) + end + + def fetch_revisions + work_package.changesets.includes(:user, :repository) + end + + def combine_and_sort_records(journals, revisions) + (journals + revisions).sort_by do |record| + timestamp = record_timestamp(record) + journal_sorting_desc? ? [-timestamp, -record.id] : [timestamp, record.id] + end + end + + def record_timestamp(record) + if record.is_a?(API::V3::Activities::ActivityEagerLoadingWrapper) + record.created_at&.to_i + elsif record.is_a?(Changeset) + record.committed_on.to_i + end end def journals - API::V3::Activities::ActivityEagerLoadingWrapper.wrap(base_journals) + 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) + if journal_sorting_desc? + base_journals.first(MAX_RECENT_JOURNALS) + else + base_journals.last(MAX_RECENT_JOURNALS) + end 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) + if journal_sorting_desc? + base_journals.drop(MAX_RECENT_JOURNALS) + else + base_journals.take(base_journals.size - MAX_RECENT_JOURNALS) + end end def journal_with_notes - base_journals.where.not(notes: "") + work_package + .journals + .where.not(notes: "") end def wp_journals_grouped_emoji_reactions diff --git a/app/components/work_packages/activities_tab/journals/index_component.sass b/app/components/work_packages/activities_tab/journals/index_component.sass index 1dc5b0064eb0..25ea1149fece 100644 --- a/app/components/work_packages/activities_tab/journals/index_component.sass +++ b/app/components/work_packages/activities_tab/journals/index_component.sass @@ -2,12 +2,12 @@ &--journals-inner-container z-index: 10 &--stem-connection - @media screen and (min-width: $breakpoint-xl) + @media screen and (min-width: $breakpoint-lg) position: absolute z-index: 9 border-left: var(--borderWidth-thin, 1px) solid var(--borderColor-default) margin-left: 19px margin-top: 20px height: 100vh - @media screen and (max-width: $breakpoint-xl) + @media screen and (max-width: $breakpoint-lg) display: none diff --git a/app/components/work_packages/activities_tab/journals/item_component.html.erb b/app/components/work_packages/activities_tab/journals/item_component.html.erb index 7ea180f1a7ae..7b0bffa9dd3f 100644 --- a/app/components/work_packages/activities_tab/journals/item_component.html.erb +++ b/app/components/work_packages/activities_tab/journals/item_component.html.erb @@ -1,14 +1,17 @@ <%= component_wrapper(data: wrapper_data_attributes, class: "work-packages-activities-tab-journals-item-component") do - flex_layout(data: { + flex_layout(data: { test_selector: "op-wp-journal-entry-#{journal.id}" }) do |journal_container| if show_comment_container? journal_container.with_row do render(border_box_container( - id: "activity-anchor-#{journal.sequence_version}", padding: :condensed, - "aria-label": I18n.t("activities.work_packages.activity_tab.commented") + "aria-label": I18n.t("activities.work_packages.activity_tab.commented"), + data: { + "anchor-activity-id": journal.sequence_version, + "anchor-comment-id": journal.id, + } )) do |border_box_component| border_box_component.with_header(px: 2, py: 1, data: { test_selector: "op-journal-notes-header" }) do flex_layout(align_items: :center, justify_content: :space_between) do |header_container| @@ -28,7 +31,7 @@ end header_start_container.with_column(mr: 1, classes: "work-packages-activities-tab-journals-item-component--user-name ellipsis hidden-for-mobile") do - truncated_user_name(journal.user) + truncated_user_name(journal.user, hover_card: true) end if journal.initial? header_start_container.with_column( @@ -41,7 +44,11 @@ end end header_start_container.with_column(mr: 1, classes: "hidden-for-mobile") do - render(Primer::Beta::Text.new(font_size: :small, color: :subtle, mt: 1)) { format_time(journal.updated_at) } + if OpenProject::FeatureDecisions.work_package_comment_id_url_active? + activity_anchor_link(journal) { journal_updated_at_formatted_time(journal) } + else + journal_updated_at_formatted_time(journal) + end end end header_container.with_column(flex_layout: true, align_items: :center) do |header_end_container| @@ -55,21 +62,13 @@ )) end end - header_end_container.with_column do - render(Primer::Beta::Link.new( - href: activity_url(journal), - scheme: :secondary, - underline: false, - font_size: :small, - data: { - turbo: false, - action: "click->work-packages--activities-tab--index#setAnchor:prevent", - "work-packages--activities-tab--index-id-param": journal.sequence_version - } - )) do - "##{journal.sequence_version}" + + unless OpenProject::FeatureDecisions.work_package_comment_id_url_active? + header_end_container.with_column do + activity_anchor_link(journal) end end + header_end_container.with_column(ml: 1, classes: "work-packages-activities-tab-journals-item-component--action-menu") do render(Primer::Alpha::ActionMenu.new(data: { test_selector: "op-wp-journal-#{journal.id}-action-menu" })) do |menu| 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 529fbcd6ab95..2d86cf1c0727 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 @@ -66,7 +66,10 @@ def render_details_header(details_container) flex_layout: true, justify_content: :space_between, classes: "work-packages-activities-tab-journals-item-component-details--journal-details-header-container", - id: "activity-anchor-#{journal.sequence_version}" + data: { + "anchor-activity-id": journal.sequence_version, + "anchor-comment-id": journal.id + } ) do |header_container| render_header_start(header_container) render_header_end(header_container) @@ -105,7 +108,7 @@ def render_user_name_for_desktop(container) mr: 1, classes: "work-packages-activities-tab-journals-item-component-details--user-name ellipsis hidden-for-mobile" ) do - truncated_user_name(journal.user) + truncated_user_name(journal.user, hover_card: true) end end @@ -160,13 +163,21 @@ def render_mobile_journal_type(container) def render_mobile_updated_time(container) container.with_column do - render(Primer::Beta::Text.new(font_size: :small, color: :subtle, mt: 1)) { format_time(journal.updated_at) } + if OpenProject::FeatureDecisions.work_package_comment_id_url_active? + activity_anchor_link(journal) { journal_updated_at_formatted_time(journal) } + else + journal_updated_at_formatted_time(journal) + end end end def render_updated_time(container) container.with_column(mr: 1, classes: "hidden-for-mobile") do - render(Primer::Beta::Text.new(font_size: :small, color: :subtle, mt: 1)) { format_time(journal.updated_at) } + if OpenProject::FeatureDecisions.work_package_comment_id_url_active? + activity_anchor_link(journal) { journal_updated_at_formatted_time(journal) } + else + journal_updated_at_formatted_time(journal) + end end end @@ -189,20 +200,13 @@ def render_notification_bubble(container) end def render_activity_link(container) + return if OpenProject::FeatureDecisions.work_package_comment_id_url_active? + container.with_column( pr: 3, classes: "work-packages-activities-tab-journals-item-component-details--activity-link-container" ) do - render(Primer::Beta::Link.new( - href: activity_url(journal), - scheme: :secondary, - underline: false, - font_size: :small, - data: { turbo: false, action: "click->work-packages--activities-tab--index#setAnchor:prevent", - "work-packages--activities-tab--index-id-param": journal.sequence_version } - )) do - "##{journal.sequence_version}" - end + activity_anchor_link(journal) end end diff --git a/app/components/work_packages/activities_tab/journals/new_component.html.erb b/app/components/work_packages/activities_tab/journals/new_component.html.erb index 5ec55df8e037..3cb7db5a178d 100644 --- a/app/components/work_packages/activities_tab/journals/new_component.html.erb +++ b/app/components/work_packages/activities_tab/journals/new_component.html.erb @@ -3,7 +3,7 @@ flex_layout(my: 2, data: { test_selector: "op-work-package-journal-form" }) do |new_form_container| new_form_container.with_row( display: button_row_display_value, - data: { + data: { "work-packages--activities-tab--index-target": "buttonRow" }) do flex_layout(justify_content: :space_between) do |button_row| @@ -39,10 +39,10 @@ id: "work-package-journal-form-element", # required for specs model: journal, method: :post, - data: { - turbo: true, - turbo_stream: true, - "work-packages--activities-tab--index-target": "form", + data: { + turbo: true, + turbo_stream: true, + "work-packages--activities-tab--index-target": "form", action: "submit->work-packages--activities-tab--index#onSubmit", "test_selector": "op-work-package-journal-form-element" }, @@ -61,7 +61,10 @@ icon: :"paper-airplane", "aria-label": t("activities.work_packages.activity_tab.label_submit_comment"), type: :submit, - data: { "test_selector": "op-submit-work-package-journal-form" } + data: { + "test_selector": "op-submit-work-package-journal-form", + "work-packages--activities-tab--index-target": "formSubmitButton" + } )) end end diff --git a/app/components/work_packages/activities_tab/journals/new_component.sass b/app/components/work_packages/activities_tab/journals/new_component.sass index 11b355b674b2..b3ab7ddeaedb 100644 --- a/app/components/work_packages/activities_tab/journals/new_component.sass +++ b/app/components/work_packages/activities_tab/journals/new_component.sass @@ -15,9 +15,16 @@ width: calc(100% - 40px) // specific ck editor adjustments .ck-content - &.ck-focused - max-height: 30vh - &.ck-blurred - max-height: 10vh + @media screen and (max-width: $breakpoint-sm) + height: 30vh // set content-height before hand so there's no shift + &.ck-focused + height: 30vh + &.ck-blurred + height: 10vh + @media screen and (min-width: $breakpoint-md) + &.ck-focused + max-height: 30vh + &.ck-blurred + max-height: 10vh .ck-editor__preview max-height: 30vh diff --git a/app/components/work_packages/activities_tab/journals/revision_component.html.erb b/app/components/work_packages/activities_tab/journals/revision_component.html.erb new file mode 100644 index 000000000000..be28bcec0dd7 --- /dev/null +++ b/app/components/work_packages/activities_tab/journals/revision_component.html.erb @@ -0,0 +1,81 @@ +<%= + component_wrapper(class: "work-packages-activities-tab-journals-item-component") do + flex_layout(data: { + test_selector: "op-wp-revision-entry-#{changeset.id}" + }) do |revision_container| + revision_container.with_row do + render(border_box_container( + id: "activity-anchor-r#{changeset.revision}", + padding: :condensed, + "aria-label": I18n.t("activities.work_packages.activity_tab.commented") + )) do |border_box_component| + border_box_component.with_header(px: 2, py: 1, data: { test_selector: "op-revision-header" }) do + flex_layout(align_items: :center, justify_content: :space_between, classes: "work-packages-activities-tab-revision-component--header") do |header_container| + header_container.with_column(flex_layout: true, + classes: "work-packages-activities-tab-journals-item-component--header-start-container ellipsis") do |header_start_container| + header_start_container.with_column(mr: 2) do + if changeset.user + render(Users::AvatarComponent.new(user: changeset.user, show_name: false, size: :mini)) + end + end + header_start_container.with_column(mr: 1, flex_layout: true, + classes: "work-packages-activities-tab-journals-item-component--user-name-container hidden-for-desktop") do |user_name_container| + user_name_container.with_row(classes: "work-packages-activities-tab-journals-item-component--user-name ellipsis") do + render_user_name + end + user_name_container.with_row do + render(Primer::Beta::Text.new(font_size: :small, color: :subtle, mr: 1)) do + committed_text = render(Primer::Beta::Link.new( + href: revision_url, + scheme: :secondary, + underline: false, + font_size: :small, + target: "_blank" + )) do + I18n.t("js.label_committed_link", revision_identifier: short_revision) + end + I18n.t("js.label_committed_at", + committed_revision_link: committed_text.html_safe, + date: format_time(changeset.committed_on)).html_safe + end + end + end + header_start_container.with_column(mr: 1, + classes: "work-packages-activities-tab-journals-item-component--user-name ellipsis hidden-for-mobile") do + render_user_name + end + header_start_container.with_column(mr: 1, classes: "hidden-for-mobile") do + render(Primer::Beta::Text.new(font_size: :small, color: :subtle, mr: 1)) do + committed_text = render(Primer::Beta::Link.new( + href: revision_url, + scheme: :secondary, + underline: false, + font_size: :small, + target: "_blank" + )) do + I18n.t("js.label_committed_link", revision_identifier: short_revision) + end + I18n.t("js.label_committed_at", + committed_revision_link: committed_text.html_safe, + date: format_time(changeset.committed_on)).html_safe + end + end + end + end + end + border_box_component.with_body( + classes: "work-packages-activities-tab-journals-item-component--journal-notes-body", + data: { test_selector: "op-revision-notes-body" } + ) do + render(Primer::Box.new(mt: 1, classes: "op-uc-container")) do + format_text(changeset, :comments) + end + end + end + end + revision_container.with_row(flex_layout: true, classes: "work-packages-activities-tab-revision-component--stem-line-container") do |stem_line_container| + stem_line_container.with_column(border: :left, classes: "work-packages-activities-tab-revision-component--stem-line") + end + end + end +%> \ No newline at end of file diff --git a/app/components/work_packages/activities_tab/journals/revision_component.rb b/app/components/work_packages/activities_tab/journals/revision_component.rb new file mode 100644 index 000000000000..666d1df7a399 --- /dev/null +++ b/app/components/work_packages/activities_tab/journals/revision_component.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2023 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 "sanitize" + +module WorkPackages + module ActivitiesTab + module Journals + class RevisionComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(changeset:, filter:) + super + + @changeset = changeset + @filter = filter + end + + def render_committer_name(committer) + render(Primer::Beta::Text.new(font_weight: :bold, mr: 1)) do + remove_email_addresses(committer) + end + end + + def remove_email_addresses(committer) + return "" if committer.blank? + + ERB::Util.html_escape( + Sanitize.fragment( + committer.gsub(%r{<[^>]+@[^>]+>}, ""), + Sanitize::Config::RESTRICTED + ).strip + ) + end + + private + + attr_reader :changeset, :filter + + def render? + filter != :only_comments + end + + def user_name + if changeset.user + changeset.user.name + else + # Extract name from committer string (format: "name ") + changeset.committer.split("<").first.strip + end + end + + def revision_url + repository = changeset.repository + project = repository.project + + show_revision_project_repository_path(project_id: project.id, rev: changeset.revision) + end + + def short_revision + changeset.revision[0..7] + end + + def copy_url_action_item(menu) + menu.with_item(label: t("button_copy_link_to_clipboard"), + tag: :button, + content_arguments: { + data: { + action: "click->work-packages--activities-tab--item#copyActivityUrlToClipboard" + } + }) do |item| + item.with_leading_visual_icon(icon: :copy) + end + end + + def render_user_name + if changeset.user + render_user_link(changeset.user) + else + render_committer_name(changeset.committer) + end + end + + def render_user_link(user) + render(Primer::Beta::Link.new( + href: user_url(user), + target: "_blank", + scheme: :primary, + underline: false, + font_weight: :bold + )) do + changeset.user.name + end + end + end + end + end +end diff --git a/app/components/work_packages/activities_tab/journals/revision_component.sass b/app/components/work_packages/activities_tab/journals/revision_component.sass new file mode 100644 index 000000000000..cb7dd8bd956a --- /dev/null +++ b/app/components/work_packages/activities_tab/journals/revision_component.sass @@ -0,0 +1,7 @@ +.work-packages-activities-tab-revision-component + &--header + min-height: 32px + &--stem-line-container + min-height: 20px + &--stem-line + margin-left: 19px \ No newline at end of file diff --git a/app/components/work_packages/activities_tab/shared_helpers.rb b/app/components/work_packages/activities_tab/shared_helpers.rb index 4540142cf74b..c10dc1d1907a 100644 --- a/app/components/work_packages/activities_tab/shared_helpers.rb +++ b/app/components/work_packages/activities_tab/shared_helpers.rb @@ -31,15 +31,31 @@ module WorkPackages module ActivitiesTab module SharedHelpers - def truncated_user_name(user) + def truncated_user_name(user, hover_card: false) + helpers.primer_link_to_user(user, scheme: :primary, font_weight: :bold, hover_card:) + end + + def activity_anchor_link(journal) render(Primer::Beta::Link.new( - href: user_url(user), - target: "_blank", - scheme: :primary, + href: activity_url(journal), + scheme: :secondary, underline: false, - font_weight: :bold + font_size: :small, + data: { + test_selector: "activity-anchor-link", + turbo: false, + action: "click->work-packages--activities-tab--index#setAnchor:prevent", + "work-packages--activities-tab--index-id-param": journal_activity_id(journal), + "work-packages--activities-tab--index-anchor-name-param": activity_anchor_name + } )) do - user.name + block_given? ? yield : "##{journal_activity_id(journal)}" + end + end + + def journal_updated_at_formatted_time(journal) + render(Primer::Beta::Text.new(font_size: :small, color: :subtle, mt: 1)) do + format_time(journal.updated_at) end end @@ -52,7 +68,19 @@ def activity_url(journal) end def activity_anchor(journal) - "#activity-#{journal.sequence_version}" + "##{activity_anchor_name}-#{journal_activity_id(journal)}" + end + + def activity_anchor_name + OpenProject::FeatureDecisions.work_package_comment_id_url_active? ? "comment" : "activity" + end + + def journal_activity_id(journal) + if OpenProject::FeatureDecisions.work_package_comment_id_url_active? + journal.id + else + journal.sequence_version + end end end end diff --git a/app/components/work_packages/details/tab_component.rb b/app/components/work_packages/details/tab_component.rb index 741792ef3968..04164e4c72f6 100644 --- a/app/components/work_packages/details/tab_component.rb +++ b/app/components/work_packages/details/tab_component.rb @@ -26,8 +26,8 @@ def menu_items .root .children .select do |node| - allowed_node?(node, User.current, project) && visible_node?(menu, node) - end + allowed_node?(node, User.current, project) && visible_node?(menu, node) + end end def full_screen_tab diff --git a/app/components/work_packages/dialogs/create_dialog_component.html.erb b/app/components/work_packages/dialogs/create_dialog_component.html.erb new file mode 100644 index 000000000000..b4643d2d5105 --- /dev/null +++ b/app/components/work_packages/dialogs/create_dialog_component.html.erb @@ -0,0 +1,39 @@ +<%= + render(Primer::Alpha::Dialog.new( + id: "create-work-package-dialog", + title: I18n.t(:label_work_package_new), + size: :xlarge, + data: { + 'keep-open-on-submit': true, + } + )) do |dialog| + dialog.with_header(variant: :large) + dialog.with_body do + render(WorkPackages::Dialogs::CreateFormComponent.new(work_package:, project:)) + end + + dialog.with_footer do + component_collection do |modal_footer| + modal_footer.with_component( + Primer::ButtonComponent.new( + data: { 'close-dialog-id': "create-work-package-dialog" } + )) do + I18n.t(:button_cancel) + end + + modal_footer.with_component( + Primer::ButtonComponent.new( + scheme: :primary, + form: "create-work-package-form", + type: :submit + )) do + if @work_package.persisted? + I18n.t(:button_save) + else + I18n.t(:button_create) + end + end + end + end + end +%> diff --git a/app/components/work_packages/dialogs/create_dialog_component.rb b/app/components/work_packages/dialogs/create_dialog_component.rb new file mode 100644 index 000000000000..063ed886e13a --- /dev/null +++ b/app/components/work_packages/dialogs/create_dialog_component.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module WorkPackages::Dialogs + class CreateDialogComponent < ApplicationComponent + include ApplicationHelper + include OpenProject::FormTagHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + attr_reader :work_package, :project + + def initialize(work_package:, project:) + super + + @work_package = work_package + @project = project + end + end +end diff --git a/app/components/work_packages/dialogs/create_form_component.html.erb b/app/components/work_packages/dialogs/create_form_component.html.erb new file mode 100644 index 000000000000..2f8a1fdb6614 --- /dev/null +++ b/app/components/work_packages/dialogs/create_form_component.html.erb @@ -0,0 +1,30 @@ +<%= + component_wrapper do + primer_form_with( + scope: :work_package, + model: work_package, + url: project_work_packages_dialog_path(project), + method: :post, + html: { + id: 'create-work-package-form', + data: { + controller: "work-packages--create-dialog", + application_target: "dynamic", + "work-packages--create-dialog-refresh-url-value": refresh_form_project_work_packages_dialog_path(project) + } + } + ) do |f| + flex_layout(mb: 3) do |modal_body| + if work_package.errors[:base].present? + modal_body.with_row do + render(Primer::Alpha::Banner.new(mb: 3, icon: :stop, scheme: :danger)) { work_package.errors[:base].join("\n") } + end + end + + modal_body.with_row do + render(WorkPackages::Dialogs::CreateForm.new(f, work_package:, wrapper_id: "#create-work-package-dialog")) + end + end + end + end +%> diff --git a/app/components/work_packages/dialogs/create_form_component.rb b/app/components/work_packages/dialogs/create_form_component.rb new file mode 100644 index 000000000000..7235d65b27cb --- /dev/null +++ b/app/components/work_packages/dialogs/create_form_component.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 WorkPackages::Dialogs + class CreateFormComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + attr_reader :work_package, :project + + def initialize(work_package:, project:) + super + + @work_package = work_package + @project = project + 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 5b11c6009870..5b3d8c255576 100644 --- a/app/components/work_packages/hover_card_component.html.erb +++ b/app/components/work_packages/hover_card_component.html.erb @@ -15,7 +15,13 @@ grid.with_area(:assignee, tag: :div, font_size: :small, color: :muted) do if @assignee.present? - render(Users::AvatarComponent.new(user: @assignee, show_name: true, link: false, size: :mini, classes: "op-wp-hover-card--principal")) + # Render the avatar without a hover card => opening another hover card within a hover card is not supported + render(Users::AvatarComponent.new(user: @assignee, + show_name: true, + link: false, + size: :mini, + classes: "op-wp-hover-card--principal", + hover_card: { active: false })) else concat(render(Primer::Beta::Octicon.new(icon: :person, mr: 1))) concat(render(Primer::Beta::Text.new) { "-" }) diff --git a/app/components/work_packages/types/pattern_input.html.erb b/app/components/work_packages/types/pattern_input.html.erb new file mode 100644 index 000000000000..281602778d80 --- /dev/null +++ b/app/components/work_packages/types/pattern_input.html.erb @@ -0,0 +1,93 @@ +<%#-- 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. + +++#%> + +<%= + render( + FormControl.new( + input: @input, + classes: "pattern-input", + "data-controller": "pattern-input", + "data-pattern-input-pattern-initial-value": @value, + "data-pattern-input-suggestions-initial-value": suggestions_for_stimulus + ) + ) do +%> + <%= @input.builder.hidden_field(name, value: @value, data: { "pattern-input-target": "formInput" }) %> + + + + + + + + + + <%= + render( + Primer::BaseComponent.new( + tag: :div, + contenteditable: true, + border: true, border_radius: 2, p: 1, classes: :input, + "data-pattern-input-target": "content", + data: { + action: "keydown->pattern-input#input_keydown + input->pattern-input#input_change + mouseup->pattern-input#input_mouseup + focus->pattern-input#input_focus + blur->pattern-input#input_blur" + } + ) + ) + %> + <%= render(Primer::BaseComponent.new(tag: :div, box_shadow: :medium, border_radius: 2)) do %> + + <%= render(suggestions_list_component) %> + + <% end %> +<% end %> diff --git a/app/components/work_packages/types/pattern_input.rb b/app/components/work_packages/types/pattern_input.rb new file mode 100644 index 000000000000..87c9d4cc14f7 --- /dev/null +++ b/app/components/work_packages/types/pattern_input.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module WorkPackages + module Types + class PatternInput < Primer::Forms::BaseComponent + delegate :name, to: :@input + + def initialize(input:, value:, suggestions:) + super() + @input = input + @value = value + @suggestions = suggestions + end + + def suggestions_for_stimulus + @suggestions_for_stimulus ||= @suggestions + .transform_keys { |key| key.to_s.humanize } + .to_json + end + + def suggestions_list_component + @suggestions_list_component ||= Primer::Alpha::ActionList.new( + role: :list, + "data-pattern-input-target": "suggestions" + ) + end + end + end +end diff --git a/app/components/work_packages/types/pattern_input.sass b/app/components/work_packages/types/pattern_input.sass new file mode 100644 index 000000000000..feafed48d007 --- /dev/null +++ b/app/components/work_packages/types/pattern_input.sass @@ -0,0 +1,4 @@ +.pattern-input + .input + white-space: pre-wrap + diff --git a/app/components/work_packages/types/settings_component.html.erb b/app/components/work_packages/types/settings_component.html.erb new file mode 100644 index 000000000000..f9c74101ee83 --- /dev/null +++ b/app/components/work_packages/types/settings_component.html.erb @@ -0,0 +1,34 @@ +<%#-- 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. + +++#%> + +<%= + primer_form_with(**form_options) do |f| + render(WorkPackages::Types::SettingsForm.new(f)) + end +%> diff --git a/app/components/work_packages/types/settings_component.rb b/app/components/work_packages/types/settings_component.rb new file mode 100644 index 000000000000..948af9a38df4 --- /dev/null +++ b/app/components/work_packages/types/settings_component.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module WorkPackages + module Types + class SettingsComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(model, copy_workflow_from: nil, **) + @copy_workflow_from = copy_workflow_from + super(model, **) + end + + def form_options + if model.new_record? + create_form_options + else + update_form_options + end + end + + private + + attr_reader :copy_workflow_from + + def create_form_options + { url: types_path, method: :post, model:, copy_workflow_from: } + end + + def update_form_options + { url: type_path(id: model.id), method: :patch, model: } + end + end + end +end diff --git a/app/components/work_packages/types/subject_configuration_component.html.erb b/app/components/work_packages/types/subject_configuration_component.html.erb index 95792457632a..6597b015094e 100644 --- a/app/components/work_packages/types/subject_configuration_component.html.erb +++ b/app/components/work_packages/types/subject_configuration_component.html.erb @@ -27,6 +27,10 @@ See COPYRIGHT and LICENSE files for more details. ++#%> +<%= + render(EnterpriseEdition::BannerComponent.new(:automatic_subject_generation, mb: 3)) +%> + <%= primer_form_with(**form_options) do |f| render(WorkPackages::Types::SubjectConfigurationForm.new(f)) diff --git a/app/components/work_packages/types/subject_configuration_component.rb b/app/components/work_packages/types/subject_configuration_component.rb index 88850e1b7e9d..b4688b84f772 100644 --- a/app/components/work_packages/types/subject_configuration_component.rb +++ b/app/components/work_packages/types/subject_configuration_component.rb @@ -35,17 +35,32 @@ class SubjectConfigurationComponent < ApplicationComponent include OpTurbo::Streamable def form_options + form_model = subject_form_object + { - url: "https://example.com", + url: subject_configuration_type_path(id: model.id), method: :put, - model:, + model: form_model, data: { application_target: "dynamic", controller: "admin--subject-configuration", - admin__subject_configuration_hide_pattern_input_value: true + admin__subject_configuration_hide_pattern_input_value: form_model.subject_configuration == :manual } } end + + private + + def subject_form_object + subject_pattern = model.patterns.subject || ::Types::Pattern.new(blueprint: "", enabled: false) + + ::Types::Forms::SubjectConfigurationFormModel.new( + subject_configuration: subject_pattern.enabled ? :generated : :manual, + pattern: subject_pattern.blueprint, + suggestions: ::Types::Patterns::TokenPropertyMapper.new.tokens_for_type(model), + validation_errors: model.errors + ) + end end end end diff --git a/app/contracts/project_life_cycle_steps/base_contract.rb b/app/contracts/project_life_cycle_steps/base_contract.rb index 9e4124d06e63..62ea58b57212 100644 --- a/app/contracts/project_life_cycle_steps/base_contract.rb +++ b/app/contracts/project_life_cycle_steps/base_contract.rb @@ -31,9 +31,7 @@ class BaseContract < ::ModelContract validate :select_custom_fields_permission validate :consecutive_steps_have_increasing_dates - def valid?(context = :saving_life_cycle_steps) - super - end + def valid?(context = :saving_life_cycle_steps) = super def select_custom_fields_permission return if user.allowed_in_project?(:edit_project_stages_and_gates, model) diff --git a/app/contracts/projects/base_contract.rb b/app/contracts/projects/base_contract.rb index 8b8fa1911614..614b6acb2480 100644 --- a/app/contracts/projects/base_contract.rb +++ b/app/contracts/projects/base_contract.rb @@ -57,6 +57,8 @@ class BaseContract < ::ModelContract validate :validate_user_allowed_to_manage + def valid?(context = :saving_custom_fields) = super + def assignable_parents Project .allowed_to(user, :add_subprojects) diff --git a/app/contracts/relations/base_contract.rb b/app/contracts/relations/base_contract.rb index 82c4d5edc2ed..cf6d293eb685 100644 --- a/app/contracts/relations/base_contract.rb +++ b/app/contracts/relations/base_contract.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -34,7 +36,7 @@ class BaseContract < ::ModelContract attribute :from attribute :to - validate :manage_relations_permission? + validate :validate_user_allowed validate :validate_from_exists validate :validate_to_exists validate :validate_nodes_relatable @@ -47,16 +49,22 @@ def self.model private def validate_from_exists - errors.add :from, :error_not_found unless visible_work_packages.exists? model.from_id + errors.add :from_id, :error_not_found unless visible_work_packages.exists? model.from_id end def validate_to_exists - errors.add :to, :error_not_found unless visible_work_packages.exists? model.to_id + errors.add :to_id, :error_not_found unless visible_work_packages.exists? model.to_id end def validate_nodes_relatable - if (model.from_id_changed? || model.to_id_changed?) && - WorkPackage.relatable(model.from, model.relation_type, ignored_relation: model).where(id: model.to_id).empty? + # when creating a relation from the work package relations tab and not selecting a WorkPackage + # the to_id is not set + # in this case we only want to show the "WorkPackage can't be blank" error instead of a + # misleading circular dependencies error + # the error is added by the `validate_from_exists` and `validate_to_exists` methods + return if to_id_or_from_id_nil? + + if relation_changed? && circular_dependency? errors.add :base, I18n.t(:"activerecord.errors.messages.circular_dependency") end end @@ -67,12 +75,26 @@ def validate_accepted_type errors.add :relation_type, :inclusion end - def manage_relations_permission? + def validate_user_allowed + return if model.to_id.nil? || model.from_id.nil? + unless manage_relations? errors.add :base, :error_unauthorized end end + def to_id_or_from_id_nil? + model.to_id.nil? || model.from_id.nil? + end + + def relation_changed? + model.from_id_changed? || model.to_id_changed? + end + + def circular_dependency? + WorkPackage.relatable(model.from, model.relation_type, ignored_relation: model).where(id: model.to_id).empty? + end + def visible_work_packages ::WorkPackage.visible(user) end diff --git a/app/contracts/relations/update_contract.rb b/app/contracts/relations/update_contract.rb index 78be6df8ff63..401836b9d4a4 100644 --- a/app/contracts/relations/update_contract.rb +++ b/app/contracts/relations/update_contract.rb @@ -34,11 +34,11 @@ class UpdateContract < BaseContract private def from_immutable - errors.add :from, :error_readonly if from_id_changed_and_not_swapped? + errors.add :from_id, :error_readonly if from_id_changed_and_not_swapped? end def to_immutable - errors.add :to, :error_readonly if to_id_changed_and_not_swapped? + errors.add :to_id, :error_readonly if to_id_changed_and_not_swapped? end def from_id_changed_and_not_swapped? diff --git a/app/contracts/types/base_contract.rb b/app/contracts/types/base_contract.rb index 5f8c5b02db95..4f189e300fd2 100644 --- a/app/contracts/types/base_contract.rb +++ b/app/contracts/types/base_contract.rb @@ -42,6 +42,7 @@ def self.model attribute :project_ids attribute :description attribute :attribute_groups + attribute :patterns validate :validate_current_user_is_admin validate :validate_attribute_group_names diff --git a/app/controllers/admin/custom_fields/custom_field_projects_controller.rb b/app/controllers/admin/custom_fields/custom_field_projects_controller.rb index 3d7dbcd33702..390ad5ddf7fc 100644 --- a/app/controllers/admin/custom_fields/custom_field_projects_controller.rb +++ b/app/controllers/admin/custom_fields/custom_field_projects_controller.rb @@ -58,7 +58,7 @@ def new def create create_service = ::CustomFields::CustomFieldProjects::BulkCreateService - .new(user: current_user, projects: @projects, custom_field: @custom_field, + .new(user: current_user, projects: @projects, model: @custom_field, include_sub_projects: include_sub_projects?) .call diff --git a/app/controllers/admin/custom_fields/hierarchy/items_controller.rb b/app/controllers/admin/custom_fields/hierarchy/items_controller.rb index 2b27fba7eafd..731de1b0c886 100644 --- a/app/controllers/admin/custom_fields/hierarchy/items_controller.rb +++ b/app/controllers/admin/custom_fields/hierarchy/items_controller.rb @@ -104,7 +104,7 @@ def destroy .delete_branch(item: @active_item) .either( ->(_) { update_via_turbo_stream(component: ItemsComponent.new(item: @active_item.parent)) }, - ->(errors) { update_flash_message_via_turbo_stream(message: errors.full_messages, scheme: :danger) } + ->(errors) { render_error_flash_message_via_turbo_stream(message: errors.full_messages) } ) respond_with_turbo_streams(&:html) diff --git a/app/controllers/admin/settings/project_custom_fields_controller.rb b/app/controllers/admin/settings/project_custom_fields_controller.rb index 4c2bfce929d9..0786974f7b17 100644 --- a/app/controllers/admin/settings/project_custom_fields_controller.rb +++ b/app/controllers/admin/settings/project_custom_fields_controller.rb @@ -82,7 +82,7 @@ def new_link def link create_service = ProjectCustomFieldProjectMappings::BulkCreateService - .new(user: current_user, projects: @projects, project_custom_field: @custom_field, + .new(user: current_user, projects: @projects, model: @custom_field, include_sub_projects: include_sub_projects?) .call diff --git a/app/controllers/admin/settings/project_life_cycle_step_definitions_controller.rb b/app/controllers/admin/settings/project_life_cycle_step_definitions_controller.rb new file mode 100644 index 000000000000..363e8038a7d0 --- /dev/null +++ b/app/controllers/admin/settings/project_life_cycle_step_definitions_controller.rb @@ -0,0 +1,145 @@ +# 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 Admin::Settings + class ProjectLifeCycleStepDefinitionsController < ::Admin::SettingsController + include FlashMessagesOutputSafetyHelper + include OpTurbo::ComponentStream + include Projects::LifeCycleDefinitionHelper + + menu_item :project_life_cycle_step_definitions_settings + + before_action :check_feature_flag + before_action :require_enterprise_token, except: %i[index] + + before_action :find_definitions, only: %i[index] + before_action :find_definition, only: %i[edit update destroy move drop] + + def index; end + + def new_stage + @definition = Project::StageDefinition.new + + render :form + end + + def new_gate + @definition = Project::GateDefinition.new + + render :form + end + + def edit + render :form + end + + def create + @definition = Project::LifeCycleStepDefinition.new(definition_params) + + if @definition.save + flash[:notice] = I18n.t(:notice_successful_create) + redirect_to action: :index, status: :see_other + else + render :form, status: :unprocessable_entity + end + end + + def update + if @definition.update(definition_params) + flash[:notice] = I18n.t(:notice_successful_update) + redirect_to action: :index, status: :see_other + else + render :form, status: :unprocessable_entity + end + end + + def destroy + if @definition.destroy + render_success_flash_message_via_turbo_stream(message: I18n.t(:notice_successful_delete)) + else + render_error_flash_message_via_turbo_stream(message: join_flash_messages(@definition.errors.full_messages)) + end + + update_definitions_via_turbo_stream + + respond_to_with_turbo_streams + end + + def move + unless @definition.update(params.permit(:move_to)) + render_error_flash_message_via_turbo_stream(message: join_flash_messages(@definition.errors.full_messages)) + end + + update_definitions_via_turbo_stream + + respond_to_with_turbo_streams + end + + def drop + unless @definition.update(params.permit(:position)) + render_error_flash_message_via_turbo_stream(message: join_flash_messages(@definition.errors.full_messages)) + end + + update_definitions_via_turbo_stream + + respond_to_with_turbo_streams + end + + private + + def check_feature_flag + render_404 unless OpenProject::FeatureDecisions.stages_and_gates_active? + end + + def require_enterprise_token + render_402 unless allowed_to_customize_life_cycle? + end + + def find_definitions + @definitions = Project::LifeCycleStepDefinition.with_project_count + end + + def find_definition + @definition = Project::LifeCycleStepDefinition.find(params[:id]) + end + + def definition_params + params.require(:project_life_cycle_step_definition).permit(:type, :name, :color_id) + end + + def update_definitions_via_turbo_stream + update_via_turbo_stream( + component: Settings::ProjectLifeCycleStepDefinitions::IndexComponent.new( + definitions: find_definitions + ) + ) + end + end +end diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 8a554ac032f1..790cdd4504a8 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -43,7 +43,7 @@ def index condition = node.condition name === :admin_overview || - (condition && !condition.call) || + (condition && !condition.call(nil)) || hidden_admin_menu_items.include?(name.to_s) end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 609198f726f0..657e26c11165 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -345,7 +345,7 @@ def back_url params[:back_url] || request.env["HTTP_REFERER"] end - def redirect_back_or_default(default, use_escaped = true) + def redirect_back_or_default(default, use_escaped: true, status: :found) policy = RedirectPolicy.new( params[:back_url], hostname: request.host, @@ -353,7 +353,7 @@ def redirect_back_or_default(default, use_escaped = true) return_escaped: use_escaped ) - redirect_to policy.redirect_url + redirect_to(policy.redirect_url, status:) end # Picks which layout to use based on the request diff --git a/app/controllers/concerns/op_turbo/component_stream.rb b/app/controllers/concerns/op_turbo/component_stream.rb index f7cd5e1369bc..ed75e2d01139 100644 --- a/app/controllers/concerns/op_turbo/component_stream.rb +++ b/app/controllers/concerns/op_turbo/component_stream.rb @@ -75,11 +75,15 @@ def add_before_via_turbo_stream(component:, target_component:) turbo_streams << target_component.insert_as_turbo_stream(component:, view_context:, action: :before) end + def render_success_flash_message_via_turbo_stream(**) + render_flash_message_via_turbo_stream(**, scheme: :success) + end + def render_error_flash_message_via_turbo_stream(**) - update_flash_message_via_turbo_stream(**, scheme: :danger, icon: :stop) + render_flash_message_via_turbo_stream(**, scheme: :danger, icon: :stop) end - def update_flash_message_via_turbo_stream(message:, component: OpPrimer::FlashComponent, **) + def render_flash_message_via_turbo_stream(message:, component: OpPrimer::FlashComponent, **) instance = component.new(**).with_content(message) turbo_streams << instance.render_as_turbo_stream(view_context:, action: :flash) end @@ -96,6 +100,10 @@ def add_caption_to_input_element_via_turbo_stream(target, caption:, clean_other_ .render_in(view_context) end + def close_dialog_via_turbo_stream(target) + turbo_streams << OpTurbo::StreamComponent.new(action: :closeDialog, target:).render_in(view_context) + end + def turbo_streams @turbo_streams ||= [] end diff --git a/app/models/types/pattern_collection_contract.rb b/app/controllers/concerns/projects/life_cycle_definition_helper.rb similarity index 84% rename from app/models/types/pattern_collection_contract.rb rename to app/controllers/concerns/projects/life_cycle_definition_helper.rb index 2a8e7c16a1e1..76cc76672e34 100644 --- a/app/models/types/pattern_collection_contract.rb +++ b/app/controllers/concerns/projects/life_cycle_definition_helper.rb @@ -28,13 +28,10 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module Types - class PatternCollectionContract < Dry::Validation::Contract - params do - required(:subject).hash do - required(:blueprint).filled(:string) - required(:enabled).filled(:bool) - end - end +module Projects::LifeCycleDefinitionHelper + private + + def allowed_to_customize_life_cycle? + EnterpriseToken.allows_to?(:customize_life_cycle) end end diff --git a/app/controllers/types_controller.rb b/app/controllers/types_controller.rb index c46d8cb09eee..57f61b721967 100644 --- a/app/controllers/types_controller.rb +++ b/app/controllers/types_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -60,19 +62,17 @@ def edit end end - def create # rubocop:disable Metrics/AbcSize + def create CreateTypeService .new(current_user) - .call(permitted_type_params, copy_workflow_from: params[:copy_workflow_from]) do |call| + .call(permitted_type_params, copy_workflow_from: params.dig(:type, :copy_workflow_from)) do |call| @type = call.result call.on_success do redirect_to_type_tab_path(@type, t(:notice_successful_create)) end - call.on_failure do |result| - flash[:error] = result.errors.full_messages.join("\n") - load_projects_and_types + call.on_failure do render action: :new, status: :unprocessable_entity end end @@ -135,8 +135,7 @@ def load_projects_and_types def redirect_to_type_tab_path(type, notice) tab = params["tab"] || "settings" - redirect_to(edit_type_tab_path(type, tab:), - notice:) + redirect_to(edit_tab_type_path(type, tab:), notice:, status: :see_other) end def render_edit_tab(type, status: :ok) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index d667d29e9c42..cb9165920828 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -148,17 +150,17 @@ def change_status_info render_400 unless %i(activate lock unlock).include? @status_change end - def change_status + def change_status # rubocop:disable Metrics/AbcSize if @user.id == current_user.id # user is not allowed to change own status - redirect_back_or_default(action: "edit", id: @user) + redirect_back_or_default({ action: "edit", id: @user }) return end if (params[:unlock] || params[:activate]) && user_limit_reached? show_user_limit_error! - return redirect_back_or_default(action: "edit", id: @user) + return redirect_back_or_default({ action: "edit", id: @user }) end if params[:unlock] @@ -184,7 +186,7 @@ def change_status flash[:error] = I18n.t("user.error_status_change_failed", errors: @user.errors.full_messages.join(", ")) end - redirect_back_or_default(action: "edit", id: @user) + redirect_back_or_default({ action: "edit", id: @user }) end def resend_invitation diff --git a/app/controllers/wiki_menu_items_controller.rb b/app/controllers/wiki_menu_items_controller.rb index 51ad5b23af87..5a6950474d1f 100644 --- a/app/controllers/wiki_menu_items_controller.rb +++ b/app/controllers/wiki_menu_items_controller.rb @@ -101,7 +101,7 @@ def update # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity flash[:notice] = t(:notice_successful_update) end - redirect_back_or_default(action: "edit", id: @page) + redirect_back_or_default({ action: "edit", id: @page }) else respond_to do |format| format.html do diff --git a/app/controllers/work_package_children_relations_controller.rb b/app/controllers/work_package_children_relations_controller.rb index 539961766bbe..4feff06746b6 100644 --- a/app/controllers/work_package_children_relations_controller.rb +++ b/app/controllers/work_package_children_relations_controller.rb @@ -43,8 +43,16 @@ def new end def create - child = WorkPackage.find(params[:work_package][:id]) - service_result = set_relation(child:, parent: @work_package) + service_result = create_service_result + + if service_result.failure? + update_via_turbo_stream( + component: WorkPackageRelationsTab::AddWorkPackageChildFormComponent.new(work_package: @work_package, + child: service_result.result, + base_errors: service_result.errors[:base]), + status: :bad_request + ) + end respond_with_relations_tab_update(service_result, relation_to_scroll_to: service_result.result) end @@ -58,6 +66,17 @@ def destroy private + def create_service_result + if params[:work_package][:id].present? + child = WorkPackage.find(params[:work_package][:id]) + set_relation(child:, parent: @work_package) + else + child = WorkPackage.new + child.errors.add(:id, :blank) + ServiceResult.failure(result: child) + end + end + def set_relation(child:, parent:) WorkPackages::UpdateService.new(user: current_user, model: child) .call(parent:) @@ -66,16 +85,9 @@ def set_relation(child:, parent:) def respond_with_relations_tab_update(service_result, **) if service_result.success? @work_package.reload - component = WorkPackageRelationsTab::IndexComponent.new( - work_package: @work_package, - relations: @work_package.relations.visible, - children: @work_package.children.visible, - ** - ) + component = WorkPackageRelationsTab::IndexComponent.new(work_package: @work_package, **) replace_via_turbo_stream(component:) - update_flash_message_via_turbo_stream( - message: I18n.t(:notice_successful_update), scheme: :success - ) + render_success_flash_message_via_turbo_stream(message: I18n.t(:notice_successful_update)) respond_with_turbo_streams else diff --git a/app/controllers/work_package_relations_controller.rb b/app/controllers/work_package_relations_controller.rb index 02e7f68b0134..e949dec17589 100644 --- a/app/controllers/work_package_relations_controller.rb +++ b/app/controllers/work_package_relations_controller.rb @@ -59,17 +59,16 @@ 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.visible, - children: @work_package.children.visible, - relation_to_scroll_to: service_result.result) - replace_via_turbo_stream(component:) - respond_with_turbo_streams - else - respond_with_turbo_streams(status: :unprocessable_entity) + if service_result.failure? + update_via_turbo_stream( + component: WorkPackageRelationsTab::WorkPackageRelationFormComponent.new(work_package: @work_package, + relation: service_result.result, + base_errors: service_result.errors[:base]), + status: :bad_request + ) end + + respond_with_relations_tab_update(service_result, relation_to_scroll_to: service_result.result) end def update @@ -78,41 +77,39 @@ def update 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.visible, - children: @work_package.children.visible) - replace_via_turbo_stream(component:) - respond_with_turbo_streams - else - respond_with_turbo_streams(status: :unprocessable_entity) + respond_with_relations_tab_update(service_result) + + if service_result.failure? + update_via_turbo_stream( + component: WorkPackageRelationsTab::WorkPackageRelationFormComponent.new(work_package: @work_package, + relation: service_result.result, + base_errors: service_result.errors[:base]), + status: :bad_request + ) end end def destroy service_result = Relations::DeleteService.new(user: current_user, model: @relation).call + respond_with_relations_tab_update(service_result) + end + + private + + def respond_with_relations_tab_update(service_result, **) if service_result.success? - @children = WorkPackage.where(parent_id: @work_package.id).visible - @relations = @work_package - .relations - .reload - .includes(:to, :from) - .visible - - component = WorkPackageRelationsTab::IndexComponent.new(work_package: @work_package, - relations: @relations, - children: @children) + @work_package.reload + component = WorkPackageRelationsTab::IndexComponent.new(work_package: @work_package, **) replace_via_turbo_stream(component:) + render_success_flash_message_via_turbo_stream(message: I18n.t(:notice_successful_update)) + 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 @@ -122,9 +119,15 @@ def set_relation end def create_relation_params - params.require(:relation) - .permit(:relation_type, :to_id, :description, :lag) - .merge(from_id: @work_package.id) + if params[:relation][:from_id].present? + params.require(:relation) + .permit(:relation_type, :from_id, :description, :lag) + .merge(to_id: @work_package.id) + else + params.require(:relation) + .permit(:relation_type, :to_id, :description, :lag) + .merge(from_id: @work_package.id) + end end def update_relation_params diff --git a/app/controllers/work_package_relations_tab_controller.rb b/app/controllers/work_package_relations_tab_controller.rb index f457a67fd936..097b4b8296e5 100644 --- a/app/controllers/work_package_relations_tab_controller.rb +++ b/app/controllers/work_package_relations_tab_controller.rb @@ -33,17 +33,7 @@ class WorkPackageRelationsTabController < ApplicationController before_action :authorize_global def index - @children = WorkPackage.where(parent_id: @work_package.id).visible - @relations = @work_package - .relations - .visible - .includes(:to, :from) - - component = WorkPackageRelationsTab::IndexComponent.new( - work_package: @work_package, - relations: @relations, - children: @children - ) + component = WorkPackageRelationsTab::IndexComponent.new(work_package: @work_package) respond_to do |format| format.html do diff --git a/app/controllers/work_packages/bulk_controller.rb b/app/controllers/work_packages/bulk_controller.rb index f2938d63a950..54b072003da3 100644 --- a/app/controllers/work_packages/bulk_controller.rb +++ b/app/controllers/work_packages/bulk_controller.rb @@ -48,7 +48,7 @@ def update if @call.success? flash[:notice] = t(:notice_successful_update) - redirect_back_or_default(controller: "/work_packages", action: :index, project_id: @project) + redirect_back_or_default({ controller: "/work_packages", action: :index, project_id: @project }) else flash[:error] = bulk_error_message(@work_packages, @call) setup_edit diff --git a/app/controllers/work_packages/dialogs_controller.rb b/app/controllers/work_packages/dialogs_controller.rb new file mode 100644 index 000000000000..d3fdd1fbc931 --- /dev/null +++ b/app/controllers/work_packages/dialogs_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 WorkPackages::DialogsController < ApplicationController + include OpTurbo::ComponentStream + include OpTurbo::DialogStreamHelper + layout false + + before_action :find_project_by_project_id + before_action :build_work_package, only: %i[new] + + authorize_with_permission :add_work_packages + + def new + respond_with_dialog WorkPackages::Dialogs::CreateDialogComponent.new(work_package: @work_package, project: @project) + end + + def create + call = WorkPackages::CreateService.new(user: current_user).call(create_params) + + if call.success? + flash[:notice] = I18n.t("work_package_relations_tab.relations.label_new_child_created") + redirect_back fallback_location: project_work_package_path(@project, call.result), status: :see_other + else + form_component = WorkPackages::Dialogs::CreateFormComponent.new(work_package: call.result, project: @project) + update_via_turbo_stream(component: form_component, status: :bad_request) + + respond_with_turbo_streams + end + end + + def refresh_form + call = WorkPackages::SetAttributesService.new( + user: current_user, + model: WorkPackage.new, + contract_class: EmptyContract + ).call(create_params) + + form_component = WorkPackages::Dialogs::CreateFormComponent.new(work_package: call.result, project: @project) + update_via_turbo_stream(component: form_component) + + respond_with_turbo_streams + end + + private + + def build_work_package + initial = WorkPackage.new(project: @project) + + call = WorkPackages::SetAttributesService + .new(model: initial, user: current_user, contract_class: WorkPackages::CreateContract) + .call(new_params.reverse_merge(default_params(initial))) + + # We ignore errors here, as we only want to build the work package + @work_package = call.result + @work_package.errors.clear + @work_package.custom_values.each { |cv| cv.errors.clear } + end + + def new_params + params.permit(*PermittedParams.permitted_attributes[:new_work_package]) + end + + def create_params + permitted_params.update_work_package.merge(project: @project) + end + + def default_params(work_package) + contract = WorkPackages::CreateContract.new(work_package, current_user) + + { + type: contract.assignable_types.first, + project: @project + } + end + + def default_breadcrumb; end +end diff --git a/app/controllers/work_packages/reminders_controller.rb b/app/controllers/work_packages/reminders_controller.rb index 7866147aea1d..0b4796824ab3 100644 --- a/app/controllers/work_packages/reminders_controller.rb +++ b/app/controllers/work_packages/reminders_controller.rb @@ -49,10 +49,7 @@ def create .call(reminder_params) if service_result.success? - update_flash_message_via_turbo_stream( - message: I18n.t("work_package.reminders.success_creation_message"), - scheme: :success - ) + render_success_flash_message_via_turbo_stream(message: I18n.t("work_package.reminders.success_creation_message")) respond_with_turbo_streams else prepare_errors_from_result(service_result) @@ -75,10 +72,7 @@ def update .call(reminder_params) if service_result.success? - update_flash_message_via_turbo_stream( - message: I18n.t("work_package.reminders.success_update_message"), - scheme: :success - ) + render_success_flash_message_via_turbo_stream(message: I18n.t("work_package.reminders.success_update_message")) respond_with_turbo_streams else prepare_errors_from_result(service_result) @@ -101,16 +95,10 @@ def destroy .call if service_result.success? - update_flash_message_via_turbo_stream( - message: I18n.t("work_package.reminders.success_deletion_message"), - scheme: :success - ) + render_success_flash_message_via_turbo_stream(message: I18n.t("work_package.reminders.success_deletion_message")) respond_with_turbo_streams else - update_flash_message_via_turbo_stream( - message: service_result.errors.full_messages, - scheme: :danger - ) + render_error_flash_message_via_turbo_stream(message: service_result.errors.full_messages) respond_with_turbo_streams(status: :unprocessable_entity) end end @@ -176,10 +164,7 @@ def find_reminder .upcoming_and_visible_to(User.current) .find(params[:id]) rescue ActiveRecord::RecordNotFound - update_flash_message_via_turbo_stream( - message: I18n.t(:error_reminder_not_found), - scheme: :danger - ) + render_error_flash_message_via_turbo_stream(message: I18n.t(:error_reminder_not_found)) respond_with_turbo_streams(status: :not_found) false end diff --git a/app/controllers/work_packages/split_view_controller.rb b/app/controllers/work_packages/split_view_controller.rb index 4a5a9057d95a..5e9389ebc26e 100644 --- a/app/controllers/work_packages/split_view_controller.rb +++ b/app/controllers/work_packages/split_view_controller.rb @@ -30,8 +30,8 @@ class WorkPackages::SplitViewController < ApplicationController # Authorization is checked in the find_work_package action - no_authorization_required! :update_counter - before_action :find_work_package, only: %i[update_counter] + no_authorization_required! :update_counter, :get_relations_counter + before_action :find_work_package def update_counter respond_to do |format| @@ -45,6 +45,10 @@ def update_counter end end + def get_relations_counter + render json: { count: WorkPackageRelationsTab::RelationsMediator.new(work_package: @work_package).all_relations_count } + end + private def find_work_package diff --git a/app/controllers/work_packages/types/subject_configuration_controller.rb b/app/controllers/work_packages/types/subject_configuration_controller.rb new file mode 100644 index 000000000000..240686626aa1 --- /dev/null +++ b/app/controllers/work_packages/types/subject_configuration_controller.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module WorkPackages + module Types + class SubjectConfigurationController < ApplicationController + layout "admin" + + before_action :require_admin + before_action :find_type, only: %i[update_subject_configuration] + + def update_subject_configuration + form_params = params.require(:types_forms_subject_configuration_form_model) + .permit(:subject_configuration, :pattern) + .to_h + + UpdateTypeService.new(@type, current_user) + .call({ patterns: pattern_collection_update(form_params) }) do |call| + call.on_success do + redirect_to tab_path, notice: I18n.t(:notice_successful_update) + end + + call.on_failure do + @default_tab = "subject_configuration" + render template: "types/edit", status: :unprocessable_entity + end + end + end + + private + + def find_type + @type = ::Type.find(params[:id]) + end + + def tab_path = edit_tab_type_path(id: @type.id, tab: :subject_configuration) + + def pattern_collection_update(form_params) + patterns = @type.patterns.to_h.symbolize_keys + + subject_pattern = + case form_params + in { subject_configuration: "generated", pattern: String => blueprint } + { subject: { blueprint:, enabled: true } } + in { subject_configuration: "manual", pattern: String => blueprint } + if blueprint.empty? + # Submitting the form with an empty blueprint and manual subject configuration will + # remove the subject pattern from the collection + nil + else + { subject: { blueprint:, enabled: false } } + end + else + nil + end + + if subject_pattern.nil? + patterns.delete(:subject) + patterns + else + patterns.merge(subject_pattern) + end + end + end + end +end diff --git a/app/controllers/work_packages_controller.rb b/app/controllers/work_packages_controller.rb index 81e3613a662e..36e8a12f8a15 100644 --- a/app/controllers/work_packages_controller.rb +++ b/app/controllers/work_packages_controller.rb @@ -109,7 +109,7 @@ def generate_pdf def show_conflict_flash_message scheme = params[:scheme]&.to_sym || :danger - update_flash_message_via_turbo_stream( + render_flash_message_via_turbo_stream( component: WorkPackages::UpdateConflictComponent, scheme:, message: I18n.t("notice_locking_conflict_#{scheme}"), diff --git a/app/forms/custom_fields/custom_field_rendering.rb b/app/forms/custom_fields/custom_field_rendering.rb new file mode 100644 index 000000000000..7acd5f4ccb39 --- /dev/null +++ b/app/forms/custom_fields/custom_field_rendering.rb @@ -0,0 +1,112 @@ +# 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 CustomFields::CustomFieldRendering + include ActiveSupport::Concern + + def render_custom_fields(form:) + custom_fields.each do |custom_field| + form.fields_for(:custom_field_values) do |builder| + custom_field_input(builder, custom_field) + end + end + end + + # override if you want to pass more attributes + def additional_custom_field_input_arguments + {} + end + + def custom_fields + raise NotImplementedError, "#custom_fields method needs to be overwritten and provide all custom fields we want to show" + end + + private + + def custom_field_input(builder, custom_field) + if custom_field.multi_value? + multi_value_custom_field_input(builder, custom_field) + else + single_value_custom_field_input(builder, custom_field) + end + end + + def form_arguments(custom_field) + { + custom_field: custom_field, + object: model + }.merge(additional_custom_field_input_arguments) + end + + # TBD: transform inputs called below to primer form dsl instead of form classes? + # TODOS: + # - initial values for user inputs are not displayed + # - allow/disallow-non-open version setting is not yet respected in the version selector + # - rich text editor is not yet supported + # - hierarchy should not use a flat list + + def single_value_custom_field_input(builder, custom_field) + form_args = form_arguments(custom_field) + + case custom_field.field_format + when "string", "link" + CustomFields::Inputs::String.new(builder, **form_args) + when "text" + CustomFields::Inputs::Text.new(builder, **form_args) + when "int" + CustomFields::Inputs::Int.new(builder, **form_args) + when "float" + CustomFields::Inputs::Float.new(builder, **form_args) + when "hierarchy", "list" + CustomFields::Inputs::SingleSelectList.new(builder, **form_args) + when "date" + CustomFields::Inputs::Date.new(builder, **form_args) + when "bool" + CustomFields::Inputs::Bool.new(builder, **form_args) + when "user" + CustomFields::Inputs::SingleUserSelectList.new(builder, **form_args) + when "version" + CustomFields::Inputs::SingleVersionSelectList.new(builder, **form_args) + end + end + + def multi_value_custom_field_input(builder, custom_field) + form_args = form_arguments(custom_field) + + case custom_field.field_format + when "hierarchy", "list" + CustomFields::Inputs::MultiSelectList.new(builder, **form_args) + when "user" + CustomFields::Inputs::MultiUserSelectList.new(builder, **form_args) + when "version" + CustomFields::Inputs::MultiVersionSelectList.new(builder, **form_args) + end + end +end diff --git a/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb b/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb index db2fa302c4d5..e1b677dc42b1 100644 --- a/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb +++ b/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb @@ -40,6 +40,7 @@ def autocomplete_options { multiple: true, decorated: decorated?, + focusDirectly: false, append_to: } end diff --git a/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb b/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb index 3a9bf73dd15d..5f678025d266 100644 --- a/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb +++ b/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb @@ -40,6 +40,7 @@ def autocomplete_options { multiple: false, decorated: decorated?, + focusDirectly: false, append_to: } end diff --git a/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb b/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb index e2d17241aed6..9f01f53464c6 100644 --- a/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb +++ b/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -51,10 +53,17 @@ def search_key end def filters - [ + filters = [ { name: "type", operator: "=", values: ["User", "Group", "PlaceholderUser"] }, - { name: "member", operator: "=", values: [@object.id.to_s] }, { name: "status", operator: "!", values: [Principal.statuses["locked"].to_s] } ] + + if @object.is_a?(Project) + filters << { name: "member", operator: "=", values: [@object.id.to_s] } + elsif @object.respond_to?(:project_id) + filters << { name: "member", operator: "=", values: [@object.project_id.to_s] } + end + + filters end end diff --git a/app/forms/custom_fields/inputs/base/input.rb b/app/forms/custom_fields/inputs/base/input.rb index 6e30709cc0f0..2e160a5a6f54 100644 --- a/app/forms/custom_fields/inputs/base/input.rb +++ b/app/forms/custom_fields/inputs/base/input.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -32,6 +34,8 @@ class CustomFields::Inputs::Base::Input < ApplicationForm attr_reader :options def initialize(custom_field:, object:, **options) + super() + @custom_field = custom_field @object = object @options = options diff --git a/app/forms/custom_fields/inputs/multi_select_list.rb b/app/forms/custom_fields/inputs/multi_select_list.rb index 832ddb524b49..a6245fcc2357 100644 --- a/app/forms/custom_fields/inputs/multi_select_list.rb +++ b/app/forms/custom_fields/inputs/multi_select_list.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -31,17 +33,19 @@ class CustomFields::Inputs::MultiSelectList < CustomFields::Inputs::Base::Autoco # autocompleter does not set key with blank value if nothing is selected or input is cleared # in order to let acts_as_customizable handle the clearing of the value, we need to set the value to blank via a hidden field # which sends blank if autocompleter is cleared - custom_value_form.hidden(**input_attributes.merge( + custom_value_form.hidden( + **input_attributes, scope_name_to_model: false, - name: "#{@object.class.name.downcase}[custom_field_values][#{input_attributes[:name]}][]", + name: "#{@object.model_name.element}[custom_field_values][#{input_attributes[:name]}][]", value: - )) + ) custom_value_form.autocompleter(**input_attributes) do |list| - @custom_field.custom_options.each do |custom_option| + list_items.each do |item| list.option( - label: custom_option.value, value: custom_option.id, - selected: selected?(custom_option) + label: item.fetch(:label), + value: item.fetch(:value), + selected: item.fetch(:selected) ) end end @@ -53,6 +57,33 @@ def decorated? true end + def list_items + case @custom_field.field_format + when "hierarchy" + hierarchy_items.map do |item| + { + label: item.ancestry_path, + value: item.id, + selected: @custom_values.pluck(:value).map(&:to_i).include?(item.id) + } + end + else + @custom_field.custom_options.map do |custom_option| + { + label: custom_option.value, + value: custom_option.id, + selected: selected?(custom_option) + } + end + end + end + + def hierarchy_items + CustomFields::Hierarchy::HierarchicalItemService.new + .get_descendants(item: @custom_field.hierarchy_root, include_self: false) + .value_or([]) + end + def selected?(custom_option) if @custom_values.any? @custom_values.pluck(:value).map { |value| value&.to_i }.include?(custom_option.id) diff --git a/app/forms/custom_fields/inputs/multi_user_select_list.rb b/app/forms/custom_fields/inputs/multi_user_select_list.rb index f086b8b8948e..0e93c3b920a9 100644 --- a/app/forms/custom_fields/inputs/multi_user_select_list.rb +++ b/app/forms/custom_fields/inputs/multi_user_select_list.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -33,11 +35,12 @@ class CustomFields::Inputs::MultiUserSelectList < CustomFields::Inputs::Base::Au # autocompleter does not set key with blank value if nothing is selected or input is cleared # in order to let acts_as_customizable handle the clearing of the value, we need to set the value to blank via a hidden field # which sends blank if autocompleter is cleared - custom_value_form.hidden(**input_attributes.merge( + custom_value_form.hidden( + **input_attributes, scope_name_to_model: false, - name: "#{@object.class.name.downcase}[custom_field_values][#{input_attributes[:name]}][]", + name: "#{@object.model_name.element}[custom_field_values][#{input_attributes[:name]}][]", value: - )) + ) custom_value_form.autocompleter(**input_attributes) end diff --git a/app/forms/custom_fields/inputs/multi_version_select_list.rb b/app/forms/custom_fields/inputs/multi_version_select_list.rb index f03ce7672f6c..9d6c65f3f91f 100644 --- a/app/forms/custom_fields/inputs/multi_version_select_list.rb +++ b/app/forms/custom_fields/inputs/multi_version_select_list.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -28,23 +30,24 @@ class CustomFields::Inputs::MultiVersionSelectList < CustomFields::Inputs::Base::Autocomplete::MultiValueInput include AssignableCustomFieldValues - - delegate :assignable_versions, to: :@object + include CustomFields::Inputs::VersionSelect form do |custom_value_form| # autocompleter does not set key with blank value if nothing is selected or input is cleared # in order to let acts_as_customizable handle the clearing of the value, we need to set the value to blank via a hidden field # which sends blank if autocompleter is cleared - custom_value_form.hidden(**input_attributes.merge( + custom_value_form.hidden( + **input_attributes, scope_name_to_model: false, - name: "#{@object.class.name.downcase}[custom_field_values][#{input_attributes[:name]}][]", + name: "#{@object.model_name.element}[custom_field_values][#{input_attributes[:name]}][]", value: - )) + ) - custom_value_form.autocompleter(**input_attributes) do |list| + custom_value_form.autocompleter(**version_input_attributes) do |list| assignable_custom_field_values(@custom_field).each do |version| list.option( - label: version.name, value: version.id, + label: version.name, + value: version.id, selected: selected?(version) ) end diff --git a/app/forms/custom_fields/inputs/single_select_list.rb b/app/forms/custom_fields/inputs/single_select_list.rb index 6b86c9c3a614..2eb3711d0f79 100644 --- a/app/forms/custom_fields/inputs/single_select_list.rb +++ b/app/forms/custom_fields/inputs/single_select_list.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -34,10 +36,11 @@ class CustomFields::Inputs::SingleSelectList < CustomFields::Inputs::Base::Autoc custom_value_form.hidden(**input_attributes.merge(value: "")) custom_value_form.autocompleter(**input_attributes) do |list| - @custom_value.custom_field.custom_options.each do |custom_option| + list_items.each do |item| list.option( - label: custom_option.value, value: custom_option.id, - selected: selected?(custom_option) + label: item.fetch(:label), + value: item.fetch(:value), + selected: item.fetch(:selected) ) end end @@ -49,6 +52,33 @@ def decorated? true end + def list_items + case @custom_field.field_format + when "hierarchy" + hierarchy_items.map do |item| + { + label: item.ancestry_path, + value: item.id, + selected: item.id == @custom_value.value&.to_i + } + end + else + @custom_field.custom_options.map do |custom_option| + { + label: custom_option.value, + value: custom_option.id, + selected: selected?(custom_option) + } + end + end + end + + def hierarchy_items + CustomFields::Hierarchy::HierarchicalItemService.new + .get_descendants(item: @custom_field.hierarchy_root, include_self: false) + .value_or([]) + end + def selected?(custom_option) custom_option.id == @custom_value.value&.to_i || custom_option.id == @custom_field.default_value&.to_i end diff --git a/app/forms/custom_fields/inputs/single_version_select_list.rb b/app/forms/custom_fields/inputs/single_version_select_list.rb index 870929858e7a..d2649566f834 100644 --- a/app/forms/custom_fields/inputs/single_version_select_list.rb +++ b/app/forms/custom_fields/inputs/single_version_select_list.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -28,16 +30,15 @@ class CustomFields::Inputs::SingleVersionSelectList < CustomFields::Inputs::Base::Autocomplete::SingleValueInput include AssignableCustomFieldValues - - delegate :assignable_versions, to: :@object + include CustomFields::Inputs::VersionSelect form do |custom_value_form| # autocompleter does not set key with blank value if nothing is selected or input is cleared # in order to let acts_as_customizable handle the clearing of the value, we need to set the value to blank via a hidden field # which sends blank if autocompleter is cleared - custom_value_form.hidden(**input_attributes.merge(value: "")) + custom_value_form.hidden(**input_attributes, value: "") - custom_value_form.autocompleter(**input_attributes) do |list| + custom_value_form.autocompleter(**version_input_attributes) do |list| assignable_custom_field_values(@custom_field).each do |version| list.option( label: version.name, value: version.id, diff --git a/app/forms/custom_fields/inputs/version_select.rb b/app/forms/custom_fields/inputs/version_select.rb new file mode 100644 index 000000000000..8ae754b7b1f2 --- /dev/null +++ b/app/forms/custom_fields/inputs/version_select.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 CustomFields + module Inputs + module VersionSelect + protected + + def version_input_attributes + input_attributes.deep_merge(additional_attributes) + end + + def additional_attributes + if @object.blank? || (@object.respond_to?(:project) && @object.project.blank?) + { + autocomplete_options: { + disabled: true, + placeholder: I18n.t("custom_fields.placeholder_version_select") + } + } + else + {} + end + end + + def assignable_versions(only_open:) + if @object.is_a?(Project) + @object.assignable_versions(only_open: only_open) + elsif @object.respond_to?(:project) && @object.project.present? + @object.project.assignable_versions(only_open: only_open) + else + Version.none + end + end + end + end +end diff --git a/app/forms/projects/custom_fields/form.rb b/app/forms/projects/custom_fields/form.rb index 23dd74316571..cd34b56779fb 100644 --- a/app/forms/projects/custom_fields/form.rb +++ b/app/forms/projects/custom_fields/form.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -27,12 +29,10 @@ #++ module Projects::CustomFields class Form < ApplicationForm + include CustomFields::CustomFieldRendering + form do |custom_fields_form| - custom_fields.each do |custom_field| - custom_fields_form.fields_for(:custom_field_values) do |builder| - custom_field_input(builder, custom_field) - end - end + render_custom_fields(form: custom_fields_form) end def initialize(project:, custom_field_section: nil, custom_field: nil, wrapper_id: nil) @@ -48,6 +48,11 @@ def initialize(project:, custom_field_section: nil, custom_field: nil, wrapper_i end end + # override since we want to add the model with @project + def additional_custom_field_input_arguments + { model: @project, wrapper_id: @wrapper_id } + end + private def custom_fields @@ -62,57 +67,5 @@ def custom_fields @project.available_custom_fields end end - - def custom_field_input(builder, custom_field) - if custom_field.multi_value? - multi_value_custom_field_input(builder, custom_field) - else - single_value_custom_field_input(builder, custom_field) - end - end - - # TBD: transform inputs called below to primer form dsl instead of form classes? - # TODOS: - # - initial values for user inputs are not displayed - # - allow/disallow-non-open version setting is not yet respected in the version selector - # - rich text editor is not yet supported - - def single_value_custom_field_input(builder, custom_field) - form_args = { custom_field:, object: @project, wrapper_id: @wrapper_id } - - case custom_field.field_format - when "string", "link" - CustomFields::Inputs::String.new(builder, **form_args) - when "text" - CustomFields::Inputs::Text.new(builder, **form_args) - when "int" - CustomFields::Inputs::Int.new(builder, **form_args) - when "float" - CustomFields::Inputs::Float.new(builder, **form_args) - when "list" - CustomFields::Inputs::SingleSelectList.new(builder, **form_args) - when "date" - CustomFields::Inputs::Date.new(builder, **form_args) - when "bool" - CustomFields::Inputs::Bool.new(builder, **form_args) - when "user" - CustomFields::Inputs::SingleUserSelectList.new(builder, **form_args) - when "version" - CustomFields::Inputs::SingleVersionSelectList.new(builder, **form_args) - end - end - - def multi_value_custom_field_input(builder, custom_field) - form_args = { custom_field:, object: @project, wrapper_id: @wrapper_id } - - case custom_field.field_format - when "list" - CustomFields::Inputs::MultiSelectList.new(builder, **form_args) - when "user" - CustomFields::Inputs::MultiUserSelectList.new(builder, **form_args) - when "version" - CustomFields::Inputs::MultiVersionSelectList.new(builder, **form_args) - end - end end end diff --git a/app/forms/projects/life_cycle_step_definitions/form.rb b/app/forms/projects/life_cycle_step_definitions/form.rb new file mode 100644 index 000000000000..8ed13372050c --- /dev/null +++ b/app/forms/projects/life_cycle_step_definitions/form.rb @@ -0,0 +1,61 @@ +# 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 Projects::LifeCycleStepDefinitions + class Form < ApplicationForm + form do |f| + f.hidden(name: :type) unless model.persisted? + + f.text_field( + label: attribute_name(:name), + name: :name, + required: true, + input_width: :medium + ) + + f.color_select_list( + label: attribute_name(:color), + name: :color, + required: true, + input_width: :medium + ) + + f.submit( + scheme: :primary, + name: :submit, + label: submit_label + ) + end + + def submit_label + I18n.t(model.persisted? ? :button_update : :button_create) + end + end +end diff --git a/app/forms/projects/life_cycles/form.rb b/app/forms/projects/life_cycles/form.rb index 6d0233b90460..06a7ec6212d0 100644 --- a/app/forms/projects/life_cycles/form.rb +++ b/app/forms/projects/life_cycles/form.rb @@ -53,7 +53,7 @@ def base_input_attributes label: "#{icon} #{text}".html_safe, # rubocop:disable Rails/OutputSafety leading_visual: { icon: :calendar }, datepicker_options: { - inDialog: true, + inDialog: ProjectLifeCycles::Sections::EditDialogComponent::DIALOG_ID, data: { action: "change->overview--project-life-cycles-form#previewForm" } }, wrapper_data_attributes: { diff --git a/app/forms/shares/invitee.rb b/app/forms/shares/invitee.rb index c60ed79d1c3c..d9bc3704304c 100644 --- a/app/forms/shares/invitee.rb +++ b/app/forms/shares/invitee.rb @@ -53,15 +53,14 @@ class Invitee < ApplicationForm appendToComponent: true, disabled: @disabled, isOpenedInModal: true, - hoverCards: @allow_hover_cards + hoverCards: true } ) end - def initialize(disabled: false, allow_hover_cards: false) + def initialize(disabled: false) super() @disabled = disabled - @allow_hover_cards = allow_hover_cards end end end diff --git a/app/forms/work_packages/dialogs/create_form.rb b/app/forms/work_packages/dialogs/create_form.rb new file mode 100644 index 000000000000..0c5e9fd8cf76 --- /dev/null +++ b/app/forms/work_packages/dialogs/create_form.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module WorkPackages::Dialogs + class CreateForm < ApplicationForm + include CustomFields::CustomFieldRendering + + attr_reader :work_package, :wrapper_id, :contract + + def initialize(work_package:, wrapper_id:) + super() + + @work_package = work_package + @schema = API::V3::WorkPackages::Schema::SpecificWorkPackageSchema.new(work_package:) + @wrapper_id = wrapper_id + @contract = WorkPackages::CreateContract.new(work_package, User.current) + end + + form do |f| + f.autocompleter( + name: :type_id, + required: true, + include_blank: false, + input_width: :small, + label: Type.model_name.human, + visually_hide_label: true, + autocomplete_options: { + multiple: false, + decorated: true, + clearable: false, + focusDirectly: false, + hiddenFieldAction: "change->work-packages--create-dialog#refreshForm", + append_to: wrapper_id, + data: { test_selector: "work_package_create_dialog_type" } + } + ) do |select| + contract + .assignable_types + .pluck(:id, :name) + .map do |value, label| + select.option(label:, + value:, + classes: "__hl_inline_type_#{value}", + selected: work_package.type_id == value) + end + end + + f.text_field( + name: :subject, + label: WorkPackage.human_attribute_name(:subject), + required: true, + autofocus: autofocus_subject?, + input_width: :large, + disabled: !@schema.writable?(:subject) + ) + + f.rich_text_area( + name: :description, + label: WorkPackage.human_attribute_name(:description), + rich_text_options: { + resource: work_package, + showAttachments: false + }, + disabled: !@schema.writable?(:description) + ) + + render_custom_fields(form: f) + + # Keep hidden fields for relevant changes + work_package.changes + .slice(*writable_attributes) + .except(:description, :subject, :type_id) + .each do |attribute, value| + f.hidden(name: attribute, value:) + end + end + + def additional_custom_field_input_arguments + { wrapper_id: } + end + + def autofocus_subject? + work_package.errors.empty? && work_package.custom_values.all? { |cv| cv.errors.empty? } + end + + private + + def custom_fields + @custom_fields ||= work_package.available_custom_fields.select(&:required?) + end + + def writable_attributes + contract = WorkPackages::CreateContract.new(work_package, User.current) + contract.writable_attributes + end + end +end diff --git a/app/forms/work_packages/types/settings_form.rb b/app/forms/work_packages/types/settings_form.rb new file mode 100644 index 000000000000..9b50d854f69d --- /dev/null +++ b/app/forms/work_packages/types/settings_form.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module WorkPackages + module Types + class SettingsForm < ApplicationForm + form do |settings_form| + settings_form.text_field( + name: :name, + label: label(:name), + placeholder: I18n.t(:label_name), + input_width: :large, + required: true, + disabled: model.is_standard? + ) + + settings_form.color_select_list( + name: :color_id, + label: Color.model_name.human, + caption: I18n.t("types.edit.settings.type_color_text"), + input_width: :large + ) + + if show_work_flow_copy? + settings_form.select_list( + name: :copy_workflow_from, + label: label(:copy_workflow_from), + include_blank: true, + input_width: :large + ) do |other_types| + work_package_types.each do |type| + other_types.option( + value: type.id, + label: type.name, + selected: type.id == prefilled_copy_workflow_from + ) + end + end + end + + settings_form.rich_text_area( + name: :description, + label: label(:description), + input_width: :large, + rich_text_options: { showAttachments: false } + ) + + settings_form.check_box( + name: :is_milestone, + label: label(:is_milestone) + ) + + settings_form.check_box( + name: :is_in_roadmap, + label: label(:is_in_roadmap) + ) + + settings_form.check_box( + name: :is_default, + label: label(:is_default) + ) + + settings_form.submit( + name: :submit, + label: I18n.t(:button_save), + scheme: :primary + ) + end + + private + + def label(attribute) + model.class.human_attribute_name(attribute) + end + + def show_work_flow_copy? + model.new_record? + end + + def work_package_types + Type.all + end + + def prefilled_copy_workflow_from + @builder.options[:copy_workflow_from] + end + end + end +end diff --git a/app/forms/work_packages/types/subject_configuration_form.rb b/app/forms/work_packages/types/subject_configuration_form.rb index 455b1213e5ad..7f6b1697552d 100644 --- a/app/forms/work_packages/types/subject_configuration_form.rb +++ b/app/forms/work_packages/types/subject_configuration_form.rb @@ -34,38 +34,53 @@ class SubjectConfigurationForm < ApplicationForm form do |subject_form| subject_form.radio_button_group(name: :subject_configuration) do |group| group.radio_button( - value: "manual", - checked: !has_pattern?, + value: :manual, + checked: subject_configuration_manual?, label: I18n.t("types.edit.subject_configuration.manually_editable_subjects.label"), caption: I18n.t("types.edit.subject_configuration.manually_editable_subjects.caption"), data: { action: "admin--subject-configuration#hidePatternInput" } ) group.radio_button( - value: "auto", - checked: has_pattern?, + value: :generated, + checked: !subject_configuration_manual?, label: I18n.t("types.edit.subject_configuration.automatically_generated_subjects.label"), caption: I18n.t("types.edit.subject_configuration.automatically_generated_subjects.caption"), + disabled: !enterprise? && subject_configuration_manual?, data: { action: "admin--subject-configuration#showPatternInput" } ) end subject_form.group(data: { "admin--subject-configuration-target": "patternInput" }) do |toggleable_group| - toggleable_group.text_field( + toggleable_group.pattern_input( name: :pattern, + value: model.pattern, + suggestions: model.suggestions, label: I18n.t("types.edit.subject_configuration.pattern.label"), caption: I18n.t("types.edit.subject_configuration.pattern.caption"), required: true, - input_width: :large + validation_message: validation_message_for(:patterns) ) end - subject_form.submit(name: :submit, label: I18n.t(:button_save), scheme: :primary) + subject_form.submit( + name: :submit, + label: I18n.t(:button_save), + scheme: :primary + ) end private - def has_pattern? - false + def subject_configuration_manual? + model.subject_configuration == :manual + end + + def enterprise? + EnterpriseToken.allows_to?(:work_package_subject_generation) + end + + def validation_message_for(attribute) + model.validation_errors.messages_for(attribute).to_sentence.presence end end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 50e94f057851..8e24190f9025 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -189,13 +189,10 @@ def authoring(created, author, options = {}) I18n.t(label, author: link_to_user(author), age: time_tag(created)).html_safe end - def authoring_at(created, author) + def authoring_at(creation_date, author) return if author.nil? - I18n.t(:"js.label_added_time_by", - author: html_escape(author.name), - age: created, - authorLink: user_path(author)).html_safe + I18n.t(:label_added_by_on, author: link_to_user(author), date: creation_date).html_safe end def time_tag(time) @@ -293,7 +290,7 @@ def theme_options_for_select def body_data_attributes(local_assigns) { - controller: "application", + controller: "application hover-card-trigger", relative_url_root: root_path, overflowing_identifier: ".__overflowing_body", rendered_at: Time.zone.now.iso8601, diff --git a/app/helpers/conversion_helper.rb b/app/helpers/conversion_helper.rb new file mode 100644 index 000000000000..e4ed37cee0e4 --- /dev/null +++ b/app/helpers/conversion_helper.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module ConversionHelper + include Dry::Monads[:maybe] + + def maybe_integer(value) + Some(Integer(value || "", 10)) + rescue ArgumentError + None() + end +end diff --git a/app/helpers/custom_fields_helper.rb b/app/helpers/custom_fields_helper.rb index 1c81066a9009..122cd405541f 100644 --- a/app/helpers/custom_fields_helper.rb +++ b/app/helpers/custom_fields_helper.rb @@ -205,8 +205,6 @@ def format_value(value, custom_field) # Return an array of custom field formats which can be used in select_tag def custom_field_formats_for_select(custom_field) 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 diff --git a/app/helpers/errors_helper.rb b/app/helpers/errors_helper.rb index 561164407eeb..1de153afb781 100644 --- a/app/helpers/errors_helper.rb +++ b/app/helpers/errors_helper.rb @@ -33,6 +33,12 @@ def render_400(options = {}) # rubocop:disable Naming/VariableNumber false end + def render_402(options = {}) # rubocop:disable Naming/VariableNumber + unset_template_magic + render_error({ message: :notice_requires_enterprise_token, status: 402 }.merge(options)) + false + end + def render_403(options = {}) # rubocop:disable Naming/VariableNumber unset_template_magic render_error({ message: :notice_not_authorized, status: 403 }.merge(options)) diff --git a/app/helpers/highlighting_helper.rb b/app/helpers/highlighting_helper.rb index eadd74133eb2..0760f745f17f 100644 --- a/app/helpers/highlighting_helper.rb +++ b/app/helpers/highlighting_helper.rb @@ -4,6 +4,6 @@ def highlight_css_version_tag(max_updated_at = highlight_css_updated_at) end def highlight_css_updated_at - ApplicationRecord.most_recently_changed Status, IssuePriority, Type, UserPreference + ApplicationRecord.most_recently_changed Status, IssuePriority, Type, UserPreference, Project::LifeCycleStepDefinition end end diff --git a/app/helpers/pagination_helper.rb b/app/helpers/pagination_helper.rb index 28e9f79f3c80..b3cb0b5c7b77 100644 --- a/app/helpers/pagination_helper.rb +++ b/app/helpers/pagination_helper.rb @@ -29,6 +29,10 @@ require "will_paginate" module PaginationHelper + SHOW_MORE_DEFAULT_LIMIT = 5 + SHOW_MORE_DEFAULT_INCREMENT = 20 + SHOW_MORE_MAX_LIMIT = 1000 + def pagination_links_full(paginator, options = {}) return unless paginator.total_entries > 0 @@ -151,6 +155,24 @@ def per_page_param(options = params) end end + ## + # For "Show more" paginated links, we want to load an initial number of items (defaulting to 5) + # unless a higher number is provided. These values do not correspond to the per_page_options + def show_more_limit_param(options = params, initial_limit: SHOW_MORE_DEFAULT_LIMIT) + limit = options[:limit].to_i + if limit.zero? + initial_limit + else + [limit, SHOW_MORE_MAX_LIMIT].min + end + end + + ## + # Paginate an AR relation for the "show more" pagination functionality + def show_more_pagination(paginator, options = params) + paginator.paginate(page: 1, per_page: show_more_limit_param(options)) + end + class LinkRenderer < ::WillPaginate::ActionView::LinkRenderer def to_html pagination.inject("") do |html, item| diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 5abcabc4a499..5ffc8c1fbeef 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -124,13 +124,19 @@ def link_to_next_search_page(pagination_next_date) def attachment_fulltexts(event) only_if_tsv_supported(event) do - Attachment.where(id: event.attachment_ids).pluck(:fulltext).join(" ") + # The compact is important here as the fulltext can be nil. + # A nil value in combination with another journal would lead to + # [nil, "The fulltext of the attachment"].join to be in US-ASCII encoding + # instead of the expected UTF-8. + # When this is then passed into Commonmarker, that will raise an error. + # https://community.openproject.org/wp/61110 + Attachment.where(id: event.attachment_ids).pluck(:fulltext).compact.join end end def attachment_filenames(event) only_if_tsv_supported(event) do - event.attachments&.map(&:filename)&.join(" ") + event.attachments.map(&:filename).join end end diff --git a/app/helpers/tabs_helper.rb b/app/helpers/tabs_helper.rb index 6305516475ac..df8228f2e6fa 100644 --- a/app/helpers/tabs_helper.rb +++ b/app/helpers/tabs_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -37,8 +39,10 @@ def render_tabs(tabs, form = nil, with_tab_nav: true) end end - def selected_tab(tabs) - tabs.detect { |t| t[:name] == params[:tab] } || tabs.first + def selected_tab(tabs, default_tab = nil) + tabs.detect { |t| t[:name] == params[:tab] } || + tabs.detect { |t| t[:name] == default_tab } || + tabs.first end def tabs_for_key(key, params = {}) diff --git a/app/helpers/types_helper.rb b/app/helpers/types_helper.rb index 2fcb024f7009..2e9c38b7012a 100644 --- a/app/helpers/types_helper.rb +++ b/app/helpers/types_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -32,20 +34,20 @@ def types_tabs tabs = [ { name: "settings", - partial: "types/form/settings", - path: edit_type_tab_path(id: @type.id, tab: :settings), - label: "types.edit.settings.tab" + path: edit_tab_type_path(id: @type.id, tab: :settings), + label: "types.edit.settings.tab", + view_component: WorkPackages::Types::SettingsComponent }, { name: "form_configuration", partial: "types/form/form_configuration", - path: edit_type_tab_path(id: @type.id, tab: :form_configuration), + path: edit_tab_type_path(id: @type.id, tab: :form_configuration), label: "types.edit.form_configuration.tab" }, { name: "projects", partial: "types/form/projects", - path: edit_type_tab_path(id: @type.id, tab: :projects), + path: edit_tab_type_path(id: @type.id, tab: :projects), label: "types.edit.projects.tab" } ] @@ -53,7 +55,7 @@ def types_tabs if OpenProject::FeatureDecisions.generate_work_package_subjects_active? subject_configuration_tab = { name: "subject_configuration", - path: edit_type_tab_path(id: @type.id, tab: :subject_configuration), + path: edit_tab_type_path(id: @type.id, tab: :subject_configuration), label: "types.edit.subject_configuration.tab", view_component: WorkPackages::Types::SubjectConfigurationComponent } diff --git a/app/helpers/work_packages_helper.rb b/app/helpers/work_packages_helper.rb index 2d37bcad6da7..0744c4502f6b 100644 --- a/app/helpers/work_packages_helper.rb +++ b/app/helpers/work_packages_helper.rb @@ -157,6 +157,23 @@ def work_package_list(work_packages, &) end end + def work_package_dates_icon(work_package) + work_package.schedule_manually ? :pin : :calendar + end + + def work_package_formatted_dates(work_package) + start_date = work_package.start_date ? format_date(work_package.start_date) : nil + due_date = work_package.due_date ? format_date(work_package.due_date) : nil + + # If both dates are missing, return just one dash + return "-" if start_date.nil? && due_date.nil? + + return start_date if start_date == due_date + + # Return the formatted date range (start_date - due_date) + "#{start_date} - #{due_date}" + end + def send_notification_option(checked = false) content_tag(:label, for: "send_notification", class: "form--label-with-check-box") do (content_tag "span", class: "form--check-box-container" do diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 40fbed060cee..17cb76e7a913 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -34,22 +34,18 @@ def read_attribute_for_validation(attribute) def self.most_recently_changed(*record_classes) queries = record_classes.map do |clz| column_name = clz.send(:timestamp_attributes_for_update_in_model)&.first || "updated_at" - "(SELECT MAX(#{column_name}) AS max_updated_at FROM #{clz.table_name})" + table = clz.arel_table + table.project(table[column_name].maximum.as("max_updated_at")).to_sql end .join(" UNION ") - union_query = <<~SQL - SELECT MAX(union_query.max_updated_at) + union_query = <<~SQL.squish + SELECT MAX(max_updated_at) FROM (#{queries}) AS union_query SQL - ActiveRecord::Base - .connection - .select_all(union_query) - .rows - &.first # first result row - &.first # max column + ActiveRecord::Base.connection.select_value(union_query) end def self.skip_optimistic_locking(&) diff --git a/app/models/custom_actions/actions/base.rb b/app/models/custom_actions/actions/base.rb index 8b64662449b5..4995d3c9371d 100644 --- a/app/models/custom_actions/actions/base.rb +++ b/app/models/custom_actions/actions/base.rb @@ -43,6 +43,12 @@ def allowed_values raise NotImplementedError end + def value_objects + values.map do |value| + allowed_values.find { |v| v[:value] == value } + end + end + def type raise NotImplementedError end diff --git a/app/models/custom_actions/conditions/base.rb b/app/models/custom_actions/conditions/base.rb index 93828f8d3880..ddd0a58fdd30 100644 --- a/app/models/custom_actions/conditions/base.rb +++ b/app/models/custom_actions/conditions/base.rb @@ -45,6 +45,12 @@ def allowed_values .map { |value, label| { value:, label: } } end + def value_objects + values.map do |value| + allowed_values.find { |v| v[:value] == value } + end + end + def human_name WorkPackage.human_attribute_name(self.class.key) end diff --git a/app/models/custom_field.rb b/app/models/custom_field.rb index 658fa0c80ce0..2825ffa357d7 100644 --- a/app/models/custom_field.rb +++ b/app/models/custom_field.rb @@ -296,7 +296,7 @@ def field_format_hierarchy? end def multi_value_possible? - version? || user? || list? || field_format_hierarchy? + OpenProject::CustomFieldFormat.find_by(name: field_format)&.multi_value_possible? end def allow_non_open_versions_possible? diff --git a/app/models/custom_style.rb b/app/models/custom_style.rb index 8e0603b2e177..6155a03d988b 100644 --- a/app/models/custom_style.rb +++ b/app/models/custom_style.rb @@ -35,7 +35,11 @@ def digest image = send(name) image&.remove! - update_columns(name => nil, updated_at: Time.zone.now) + if new_record? + send(:"#{name}=", nil) + else + update_columns(name => nil, updated_at: Time.zone.now) + end end end end diff --git a/app/models/enumeration.rb b/app/models/enumeration.rb index da80df871462..c12c715822a8 100644 --- a/app/models/enumeration.rb +++ b/app/models/enumeration.rb @@ -75,6 +75,11 @@ def self.default end end + # boolean to define if the enumeration can set a default + def self.can_have_default_value? + true + end + # Destroys enumerations in a single transaction # It ensures, that the transactions can be safely transferred to each # entry's parent diff --git a/app/models/principal.rb b/app/models/principal.rb index 20a0b51e10a8..aa06360d3964 100644 --- a/app/models/principal.rb +++ b/app/models/principal.rb @@ -31,12 +31,12 @@ class Principal < ApplicationRecord # Account statuses # Disables enum scopes to include not_builtin (cf. Principals::Scopes::Status) - enum status: { + enum :status, { active: 1, registered: 2, locked: 3, invited: 4 - }.freeze, _scopes: false + }.freeze, scopes: false self.table_name = "#{table_name_prefix}users#{table_name_suffix}" @@ -176,6 +176,11 @@ def activatable? false end + # Returns true if usr or current user is allowed to view the user + def visible?(usr = User.current) + User.visible(usr).exists?(id: id) + end + def <=>(other) if instance_of?(other.class) to_s.downcase <=> other.to_s.downcase diff --git a/app/models/project.rb b/app/models/project.rb index 5bdb1f1f83f5..0a591573f541 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -100,9 +100,29 @@ class Project < ApplicationRecord acts_as_favorable - acts_as_customizable # extended in Projects::CustomFields in order to support sections + acts_as_customizable validate_on: :saving_custom_fields + # extended in Projects::CustomFields in order to support sections # and project-level activation of custom fields + # Override the `validation_context` getter to include the `default_validation_context` when the + # context is `:saving_custom_fields`. This is required, because the `acts_as_url` plugin from + # `stringex` defines a callback on the `:create` context for initialising the `identifier` field. + # Providing a custom context while creating the project, will not execute the callbacks on the + # `:create` or `:update` contexts, meaning the identifier will not get initialised. + # In order to initialise the identifier, the `default_validation_context` (`:create`, or `:update`) + # should be included when validating via the `:saving_custom_fields`. This way every create + # or update callback will also be executed alongside the `:saving_custom_fields` callbacks. + # This problem does not affect the contextless callbacks, they are always executed. + + def validation_context + case Array(@validation_context) + in [*, :saving_custom_fields, *] => context + context << default_validation_context + else + @validation_context + end + end + acts_as_searchable columns: %W(#{table_name}.name #{table_name}.identifier #{table_name}.description), date_column: "#{table_name}.created_at", project_key: "id", diff --git a/app/models/project/life_cycle_step_definition.rb b/app/models/project/life_cycle_step_definition.rb index f80cc6116931..031e72d4ee55 100644 --- a/app/models/project/life_cycle_step_definition.rb +++ b/app/models/project/life_cycle_step_definition.rb @@ -27,6 +27,8 @@ #++ class Project::LifeCycleStepDefinition < ApplicationRecord + include ::Scopes::Scoped + has_many :life_cycle_steps, class_name: "Project::LifeCycleStep", foreign_key: :definition_id, @@ -43,6 +45,10 @@ class Project::LifeCycleStepDefinition < ApplicationRecord acts_as_list + default_scope { order(:position) } + + scopes :with_project_count + def step_class raise NotImplementedError end diff --git a/app/models/project/life_cycle_step_definitions/scopes/with_project_count.rb b/app/models/project/life_cycle_step_definitions/scopes/with_project_count.rb new file mode 100644 index 000000000000..e4448a90187b --- /dev/null +++ b/app/models/project/life_cycle_step_definitions/scopes/with_project_count.rb @@ -0,0 +1,50 @@ +# 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 Project::LifeCycleStepDefinitions::Scopes + module WithProjectCount + extend ActiveSupport::Concern + + class_methods do + def with_project_count + project_counts = Project::LifeCycleStep + .where(active: true) + .group(:definition_id) + .select(:definition_id, "COUNT(project_id) AS count") + + Project::LifeCycleStepDefinition + .with(project_counts:) + .joins("LEFT OUTER JOIN project_counts ON #{quoted_table_name}.id = project_counts.definition_id") + .select("#{quoted_table_name}.*") + .select("COALESCE(project_counts.count, 0) AS project_count") + end + end + end +end diff --git a/app/models/queries/filters/strategies/huge_list.rb b/app/models/queries/filters/strategies/huge_list.rb index 83ab9ac173b0..12ae9014a3ac 100644 --- a/app/models/queries/filters/strategies/huge_list.rb +++ b/app/models/queries/filters/strategies/huge_list.rb @@ -33,10 +33,18 @@ class HugeList < List def validate unique_values = values.uniq - allowed_and_desired_values = allowed_values_subset & unique_values - if allowed_and_desired_values.sort != unique_values.sort - errors.add(:values, :inclusion) + case allowed_values_subset + when ActiveRecord::Relation + unless allowed_values_subset.exists?(id: values) + errors.add(:values, :inclusion) + end + else + allowed_and_desired_values = allowed_values_subset & unique_values + + if allowed_and_desired_values.sort != unique_values.sort + errors.add(:values, :inclusion) + end end end diff --git a/app/models/queries/principals/filters/mentionable_on_work_package_filter.rb b/app/models/queries/principals/filters/mentionable_on_work_package_filter.rb index 2b1bab0a0462..9e43b20367c4 100644 --- a/app/models/queries/principals/filters/mentionable_on_work_package_filter.rb +++ b/app/models/queries/principals/filters/mentionable_on_work_package_filter.rb @@ -31,8 +31,11 @@ class Queries::Principals::Filters::MentionableOnWorkPackageFilter < Queries::Principals::Filters::PrincipalFilter def allowed_values - # We don't care for the first value as we do not display the values visibly - @allowed_values ||= ::WorkPackage.visible.pluck(:id).map { |id| [id, id.to_s] } + raise NotImplementedError, "There would be too many candidates" + end + + def allowed_values_subset + @allowed_values_subset ||= ::WorkPackage.visible end def type @@ -54,6 +57,10 @@ def apply_to(query_scope) private + def type_strategy + @type_strategy ||= Queries::Filters::Strategies::HugeList.new(self) + end + def principals_with_a_membership visible_scope.where(id: work_package_members.select(:user_id)) .or(visible_scope.where(id: project_members.select(:user_id))) diff --git a/app/models/queries/projects.rb b/app/models/queries/projects.rb index 64b721b7596a..147e04f4b33c 100644 --- a/app/models/queries/projects.rb +++ b/app/models/queries/projects.rb @@ -54,6 +54,7 @@ module Queries::Projects order Orders::LatestActivityAtOrder order Orders::RequiredDiskSpaceOrder order Orders::CustomFieldOrder + order Orders::LifeCycleStepOrder order Orders::ProjectStatusOrder order Orders::NameOrder order Orders::TypeaheadOrder diff --git a/app/models/queries/projects/orders/life_cycle_step_order.rb b/app/models/queries/projects/orders/life_cycle_step_order.rb new file mode 100644 index 000000000000..7234a0fac860 --- /dev/null +++ b/app/models/queries/projects/orders/life_cycle_step_order.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Queries::Projects::Orders::LifeCycleStepOrder < Queries::Orders::Base + self.model = Project + + validates :life_cycle_step_definition, presence: { message: I18n.t(:"activerecord.errors.messages.does_not_exist") } + + def self.key + /\Alcsd_(\d+)\z/ + end + + def life_cycle_step_definition + return @life_cycle_step_definition if defined?(@life_cycle_step_definition) + + @life_cycle_step_definition = Project::LifeCycleStepDefinition.find_by(id: attribute[/\Alcsd_(\d+)\z/, 1]) + end + + def available? + life_cycle_step_definition.present? && + OpenProject::FeatureDecisions.stages_and_gates_active? && + User.current.allowed_in_any_project?(:view_project_stages_and_gates) + end + + private + + def joins + join = <<~SQL.squish + LEFT JOIN ( + SELECT steps.*, steps.definition_id as def_id + FROM project_life_cycle_steps steps + WHERE + steps.active = true + AND steps.definition_id = :definition_id + AND steps.project_id IN (#{viewable_project_ids.to_sql}) + ) #{subquery_table_name} ON #{subquery_table_name}.project_id = projects.id + SQL + + ActiveRecord::Base.sanitize_sql([join, { definition_id: life_cycle_step_definition.id }]) + end + + # Since we can combine multiple queries with their respective ORDER BY clauses, we need to make sure + # that the names of our tables are unique. It suffices to include the definition id into the name as there can only + # ever be one order statement per definition. + def subquery_table_name + definition_id = life_cycle_step_definition.id + + :"life_cycle_steps_subquery_#{definition_id}" + end + + def order(scope) + with_raise_on_invalid do + scope.where(order_condition) + .order(*order_by_start_and_end_date) + end + end + + # Ensure that only life cycle columns viewable to the current user are considered + # for ordering the query result. + def viewable_project_ids + Project.allowed_to(User.current, :view_project_stages_and_gates).select(:id) + end + + def order_condition + # To avoid SQL injection warnings, we use Arel to build the condition. + # Note that this SQL query uses the subquery defined in `joins`. + steps_table = Arel::Table.new(subquery_table_name.to_s) + + # WHERE subquery_table_name.def_id = life_cycle_step_definition.id OR subquery_table_name.def_id IS NULL + steps_table[:def_id] + .eq(life_cycle_step_definition.id) + .or(steps_table[:def_id].eq(nil)) + end + + def order_by_start_and_end_date + steps_table = Arel::Table.new(subquery_table_name.to_s) + + # Even though a gate does not define an end_date, this code still works. + [ + steps_table[:start_date].send(direction), + steps_table[:end_date].send(direction) + ] + end +end diff --git a/app/models/queries/work_packages/filter/role_filter.rb b/app/models/queries/work_packages/filter/role_filter.rb index 16ddd8583c86..d320b76943a1 100644 --- a/app/models/queries/work_packages/filter/role_filter.rb +++ b/app/models/queries/work_packages/filter/role_filter.rb @@ -67,7 +67,9 @@ def where private def roles - ::Role.givable + Role + .includes(:role_permissions) + .where(role_permissions: { permission: "work_package_assigned" }) end def operator_for_filtering diff --git a/app/models/relation.rb b/app/models/relation.rb index 947312b1c213..1cad5d0bc23f 100644 --- a/app/models/relation.rb +++ b/app/models/relation.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -30,41 +32,34 @@ class Relation < ApplicationRecord belongs_to :from, class_name: "WorkPackage" belongs_to :to, class_name: "WorkPackage" - TYPE_RELATES = "relates".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 - TYPE_REQUIRED = "required".freeze + TYPE_RELATES = "relates" + TYPE_PRECEDES = "precedes" + TYPE_FOLLOWS = "follows" + TYPE_BLOCKS = "blocks" + TYPE_BLOCKED = "blocked" + TYPE_DUPLICATES = "duplicates" + TYPE_DUPLICATED = "duplicated" + TYPE_INCLUDES = "includes" + TYPE_PARTOF = "partof" + TYPE_REQUIRES = "requires" + TYPE_REQUIRED = "required" # The parent/child relation is maintained separately # (in WorkPackage and WorkPackageHierarchy) and a relation cannot # have the type 'parent' but this is abstracted to simplify the code. - TYPE_PARENT = "parent".freeze - TYPE_CHILD = "child".freeze + TYPE_PARENT = "parent" + TYPE_CHILD = "child" TYPES = { TYPE_RELATES => { name: :label_relates_to, sym_name: :label_relates_to, order: 1, sym: TYPE_RELATES }, - TYPE_PRECEDES => { - name: :label_precedes, sym_name: :label_follows, order: 6, - sym: TYPE_FOLLOWS, reverse: TYPE_FOLLOWS - }, 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 - }, - TYPE_BLOCKED => { - 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 @@ -73,6 +68,13 @@ class Relation < ApplicationRecord name: :label_duplicated_by, sym_name: :label_duplicates, order: 7, sym: TYPE_DUPLICATES, reverse: TYPE_DUPLICATES }, + TYPE_BLOCKS => { + name: :label_blocks, sym_name: :label_blocked_by, order: 4, sym: TYPE_BLOCKED + }, + TYPE_BLOCKED => { + name: :label_blocked_by, sym_name: :label_blocks, order: 5, + sym: TYPE_BLOCKS, reverse: TYPE_BLOCKS + }, TYPE_INCLUDES => { name: :label_includes, sym_name: :label_part_of, order: 8, sym: TYPE_PARTOF @@ -103,7 +105,11 @@ class Relation < ApplicationRecord scope :follows_with_lag, -> { follows.where("lag > 0") } - validates :lag, numericality: { allow_nil: true } + validates :lag, numericality: { + allow_nil: true, + less_than_or_equal_to: 2_147_483_647, + greater_than_or_equal_to: 0 + } validates :to, uniqueness: { scope: :from } diff --git a/app/models/relations/scopes/visible.rb b/app/models/relations/scopes/visible.rb index e2522cc1fdbe..fdcb4821698e 100644 --- a/app/models/relations/scopes/visible.rb +++ b/app/models/relations/scopes/visible.rb @@ -34,10 +34,25 @@ module Visible # Returns all relationships visible to the user. The relationships have to be: # * Start (from_id) on a work package visible to the user (view_work_packages in the work package's project) # * End (to_id) on a work package visible to the user (view_work_packages in the work package's project) + # + # In some cases, the resulting SQL query as is might not run in a performant matter. This happens in + # cases where there are a lot of work packages and relations. PostgreSql then sometimes chooses to use a + # full table scan on work_packages expecting a lot of results. + # Then, the query can be optimized by providing a +work_package_focus_scope+ which is used as a subquery + # on work_packages on the id column. This is beneficial if not all work packages are to be + # considered but only a subset. As this directly excludes work packages, it can also be used + # the wrong way. # @param [User] user - def visible(user = User.current) - where(from_id: WorkPackage.visible(user)) - .where(to_id: WorkPackage.visible(user)) + # @param [ActiveRecord::Relation, Arel::SelectManager] work_package_focus_scope + def visible(user = User.current, work_package_focus_scope: nil) + visible_work_packages = WorkPackage.visible(user) + + wp_arel = work_package_focus_scope.respond_to?(:arel) ? work_package_focus_scope.arel : work_package_focus_scope + visible_work_packages = visible_work_packages.where(WorkPackage.arel_table[:id].in(wp_arel)) if wp_arel + + with(visible_work_packages:) + .where(from_id: WorkPackage.from("visible_work_packages #{WorkPackage.table_name}").select(:id)) + .where(to_id: WorkPackage.from("visible_work_packages #{WorkPackage.table_name}").select(:id)) end end end diff --git a/app/models/sessions/user_session.rb b/app/models/sessions/user_session.rb index 1a0d40e751d9..0f81c8d30293 100644 --- a/app/models/sessions/user_session.rb +++ b/app/models/sessions/user_session.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -33,6 +35,8 @@ module Sessions class UserSession < ::ApplicationRecord self.table_name = "sessions" + belongs_to :user + scope :for_user, ->(user) do user_id = user.is_a?(User) ? user.id : user.to_i diff --git a/app/models/setting.rb b/app/models/setting.rb index 654e24527512..4ee805e1410b 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -335,12 +335,14 @@ def self.settings_table_exists_yet? def self.deserialize(name, value) definition = Settings::Definition[name] - if definition.serialized? && value.is_a?(String) + if definition.nil? + nil + elsif definition.serialized? && value.is_a?(String) deserialize_hash(value) elsif value != "".freeze && !value.nil? read_formatted_setting(value, definition.format) - else - definition.format == :string ? value : nil + elsif definition.format == :string + value end end diff --git a/app/models/sharing_strategies/base_strategy.rb b/app/models/sharing_strategies/base_strategy.rb index 25c00ff2e859..bff01d94d896 100644 --- a/app/models/sharing_strategies/base_strategy.rb +++ b/app/models/sharing_strategies/base_strategy.rb @@ -50,10 +50,6 @@ def manageable? raise NotImplementedError, "Override in a subclass and return true if the current user can manage sharing" end - def allow_hover_cards? - raise NotImplementedError, "Override in a subclass and return true if hover cards should appear hovering users" - end - def create_contract_class raise NotImplementedError, "Override in a subclass and return the contract class for creating a share" end diff --git a/app/models/sharing_strategies/project_query_strategy.rb b/app/models/sharing_strategies/project_query_strategy.rb index 7a898438bb62..eb05b1970d78 100644 --- a/app/models/sharing_strategies/project_query_strategy.rb +++ b/app/models/sharing_strategies/project_query_strategy.rb @@ -54,10 +54,6 @@ def viewable? @entity.visible? end - def allow_hover_cards? - true - end - def create_contract_class Shares::ProjectQueries::CreateContract end diff --git a/app/models/sharing_strategies/work_package_strategy.rb b/app/models/sharing_strategies/work_package_strategy.rb index 018c6b0f5c71..556767dc08aa 100644 --- a/app/models/sharing_strategies/work_package_strategy.rb +++ b/app/models/sharing_strategies/work_package_strategy.rb @@ -53,13 +53,6 @@ def viewable? user.allowed_in_project?(:view_shared_work_packages, @entity.project) end - # Since the work package share dialog is embedded into an angular page, hover cards would compete for the - # portal outlet when rendering, causing bugs. Until the work package share dialog is refactored to be an - # async-dialog, we must disable hover cards for it. - def allow_hover_cards? - false - end - def share_description(share) # rubocop:disable Metrics/PerceivedComplexity,Metrics/AbcSize scope = %i[sharing user_details] diff --git a/app/models/type.rb b/app/models/type.rb index 0117f8aaa6b8..1eadea9c31ce 100644 --- a/app/models/type.rb +++ b/app/models/type.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -34,10 +36,12 @@ class Type < ApplicationRecord include ::Scopes::Scoped - attribute :patterns, Types::PatternCollectionType.new + attribute :patterns, Types::Patterns::CollectionType.new before_destroy :check_integrity + belongs_to :color, optional: true, class_name: "Color" + has_many :work_packages has_many :workflows, dependent: :delete_all do def copy_from_type(source_type) @@ -52,12 +56,9 @@ def copy_from_type(source_type) join_table: "#{table_name_prefix}custom_fields_types#{table_name_suffix}", association_foreign_key: "custom_field_id" - belongs_to :color, optional: true, class_name: "Color" - acts_as_list validates :name, presence: true, uniqueness: { case_sensitive: false }, length: { maximum: 255 } - validates :is_default, :is_milestone, inclusion: { in: [true, false] } scopes :milestone @@ -65,8 +66,9 @@ def copy_from_type(source_type) default_scope { order("position ASC") } scope :without_standard, -> { where(is_standard: false).order(:position) } + scope :default, -> { where(is_default: true) } - def to_s; name end + delegate :to_s, to: :name def <=>(other) name <=> other.name @@ -81,26 +83,20 @@ def self.statuses(types) end def self.standard_type - ::Type.where(is_standard: true).first - end - - def self.default - ::Type.where(is_default: true) + where(is_standard: true).first end def self.enabled_in(project) - ::Type.includes(:projects).where(projects: { id: project }) + includes(:projects).where(projects: { id: project }) end def statuses(include_default: false) if new_record? Status.none elsif include_default - ::Type - .statuses([id]) - .or(Status.where_default) + self.class.statuses([id]).or(Status.where_default) else - ::Type.statuses([id]) + self.class.statuses([id]) end end @@ -108,9 +104,20 @@ def enabled_in?(object) object.types.include?(self) end + def replacement_pattern_defined_for?(attribute) + enabled_patterns.key?(attribute) + end + + def enabled_patterns + patterns.all_enabled + end + private def check_integrity - raise "Can't delete type" if WorkPackage.where(type_id: id).any? + throw :abort if is_standard? + throw :abort if WorkPackage.exists?(type_id: id) + + true end end diff --git a/app/models/types/forms/subject_configuration_form_model.rb b/app/models/types/forms/subject_configuration_form_model.rb new file mode 100644 index 000000000000..3a8a53cff944 --- /dev/null +++ b/app/models/types/forms/subject_configuration_form_model.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 Types + module Forms + class SubjectConfigurationFormModel + extend ActiveModel::Naming + + attr_reader :subject_configuration, :pattern, :suggestions, :validation_errors + + def initialize(subject_configuration:, pattern:, suggestions:, validation_errors: {}) + @subject_configuration = subject_configuration + @pattern = pattern + @suggestions = suggestions + @validation_errors = validation_errors + end + end + end +end diff --git a/app/models/types/pattern.rb b/app/models/types/pattern.rb index 62dcbd2b2c18..8efe8eac5c41 100644 --- a/app/models/types/pattern.rb +++ b/app/models/types/pattern.rb @@ -30,9 +30,10 @@ module Types Pattern = Data.define(:blueprint, :enabled) do - def call(object) - # calculate string using object - blueprint.to_s + object.to_s + def enabled? = !!enabled + + def resolve(work_package) + PatternResolver.new(blueprint).resolve(work_package) end def to_h diff --git a/app/models/types/pattern_resolver.rb b/app/models/types/pattern_resolver.rb new file mode 100644 index 000000000000..905b5484b4f4 --- /dev/null +++ b/app/models/types/pattern_resolver.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. +#++ + +module Types + class PatternResolver + TOKEN_REGEX = /{{[0-9A-Za-z_]+}}/ + private_constant :TOKEN_REGEX + + def initialize(pattern) + @mapper = Patterns::TokenPropertyMapper.new + @pattern = pattern + @tokens = pattern.scan(TOKEN_REGEX).map { |token| Patterns::Token.build(token) } + end + + def resolve(work_package) + @tokens.inject(@pattern) do |pattern, token| + pattern.gsub(token.pattern, get_value(work_package, token)) + end + end + + private + + def get_value(work_package, token) + context = token.context == :work_package ? work_package : work_package.public_send(token.context) + + stringify(@mapper[token.context_key].call(context)) + end + + def stringify(value) + case value + when Date, Time, DateTime + value.strftime("%Y-%m-%d") + when NilClass + "NA" + else + value.to_s + end + end + end +end diff --git a/app/models/types/patterns/collection.rb b/app/models/types/patterns/collection.rb new file mode 100644 index 000000000000..cc94bad0907f --- /dev/null +++ b/app/models/types/patterns/collection.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Types + module Patterns + Collection = Data.define(:patterns) do + extend Dry::Monads[:result] + private_class_method :new + + def self.empty + new(patterns: {}) + end + + def self.build(patterns:, contract: CollectionContract.new) + contract.call(patterns).to_monad.fmap { |success| new(success.to_h) } + rescue ArgumentError => e + Failure(e) + end + + def initialize(patterns:) + transformed = patterns.transform_values { Pattern.new(**_1) }.freeze + + super(patterns: transformed) + end + + def subject + patterns[:subject] + end + + def all_enabled + patterns.select { |_, pattern| pattern.enabled? } + end + + def to_h + patterns.stringify_keys.transform_values(&:to_h) + end + end + end +end diff --git a/app/models/types/patterns/collection_contract.rb b/app/models/types/patterns/collection_contract.rb new file mode 100644 index 000000000000..74f06b8a2da4 --- /dev/null +++ b/app/models/types/patterns/collection_contract.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Types + module Patterns + class CollectionContract < Dry::Validation::Contract + config.messages.backend = :i18n + + params do + optional(:subject).hash do + required(:blueprint).filled(:string) + required(:enabled).filled(:bool) + end + end + end + end +end diff --git a/app/models/types/patterns/collection_type.rb b/app/models/types/patterns/collection_type.rb new file mode 100644 index 000000000000..f7c84b23416f --- /dev/null +++ b/app/models/types/patterns/collection_type.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. +#++ + +module Types + module Patterns + class CollectionType < ActiveModel::Type::Value + def assert_valid_value(value) + cast(value) + end + + def cast(value) + return value if value.is_a?(Collection) + + Collection.build(patterns: value).value_or { Collection.empty } + end + + def serialize(pattern) + return super if pattern.nil? + + YAML.dump(pattern.to_h) + end + + def deserialize(value) + return Collection.empty if value.blank? + + data = YAML.safe_load(value) + cast(data) + end + end + end +end diff --git a/modules/meeting/app/seeders/meetings/demo_data/meeting_seeder.rb b/app/models/types/patterns/token.rb similarity index 67% rename from modules/meeting/app/seeders/meetings/demo_data/meeting_seeder.rb rename to app/models/types/patterns/token.rb index ffab1b2ba05b..e17f87e58fd9 100644 --- a/modules/meeting/app/seeders/meetings/demo_data/meeting_seeder.rb +++ b/app/models/types/patterns/token.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -26,30 +28,30 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module Meetings - module DemoData - class MeetingSeeder < ::BasicData::ModelSeeder - self.model_class = StructuredMeeting - self.seed_data_model_key = "meetings" - - attr_reader :project +module Types + module Patterns + Token = Data.define(:pattern, :key) do + private_class_method :new - def initialize(project, seed_data) - super(seed_data) - @project = project + def self.build(pattern) + new(pattern, pattern.tr("{}", "").to_sym) end - def model_attributes(meeting_data) - { - title: meeting_data["title"], - author: seed_data.find_reference(meeting_data["author"]), - duration: minutes_to_hours(meeting_data["duration"]), - project: - } + def custom_field? = key.to_s.include?("custom_field") + + def context_key + return key unless custom_field? + + key.to_s.gsub("#{context}_", "").to_sym end - def minutes_to_hours(duration) - duration && (duration / 60.0) + def context + return :work_package unless custom_field? + + context = key.to_s.gsub(/_?custom_field_\d+/, "") + return :work_package if context.blank? + + context.to_sym end end end diff --git a/app/models/types/patterns/token_property_mapper.rb b/app/models/types/patterns/token_property_mapper.rb new file mode 100644 index 000000000000..171c17076e2b --- /dev/null +++ b/app/models/types/patterns/token_property_mapper.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Types + module Patterns + class TokenPropertyMapper + DEFAULT_FUNCTION = ->(key, context) { context.public_send(key.to_sym) }.curry + + TOKEN_PROPERTY_MAP = IceNine.deep_freeze( + { + accountable: { fn: ->(wp) { wp.responsible&.name }, label: -> { WorkPackage.human_attribute_name(:responsible) } }, + assignee: { fn: ->(wp) { wp.assigned_to&.name }, label: -> { WorkPackage.human_attribute_name(:assigned_to) } }, + author: { fn: ->(wp) { wp.author&.name }, label: -> { WorkPackage.human_attribute_name(:author) } }, + category: { fn: ->(wp) { wp.category&.name }, label: -> { WorkPackage.human_attribute_name(:category) } }, + creation_date: { fn: ->(wp) { wp.created_at }, label: -> { WorkPackage.human_attribute_name(:created_at) } }, + estimated_time: { fn: ->(wp) { wp.estimated_hours }, label: -> { WorkPackage.human_attribute_name(:estimated_hours) } }, + finish_date: { fn: ->(wp) { wp.due_date }, label: -> { WorkPackage.human_attribute_name(:due_date) } }, + parent: { fn: ->(wp) { wp.parent&.id }, label: -> { WorkPackage.human_attribute_name(:parent) } }, + parent_author: { fn: ->(wp) { wp.parent&.author&.name }, label: -> { WorkPackage.human_attribute_name(:author) } }, + parent_category: { fn: ->(wp) { wp.parent&.category&.name }, + label: -> { WorkPackage.human_attribute_name(:category) } }, + parent_creation_date: { fn: ->(wp) { wp.parent&.created_at }, + label: -> { WorkPackage.human_attribute_name(:created_at) } }, + parent_estimated_time: { fn: ->(wp) { wp.parent&.estimated_hours }, + label: -> { WorkPackage.human_attribute_name(:estimated_hours) } }, + parent_finish_date: { fn: ->(wp) { wp.parent&.due_date }, + label: -> { WorkPackage.human_attribute_name(:due_date) } }, + parent_priority: { fn: ->(wp) { wp.parent&.priority }, label: -> { WorkPackage.human_attribute_name(:priority) } }, + priority: { fn: ->(wp) { wp.priority }, label: -> { WorkPackage.human_attribute_name(:priority) } }, + project: { fn: ->(wp) { wp.project_id }, label: -> { WorkPackage.human_attribute_name(:project) } }, + project_active: { fn: ->(wp) { wp.project&.active? }, label: -> { Project.human_attribute_name(:active) } }, + project_name: { fn: ->(wp) { wp.project&.name }, label: -> { Project.human_attribute_name(:name) } }, + project_status: { fn: ->(wp) { wp.project&.status_code }, label: -> { Project.human_attribute_name(:status_code) } }, + project_parent: { fn: ->(wp) { wp.project&.parent_id }, label: -> { Project.human_attribute_name(:parent) } }, + project_public: { fn: ->(wp) { wp.project&.public? }, label: -> { Project.human_attribute_name(:public) } }, + start_date: { fn: ->(wp) { wp.start_date }, label: -> { WorkPackage.human_attribute_name(:start_date) } }, + status: { fn: ->(wp) { wp.status&.name }, label: -> { WorkPackage.human_attribute_name(:status) } }, + type: { fn: ->(wp) { wp.type&.name }, label: -> { WorkPackage.human_attribute_name(:type) } } + } + ) + + def fetch(key) + TOKEN_PROPERTY_MAP.dig(key, :fn) || DEFAULT_FUNCTION.call(key) + end + + alias :[] :fetch + + def tokens_for_type(type) + base = default_tokens + base[:work_package].merge!(tokenize(work_package_cfs_for(type))) + base[:project].merge!(tokenize(project_attributes, "project_")) + base[:parent].merge!(tokenize(all_work_package_cfs, "parent_")) + + base.transform_values { _1.sort_by(&:last).to_h } + end + + private + + def default_tokens + TOKEN_PROPERTY_MAP.keys.each_with_object({ project: {}, work_package: {}, parent: {} }) do |key, obj| + label = TOKEN_PROPERTY_MAP.dig(key, :label).call + + case key.to_s + when /^project_/ + obj[:project][key] = label + when /^parent_/ + obj[:parent][key] = label + else + obj[:work_package][key] = label + end + end + end + + def tokenize(custom_field_scope, prefix = nil) + custom_field_scope.pluck(:name, :id).to_h { |name, id| [:"#{prefix}custom_field_#{id}", name] } + end + + def work_package_cfs_for(type) + all_work_package_cfs.where(type: type) + end + + def all_work_package_cfs + WorkPackageCustomField.where(multi_value: false).where.not(field_format: %w[text bool link empty]).order(:name) + end + + def project_attributes + ProjectCustomField.where.not(field_format: %w[text bool link empty]) + .where(admin_only: false, multi_value: false).order(:name) + end + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 4b3d33175723..e5f2d4622194 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -304,16 +304,16 @@ def name(formatter = nil) end end - # Return user's authentication provider for display def authentication_provider - return if identity_url.blank? + return nil if identity_url.blank? - identity_url.split(":", 2).first + slug = identity_url.split(":", 2).first + AuthProvider.find_by(slug:) end # Return user's authentication provider for display def human_authentication_provider - authentication_provider&.titleize + authentication_provider&.display_name end ## diff --git a/app/models/users/scopes/having_reminder_mail_to_send.rb b/app/models/users/scopes/having_reminder_mail_to_send.rb index f9170672bf01..3ba8f050556e 100644 --- a/app/models/users/scopes/having_reminder_mail_to_send.rb +++ b/app/models/users/scopes/having_reminder_mail_to_send.rb @@ -183,8 +183,13 @@ def quarters_between_earliest_and_latest(earliest_time, latest_time) # rubocop:d # The last quarter is the one smaller than the latest time. But needs to be at least equal to the first quarter. last_quarter = [first_quarter, latest_time.change(min: latest_time.min / 15 * 15)].max - (first_quarter.to_i..last_quarter.to_i) - .step(15.minutes) + quarters = if first_quarter == last_quarter + [first_quarter] + else + (first_quarter.to_i..last_quarter.to_i).step(15.minutes) + end + + quarters .map do |time| Time.zone.at(time) end diff --git a/app/models/work_package.rb b/app/models/work_package.rb index a16611e48b03..9e1a6eb34415 100644 --- a/app/models/work_package.rb +++ b/app/models/work_package.rb @@ -242,11 +242,6 @@ def blocked? .exists? end - def visible_relations(user) - relations - .visible(user) - end - def add_time_entry(attributes = {}) attributes.reverse_merge!( project:, diff --git a/app/models/work_package/pdf_export/common/common.rb b/app/models/work_package/pdf_export/common/common.rb index 63c055a40044..cf21300582f9 100644 --- a/app/models/work_package/pdf_export/common/common.rb +++ b/app/models/work_package/pdf_export/common/common.rb @@ -300,4 +300,9 @@ def write_horizontal_line(y_position, height, color) height, color ) end + + def start_new_page_if_needed + is_first_on_page = pdf.bounds.absolute_top - pdf.y < 10 + pdf.start_new_page unless is_first_on_page + end end diff --git a/app/models/work_package/pdf_export/export/schema.json b/app/models/work_package/pdf_export/export/schema.json index 316f60469667..f82609c7b715 100644 --- a/app/models/work_package/pdf_export/export/schema.json +++ b/app/models/work_package/pdf_export/export/schema.json @@ -769,6 +769,45 @@ } ] }, + "html_table" : { + "type" : "object", + "title" : "HTML table", + "x-example" : { + "table" : { + "auto_width" : true, + "header" : { + "background_color" : "F0F0F0", + "no_repeating" : true, + "size" : 12 + }, + "cell" : { + "background_color" : "000FFF", + "size" : 10 + } + } + }, + "properties" : { + "auto_width" : { + "title" : "Automatic column widths", + "description" : "Table columns should fit the content, equal spacing of columns if value is `false`", + "type" : "boolean" + }, + "header" : { + "$ref" : "#/$defs/table_header" + }, + "cell" : { + "$ref" : "#/$defs/table_cell" + } + }, + "allOf" : [ + { + "$ref" : "#/$defs/margin" + }, + { + "$ref" : "#/$defs/border" + } + ] + }, "headless_table" : { "type" : "object", "title" : "Markdown headless table", @@ -1554,7 +1593,7 @@ "x-example" : { "ordered_list_point" : { "template" : ".", - "alphabetical" : false, + "list_style_type" : "decimal", "spacing" : "0.75mm", "spanning" : true } @@ -1563,10 +1602,22 @@ "spacing" : { "$ref" : "#/$defs/measurement" }, - "alphabetical" : { - "title" : "Alphabetical bullet points", - "description" : "Convert the list item number into a character, eg. `a.` `b.` `c.`", - "type" : "boolean" + "alphabetical": { + "title": "Alphabetical bullet points", + "description": "(deprecated; use list_style_type) Convert the list item number into a character, eg. `a.` `b.` `c.`", + "type": "boolean" + }, + "list_style_type": { + "title": "List style type", + "description": "The style of the list bullet points, eg. `decimal`, `lower-latin`, `upper-roman`", + "type": "string", + "enum": [ + "decimal", + "lower-latin", + "lower-roman", + "upper-latin", + "upper-roman" + ] }, "spanning" : { "title" : "Spanning", diff --git a/app/models/work_package/pdf_export/export/standard.yml b/app/models/work_package/pdf_export/export/standard.yml index ecfc8a0a904b..90bb690fe34a 100644 --- a/app/models/work_package/pdf_export/export/standard.yml +++ b/app/models/work_package/pdf_export/export/standard.yml @@ -138,6 +138,16 @@ work_package: spacing: 1 ordered_list_point: spacing: 4 + spanning: true + list_style_type: decimal + ordered_list_point_2: + list_style_type: lower-latin + ordered_list_point_3: + list_style_type: lower-roman + ordered_list_point_4: + list_style_type: upper-latin + ordered_list_point_5: + list_style_type: upper-roman task_list: spacing: 1 task_list_point: @@ -192,6 +202,17 @@ work_package: size: 8 border_width: 0.25 padding: 5 + html_table: + auto_width: true + margin_top: 4 + margin_bottom: 4 + header: + size: 9 + styles: [ "bold" ] + cell: + size: 8 + border_width: 0.25 + padding: 5 alerts: NOTE: border_color: '0969da' diff --git a/app/models/work_package/pdf_export/generator/contracts.yml b/app/models/work_package/pdf_export/generator/contracts.yml index 97930db5db6b..1559578f0bd0 100644 --- a/app/models/work_package/pdf_export/generator/contracts.yml +++ b/app/models/work_package/pdf_export/generator/contracts.yml @@ -62,15 +62,25 @@ ordered-list: spacing: 3mm padding-bottom: 3mm -ordered-list-point: +ordered_list_point: spanning: true + list_style_type: decimal ordered-list-point-1: template: '()' -ordered-list-point-2: +ordered_list_point_2: template: ')' - alphabetical: true + list_style_type: lower-latin + +ordered_list_point_3: + list_style_type: lower-roman + +ordered_list_point_4: + list_style_type: upper-latin + +ordered_list_point_5: + list_style_type: upper-roman hrule: margin-top: 2mm @@ -111,15 +121,17 @@ table: background-color: 'F0F0F0' size: 10 cell: - no-border: true padding: 1mm size: 9 -headless-table: +html_table: margin-top: 2mm margin-bottom: 4mm + header: + styles: [ 'bold' ] + background-color: 'F0F0F0' + size: 10 cell: - no-border: true padding: 1mm size: 9 @@ -211,10 +223,11 @@ codeblock: margin-bottom: 2mm image: - max-width: 50mm - margin: 2mm margin-bottom: 3mm align: "center" + caption: + size: 8 + align: "center" image-classes: small: diff --git a/app/models/work_package/pdf_export/generator/generator.rb b/app/models/work_package/pdf_export/generator/generator.rb index 2e152e561526..f9be2ea97186 100644 --- a/app/models/work_package/pdf_export/generator/generator.rb +++ b/app/models/work_package/pdf_export/generator/generator.rb @@ -29,6 +29,7 @@ #++ require "md_to_pdf/core" +require "md_to_pdf/hyphen" module WorkPackage::PDFExport::Generator::Generator class MD2PDFGenerator @@ -65,7 +66,7 @@ def generate!(markdown, options, image_loader) .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? + @hyphens = Hyphen.new(options[:language], true) if options[:language].present? render_doc(doc) end @@ -89,7 +90,7 @@ def image_url_to_local_file(url, _node = nil) def hyphenate(text) return text if @hyphens.nil? - @hyphens.visualize(text, Prawn::Text::SHY) + @hyphens.hyphenate(text) end def handle_mention_html_tag(tag, node, opts) diff --git a/app/models/work_packages/relations.rb b/app/models/work_packages/relations.rb index 12dc4d3da507..d28b23f159f8 100644 --- a/app/models/work_packages/relations.rb +++ b/app/models/work_packages/relations.rb @@ -29,14 +29,23 @@ module WorkPackages::Relations included do # All relations of the work package in both directions. - # Mostly used to have the relations destroyed upon destruction of the work package. # rubocop:disable Rails/InverseOf has_many :relations, ->(work_package) { unscope(:where) .of_work_package(work_package) }, - dependent: :destroy + dependent: :destroy do + def visible(user = User.current) + # The work_package_focus_scope in here is used to improve performance by reducing the number of + # work packages that have to be checked for whether they are visible. + # Since the method looks for relations on the current work package, only work packages + # that are on either end of a relation with the current work package need to be considered. + # The number should be quite small compared to the total number of work packages. + merge(Relation.visible(user, + work_package_focus_scope: Arel::Nodes::UnionAll.new(select(:from_id).arel, select(:to_id).arel))) + end + end # rubocop:enable Rails/InverseOf # Relations where the current work package follows another one. diff --git a/app/seeders/basic_data/model_seeder.rb b/app/seeders/basic_data/model_seeder.rb index 979518804194..37e7a4e57f81 100644 --- a/app/seeders/basic_data/model_seeder.rb +++ b/app/seeders/basic_data/model_seeder.rb @@ -39,13 +39,21 @@ class ModelSeeder < Seeder def seed_data! model_class.transaction do + prepare! models_data.each do |model_data| - model = model_class.create!(model_attributes(model_data)) - seed_data.store_reference(model_data["reference"], model) + create_model!(model_data) end end end + def prepare!; end + + def create_model!(model_data) + model_class + .create!(model_attributes(model_data)) + .tap { |model| seed_data.store_reference(model_data["reference"], model) } + end + def mapped_models_data models_data.each_with_object({}) do |model_data, models| models[model_data["reference"]] = model_attributes(model_data) diff --git a/app/seeders/demo_data/project_seeder.rb b/app/seeders/demo_data/project_seeder.rb index cb7f1fb66190..5b4090e6e43d 100644 --- a/app/seeders/demo_data/project_seeder.rb +++ b/app/seeders/demo_data/project_seeder.rb @@ -55,7 +55,7 @@ def project_content_seeder_classes DemoData::WikiSeeder, DemoData::WorkPackageSeeder, DemoData::WorkPackageBoardSeeder, - ::Meetings::DemoData::MeetingSeeder, + ::Meetings::DemoData::MeetingSeriesSeeder, ::Meetings::DemoData::MeetingAgendaItemsSeeder ] end diff --git a/app/seeders/env_data/custom_design_seeder.rb b/app/seeders/env_data/custom_design_seeder.rb new file mode 100644 index 000000000000..82bd3fa07201 --- /dev/null +++ b/app/seeders/env_data/custom_design_seeder.rb @@ -0,0 +1,146 @@ +# 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 EnvData + class CustomDesignSeeder < Seeder + def seed_data! + custom_style = CustomStyle.current || CustomStyle.create! + + print_status " ↳ Setting custom design colors" do + seed_colors + seed_export_color(custom_style) + end + + print_status " ↳ Setting custom logos" do + seed_logos(custom_style) + end + + custom_style.save! + end + + def applicable? + Setting.seed_design.present? + end + + private + + def seed_logos(custom_style) + CustomStyle.uploaders.each_key do |key| + data = Setting.seed_design[key.to_s] + + if data.blank? + custom_style.public_send(:"remove_#{key}") + elsif data.match?(/^https?:\/\//) + seed_remote_url(custom_style, key, data) + else + io = Base64StringIO.new(data, key.to_s) + custom_style.public_send(:"#{key}=", io) + end + end + end + + def seed_colors + OpenProject::CustomStyles::Design.customizable_variables.each do |variable| + key = variable.to_s.underscore + value = Setting.seed_design[key] + + if value.blank? + DesignColor.where(variable:).delete_all + else + design_color = DesignColor.find_or_initialize_by(variable:) + design_color.hexcode = value + design_color.save! + end + end + end + + def seed_export_color(custom_style) + value = Setting.seed_design["export_cover_text_color"] + custom_style.export_cover_text_color = value.presence + end + + def seed_remote_url(custom_style, key, url) + response = HTTPX.get(url) + raise "Failed to set #{key} from #{url}: #{response}" unless response.status == 200 + + build_attachable_file(key.to_s, response.body.to_s) do |file| + custom_style.public_send(:"#{key}=", file) + custom_style.save! + end + end + + def build_attachable_file(file_name, data) + Tempfile.open(file_name) do |tempfile| + tempfile.binmode + tempfile.write(data) + tempfile.rewind + + content_type = OpenProject::ContentTypeDetector.new(tempfile.path).detect + mime_type = MIME::Types[content_type].last + raise ArgumentError, "Unknown mime type: #{content_type}" if mime_type.nil? + + file = OpenProject::Files.build_uploaded_file(tempfile, + content_type, + file_name: "#{file_name}.#{mime_type.preferred_extension}") + + yield(file) + end + end + + class Base64StringIO < StringIO + attr_reader :filename + + def initialize(data_url, base_name) + metadata, encoded = data_url.split(",") + + if metadata.blank? || encoded.blank? || !metadata.start_with?("data:") + raise ArgumentError, "Expected data URL, got #{data_url}" + end + + @filename = "#{base_name}.#{extension(metadata)}" + bytes = ::Base64.strict_decode64(encoded) + + super(bytes) + end + + def original_filename + filename + end + + def extension(metadata) + content_type = metadata.match(%r{data:([^;]+)})&.captures&.first + raise ArgumentError, "Failed to parse content type from metadata: #{metadata}" if content_type.nil? + + mime_type = MIME::Types[content_type].last + raise ArgumentError, "Unknown mime type: #{content_type}" if mime_type.nil? + + mime_type.preferred_extension + end + end + end +end diff --git a/app/seeders/env_data/token_seeder.rb b/app/seeders/env_data/token_seeder.rb new file mode 100644 index 000000000000..36ed8740ca39 --- /dev/null +++ b/app/seeders/env_data/token_seeder.rb @@ -0,0 +1,45 @@ +# 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 EnvData + class TokenSeeder < Seeder + def seed_data! + Rails.logger.debug "*** Seeding Enterprise support token from ENV" + + token = Setting.seed_enterprise_token + if token.present? + EnterpriseToken.create! encoded_token: token.gsub("\\n", "\n") + end + end + + def applicable? + !EnterpriseToken.exists? + end + end +end diff --git a/app/seeders/env_data_seeder.rb b/app/seeders/env_data_seeder.rb index bbda615cb625..a8ca8e16b5cb 100644 --- a/app/seeders/env_data_seeder.rb +++ b/app/seeders/env_data_seeder.rb @@ -27,7 +27,9 @@ class EnvDataSeeder < CompositeSeeder def data_seeder_classes [ - EnvData::LdapSeeder + EnvData::CustomDesignSeeder, + EnvData::LdapSeeder, + EnvData::TokenSeeder ] end diff --git a/app/seeders/source/seed_data.rb b/app/seeders/source/seed_data.rb index 13f9726bfe39..55d0e9829aec 100644 --- a/app/seeders/source/seed_data.rb +++ b/app/seeders/source/seed_data.rb @@ -60,10 +60,10 @@ def find_reference(reference, *fallbacks, default: :__unset__) default else references = [reference, *fallbacks].map(&:inspect) - 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 + message = "Nothing registered with #{'reference'.pluralize(references.count)} #{references.to_sentence(locale: false)}" + if Rails.env.local? + message += "\nPerhaps you forgot to add the `attribute_names_for_lookups` for your seeder?" + end raise ArgumentError, message end end diff --git a/app/services/add_work_package_note_service.rb b/app/services/add_work_package_note_service.rb index d0bf6ff79b61..69bb8054f419 100644 --- a/app/services/add_work_package_note_service.rb +++ b/app/services/add_work_package_note_service.rb @@ -32,6 +32,8 @@ class AddWorkPackageNoteService include Contracted + include Shared::ServiceContext + attr_accessor :user, :work_package def initialize(user:, work_package:) @@ -41,7 +43,7 @@ def initialize(user:, work_package:) end def call(notes, send_notifications: nil) - Journal::NotificationConfiguration.with send_notifications do + in_context(work_package, send_notifications:) do work_package.add_journal(user:, notes:) success, errors = validate_and_yield(work_package, user) do diff --git a/app/services/authentication/omniauth_service.rb b/app/services/authentication/omniauth_service.rb index 47f18d600691..bade473d3233 100644 --- a/app/services/authentication/omniauth_service.rb +++ b/app/services/authentication/omniauth_service.rb @@ -159,13 +159,11 @@ def find_existing_user end ## - # Allow to map existing users with an Omniauth source if the login - # already exists, and no existing auth source or omniauth provider is - # linked + # Allow to map existing users with an Omniauth source if the login already exists def remap_existing_user return unless Setting.oauth_allow_remapping_of_existing_users? - User.not_builtin.find_by_login(user_attributes[:login]) # rubocop:disable Rails/DynamicFindBy + User.not_builtin.find_by_login(user_attributes[:login]) end ## @@ -285,7 +283,7 @@ def identity_url_from_omniauth # Try to provide some context of the auth_hash in case of errors def auth_uid hash = auth_hash || {} - hash.dig(:info, :uid) || hash.dig(:uid) || "unknown" + hash.dig(:info, :uid) || hash[:uid] || "unknown" end end end diff --git a/app/services/authorization/enterprise_service.rb b/app/services/authorization/enterprise_service.rb index 4e419b03d20e..e070d85775ce 100644 --- a/app/services/authorization/enterprise_service.rb +++ b/app/services/authorization/enterprise_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -35,6 +37,7 @@ class Authorization::EnterpriseService conditional_highlighting custom_actions custom_field_hierarchies + customize_life_cycle date_alerts define_custom_style edit_attribute_groups @@ -51,6 +54,7 @@ class Authorization::EnterpriseService virus_scanning work_package_query_relation_columns work_package_sharing + work_package_subject_generation ].freeze def initialize(token) diff --git a/app/services/base_type_service.rb b/app/services/base_type_service.rb index 36750ca1a461..8ba1b086bdae 100644 --- a/app/services/base_type_service.rb +++ b/app/services/base_type_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -71,13 +73,14 @@ def update(params, options) def set_params_and_validate(params) # Only set attribute groups when it exists # (Regression #28400) - set_attribute_groups(params) unless params[:attribute_groups].nil? + set_attribute_groups(params) if params[:attribute_groups] # This should go before `set_scalar_params` call to get the # project_ids, custom_field_ids diffs from the type and the params. # For determining the active custom fields for the type, it is necessary # to know whether the type is a milestone or not. set_milestone_param(params) unless params[:is_milestone].nil? + set_active_custom_fields set_active_custom_fields_for_project_ids(params[:project_ids]) if params[:project_ids].present? @@ -92,7 +95,7 @@ def set_milestone_param(params) end def set_scalar_params(params) - type.attributes = params.except(:attribute_groups) + type.attributes = params.except(:attribute_groups, :subject_configuration, :subject_pattern) end def set_attribute_groups(params) diff --git a/app/services/bulk_services/project_mappings/base_create_service.rb b/app/services/bulk_services/project_mappings/base_create_service.rb new file mode 100644 index 000000000000..03d7c209301c --- /dev/null +++ b/app/services/bulk_services/project_mappings/base_create_service.rb @@ -0,0 +1,131 @@ +# 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 BulkServices + module ProjectMappings + class BaseCreateService < ::BaseServices::BaseCallable + attr_reader :mapping_context + + delegate :incoming_projects, :mapping_model_class, to: :mapping_context + + def initialize(user:, mapping_context: nil) + super() + @user = user + @mapping_context = mapping_context + end + + def perform(params = {}) + service_call = validate_permissions + service_call = validate_contract(service_call, params) if service_call.success? + service_call = perform_bulk_create(service_call) if service_call.success? + service_call = after_perform(service_call, params) if service_call.success? + + service_call + end + + private + + def validate_permissions + return ServiceResult.failure(errors: I18n.t(:label_not_found)) if incoming_projects.empty? + + if @user.allowed_in_project?(permission, incoming_projects) + ServiceResult.success + else + ServiceResult.failure(errors: I18n.t("activerecord.errors.messages.error_unauthorized")) + end + end + + def validate_contract(service_call, params) + extra_attributes = attributes_from_params(params) + mapping_attributes_for_all_projects = mapping_context.mapping_attributes_for_all_projects(extra_attributes) + set_attributes_results = mapping_attributes_for_all_projects.map do |mapping_attributes| + set_attributes(mapping_attributes) + end + + if (failures = set_attributes_results.select(&:failure?)).any? + service_call.success = false + service_call.errors = failures.map(&:errors) + end + + service_call.result = set_attributes_results.map(&:result) + + service_call + end + + # override in subclasses to pass additional parameters to the `SetAttributesService`. + def attributes_from_params(_params) + {} + end + + def perform_bulk_create(service_call) + mapping_model_class.insert_all( + service_call.result.map { |model| model.attributes.compact }, + unique_by: [:project_id, model_foreign_key_id.to_sym] + ) + + service_call + end + + def after_perform(service_call, _params) + service_call # Subclasses can override this method to add additional logic + end + + def set_attributes(params) + attributes_service_class + .new(user: @user, + model: mapping_model_class.new, + contract_class: default_contract_class) + .call(params) + end + + # @return [Symbol] the permission required to create the mapping + def permission + raise NotImplementedError + end + + # @return [Symbol] the column name of the mapping + def model_foreign_key_id + raise NotImplementedError + end + + def attributes_service_class + "#{namespace}::SetAttributesService".constantize + end + + def default_contract_class + "#{namespace}::UpdateContract".constantize + end + + def namespace + self.class.name.deconstantize.pluralize + end + end + end +end diff --git a/app/services/bulk_services/project_mappings/mapping_context.rb b/app/services/bulk_services/project_mappings/mapping_context.rb new file mode 100644 index 000000000000..318e81d18b9b --- /dev/null +++ b/app/services/bulk_services/project_mappings/mapping_context.rb @@ -0,0 +1,85 @@ +# 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. +#++ + +# Gives the projects to map and the attributes to be used for the mapping. +module BulkServices + module ProjectMappings + class MappingContext < MappingContextBase + attr_reader :model, + :projects, + :model_foreign_key_id, + :include_sub_projects + + def initialize(mapping_model_class:, + model:, + projects:, + model_foreign_key_id:, + include_sub_projects: false) + super(mapping_model_class:) + @model = model + @projects = projects + @model_foreign_key_id = model_foreign_key_id + @include_sub_projects = include_sub_projects + end + + def mapping_attributes_for_all_projects(extra_attributes) + project_ids_to_map.map do |project_id| + { + project_id:, + model_foreign_key_id => model.id + }.merge(extra_attributes) + end + end + + def incoming_projects + projects.each_with_object(Set.new) do |project, projects_set| + next unless project.active? + + projects_set << project + projects_set.merge(project.active_subprojects.to_a) if include_sub_projects + end.to_a + end + + private + + def project_ids_to_map + project_ids = incoming_projects.pluck(:id) + project_ids - project_ids_already_mapped(project_ids) + end + + def project_ids_already_mapped(project_ids) + mapping_model_class.where( + model_foreign_key_id => @model.id, + project_id: project_ids + ).pluck(:project_id) + end + end + end +end diff --git a/app/services/bulk_services/project_mappings/mapping_context_base.rb b/app/services/bulk_services/project_mappings/mapping_context_base.rb new file mode 100644 index 000000000000..9a27261df030 --- /dev/null +++ b/app/services/bulk_services/project_mappings/mapping_context_base.rb @@ -0,0 +1,50 @@ +# 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. +#++ + +# Gives the projects to map and the attributes to be used for the mapping. +module BulkServices + module ProjectMappings + class MappingContextBase + attr_reader :mapping_model_class + + def initialize(mapping_model_class:) + @mapping_model_class = mapping_model_class + end + + def mapping_attributes_for_all_projects(params) + raise NotImplementedError, "This method must be implemented in a subclass" + end + + def incoming_projects + raise NotImplementedError, "This method must be implemented in a subclass" + end + end + end +end diff --git a/app/services/custom_fields/custom_field_projects/bulk_create_service.rb b/app/services/custom_fields/custom_field_projects/bulk_create_service.rb index 3b452006ce68..7dd66c4c3e15 100644 --- a/app/services/custom_fields/custom_field_projects/bulk_create_service.rb +++ b/app/services/custom_fields/custom_field_projects/bulk_create_service.rb @@ -30,96 +30,20 @@ module CustomFields module CustomFieldProjects - class BulkCreateService < ::BaseServices::BaseCallable - def initialize(user:, projects:, custom_field:, include_sub_projects: false) - super() - @user = user - @projects = projects - @custom_field = custom_field - @include_sub_projects = include_sub_projects - end - - def perform - service_call = validate_permissions - service_call = validate_contract(service_call, incoming_mapping_ids) if service_call.success? - service_call = perform_bulk_create(service_call) if service_call.success? - - service_call - end - - private - - def validate_permissions(permission: :select_custom_fields) - return ServiceResult.failure(errors: I18n.t(:label_not_found)) if incoming_projects.empty? - - if @user.allowed_in_project?(permission, incoming_projects) - ServiceResult.success - else - ServiceResult.failure(errors: I18n.t("activerecord.errors.messages.error_unauthorized")) - end - end - - def validate_contract(service_call, project_ids) - set_attributes_results = project_ids.map do |id| - set_attributes(project_id: id, custom_field_id: @custom_field.id) - end - - if (failures = set_attributes_results.select(&:failure?)).any? - service_call.success = false - service_call.errors = failures.map(&:errors) - else - service_call.result = set_attributes_results.map(&:result) - end - - service_call - end - - def perform_bulk_create(service_call) - custom_field_project_mapping_class.insert_all( - service_call.result.map { |model| model.attributes.slice("project_id", "custom_field_id") }, - unique_by: %i[project_id custom_field_id] + class BulkCreateService < ::BulkServices::ProjectMappings::BaseCreateService + def initialize(user:, projects:, model:, include_sub_projects: false) + mapping_context = ::BulkServices::ProjectMappings::MappingContext.new( + mapping_model_class: CustomFieldsProject, + model:, + projects:, + model_foreign_key_id:, + include_sub_projects: ) - - service_call - end - - def incoming_mapping_ids - project_ids = incoming_projects.pluck(:id) - project_ids - existing_project_mappings(project_ids) - end - - def incoming_projects - @projects.each_with_object(Set.new) do |project, projects_set| - next unless project.active? - - projects_set << project - projects_set.merge(project.active_subprojects.to_a) if @include_sub_projects - end.to_a - end - - def existing_project_mappings(project_ids) - custom_field_project_mapping_class.where( - custom_field_id: @custom_field.id, - project_id: project_ids - ).pluck(:project_id) - end - - def set_attributes(params) - attributes_service_class - .new(user: @user, - model: instance(params), - contract_class: default_contract_class, - contract_options: {}) - .call(params) - end - - def instance(params) - custom_field_project_mapping_class.new(params) + super(user:, mapping_context:) end - def attributes_service_class = CustomFields::CustomFieldProjects::SetAttributesService - def default_contract_class = CustomFields::CustomFieldProjects::UpdateContract - def custom_field_project_mapping_class = CustomFieldsProject + def permission = :select_custom_fields + def model_foreign_key_id = :custom_field_id end end end diff --git a/app/services/duration_converter.rb b/app/services/duration_converter.rb index c16c3b58b0bf..65b023af8517 100644 --- a/app/services/duration_converter.rb +++ b/app/services/duration_converter.rb @@ -111,7 +111,9 @@ def output(duration_in_hours, format: default_format) def parseable?(duration_string) if number = Integer(duration_string, 10, exception: false) || Float(duration_string, exception: false) - number >= 0 + # ruby 3.4 started being able to parse strings like "1." as 1.0. + # However, that does not work with ChronicDuration. + number >= 0 && !duration_string.ends_with?(".") else begin do_parse(duration_string) diff --git a/app/services/ldap/base_service.rb b/app/services/ldap/base_service.rb index a2afb8b3642e..00ce75cb1212 100644 --- a/app/services/ldap/base_service.rb +++ b/app/services/ldap/base_service.rb @@ -124,7 +124,7 @@ def find_entries_by(login:, ldap_con: new_ldap_connection) filter: ldap.login_filter(login), attributes: ldap.search_attributes ) - .map { |entry| ldap.get_user_attributes_from_ldap_entry(entry).except(:dn) } + &.map { |entry| ldap.get_user_attributes_from_ldap_entry(entry).except(:dn) } || [] end def new_ldap_connection diff --git a/app/services/project_custom_field_project_mappings/bulk_create_service.rb b/app/services/project_custom_field_project_mappings/bulk_create_service.rb index 432112326ac1..19bbd7594ba8 100644 --- a/app/services/project_custom_field_project_mappings/bulk_create_service.rb +++ b/app/services/project_custom_field_project_mappings/bulk_create_service.rb @@ -29,19 +29,21 @@ #++ module ProjectCustomFieldProjectMappings - class BulkCreateService < ::CustomFields::CustomFieldProjects::BulkCreateService - def initialize(user:, projects:, project_custom_field:, include_sub_projects: false) - super(user:, projects:, custom_field: project_custom_field, include_sub_projects:) + class BulkCreateService < ::BulkServices::ProjectMappings::BaseCreateService + def initialize(user:, projects:, model:, include_sub_projects: false) + mapping_context = ::BulkServices::ProjectMappings::MappingContext.new( + mapping_model_class: ProjectCustomFieldProjectMapping, + model:, + projects:, + model_foreign_key_id:, + include_sub_projects: + ) + super(user:, mapping_context:) end private - def validate_permissions(permission: :select_project_custom_fields) - super - end - - def attributes_service_class = ProjectCustomFieldProjectMappings::SetAttributesService - def default_contract_class = ProjectCustomFieldProjectMappings::UpdateContract - def custom_field_project_mapping_class = ProjectCustomFieldProjectMapping + def permission = :select_project_custom_fields + def model_foreign_key_id = :custom_field_id end end diff --git a/app/services/service_result.rb b/app/services/service_result.rb index 950a140f5d49..f22a576ef742 100644 --- a/app/services/service_result.rb +++ b/app/services/service_result.rb @@ -223,6 +223,13 @@ def state @state ||= ::Shared::ServiceState.build end + ## + # Required as we create an errors object bound to this ServiceResult. + # calling `errors#full_messages` will call `human_attribute_name` here. + def self.human_attribute_name(*) + ApplicationRecord.human_attribute_name(*) + end + private def initialize_errors(errors) diff --git a/app/services/update_type_service.rb b/app/services/update_type_service.rb index be475bdf48ec..cfb28450dfe2 100644 --- a/app/services/update_type_service.rb +++ b/app/services/update_type_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -35,4 +37,40 @@ def call(params) super(params, {}) end + + private + + def set_params_and_validate(params) + # Set patterns includes a data validation before assigning the value to the attribute. + # A validation failure should return a service call failure. + if params[:patterns].present? + validate_enterprise_action(params[:patterns]) + set_patterns(params[:patterns]) + return [false, type.errors] if type.errors.any? + end + + super + end + + def validate_enterprise_action(patterns) + change_from_manual_to_generated = !type.patterns.subject&.enabled? && patterns.dig(:subject, :enabled) + action = :work_package_subject_generation + + if change_from_manual_to_generated && !EnterpriseToken.allows_to?(action) + type.errors.add(:patterns, :error_enterprise_only, action: action.to_s.titleize) + end + end + + def set_patterns(patterns) + Types::Patterns::Collection + .build(patterns:) + .either( + ->(collection) { type.patterns = collection }, + ->(result) do + result.errors(full: true).messages.each do |message| + type.errors.add(:patterns, message.text) + end + end + ) + end end diff --git a/app/services/users/register_user_service.rb b/app/services/users/register_user_service.rb index 3ab6efc7baad..b498044d593d 100644 --- a/app/services/users/register_user_service.rb +++ b/app/services/users/register_user_service.rb @@ -137,7 +137,7 @@ def limited_provider?(user) end def provider_name(user) - user.authentication_provider&.downcase + user.authentication_provider&.slug&.downcase end def register_by_email_activation diff --git a/app/services/work_packages/create_service.rb b/app/services/work_packages/create_service.rb index 600c0f5d0eb2..69961b847cda 100644 --- a/app/services/work_packages/create_service.rb +++ b/app/services/work_packages/create_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -30,17 +32,15 @@ class WorkPackages::CreateService < BaseServices::BaseCallable include ::WorkPackages::Shared::UpdateAncestors include ::Shared::ServiceContext - attr_accessor :user, - :contract_class + attr_reader :user, :contract_class def initialize(user:, contract_class: WorkPackages::CreateContract) - self.user = user - self.contract_class = contract_class + super() + @user = user + @contract_class = contract_class end - def perform(work_package: WorkPackage.new, - send_notifications: nil, - **attributes) + def perform(work_package: WorkPackage.new, send_notifications: nil, **attributes) in_user_context(send_notifications:) do create(attributes, work_package) end @@ -55,8 +55,8 @@ def create(attributes, work_package) if result.success work_package.attachments = work_package.attachments_replacements if work_package.attachments_replacements work_package.save - else - false + + set_templated_subject(work_package) end if result.success? @@ -67,25 +67,24 @@ def create(attributes, work_package) end set_user_as_watcher(work_package) - else - result.success = false end result end - def set_attributes(attributes, wp) - attributes_service_class - .new(user:, - model: wp, - contract_class:) - .call(attributes) + def set_templated_subject(work_package) + return true unless work_package.type&.replacement_pattern_defined_for?(:subject) + + work_package.subject = work_package.type.enabled_patterns[:subject].resolve(work_package) + work_package.save + end + + def set_attributes(attributes, work_package) + attributes_service_class.new(user:, model: work_package, contract_class:).call(attributes) end def reschedule_related(work_package) - result = WorkPackages::SetScheduleService - .new(user:, work_package:) - .call + result = WorkPackages::SetScheduleService.new(user:, work_package:).call result.self_and_dependent.each do |r| unless r.result.save @@ -100,9 +99,7 @@ def reschedule_related(work_package) def set_user_as_watcher(work_package) # We don't care if it fails here. If it does # the user simply does not become watcher - Services::CreateWatcher - .new(work_package, user) - .run(send_notifications: false) + Services::CreateWatcher.new(work_package, user).run(send_notifications: false) end def attributes_service_class diff --git a/app/services/work_packages/set_attributes_service.rb b/app/services/work_packages/set_attributes_service.rb index cd1118d965af..b7fe5d9312f8 100644 --- a/app/services/work_packages/set_attributes_service.rb +++ b/app/services/work_packages/set_attributes_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -43,6 +45,13 @@ def set_attributes(attributes) end set_custom_attributes(attributes) + mark_templated_subject + end + + def mark_templated_subject + if work_package.type&.replacement_pattern_defined_for?(:subject) + work_package.subject = I18n.t("work_packages.templated_subject_hint", type: work_package.type.name) + end end def set_static_attributes(attributes) @@ -298,15 +307,14 @@ def derive_progress_values_class def set_version_to_nil if work_package.version && - work_package.project && - work_package.project.shared_versions.exclude?(work_package.version) + work_package.project&.shared_versions&.exclude?(work_package.version) work_package.version = nil end end def set_parent_to_nil if !Setting.cross_project_work_package_relations? && - !work_package.parent_changed? + !work_package.parent_changed? work_package.parent = nil end @@ -368,7 +376,7 @@ def new_start_date def new_start_date_from_parent return unless work_package.parent_id_changed? && - work_package.parent + work_package.parent work_package.parent.soonest_start end diff --git a/app/services/work_packages/update_service.rb b/app/services/work_packages/update_service.rb index 4788e7374239..bed33bd22148 100644 --- a/app/services/work_packages/update_service.rb +++ b/app/services/work_packages/update_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -39,7 +41,14 @@ def initialize(user:, model:, contract_class: nil, contract_options: {}, cause_o private + def set_templated_attributes + model.type.enabled_patterns.each do |key, pattern| + model.public_send(:"#{key}=", pattern.resolve(model)) + end + end + def after_perform(service_call) + set_templated_attributes update_related_work_packages(service_call) cleanup(service_call.result) diff --git a/app/views/admin/settings/api_settings/show.html.erb b/app/views/admin/settings/api_settings/show.html.erb index 2c8a696ca2e3..264c3b31223a 100644 --- a/app/views/admin/settings/api_settings/show.html.erb +++ b/app/views/admin/settings/api_settings/show.html.erb @@ -70,7 +70,7 @@ See COPYRIGHT and LICENSE files for more details.
- Cross-Origin Resource Sharing (CORS) + <%= I18n.t("setting_apiv3_cors_title") %>
<%= setting_check_box :apiv3_cors_enabled %> diff --git a/app/views/admin/settings/new_project_settings/show.html.erb b/app/views/admin/settings/new_project_settings/show.html.erb index b879a6594091..61bdec015e14 100644 --- a/app/views/admin/settings/new_project_settings/show.html.erb +++ b/app/views/admin/settings/new_project_settings/show.html.erb @@ -42,13 +42,15 @@ See COPYRIGHT and LICENSE files for more details.
<%= setting_check_box :default_projects_public %>
<%= setting_multiselect(:default_projects_modules, - OpenProject::AccessControl.available_project_modules.collect {|m| [l_or_humanize(m, prefix: "project_module_"), m.to_s]}) %> + OpenProject::AccessControl + .available_project_modules(sorted: true) + .collect { |m| [l_or_humanize(m, prefix: "project_module_"), m.to_s] }) %>
<%= setting_select :new_project_user_role_id, - Role.givable.collect {|r| [r.name, r.id.to_s]}, - blank: "--- #{t(:actionview_instancetag_blank_option)} ---", - container_class: '-middle' %>
+ ProjectRole.givable.collect { |r| [r.name, r.id.to_s] }, + blank: "--- #{t(:actionview_instancetag_blank_option)} ---", + container_class: "-middle" %>
- <%= styled_button_tag t(:button_save), class: '-primary -with-icon icon-checkmark' %> + <%= styled_button_tag t(:button_save), class: "-primary -with-icon icon-checkmark" %> <% end %> diff --git a/app/views/admin/settings/project_life_cycle_step_definitions/form.html.erb b/app/views/admin/settings/project_life_cycle_step_definitions/form.html.erb new file mode 100644 index 000000000000..e11d2ad56bac --- /dev/null +++ b/app/views/admin/settings/project_life_cycle_step_definitions/form.html.erb @@ -0,0 +1,44 @@ +<%#-- 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. + +++#%> + +<% heading_scope = "#{@definition.persisted? ? :edit : :new}_#{@definition.is_a?(Project::StageDefinition) ? :stage : :gate}" %> + +<% html_title t(:label_administration), + t("settings.project_life_cycle_step_definitions.heading"), + t("settings.project_life_cycle_step_definitions.#{heading_scope}.heading") %> + +<%= render Settings::ProjectLifeCycleStepDefinitions::FormHeaderComponent.new(@definition, heading_scope:) %> + +<%= + primer_form_with( + model: [:admin, :settings, @definition.becomes(Project::LifeCycleStepDefinition)] + ) do |f| + render Projects::LifeCycleStepDefinitions::Form.new(f) + end +%> diff --git a/app/views/admin/settings/project_life_cycle_step_definitions/index.html.erb b/app/views/admin/settings/project_life_cycle_step_definitions/index.html.erb new file mode 100644 index 000000000000..cf8877a719e5 --- /dev/null +++ b/app/views/admin/settings/project_life_cycle_step_definitions/index.html.erb @@ -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. + +++#%> + +<% html_title t(:label_administration), t("settings.project_life_cycle_step_definitions.heading") %> + +<%= render Settings::ProjectLifeCycleStepDefinitions::IndexHeaderComponent.new %> +<%= render Settings::ProjectLifeCycleStepDefinitions::IndexComponent.new(definitions: @definitions) %> diff --git a/app/views/augmented/_autocomplete_select_decoration.html.erb b/app/views/augmented/_autocomplete_select_decoration.html.erb deleted file mode 100644 index e96307d43051..000000000000 --- a/app/views/augmented/_autocomplete_select_decoration.html.erb +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/app/views/custom_actions/_form.html.erb b/app/views/custom_actions/_form.html.erb index b1e2620f55a7..0a36eeb5ceca 100644 --- a/app/views/custom_actions/_form.html.erb +++ b/app/views/custom_actions/_form.html.erb @@ -1,5 +1,5 @@ <% initialize_hide_sections_with @custom_action.all_actions.map { |a| { key: a.key, label: a.human_name } }, - @custom_action.actions.map { |a| { key: a.key, label: a.human_name } } %> + @custom_action.actions.map { |a| { key: a.key, label: a.human_name } } %>
<%= f.text_field :name, required: true, container_class: '-middle' %> @@ -17,18 +17,36 @@
<%= styled_label_tag("custom_action_conditions_#{condition.key}", condition.human_name, class: '-top') %> <% input_name = "custom_action[conditions][#{condition.key}]" %> - <% selected_values = condition.values - select_options = condition.allowed_values.map { |v| { label: v[:label], value: v[:value], selected: selected_values.include?(v[:value]) } } %>
- <%= render partial: 'augmented/autocomplete_select_decoration', - locals: { input_name: input_name, - input_id: "custom_action_conditions_#{condition.key}", - select_options: select_options, - multiple: true, - key: condition.key.to_s - } %> + <% if condition.key == :project %> + <%= angular_component_tag 'opce-project-autocompleter', + inputs: { + multiple: true, + filters: [{ name: 'active', operator: '=', values: ['t'] }], + resource: "projects", + inputName: input_name, + appendTo: "body", + labelForId: "custom_action_actions_#{condition.key}", + inputValue: condition.values + } + %> + <% else %> + <%= angular_component_tag 'opce-autocompleter', + inputs: { + multiple: true, + defaultData: false, + items: condition.allowed_values.map { |v| { id: v[:value], name: v[:label] } }, + model: condition.value_objects.map { |v| { id: v[:value], name: v[:label] } }, + inputName: input_name, + bindLabel: "name", + bindValue: "id", + labelForId: "custom_action_conditions_#{condition.key}", + appendTo: "body", + } + %> + <% end %>
@@ -49,18 +67,35 @@ <% input_name = "custom_action[actions][#{action.key}]" %> <% if %i(associated_property boolean).include?(action.type) %> - <% selected_values = action.values - select_options = action.allowed_values.map { |v| { label: v[:label], value: v[:value], selected: selected_values.include?(v[:value]) } } %> -
- <%= render partial: 'augmented/autocomplete_select_decoration', - locals: { input_name: input_name, - input_id: "custom_action_actions_#{action.key}", - select_options: select_options, - multiple: action.multi_value?, - key: action.key.to_s - } %> + <% if action.key == :project %> + <%= angular_component_tag 'opce-project-autocompleter', + inputs: { + multiple: false, + filters: [{ name: 'active', operator: '=', values: ['t'] }], + resource: "projects", + inputName: input_name, + appendTo: "body", + labelForId: "custom_action_actions_#{action.key}", + inputValue: action.values.first + } + %> + <% else %> + <%= angular_component_tag 'opce-autocompleter', + inputs: { + multiple: action.multi_value?, + defaultData: false, + items: action.allowed_values.map { |v| { id: v[:value], name: v[:label] } }, + model: action.value_objects.map { |v| { id: v[:value], name: v[:label] } }, + inputName: input_name, + bindLabel: "name", + bindValue: "id", + labelForId: "custom_action_actions_#{action.key}", + appendTo: "body", + } + %> + <% end %>
<% elsif %i(date_property).include?(action.type) %> diff --git a/app/views/custom_fields/_tab.html.erb b/app/views/custom_fields/_tab.html.erb index 5616d5199745..13437c013950 100644 --- a/app/views/custom_fields/_tab.html.erb +++ b/app/views/custom_fields/_tab.html.erb @@ -140,7 +140,7 @@ See COPYRIGHT and LICENSE files for more details. <%= t(:label_x_projects, count: custom_field.projects.count) %> <% end %> - <% type_links = custom_field.types.map { |t| link_to(t.name, edit_type_tab_path(id: t.id, tab: 'form_configuration')) } %> + <% type_links = custom_field.types.map { |t| link_to(t.name, edit_tab_type_path(id: t.id, tab: "form_configuration")) } %> <% if type_links.empty? %> <%= link_to t(:label_custom_field_add_no_type), types_path %> diff --git a/app/views/enumerations/_form.html.erb b/app/views/enumerations/_form.html.erb index db1be44dbc1e..8e0fe7566e9d 100644 --- a/app/views/enumerations/_form.html.erb +++ b/app/views/enumerations/_form.html.erb @@ -37,7 +37,10 @@ See COPYRIGHT and LICENSE files for more details. <%= f.hidden_field 'type' %>
<%= f.text_field 'name', required: true, container_class: '-middle' %>
<%= f.check_box 'active' %>
-
<%= f.check_box 'is_default' %>
+ + <% if @enumeration.class.can_have_default_value? %> +
<%= f.check_box 'is_default' %>
+ <% end %> <% if @enumeration.class.colored? %> <%= render partial: '/colors/color_autocomplete_field', diff --git a/app/views/enumerations/index.html.erb b/app/views/enumerations/index.html.erb index bc61f86715e5..d9e9e910333d 100644 --- a/app/views/enumerations/index.html.erb +++ b/app/views/enumerations/index.html.erb @@ -38,5 +38,5 @@ See COPYRIGHT and LICENSE files for more details. <% Enumeration.subclasses.each do |klass| %>

<%= t(klass::OptionName) %>

- <%= render(::Enumerations::TableComponent.new(rows: klass.shared)) %> + <%= render(::Enumerations::TableComponent.new(rows: klass.shared, enumeration: klass)) %> <% end %> diff --git a/app/views/filters/_autocomplete.html.erb b/app/views/filters/_autocomplete.html.erb index 57a5df7653b7..c85518e08440 100644 --- a/app/views/filters/_autocomplete.html.erb +++ b/app/views/filters/_autocomplete.html.erb @@ -12,7 +12,6 @@ # of the #connect lifecycle hook of the filter--filters-form # Stimulus controller. }.merge(autocomplete_options.except(:component)), - class: 'form--field', id: "#{filter.name}_value", data: { 'filter-autocomplete': true, diff --git a/app/views/forums/show.html.erb b/app/views/forums/show.html.erb index 3f0ddfa73894..e99fa7d54636 100644 --- a/app/views/forums/show.html.erb +++ b/app/views/forums/show.html.erb @@ -34,22 +34,21 @@ See COPYRIGHT and LICENSE files for more details. url: forum_topics_path(@forum), html: { multipart: true, - id: 'message-form', - class: 'form', + id: "message-form", + class: "form", data: { turbo: false } } do |f| %> - <%= render partial: 'messages/form', locals: { f: f } %> + <%= render partial: "messages/form", locals: { f: f } %> -
- <%= styled_button_tag t(:button_create), class: '-primary -with-icon icon-checkmark' %> - <%= link_to t(:button_cancel), '', class: 'cancel-add-message-button button -with-icon icon-cancel' %> - <% csp_onclick('jQuery("#add-message").hide();', '.cancel-add-message-button') %> +
+ <%= styled_button_tag t(:button_create), class: "-primary -with-icon icon-checkmark" %> + <%= link_to t(:button_cancel), "", class: "cancel-add-message-button button -with-icon icon-cancel" %> + <% csp_onclick('jQuery("#add-message").hide();', ".cancel-add-message-button") %> <% end %>
<% end %>
- <%= render(Primer::OpenProject::PageHeader.new) do |header| header.with_title { @forum.name } @@ -69,8 +68,8 @@ See COPYRIGHT and LICENSE files for more details. aria: { label: t(:label_message_new) }, title: t(:label_message_new), tag: :a, - class: 'add-message-button', - href: url_for({ controller: '/messages', action: 'new', forum_id: @forum })) do |button| + class: "add-message-button", + href: url_for({ controller: "/messages", action: "new", forum_id: @forum })) do |button| button.with_leading_visual_icon(icon: :plus) t(:label_message) end @@ -120,16 +119,16 @@ See COPYRIGHT and LICENSE files for more details. <% if message.sticky? %> - <%= op_icon('icon-wiki', title: I18n.t('js.label_board_sticky')) %> + <%= op_icon("icon-wiki", title: I18n.t("js.label_board_sticky")) %> <% end %> <% if message.locked? %> - <%= op_icon('icon-locked', title: I18n.t('js.label_board_locked')) %> + <%= op_icon("icon-locked", title: I18n.t("js.label_board_locked")) %> <% end %> <%= link_to message.subject, topic_path(message) %> <% if message.author %> - <%= link_to message.author.name, user_path(message.author) %> + <%= link_to_user message.author %> <% end %> @@ -138,8 +137,8 @@ See COPYRIGHT and LICENSE files for more details. <%= message.replies_count %> <% if message.last_reply %> - <%= authoring message.last_reply.created_at, message.last_reply.author %>
-
+ <%= authoring message.last_reply.created_at, message.last_reply.author %>
+
<%= link_to_message message.last_reply, no_root: true %> <% end %> @@ -155,9 +154,9 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <%= other_formats_links do |f| %> - <%= f.link_to 'Atom', url: { key: User.current.rss_key } %> + <%= f.link_to "Atom", url: { key: User.current.rss_key } %> <% end %> <% html_title h(@forum.name) %> <% content_for :header_tags do %> - <%= auto_discovery_link_tag(:atom, { format: 'atom', key: User.current.rss_key }, title: "#{@project}: #{@forum}") %> + <%= auto_discovery_link_tag(:atom, { format: "atom", key: User.current.rss_key }, title: "#{@project}: #{@forum}") %> <% end %> diff --git a/app/views/homescreen/blocks/_users.html.erb b/app/views/homescreen/blocks/_users.html.erb index 2bc2b31a58f4..45584c6973eb 100644 --- a/app/views/homescreen/blocks/_users.html.erb +++ b/app/views/homescreen/blocks/_users.html.erb @@ -6,7 +6,7 @@