diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c6a7a131792..477610d5bf0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,16 +13,40 @@ updates: groups: angular: patterns: - - '@angular*' + - '@angular/*' + - '@angular-devkit/*' + - '@angular-builders/*' + angular-eslint: + patterns: + - 'angular-eslint' + - '@angular-eslint/*' blocknote: patterns: - '@blocknote/*' fullcalendar: patterns: - '@fullcalendar/*' + eslint: + patterns: + - 'eslint' + - 'eslint-plugin-*' + - '@eslint/*' + - '@stylistic/eslint-plugin' + - 'globals' html-eslint: patterns: - '@html-eslint/*' + hotwired: + patterns: + - '@hotwired/turbo' + - '@hotwired/turbo-rails' + jquery: + patterns: + - 'jquery' + - 'jquery-migrate' + mantine: + patterns: + - '@mantine/*' ng-select: patterns: - '@ng-select/*' @@ -35,8 +59,30 @@ updates: - 'react-dom' - '@types/react' - '@types/react-dom' + testing-library: + patterns: + - '@testing-library/*' + typescript-eslint: + patterns: + - 'typescript-eslint' + - '@typescript-eslint/*' + uirouter: + patterns: + - '@uirouter/*' + vitest: + patterns: + - 'vitest' + - '@vitest/*' ignore: - - dependency-name: "@angular*" + - dependency-name: "@angular/*" + update-types: ["version-update:semver-major"] + - dependency-name: "@angular-builders/*" + update-types: ["version-update:semver-major"] + - dependency-name: "@angular-devkit/*" + update-types: ["version-update:semver-major"] + - dependency-name: "angular-eslint" + update-types: ["version-update:semver-major"] + - dependency-name: "@angular-eslint/*" update-types: ["version-update:semver-major"] - dependency-name: "@openproject/octicons" - dependency-name: "@openproject/primer-view-components" diff --git a/.github/workflows/brakeman-scan-core.yml b/.github/workflows/brakeman-scan-core.yml index 8ee3db571bd..ed24694fac3 100644 --- a/.github/workflows/brakeman-scan-core.yml +++ b/.github/workflows/brakeman-scan-core.yml @@ -30,7 +30,7 @@ jobs: persist-credentials: false - name: Setup Ruby - uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1 + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 - name: Setup Brakeman run: | @@ -46,6 +46,6 @@ jobs: --output output.sarif.json - name: Upload SARIF - uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4 + uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4 with: sarif_file: output.sarif.json diff --git a/.github/workflows/codeql-scan-core.yml b/.github/workflows/codeql-scan-core.yml index 5a3b4699184..8c99ba9e17b 100644 --- a/.github/workflows/codeql-scan-core.yml +++ b/.github/workflows/codeql-scan-core.yml @@ -36,7 +36,7 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4 + uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4 with: config-file: ./.github/codeql/config.yml languages: ${{ matrix.language }} @@ -44,6 +44,6 @@ jobs: queries: security-extended,security-and-quality - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4 + uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/create-merge-from-previous-release-branch-pr.yml b/.github/workflows/create-merge-from-previous-release-branch-pr.yml index 0862bea6c7f..c522501478b 100644 --- a/.github/workflows/create-merge-from-previous-release-branch-pr.yml +++ b/.github/workflows/create-merge-from-previous-release-branch-pr.yml @@ -10,40 +10,28 @@ jobs: if: github.repository == 'opf/openproject' runs-on: ubuntu-latest outputs: - previous_release_branch: ${{ steps.find_previous_release.outputs.branch }} - latest_release_branch: ${{ steps.find_latest_release.outputs.branch }} + previous_release_branch: ${{ steps.find_release_branches.outputs.previous_branch }} + latest_release_branch: ${{ steps.find_release_branches.outputs.latest_branch }} steps: - - id: find_previous_release + - id: find_release_branches env: GITHUB_TOKEN: ${{ secrets.OPENPROJECTCI_GH_CORE_PAT }} GITHUB_REPOSITORY: ${{ github.repository }} run: | - BRANCH=$(curl -H "Authorization: token $GITHUB_TOKEN" \ - https://api.github.com/repos/$GITHUB_REPOSITORY/branches?protected=true | \ - jq -r '.[].name' | grep '^release/' | sort --version-sort | tail -2 | head -1 + LATEST_BRANCHES=$(gh api --paginate \ + "repos/$GITHUB_REPOSITORY/branches?protected=true&per_page=100" \ + --jq '.[].name' | grep '^release/' | sort --version-sort | tail -2 ) - if [ "$BRANCH" = "" ]; then - echo "Invalid release branch found: $BRANCH" + LATEST_BRANCH=$(echo "$LATEST_BRANCHES" | tail -1) + PREVIOUS_BRANCH=$(echo "$LATEST_BRANCHES" | head -1) + if [ -z "$LATEST_BRANCH" ] || [ -z "$PREVIOUS_BRANCH" ]; then + echo "Invalid release branches found: latest=$LATEST_BRANCH, previous=$PREVIOUS_BRANCH" exit 1 fi - echo "Found previous release branch: $BRANCH" - echo "branch=${BRANCH}" >> $GITHUB_OUTPUT - - id: find_latest_release - env: - GITHUB_TOKEN: ${{ secrets.OPENPROJECTCI_GH_CORE_PAT }} - GITHUB_REPOSITORY: ${{ github.repository }} - run: | - BRANCH=$(curl -H "Authorization: token $GITHUB_TOKEN" \ - https://api.github.com/repos/$GITHUB_REPOSITORY/branches?protected=true | \ - jq -r '.[].name' | grep '^release/' | sort --version-sort | tail -1 - ) - if [ "$BRANCH" = "" ]; then - echo "Invalid release branch found: $BRANCH" - exit 1 - fi - - echo "Found current release branch: $BRANCH" - echo "branch=${BRANCH}" >> $GITHUB_OUTPUT + echo "Found previous release branch: $PREVIOUS_BRANCH" + echo "previous_branch=${PREVIOUS_BRANCH}" >> $GITHUB_OUTPUT + echo "Found latest release branch: $LATEST_BRANCH" + echo "latest_branch=${LATEST_BRANCH}" >> $GITHUB_OUTPUT merge-or-create-pr: if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref_name == needs.setup.outputs.previous_release_branch) env: diff --git a/.github/workflows/create-merge-release-into-dev-pr.yml b/.github/workflows/create-merge-release-into-dev-pr.yml index 57e27fb2ac1..5b00d90e295 100644 --- a/.github/workflows/create-merge-release-into-dev-pr.yml +++ b/.github/workflows/create-merge-release-into-dev-pr.yml @@ -20,9 +20,9 @@ jobs: GITHUB_TOKEN: ${{ secrets.OPENPROJECTCI_GH_CORE_PAT }} GITHUB_REPOSITORY: ${{ github.repository }} run: | - BRANCH=$(curl -H "Authorization: token $GITHUB_TOKEN" \ - https://api.github.com/repos/$GITHUB_REPOSITORY/branches?protected=true | \ - jq -r '.[].name' | grep '^release/' | sort --version-sort | tail -1 + BRANCH=$(gh api --paginate \ + "repos/$GITHUB_REPOSITORY/branches?protected=true&per_page=100" --jq '.[].name' | \ + grep '^release/' | sort --version-sort | tail -1 ) if [ "$BRANCH" = "" ]; then echo "Invalid release branch found: $BRANCH" diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index 165df9c1f9d..8ec944ab4eb 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -17,9 +17,9 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} run: | - BRANCH=$(curl -H "Authorization: token $GITHUB_TOKEN" \ - https://api.github.com/repos/$GITHUB_REPOSITORY/branches?protected=true | \ - jq -r '.[].name' | grep '^release/' | sort --version-sort | tail -1 + BRANCH=$(gh api --paginate \ + "repos/$GITHUB_REPOSITORY/branches?protected=true&per_page=100" --jq '.[].name' | \ + grep '^release/' | sort --version-sort | tail -1 ) if [ "$BRANCH" = "" ]; then echo "Invalid release branch found: $BRANCH" @@ -46,7 +46,7 @@ jobs: with: ref: ${{ matrix.branch }} fetch-depth: 1 - - uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1 + - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 with: bundler-cache: true - name: "Set crowdin branch name" diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 1fe39a33836..caca55b850f 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -22,7 +22,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1 + - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 with: ruby-version: .ruby-version bundler-cache: true diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index fb744003a09..8ad544d194d 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -106,7 +106,7 @@ jobs: STEPS_EXTRACT_VERSION_OUTPUTS_DOCKER_TAGS: ${{ steps.extract_version.outputs.docker_tags }} STEPS_EXTRACT_VERSION_OUTPUTS_REGISTRY_IMAGE: ${{ steps.extract_version.outputs.registry_image }} - name: Cache NPM - uses: runs-on/cache@bd330c5a5f6cbb837823ee25864f3c71a211c2e3 # v5 + uses: runs-on/cache@88d90644011a3a9957fd141a106f5a94f9794203 # v5 with: path: | frontend/node_modules @@ -114,12 +114,12 @@ jobs: key: nodejs-x64-${{ hashFiles('**/package-lock.json') }} restore-keys: nodejs-x64- - name: Cache angular - uses: runs-on/cache@bd330c5a5f6cbb837823ee25864f3c71a211c2e3 # v5 + uses: runs-on/cache@88d90644011a3a9957fd141a106f5a94f9794203 # v5 with: path: frontend/.angular key: angular-${{ github.ref }} restore-keys: angular- - - uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1 + - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 with: bundler-cache: true - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index b9545e43ed1..de5a9314be1 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -20,7 +20,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1 + - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 with: bundler-cache: true - run: bundle exec ./script/docs/check_links @@ -39,7 +39,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1 + - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 with: bundler-cache: true - run: script/docs/check_readme_yaml_header_syntax diff --git a/.github/workflows/email-notification.yml b/.github/workflows/email-notification.yml index cf5dba17ae0..37c85f51d5a 100644 --- a/.github/workflows/email-notification.yml +++ b/.github/workflows/email-notification.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Send mail - uses: dawidd6/action-send-mail@d38f3f7cd391cdebfe0d38efc3998b935e951c4f # v16 + uses: dawidd6/action-send-mail@42942bc2f8fba4e611b459a018967a6a7c78c68c # v17 with: subject: ${{ inputs.subject }} body: ${{ inputs.body }} diff --git a/.github/workflows/hocuspocus-docker.yml b/.github/workflows/hocuspocus-docker.yml index 4a6e78b286f..8fb802dc2b8 100644 --- a/.github/workflows/hocuspocus-docker.yml +++ b/.github/workflows/hocuspocus-docker.yml @@ -161,7 +161,7 @@ jobs: - name: Deploy EDGE if: github.ref == 'refs/heads/dev' && github.repository == 'opf/openproject' - uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 + uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 with: workflow: edge-deploy-shards.yml repo: opf/saas-deploy @@ -172,7 +172,7 @@ jobs: - name: Deploy STAGE # make sure to always use the latest release branch here if: github.ref == 'refs/heads/release/17.5' && github.repository == 'opf/openproject' - uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 + uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 with: workflow: stage-deploy-shards.yml repo: opf/saas-deploy diff --git a/.github/workflows/i18n-tasks.yml b/.github/workflows/i18n-tasks.yml index e618e573fa0..a48ebf7c58b 100644 --- a/.github/workflows/i18n-tasks.yml +++ b/.github/workflows/i18n-tasks.yml @@ -33,7 +33,7 @@ jobs: persist-credentials: false - name: Setup Ruby - uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1 + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 - name: Setup i18n-tasks run: | diff --git a/.github/workflows/npm-audit.yml b/.github/workflows/npm-audit.yml new file mode 100644 index 00000000000..6d2b82ecdb5 --- /dev/null +++ b/.github/workflows/npm-audit.yml @@ -0,0 +1,224 @@ +name: npm audit fix + +on: + workflow_dispatch: + schedule: + - cron: "23 4 * * 1" + +env: + BASE_BRANCH: dev + BRANCH_PREFIX: npm-audit-fix + +permissions: {} + +concurrency: + group: npm-audit-fix + cancel-in-progress: false + +jobs: + audit-fix: + name: npm audit fix + runs-on: ubuntu-latest + timeout-minutes: 15 + if: github.repository == 'opf/openproject' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + + permissions: + contents: write # for git push + pull-requests: write # for creating pull requests + + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ env.BASE_BRANCH }} + token: ${{ secrets.OPENPROJECTCI_GH_CORE_PAT }} + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: '22.21' + package-manager-cache: false + + - name: Required git config + run: | + git config user.name "OpenProject Actions CI" + git config user.email "operations+ci@openproject.com" + + - name: Run npm audit fix + id: audit_fix + run: | + set -euo pipefail + + mkdir -p reports + + print_audit_report() { + local report="$1" + local label="$2" + + node - "$report" "$label" <<'NODE' + const fs = require('fs'); + + const reportPath = process.argv[2]; + const label = process.argv[3]; + const report = JSON.parse(fs.readFileSync(reportPath, 'utf8')); + const vulnerabilities = Object.entries(report.vulnerabilities || {}); + const counts = report.metadata?.vulnerabilities || {}; + + console.log(`${label}: ${vulnerabilities.length} vulnerable package(s)`); + console.log(`severity counts: ${JSON.stringify(counts)}`); + + for (const [name, details] of vulnerabilities) { + const via = (details.via || []) + .map((finding) => typeof finding === 'string' ? finding : finding.title) + .filter(Boolean) + .join('; '); + + console.log([ + `- ${name}`, + `severity: ${details.severity}`, + `range: ${details.range || 'n/a'}`, + `fixAvailable: ${JSON.stringify(details.fixAvailable)}`, + via ? `via: ${via}` : null + ].filter(Boolean).join(' | ')); + } + NODE + } + + run_for_package() { + local name="$1" + local directory="$2" + local before_report="$GITHUB_WORKSPACE/reports/npm-audit-${name}-before.json" + local after_report="$GITHUB_WORKSPACE/reports/npm-audit-${name}-after.json" + + echo "::group::npm audit before fix (${name})" + set +e + npm --prefix "$directory" audit --audit-level=high --json > "$before_report" + audit_before_exit=$? + set -e + print_audit_report "$before_report" "${name} before fix" + echo "npm audit before fix exited with ${audit_before_exit}" + echo "::endgroup::" + + echo "::group::npm audit fix (${name})" + set +e + npm --prefix "$directory" audit fix --package-lock-only --ignore-scripts --audit-level=high + audit_fix_exit=$? + set -e + echo "npm audit fix exited with ${audit_fix_exit}" + echo "::endgroup::" + + echo "::group::npm audit after fix (${name})" + set +e + npm --prefix "$directory" audit --audit-level=high --json > "$after_report" + audit_after_exit=$? + set -e + print_audit_report "$after_report" "${name} after fix" + echo "npm audit after fix exited with ${audit_after_exit}" + echo "::endgroup::" + } + + run_for_package root . + run_for_package frontend frontend + + if git diff --quiet -- package.json package-lock.json frontend/package.json frontend/package-lock.json; then + echo "has_changes=false" >> "$GITHUB_OUTPUT" + echo "npm audit fix produced no package or lockfile changes." + else + echo "has_changes=true" >> "$GITHUB_OUTPUT" + echo "npm audit fix produced package or lockfile changes." + fi + + - name: Show diff + if: steps.audit_fix.outputs.has_changes == 'true' + run: git diff -- package.json package-lock.json frontend/package.json frontend/package-lock.json + + - name: Create pull request + if: steps.audit_fix.outputs.has_changes == 'true' + env: + GITHUB_TOKEN: ${{ secrets.OPENPROJECTCI_GH_CORE_PAT }} + run: | + set -euo pipefail + + pr_numbers=$(gh pr list \ + --base "$BASE_BRANCH" \ + --state open \ + --limit 100 \ + --json number,headRefName \ + --jq '.[] | select(.headRefName | startswith("'"$BRANCH_PREFIX"'-")) | .number') + + for pr_number in $pr_numbers; do + gh pr close "$pr_number" --delete-branch + done + + pr_body_file=$(mktemp) + + { + echo 'Created by GitHub action.' + echo + echo '## Impacted packages' + } > "$pr_body_file" + + node <<'NODE' >> "$pr_body_file" + const fs = require('fs'); + + const reports = [ + ['root', 'reports/npm-audit-root-before.json'], + ['frontend', 'reports/npm-audit-frontend-before.json'] + ]; + let foundPackages = false; + + for (const [label, reportPath] of reports) { + if (!fs.existsSync(reportPath)) { + continue; + } + + const report = JSON.parse(fs.readFileSync(reportPath, 'utf8')); + const vulnerabilities = Object.entries(report.vulnerabilities || {}) + .sort(([left], [right]) => left.localeCompare(right)); + + if (vulnerabilities.length === 0) { + continue; + } + + foundPackages = true; + console.log(''); + console.log(`### ${label}`); + + for (const [name, details] of vulnerabilities) { + console.log(`- \`${name}\` (${details.severity})`); + } + } + + if (!foundPackages) { + console.log(''); + console.log('No vulnerable packages were reported before the fix.'); + } + NODE + + { + for pr_number in $pr_numbers; do + if [ "$pr_number" = "$(echo "$pr_numbers" | head -1)" ]; then + echo + echo '## Replaced PRs' + echo + fi + echo "Replaces #$pr_number" + done + } >> "$pr_body_file" + + temp_branch="$BRANCH_PREFIX-$(date "+%Y%m%d%H%M%S")" + + git switch -c "$temp_branch" + git add package.json package-lock.json frontend/package.json frontend/package-lock.json + git commit -m "Run npm audit fix" + gh auth setup-git + git push origin "$temp_branch" + + gh pr create \ + --base "$BASE_BRANCH" \ + --head "$temp_branch" \ + --title "Run npm audit fix" \ + --body-file "$pr_body_file" + + echo "Created a PR with npm audit fixes (${temp_branch}) against ${BASE_BRANCH}" diff --git a/.github/workflows/openapi.yaml b/.github/workflows/openapi.yaml index 57d427055d7..b6e4037be6a 100644 --- a/.github/workflows/openapi.yaml +++ b/.github/workflows/openapi.yaml @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1 + - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 with: bundler-cache: false - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 diff --git a/.github/workflows/rubocop-core.yml b/.github/workflows/rubocop-core.yml index 3071a5aeb7e..11e8d647c7f 100644 --- a/.github/workflows/rubocop-core.yml +++ b/.github/workflows/rubocop-core.yml @@ -18,7 +18,7 @@ jobs: with: persist-credentials: false - name: Set up Ruby - uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1 + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 - name: Run Rubocop uses: reviewdog/action-rubocop@b6d5e953a5fc0bf3ab65254e77730ea2174d6d6d # v2 with: diff --git a/.github/workflows/seed-all-locales.yml b/.github/workflows/seed-all-locales.yml index a6fea961d3d..a32c7c3d8a0 100644 --- a/.github/workflows/seed-all-locales.yml +++ b/.github/workflows/seed-all-locales.yml @@ -98,7 +98,7 @@ jobs: run: sudo apt-get update && sudo apt-get install -y libpq-dev - name: Setup Ruby - uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1 + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 with: bundler-cache: true diff --git a/.github/workflows/test-core.yml b/.github/workflows/test-core.yml index 87f3f08d1e3..18c175d5a55 100644 --- a/.github/workflows/test-core.yml +++ b/.github/workflows/test-core.yml @@ -38,7 +38,7 @@ jobs: persist-credentials: false - name: Cache DOCKER id: cache_docker - uses: runs-on/cache@bd330c5a5f6cbb837823ee25864f3c71a211c2e3 # v5 + uses: runs-on/cache@88d90644011a3a9957fd141a106f5a94f9794203 # v5 with: path: cache/docker # Note: no restore keys since whenever the files below change, we want to rebuild the full image from scratch @@ -47,28 +47,28 @@ jobs: if: steps.cache_docker.outputs.cache-hit == 'true' run: docker load -i cache/docker/image.tar - name: Cache GEM - uses: runs-on/cache@bd330c5a5f6cbb837823ee25864f3c71a211c2e3 # v5 + uses: runs-on/cache@88d90644011a3a9957fd141a106f5a94f9794203 # v5 with: path: cache/bundle key: gem-trixie-${{ hashFiles('.ruby-version') }}-${{ hashFiles('Gemfile.lock') }} restore-keys: | gem-trixie-${{ hashFiles('.ruby-version') }}- - name: Cache NPM - uses: runs-on/cache@bd330c5a5f6cbb837823ee25864f3c71a211c2e3 # v5 + uses: runs-on/cache@88d90644011a3a9957fd141a106f5a94f9794203 # v5 with: path: cache/node key: node-${{ hashFiles('package.json', 'frontend/package-lock.json') }} restore-keys: | node- - name: Cache ANGULAR - uses: runs-on/cache@bd330c5a5f6cbb837823ee25864f3c71a211c2e3 # v5 + uses: runs-on/cache@88d90644011a3a9957fd141a106f5a94f9794203 # v5 with: path: cache/angular key: angular-${{ hashFiles('package.json', 'frontend/package-lock.json') }} restore-keys: | angular- - name: Cache TEST RUNTIME - uses: runs-on/cache@bd330c5a5f6cbb837823ee25864f3c71a211c2e3 # v5 + uses: runs-on/cache@88d90644011a3a9957fd141a106f5a94f9794203 # v5 with: path: cache/runtime-logs key: runtime-logs-${{ github.head_ref || github.ref }}-${{ github.sha }} diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index 05b831e19f6..4cda5ca3288 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -24,7 +24,7 @@ jobs: persist-credentials: false - name: Set up Ruby - uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1 + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1 - name: Verify linked version matches core version id: version-check diff --git a/.github/workflows/yamllint-core.yml b/.github/workflows/yamllint-core.yml new file mode 100644 index 00000000000..496f8006a1a --- /dev/null +++ b/.github/workflows/yamllint-core.yml @@ -0,0 +1,34 @@ +name: yamllint + +on: + pull_request: + paths: + - '.yamllint.yml' + - 'config/locales/en.yml' + - 'config/locales/js-en.yml' + - 'modules/*/config/locales/en.yml' + - 'modules/*/config/locales/js-en.yml' + +permissions: {} + +jobs: + rubocop: + name: yamllint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run Yamllint + uses: reviewdog/action-yamllint@f01d8a48fd8d89f89895499fca2cff09f9e9e8c0 # v1.21.0 + with: + github_token: ${{ secrets.github_token }} + fail_level: error + yamllint_flags: > + .yamllint.yml + config/locales/en.yml + config/locales/js-en.yml + modules/*/config/locales/en.yml + modules/*/config/locales/js-en.yml diff --git a/.github/workflows/zizmor-scan.yml b/.github/workflows/zizmor-scan.yml index fc5f85283d3..a332166cff4 100644 --- a/.github/workflows/zizmor-scan.yml +++ b/.github/workflows/zizmor-scan.yml @@ -27,4 +27,4 @@ jobs: persist-credentials: false - name: Run zizmor 🌈 - uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3 + uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 00000000000..8fae8fc9dd9 --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,8 @@ +--- +extends: default + +rules: + comments: + require-starting-space: false + key-ordering: {} + line-length: disable diff --git a/Gemfile b/Gemfile index 43fc700f7ce..bc82fd4bd20 100644 --- a/Gemfile +++ b/Gemfile @@ -162,7 +162,7 @@ gem "ttfunk", "~> 1.7.0" # remove after https://github.com/prawnpdf/prawn/issues # prawn implicitly depends on matrix gem no longer in ruby core with 3.1 gem "matrix", "~> 0.4.3" -gem "mcp", "~> 0.14.0" +gem "mcp", "~> 0.17.0" gem "meta-tags", "~> 2.23.0" @@ -200,7 +200,7 @@ gem "rack-timeout", "~> 0.7.0", require: "rack/timeout/base" gem "nokogiri", "~> 1.19.2" -gem "carrierwave", "~> 2.2.6" +gem "carrierwave", "~> 2.2.7" gem "carrierwave_direct", "~> 3.0.0" gem "fog-aws" gem "ssrf_filter", "~> 1.3" @@ -238,10 +238,10 @@ gem "yabeda-rails" # opentelemetry gem "opentelemetry-exporter-otlp", "~> 0.34.0", require: false -gem "opentelemetry-instrumentation-all", "~> 0.93.0", require: false +gem "opentelemetry-instrumentation-all", "~> 0.94.0", require: false gem "opentelemetry-sdk", "~> 1.10", require: false -gem "view_component", "~> 4.10.0" +gem "view_component", "~> 4.11.0" # Lookbook gem "lookbook", "2.3.14" @@ -265,11 +265,10 @@ group :test do gem "rack-test", "~> 2.2.0" gem "shoulda-context", "~> 2.0" - gem "parallel_tests", "~> 5.7" # Test prof provides factories from code # and other niceties gem "test-prof", "~> 1.6.0" - gem "turbo_tests", github: "opf/turbo_tests", ref: "2_2_5_with_patches" + gem "turbo_tests", github: "opf/turbo_tests", ref: "with-patches" gem "rack_session_access" gem "rspec", "~> 3.13.2" @@ -296,7 +295,7 @@ group :test do gem "rails-controller-testing", "~> 1.0.2" gem "capybara", "~> 3.40.0" - gem "capybara_accessible_selectors", git: "https://github.com/citizensadvice/capybara_accessible_selectors", tag: "v0.15.0" + gem "capybara_accessible_selectors", git: "https://github.com/citizensadvice/capybara_accessible_selectors", tag: "v0.16.0" gem "capybara-screenshot", "~> 1.0.17" gem "cuprite", "~> 0.17.0" gem "rspec-wait" @@ -318,6 +317,8 @@ group :test do gem "equivalent-xml", "~> 0.6" gem "json_spec", "~> 1.1.4" gem "shoulda-matchers", "~> 7.0", require: nil + + gem "parallel_tests", "~> 4.0" end group :ldap do @@ -366,7 +367,7 @@ group :development, :test do gem "rubocop-factory_bot", require: false gem "rubocop-openproject", require: false gem "rubocop-performance", require: false - gem "rubocop-rails", "~> 2.35.1" + gem "rubocop-rails", "~> 2.35.2" gem "rubocop-rspec", require: false gem "rubocop-rspec_rails", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 389fc7d5d30..119b901e1ee 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ GIT remote: https://github.com/citizensadvice/capybara_accessible_selectors - revision: 5b9ce7840d04270e99f4f0cb03989e05437326a6 - tag: v0.15.0 + revision: 568699fc71b6648e7186a4ac77bba072447c131e + tag: v0.16.0 specs: capybara_accessible_selectors (0.15.0) capybara (~> 3.36) @@ -54,11 +54,11 @@ GIT GIT remote: https://github.com/opf/turbo_tests.git - revision: 4208e7372a7987c972dda634eb4f29ad9f1448e1 - ref: 2_2_5_with_patches + revision: c1c4707f536a5642a168650d273d714dfb62d842 + ref: with-patches specs: - turbo_tests (2.2.5) - parallel_tests (>= 3.3.0, < 6) + turbo_tests (2.2.0) + parallel_tests (>= 3.3.0, < 5) rspec (>= 3.10) GIT @@ -217,7 +217,7 @@ PATH remote: modules/two_factor_authentication specs: openproject-two_factor_authentication (1.0.0) - aws-sdk-sns (>= 1.101, < 1.114) + aws-sdk-sns (~> 1.116) messagebird-rest (>= 1.4.2, < 5.1.0) rotp (~> 6.1) webauthn (~> 3.0) @@ -361,8 +361,8 @@ GEM awesome_nested_set (3.9.0) activerecord (>= 4.0.0, < 8.2) aws-eventstream (1.4.0) - aws-partitions (1.1249.0) - aws-sdk-core (3.247.0) + aws-partitions (1.1255.0) + aws-sdk-core (3.250.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -370,15 +370,15 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.125.0) - aws-sdk-core (~> 3, >= 3.247.0) + aws-sdk-kms (1.128.0) + aws-sdk-core (~> 3, >= 3.248.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.222.0) - aws-sdk-core (~> 3, >= 3.247.0) + aws-sdk-s3 (1.224.0) + aws-sdk-core (~> 3, >= 3.248.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sdk-sns (1.113.0) - aws-sdk-core (~> 3, >= 3.244.0) + aws-sdk-sns (1.116.0) + aws-sdk-core (~> 3, >= 3.248.0) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) @@ -425,7 +425,7 @@ GEM capybara-screenshot (1.0.27) capybara (>= 1.0, < 4) launchy - carrierwave (2.2.6) + carrierwave (2.2.7) activemodel (>= 5.0.0) activesupport (>= 5.0.0) addressable (~> 2.6) @@ -479,7 +479,7 @@ GEM capybara (~> 3.0) ferrum (~> 0.17.0) daemons (1.4.1) - dalli (5.0.4) + dalli (5.0.5) logger date (3.5.1) date_validator (0.12.0) @@ -497,7 +497,7 @@ GEM disposable (0.6.3) declarative (>= 0.0.9, < 1.0.0) representable (>= 3.1.1, < 4) - doorkeeper (5.9.0) + doorkeeper (5.9.1) railties (>= 5) dotenv (3.2.0) dotenv-rails (3.2.0) @@ -587,7 +587,7 @@ GEM logger faraday-follow_redirects (0.5.0) faraday (>= 1, < 3) - faraday-net_http (3.4.2) + faraday-net_http (3.4.3) net-http (~> 0.5) ferrum (0.17.2) addressable (~> 2.5) @@ -625,13 +625,13 @@ GEM friendly_id (5.7.0) activerecord (>= 4.0.0) front_matter_parser (1.0.1) - fugit (1.12.1) + fugit (1.12.2) et-orbi (~> 1.4) raabro (~> 1.4) fuubar (2.5.1) rspec-core (~> 3.0) ruby-progressbar (~> 1.4) - glob (0.4.0) + glob (0.5.0) globalid (1.3.0) activesupport (>= 6.1) good_job (4.18.2) @@ -649,34 +649,34 @@ GEM mini_mime (~> 1.1) representable (~> 3.0) retriable (~> 3.1) - google-apis-gmail_v1 (0.49.0) + google-apis-gmail_v1 (0.51.0) google-apis-core (>= 0.15.0, < 2.a) google-cloud-env (2.3.1) base64 (~> 0.2) faraday (>= 1.0, < 3.a) google-logging-utils (0.2.0) - google-protobuf (4.34.1) + google-protobuf (4.35.0) bigdecimal rake (~> 13.3) - google-protobuf (4.34.1-aarch64-linux-gnu) + google-protobuf (4.35.0-aarch64-linux-gnu) bigdecimal rake (~> 13.3) - google-protobuf (4.34.1-aarch64-linux-musl) + google-protobuf (4.35.0-aarch64-linux-musl) bigdecimal rake (~> 13.3) - google-protobuf (4.34.1-arm64-darwin) + google-protobuf (4.35.0-arm64-darwin) bigdecimal rake (~> 13.3) - google-protobuf (4.34.1-x86_64-darwin) + google-protobuf (4.35.0-x86_64-darwin) bigdecimal rake (~> 13.3) - google-protobuf (4.34.1-x86_64-linux-gnu) + google-protobuf (4.35.0-x86_64-linux-gnu) bigdecimal rake (~> 13.3) - google-protobuf (4.34.1-x86_64-linux-musl) + google-protobuf (4.35.0-x86_64-linux-musl) bigdecimal rake (~> 13.3) - googleapis-common-protos-types (1.22.0) + googleapis-common-protos-types (1.23.0) google-protobuf (~> 4.26) googleauth (1.16.2) faraday (>= 1.0, < 3.a) @@ -755,8 +755,8 @@ GEM jmespath (1.6.2) job-iteration (1.14.0) activejob (>= 7.1) - json (2.19.5) - json-jwt (1.17.0) + json (2.19.7) + json-jwt (1.17.1) activesupport (>= 4.2) aes_key_wrap base64 @@ -829,7 +829,7 @@ GEM marcel (1.0.4) markly (0.16.0) matrix (0.4.3) - mcp (0.14.0) + mcp (0.17.0) json-schema (>= 4.1) messagebird-rest (5.0.0) jwt (< 4) @@ -846,7 +846,7 @@ GEM minitest (6.0.6) drb (~> 2.0) prism (~> 1.5) - msgpack (1.8.0) + msgpack (1.8.1) multi_json (1.20.1) mustermann (4.0.0) mustermann-grape (1.1.0) @@ -885,7 +885,7 @@ GEM oj (3.17.1) bigdecimal (>= 3.0) ostruct (>= 0.2) - okcomputer (1.19.1) + okcomputer (1.19.2) benchmark omniauth-saml (1.10.6) omniauth (~> 1.3, >= 1.3.2) @@ -954,7 +954,7 @@ GEM opentelemetry-instrumentation-active_support (~> 0.10) opentelemetry-instrumentation-active_support (0.12.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-all (0.93.0) + opentelemetry-instrumentation-all (0.94.0) opentelemetry-instrumentation-active_model_serializers (~> 0.25.0) opentelemetry-instrumentation-anthropic (~> 0.5.0) opentelemetry-instrumentation-aws_lambda (~> 0.7.0) @@ -991,14 +991,14 @@ GEM opentelemetry-instrumentation-ruby_kafka (~> 0.25.0) opentelemetry-instrumentation-sidekiq (~> 0.29.0) opentelemetry-instrumentation-sinatra (~> 0.30.0) - opentelemetry-instrumentation-trilogy (~> 0.68.0) + opentelemetry-instrumentation-trilogy (~> 0.69.0) opentelemetry-instrumentation-anthropic (0.5.0) opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-aws_lambda (0.7.0) opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-aws_sdk (0.12.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-base (0.26.0) + opentelemetry-instrumentation-base (0.26.1) opentelemetry-api (~> 1.7) opentelemetry-common (~> 0.21) opentelemetry-registry (~> 0.1) @@ -1020,9 +1020,9 @@ GEM opentelemetry-instrumentation-rack (~> 0.29) opentelemetry-instrumentation-graphql (0.32.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-grpc (0.5.0) + opentelemetry-instrumentation-grpc (0.5.1) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-gruf (0.6.0) + opentelemetry-instrumentation-gruf (0.6.1) opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-http (0.30.0) opentelemetry-instrumentation-base (~> 0.25) @@ -1051,7 +1051,7 @@ GEM opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-racecar (0.7.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-rack (0.31.0) + opentelemetry-instrumentation-rack (0.31.1) opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-rails (0.42.0) opentelemetry-instrumentation-action_mailer (~> 0.7) @@ -1078,7 +1078,7 @@ GEM opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-sinatra (0.30.0) opentelemetry-instrumentation-rack (~> 0.29) - opentelemetry-instrumentation-trilogy (0.68.0) + opentelemetry-instrumentation-trilogy (0.69.0) opentelemetry-helpers-mysql opentelemetry-helpers-sql opentelemetry-helpers-sql-processor @@ -1092,14 +1092,14 @@ GEM opentelemetry-common (~> 0.20) opentelemetry-registry (~> 0.2) opentelemetry-semantic_conventions - opentelemetry-semantic_conventions (1.37.1) + opentelemetry-semantic_conventions (1.38.0) opentelemetry-api (~> 1.0) optimist (3.2.1) os (1.1.4) ostruct (0.6.3) ox (2.14.26) bigdecimal (>= 3.0) - pagy (43.5.4) + pagy (43.5.5) json uri yaml @@ -1107,7 +1107,7 @@ GEM activerecord (>= 7.1) request_store (~> 1.4) parallel (2.1.0) - parallel_tests (5.7.0) + parallel_tests (4.10.1) parallel parser (3.3.11.1) ast (~> 2.4.1) @@ -1197,7 +1197,7 @@ GEM eventmachine_httpserver http_parser.rb (~> 0.8.0) multi_json - puma (8.0.1) + puma (8.0.2) nio4r (~> 2.0) puma-plugin-statsd (2.8.0) puma (>= 5.0, < 9) @@ -1335,7 +1335,7 @@ GEM rspec-support (3.13.7) rspec-wait (1.0.2) rspec (>= 3.4) - rubocop (1.86.2) + rubocop (1.87.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -1355,13 +1355,13 @@ GEM rubocop-factory_bot (2.28.0) lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) - rubocop-openproject (0.4.0) + rubocop-openproject (0.5.0) rubocop rubocop-performance (1.26.1) lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.47.1, < 2.0) - rubocop-rails (2.35.1) + rubocop-rails (2.35.3) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) @@ -1425,7 +1425,7 @@ GEM bigdecimal logger ruby-ole - spring (4.5.0) + spring (4.6.0) spring-commands-rspec (1.0.4) spring (>= 0.9.1) spring-commands-rubocop (0.4.0) @@ -1493,7 +1493,7 @@ GEM public_suffix vcr (6.4.0) vernier (1.10.1) - view_component (4.10.0) + view_component (4.11.0) actionview (>= 7.1.0) activesupport (>= 7.1.0) concurrent-ruby (~> 1) @@ -1554,8 +1554,8 @@ GEM railties yabeda (~> 0.8) yaml (0.4.0) - yard (0.9.43) - zeitwerk (2.7.5) + yard (0.9.44) + zeitwerk (2.8.2) PLATFORMS aarch64-linux @@ -1594,7 +1594,7 @@ DEPENDENCIES capybara (~> 3.40.0) capybara-screenshot (~> 1.0.17) capybara_accessible_selectors! - carrierwave (~> 2.2.6) + carrierwave (~> 2.2.7) carrierwave_direct (~> 3.0.0) climate_control closure_tree (~> 9.7.0) @@ -1656,7 +1656,7 @@ DEPENDENCIES mail (= 2.9.0) markly (~> 0.15) matrix (~> 0.4.3) - mcp (~> 0.14.0) + mcp (~> 0.17.0) md_to_pdf! meta-tags (~> 2.23.0) mini_magick (~> 5.3.0) @@ -1700,13 +1700,13 @@ DEPENDENCIES openproject-wikis! openproject-xls_export! opentelemetry-exporter-otlp (~> 0.34.0) - opentelemetry-instrumentation-all (~> 0.93.0) + opentelemetry-instrumentation-all (~> 0.94.0) opentelemetry-sdk (~> 1.10) overviews! ox pagy paper_trail (~> 17.0.0) - parallel_tests (~> 5.7) + parallel_tests (~> 4.0) pdf-inspector (~> 1.2) pg (~> 1.6.2) plaintext (~> 0.3.7) @@ -1746,7 +1746,7 @@ DEPENDENCIES rubocop-factory_bot rubocop-openproject rubocop-performance - rubocop-rails (~> 2.35.1) + rubocop-rails (~> 2.35.2) rubocop-rspec rubocop-rspec_rails ruby-duration (~> 3.2.0) @@ -1784,7 +1784,7 @@ DEPENDENCIES validate_url vcr vernier - view_component (~> 4.10.0) + view_component (~> 4.11.0) warden (~> 1.2) warden-basic_auth (~> 0.2.1) webmock (~> 3.26) @@ -1830,11 +1830,11 @@ CHECKSUMS auto_strip_attributes (2.6.0) sha256=a7e2e0cf744de2bcd947fd68014220702bcc88c81274c1cd9ce6f7316aae39b0 awesome_nested_set (3.9.0) sha256=3ce99e816550f97f4de118e621630070aacf24928b920fe4a68846578a8daaed aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b - aws-partitions (1.1249.0) sha256=f0bed070aba353e6ffe439953d6c354b3f51d16770386d2b198e71d34612f2e9 - aws-sdk-core (3.247.0) sha256=789864594ce8cef05ee3d81fa8ed506099280bda6ea12a7612b8b7c5e5e62851 - aws-sdk-kms (1.125.0) sha256=23f81bc0838ae6ec2e8de3eae88af521d0e29d3a59b6c9dbb4b21343ba476bc8 - aws-sdk-s3 (1.222.0) sha256=bae4b06fccf0b81b8d77e7abfc56e5e2146590d43c4ab58db0cee3ff5bbfb7f1 - aws-sdk-sns (1.113.0) sha256=15fe37d010e86f4c28b4c2f2133c463ce5c14189ec3673a1f43c30dfee511b0f + aws-partitions (1.1255.0) sha256=490ffc1f032d3eac771cf4a94ab7a3c7999c5edbe6fb7660904e702e291c93b5 + aws-sdk-core (3.250.0) sha256=8df71164c7e5f2ff411782a3dc3a1ab5c0a73170284409ead111f385e9992963 + aws-sdk-kms (1.128.0) sha256=20ce3957f80c7b40b3115a7e902af52b15a6eef42fe6b2e19db72f8e8c9e5658 + aws-sdk-s3 (1.224.0) sha256=7d443c4ae9eda795b60e081d41f49b498e2483f1fc204f3239176ec922ed7879 + aws-sdk-sns (1.116.0) sha256=5536532b8f9d54c92a44d8f38fae84333e5eea9b53fdf30bee18cba99e29a9c2 aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00 axe-core-api (4.11.3) sha256=f5f6e802743644a50e2d8ef24c22aefbb6df49dd169024ff0144b47f37e652ba axe-core-rspec (4.11.3) sha256=246c8d443517354e9a9962a10a8cc456bcef4c617516c0924b051a9af9d7da99 @@ -1853,7 +1853,7 @@ CHECKSUMS capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef capybara-screenshot (1.0.27) sha256=afa1896cc23df77be1774e8d3b3ce3953bf060aeaa04ff87607b5daf689174f2 capybara_accessible_selectors (0.15.0) - carrierwave (2.2.6) sha256=cd9b6108fc7544e97e7fbcc561bd319a09f23c96816fdd0df8f2f45ffdc0dac3 + carrierwave (2.2.7) sha256=1ede8f502f420e8a34ca1ccbbc9dc92ea159e94583e1194b4ea720a5ccc2d855 carrierwave_direct (3.0.0) sha256=da4105ec7beea2687817b95ad95181be3d657248ec1cb5a0e5c35a45b176fc2f cbor (0.5.10.2) sha256=df5104f7a62c881123e6505441b1e276208be1771540c2cc3b1de8a210a7c52c cgi (0.5.1) sha256=e93fcafc69b8a934fe1e6146121fa35430efa8b4a4047c4893764067036f18e9 @@ -1884,7 +1884,7 @@ CHECKSUMS csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f cuprite (0.17) sha256=b140d5dc70d08b97ad54bcf45cd95d0bd430e291e9dffe76fff851fddd57c12b daemons (1.4.1) sha256=8fc76d76faec669feb5e455d72f35bd4c46dc6735e28c420afb822fac1fa9a1d - dalli (5.0.4) sha256=3b258f100b8e3acd128a095fbd090c5a1324a6f5238fd1f86a2547473e6d49fc + dalli (5.0.5) sha256=5f51502200999278806654c846054856d6d0b5607cc1d14d06c617042f71e6f9 date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 date_validator (0.12.0) sha256=68c9834da240347b9c17441c553a183572508617ebfbe8c020020f3192ce3058 deckar01-task_list (2.3.4) sha256=66abdc7e009ea759732bb53867e1ea42de550e2aa03ac30a015cbf42a04c1667 @@ -1893,7 +1893,7 @@ CHECKSUMS descendants_tracker (0.0.4) sha256=e9c41dd4cfbb85829a9301ea7e7c48c2a03b26f09319db230e6479ccdc780897 diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 disposable (0.6.3) sha256=7f2a3fb251bff6cd83f25b164043d4ec3531209b51b066ed476a9df9c2d384cc - doorkeeper (5.9.0) sha256=edad053b3dcb6bf51e3181fc423333e7fba28863beb2122379643ce6463b6336 + doorkeeper (5.9.1) sha256=f74a35af9da40e59cac7cfd72c6d927421ef171b6d6d9ca0babf66e3fa8b9c90 dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d dotenv-rails (3.2.0) sha256=657e25554ba622ffc95d8c4f1670286510f47f2edda9f68293c3f661b303beab drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 @@ -1925,7 +1925,7 @@ CHECKSUMS factory_bot_rails (6.5.1) sha256=d3cc4851eae4dea8a665ec4a4516895045e710554d2b5ac9e68b94d351bc6d68 faraday (2.14.2) sha256=73ccb9994a9e8648f010e32eca2ae82e41c57860aa10932cda29418b9e0223ad faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad - faraday-net_http (3.4.2) sha256=f147758260d3526939bf57ecf911682f94926a3666502e24c69992765875906c + faraday-net_http (3.4.3) sha256=9db13becec9312f345a769eeeecf9049c9287d54c0ae053d7235228993a4eec1 ferrum (0.17.2) sha256=2c2540a850b211a46f4d81de21bfd62048f507e4c327d1807225c3823c17e6ee ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39 @@ -1943,23 +1943,23 @@ CHECKSUMS formatador (1.2.3) sha256=19fa898133c2c26cdbb5d09f6998c1e137ad9427a046663e55adfe18b950d894 friendly_id (5.7.0) sha256=b8994416ebaceebc7c60bc556e84ef32a3a663e2e582eb04c429216cc98de4c4 front_matter_parser (1.0.1) sha256=bae298bda01db95788a4d6452f1670a3d198c6716c8d3727db9a95533deb7b7b - fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68 + fugit (1.12.2) sha256=643f2bf28db263bd400cbf8e0dd8b76b2c9b94bdb130e12d2394de04d9c20e5e fuubar (2.5.1) sha256=b272a7804b282661c7fab583a3764f92543cb482c365ae39c685cd218fdd4880 - glob (0.4.0) sha256=893dc9e2d24abe13dda907ce0cda576f680ff382f2a6cf9e543f98ecbe29238c + glob (0.5.0) sha256=6397ae620b2f71b00424ec6c880c92d5ddcf6c44e6035c0b610a59efe16418fd globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 good_job (4.18.2) sha256=7e557a15865fc7b7ad4ab71644cf1d4189a2a1869d3b381e5e88741c540beca6 google-apis-core (1.0.2) sha256=ba4579aaadc902d6cc7bc8db88f566ab00f5e31ea87ab41e9f9a032c470f2629 - google-apis-gmail_v1 (0.49.0) sha256=f5b5e597c8018d9e4b8df37fb06a8df429bd2de5f0fc94fb21a4d32b139f6ff1 + google-apis-gmail_v1 (0.51.0) sha256=cec89406ee645e71697a0eb0237e972df213d4dde86a772b05979175c48a6d11 google-cloud-env (2.3.1) sha256=0faac01eb27be78c2591d64433663b1a114f8f7af55a4f819755426cac9178e7 google-logging-utils (0.2.0) sha256=675462b4ea5affa825a3442694ca2d75d0069455a1d0956127207498fca3df7b - google-protobuf (4.34.1) sha256=347181542b8d659c60f028fa3791c9cccce651a91ad27782dbc5c5e374796cdc - google-protobuf (4.34.1-aarch64-linux-gnu) sha256=f9c07607dc139c895f2792a7740fcd01cd94d4d7b0e0a939045b50d7999f0b1d - google-protobuf (4.34.1-aarch64-linux-musl) sha256=db58e5a4a492b43c6614486aea31b7fb86955b175d1d48f28ebf388f058d78a9 - google-protobuf (4.34.1-arm64-darwin) sha256=2745061f973119e6e7f3c81a0c77025d291a3caa6585a2cd24a25bbc7bedb267 - google-protobuf (4.34.1-x86_64-darwin) sha256=4dc498376e218871613589c4d872400d42ad9ae0c700bdb2606fe1c77a593075 - google-protobuf (4.34.1-x86_64-linux-gnu) sha256=87088c9fd8e47b5b40ca498fc1195add6149e941ff7e81c532a5b0b8876d4cc9 - google-protobuf (4.34.1-x86_64-linux-musl) sha256=8c0e91436fbe504ffc64f0bd621f2e69adbcce8ed2c58439d7a21117069cfdd7 - googleapis-common-protos-types (1.22.0) sha256=f97492b77bd6da0018c860d5004f512fe7cd165554d7019a8f4df6a56fbfc4c7 + google-protobuf (4.35.0) sha256=95346162c792ed78c9a28cbf2d937a53f706de6df36a27471582f63f03c30c0d + google-protobuf (4.35.0-aarch64-linux-gnu) sha256=2cabd61b420918aec1564f9f7414e455bf922d20c5f8139d62783df5558a7aea + google-protobuf (4.35.0-aarch64-linux-musl) sha256=f6b11f3420a4564f68e8233c95eac6924f6a727dfc2f81a56af2e09add551501 + google-protobuf (4.35.0-arm64-darwin) sha256=66ab26d3fc82b8950702e53ab16c198e3c0ea3f2a38aaaf1f32152da45593ac5 + google-protobuf (4.35.0-x86_64-darwin) sha256=05eb5c8bc9899135befff496fc0a3642e7ff3d0943f043841dcc456f5654fea0 + google-protobuf (4.35.0-x86_64-linux-gnu) sha256=999226f3b00cd9fddb1b26851d16060212fa1d90c406aaad47e574682b716059 + google-protobuf (4.35.0-x86_64-linux-musl) sha256=be0218520d77b2aee898b363514b03819f6f63f9c041ae0d0d79b4ce5247bffd + googleapis-common-protos-types (1.23.0) sha256=992e740a523794d9fc5f29a504465d8fc737aaa16c930fe7228e3346860faf0a googleauth (1.16.2) sha256=15009502e2e38af71948cda918f230e27d327f6882a1e47967a5a4664930a638 grape (3.2.1) sha256=448072f55904e5a4dca2e3781f0a373942514be65402cafb6177f5bc73db1b94 grape_logging (3.0.0) sha256=7b62d984ce96df15d120508668debe307e6a59ac1c511f1d9b5f3b4bea793e13 @@ -1991,8 +1991,8 @@ CHECKSUMS iso8601 (0.13.0) sha256=298c2b15b7be5fa95a1372813d36a2257656cd8e906dfbc1f5cb409851425aa2 jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 job-iteration (1.14.0) sha256=f154f978109acc838c0359ecde2fdd4dccc3382f95a22e03a58ac561a3615224 - json (2.19.5) sha256=218a18553e4801d579ca7e0f5bc72bafd776d7397238a1fb4e74db5b0a812c59 - json-jwt (1.17.0) sha256=6ff99026b4c54281a9431179f76ceb81faa14772d710ef6169785199caadc4cc + json (2.19.7) sha256=fe432c8639f6efff69f9d73b518a3705d9581ab93156f981ea72806e1e5bcc3e + json-jwt (1.17.1) sha256=5e1ced0f7b206b4c567efee19e6503c1426a819749132926cda579ec013d1f46 json-schema (6.2.0) sha256=e8bff46ed845a22c1ab2bd0d7eccf831c01fe23bb3920caa4c74db4306813666 json_schemer (2.5.0) sha256=2f01fb4cce721a4e08dd068fc2030cffd0702a7f333f1ea2be6e8991f00ae396 json_spec (1.1.5) sha256=7a77b97a92c787e2aa3fbc4a1239afc3342c781151dc98cfb81461b3b7cad10f @@ -2013,7 +2013,7 @@ CHECKSUMS marcel (1.0.4) sha256=0d5649feb64b8f19f3d3468b96c680bae9746335d02194270287868a661516a4 markly (0.16.0) sha256=6f70d79e385b1efc9e171f74c81628826259039fe6c778e03c3924c71dac5511 matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b - mcp (0.14.0) sha256=9e3ca2e6b5e568739e8c07090982829896f2e4d884ffbb668d06f0fe758489e1 + mcp (0.17.0) sha256=a66b71254cd8c49c471cfa55f11a9f050817ab7168cadf961555784d365b8474 md_to_pdf (0.2.6) messagebird-rest (5.0.0) sha256=da4cc1efba3d5e4aa021fad07426c2cb6b326ce5670da5104bb8f6056a39d59c meta-tags (2.23.0) sha256=ffe78b5bee398de4ff5ac3316f5a786049538a651643b8476def06c3acc762c1 @@ -2023,7 +2023,7 @@ CHECKSUMS mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4 mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 - msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 + msgpack (1.8.1) sha256=3fef787cd3965fd119c08a22724a56a93ca25008c3421fc15039f603a8b7c86c multi_json (1.20.1) sha256=2f3934e805cc45ef91b551a1f89d0e9191abd06a5e04a2ef09a6a036c452ca6d mustermann (4.0.0) sha256=91f67411bb208d1d93c41e6128cb3b0f8ddd9ec7c45966f1007e1c43c08040d7 mustermann-grape (1.1.0) sha256=8d258a986004c8f01ce4c023c0b037c168a9ed889cf5778068ad54398fa458c5 @@ -2044,7 +2044,7 @@ CHECKSUMS nokogiri (1.19.3-x86_64-linux-gnu) sha256=2f5078620fe12e83669b5b17311b32532a8153d02eee7ad06948b926d6080976 nokogiri (1.19.3-x86_64-linux-musl) sha256=248c906d2166eca5efb56d52fdee5f9a1f51d69a72e2b64fdac647b4ce39ea3f oj (3.17.1) sha256=b00687f10bf68a32bfb633b87624174faf0989a5c96aff2f3f96f992717ce782 - okcomputer (1.19.1) sha256=7df770e768434816d228407f0786563827cbf34cb379933578829720cb4f1e77 + okcomputer (1.19.2) sha256=d069aedf1e31b8ebe7e1fdf9e327dee158ea49b9fbdebebc2f1bed4690cb7a6d omniauth (1.9.2) omniauth-openid-connect (0.5.0) omniauth-openid_connect-providers (0.2.0) @@ -2096,11 +2096,11 @@ CHECKSUMS opentelemetry-instrumentation-active_record (0.13.0) sha256=239fceaae5a42e82dd9dd87bc63b1888bea32058dac0779b5ea36110fcb3a299 opentelemetry-instrumentation-active_storage (0.5.0) sha256=39920d405fd111cd98c01a90a24b19413f4cb5bc8de2a24d7882f785e8f02c19 opentelemetry-instrumentation-active_support (0.12.0) sha256=29a2cbdcb3aad4a42f4c9e829dab11167b71ed8a5205ad54587fe4d59d8ee704 - opentelemetry-instrumentation-all (0.93.0) sha256=e1f918add0d5ec48502cb2fbb49c122457fc2bd2cb54ee85e2db872308bfcb24 + opentelemetry-instrumentation-all (0.94.0) sha256=f1d1bb66638c57a4a38bf8f1a26eaa80094a35c26a839ab990b88f238809936f opentelemetry-instrumentation-anthropic (0.5.0) sha256=a6b1e1f324d35323d4714a7f204c9d34c46da07529877a725829420a4a44bb13 opentelemetry-instrumentation-aws_lambda (0.7.0) sha256=50e5a32c454f2d38ecb53cc94e77dc646b33c47294cc6e6363e7c226097fa132 opentelemetry-instrumentation-aws_sdk (0.12.0) sha256=e2f48bf471cefe4d4bd9cfdafabffce65790b73381040e82d337933ac8bfb366 - opentelemetry-instrumentation-base (0.26.0) sha256=fdec8bff9a8de04d113bd4e8d490b17414c92d6c79dd457dfa079c97ba922be0 + opentelemetry-instrumentation-base (0.26.1) sha256=49e8d35d565e1a13b4a6b794a4cca4ed8c590d9b33bda5244249bd98c8673775 opentelemetry-instrumentation-bunny (0.25.0) sha256=a8b20b7b4cdbfcfd64036b41c160061b164da2938aa7a7621849ee9f7ecb81b8 opentelemetry-instrumentation-concurrent_ruby (0.25.0) sha256=722912e7078e3025a84a25d0b6085702417598c58187c19a762234702cbf7b2d opentelemetry-instrumentation-dalli (0.30.0) sha256=5e2fc0ef1f7eb684c6a987789ad0bad22ea9350376e134cd7c803e5eb02776ee @@ -2110,8 +2110,8 @@ CHECKSUMS opentelemetry-instrumentation-faraday (0.33.0) sha256=f4320bece35997b8ce2ba520eaf52499b89a0c048fce9ce0a10c1ae5d783f801 opentelemetry-instrumentation-grape (0.7.0) sha256=1b7dddd8e2baad62de6cd20fc924089fb5b8953e23ba41b83b4116ad5bcc03bf opentelemetry-instrumentation-graphql (0.32.0) sha256=c3af73b42ac5ac873476f2c4c4cf46de2fb81bb74612cea61214a9911708afaa - opentelemetry-instrumentation-grpc (0.5.0) sha256=09bd9ccbedea4668e80546a24dbc1b148fd3775658228050f7378fc0e03b863d - opentelemetry-instrumentation-gruf (0.6.0) sha256=b2a2455b2c622962fc27a943572ee0c660297206bc5eb51b61095d5eb37eae0c + opentelemetry-instrumentation-grpc (0.5.1) sha256=e7a40d1b394745bf638aba27f2c207b439c9e7fd1196f713c731e91697d19bc1 + opentelemetry-instrumentation-gruf (0.6.1) sha256=875407e0063f874a01768ed567b13913ab8d03115bb9ff76f179149abc597dd5 opentelemetry-instrumentation-http (0.30.0) sha256=36d2639eab81d386b25e99e0d91fe31888a255159a1213b9648e8359751055c8 opentelemetry-instrumentation-http_client (0.29.0) sha256=92363f0aa7a4286cb02e551c483078a2d5323e9f4ca2b706f2066834f7793d3d opentelemetry-instrumentation-httpx (0.8.0) sha256=694b6e3eb6df04f1534d7713b5bd67ab5c2e2f2d5438f2c972542b1617378fec @@ -2123,7 +2123,7 @@ CHECKSUMS opentelemetry-instrumentation-pg (0.36.0) sha256=f7346f8c4377c053ca2720ac112eacef884840e3f97621ec6f553f3cb23baec7 opentelemetry-instrumentation-que (0.13.0) sha256=95e04b8c17e89eaa446f5dff7975cf72c2e74f7e1e84717be47facf510613112 opentelemetry-instrumentation-racecar (0.7.0) sha256=93a991917687aa0a6e785ccd8d0de5746af110122552b2bf1e3abe12ab04584a - opentelemetry-instrumentation-rack (0.31.0) sha256=bae9f424a2bb1dde2aa5e2c7e02dbbb80c972e7f4964ab820431229f276e0bfc + opentelemetry-instrumentation-rack (0.31.1) sha256=29a9c145ce6d3fa2938f30334209787c29b581a51fdb472e2098a1d69e15536a opentelemetry-instrumentation-rails (0.42.0) sha256=5ea3808373ca73ee9fa4ecf337471bad1c28e98ea64460b89ab225f5b6eaf8b3 opentelemetry-instrumentation-rake (0.6.0) sha256=71746e4e172560f8ccf1d3c91354196f5aa0fd9b0c477d6ff17a451cef901822 opentelemetry-instrumentation-rdkafka (0.10.0) sha256=ad1a4aa78c0ab43c1f130d961f54e1d6ac20c674b0180b87d4364770b81ec209 @@ -2133,19 +2133,19 @@ CHECKSUMS opentelemetry-instrumentation-ruby_kafka (0.25.0) sha256=33ceccd5cef4f648e652fa45896d0b014da1a71b3a80f17064829ee5aa84e285 opentelemetry-instrumentation-sidekiq (0.29.0) sha256=b1d2a0cb9041a5e14239fe7c94d99e3dd07f870e2759460ab63592d7cdd8aadc opentelemetry-instrumentation-sinatra (0.30.0) sha256=b67301153420f43264a0c68cdb3ca5bd77467cf5054e57b83a2bf891aaaa0361 - opentelemetry-instrumentation-trilogy (0.68.0) sha256=24b31efdf21a08644ad26038b574f4b0876195b1502f3f64b1065eff3fe0f588 + opentelemetry-instrumentation-trilogy (0.69.0) sha256=0676dd720eeab284abfa52f273967442156fcac7084a1e1411373cf14ec026ad opentelemetry-registry (0.6.0) sha256=5d3ed32ab9eee0fbdb30d4f0d0bb61ad11a4040b267b475ae815b80a8498a728 opentelemetry-sdk (1.12.0) sha256=a224abe0c59023d41cb7ac1c634d9d28843907efcd045ed1ae320796c48b864b - opentelemetry-semantic_conventions (1.37.1) sha256=74e87b31ab88c38410c5447d3bc5e692dcc0a73ee2114910c0142fc4546531c1 + opentelemetry-semantic_conventions (1.38.0) sha256=f0c2275a9c921ad7b5e70b658e269bfc6d46e3471d0636e1529fd7f34ae9a663 optimist (3.2.1) sha256=8cf8a0fd69f3aa24ab48885d3a666717c27bc3d9edd6e976e18b9d771e72e34e os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 overviews (1.0.0) ox (2.14.26) sha256=d4a9cb49fb55751631d469623cfb2e7dad7cea89119c93749c1e31a3fb2f4896 - pagy (43.5.4) sha256=2bdf3fa6b1e0cac5bbafe5d077fb24eb971f72f3194f8c6863a0f3867261ce59 + pagy (43.5.5) sha256=86790336da3a1966e3503c226989577f1f56e3353dad10353445e8c988412548 paper_trail (17.0.0) sha256=1c2842061d3874ca7015908e821e2aa14f9b982af2acb2a7974713bf79021c85 parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 - parallel_tests (5.7.0) sha256=3f1762c46ca2c223b8af8ef877217f9d76974e191bfa934f2580b58bcf1d005c + parallel_tests (4.10.1) sha256=df05458c691462b210f7a41fc2651d4e4e8a881e8190e6d1e122c92c07735d70 parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 pdf-core (0.9.0) sha256=4f368b2f12b57ec979872d4bf4bd1a67e8648e0c81ab89801431d2fc89f4e0bb pdf-inspector (1.3.0) sha256=fc107579d6f29b636e2da3d6743479b2624d9e390bf2d84beef8fd4ebe1a05bd @@ -2177,7 +2177,7 @@ CHECKSUMS psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974 public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 puffing-billy (4.0.4) sha256=87015b0c41e0722b2171a0c5aa8130fd3f58aa1c016a1dc6dc569b2028aa846f - puma (8.0.1) sha256=7b94e50c07655718c1fb8ae41a11fc06c7d61293208b3aa608ff71a46d3ad37c + puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb puma-plugin-statsd (2.8.0) sha256=e515445f93232b6b3571a23b832f93a776d4ce0fc8a5edee798013b82f3488f3 raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f @@ -2230,13 +2230,13 @@ CHECKSUMS rspec-retry (0.6.2) sha256=6101ba23a38809811ae3484acde4ab481c54d846ac66d5037ccb40131a60d858 rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c rspec-wait (1.0.2) sha256=865f921239325d3d26fc10ded4bdd485d8b58bcaaad1a28dd85ed15266b5a912 - rubocop (1.86.2) sha256=bb2e97f635eda42c448f2588f4a6ff78f221b8bdfdf65b1e9b07fbd57521b45d + rubocop (1.87.0) sha256=b9d9ddf55116a513f8ef2c7ae660662d8b49301f118d3f0df61865b33a5c188d rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 rubocop-capybara (2.23.0) sha256=f9ea1ba3a7561ee8e88cf76fc378ce517ce5327155f305ee7b5c2500e5aee357 rubocop-factory_bot (2.28.0) sha256=4b17fc02124444173317e131759d195b0d762844a71a29fe8139c1105d92f0cb - rubocop-openproject (0.4.0) sha256=ce56d9e591f9be5a4d98125b10a73564b0557a5e408f97918f9630fb15ae66ae + rubocop-openproject (0.5.0) sha256=564d4a0d240bec8543c96d1cb38facba9afd3fbe7e91ff28d254d3ebf329c323 rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 - rubocop-rails (2.35.1) sha256=4ad270f5fb968ce86ac19f53c4b97354e4f91bc5334177478238674f51568377 + rubocop-rails (2.35.3) sha256=6edd45410866912b9b2e90ae3aeafd31d576df2bb2a9c9408f1667a50c32c7de rubocop-rspec (3.9.0) sha256=8fa70a3619408237d789aeecfb9beef40576acc855173e60939d63332fdb55e2 rubocop-rspec_rails (2.32.0) sha256=4a0d641c72f6ebb957534f539d9d0a62c47abd8ce0d0aeee1ef4701e892a9100 ruby-duration (3.2.3) sha256=eb3d13b1df85067a015a8fb2ed8f1eec842a3b721e47c9b6fd74d2f356069784 @@ -2262,7 +2262,7 @@ CHECKSUMS simpleidn (0.2.3) sha256=08ce96f03fa1605286be22651ba0fc9c0b2d6272c9b27a260bc88be05b0d2c29 smart_properties (1.17.0) sha256=f9323f8122e932341756ddec8e0ac9ec6e238408a7661508be99439ca6d6384b spreadsheet (1.3.5) sha256=cd83ea66803d9cae4ac258dfe16cd8c2b85da33eec18a6d7b48fd4a45840ab7d - spring (4.5.0) sha256=9124b954f6d079cb9aa610d7069cbada7c146c9368599557806a9ff7067b2e1b + spring (4.6.0) sha256=e9e41f9a56fa5f111df7c85110a7d606ca731dc116c906ba395fd74c9fd474e3 spring-commands-rspec (1.0.4) sha256=6202e54fa4767452e3641461a83347645af478bf45dddcca9737b43af0dd1a2c spring-commands-rubocop (0.4.0) sha256=3e677a2c8a27ae8a986f04bfb69e66d5d55b017541e8be93bf0dc48a7f5690c1 sprockets (3.7.5) sha256=72c20f256548f8a37fe7db41d96be86c3262fddaf4ebe9d69ec8317394fed383 @@ -2291,7 +2291,7 @@ CHECKSUMS ttfunk (1.7.0) sha256=2370ba484b1891c70bdcafd3448cfd82a32dd794802d81d720a64c15d3ef2a96 turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f turbo_power (0.7.0) sha256=ad95d147e0fa761d0023ad9ca00528c7b7ddf6bba8ca2e23755d5b21b290d967 - turbo_tests (2.2.5) + turbo_tests (2.2.0) tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b tzinfo-data (1.2026.2) sha256=7db0d3d3d53b8d7601fc183fccc8c6d056a3004e14eb59ea995bf6aec4ae10bc uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc @@ -2303,7 +2303,7 @@ CHECKSUMS validate_url (1.0.15) sha256=72fe164c0713d63a9970bd6700bea948babbfbdcec392f2342b6704042f57451 vcr (6.4.0) sha256=077ac92cc16efc5904eb90492a18153b5e6ca5398046d8a249a7c96a9ea24ae6 vernier (1.10.1) sha256=f2ab2e163b75ac3ef4e3173262d27d27da6219cf04b1b4a7171e4798114275ce - view_component (4.10.0) sha256=9e86960ee7ea4da3d1d07f65b1a66e4a9fb656be5434f1a649eb6fce6ac9baf9 + view_component (4.11.0) sha256=92df960575b8fa5e984522532df225ec333015a07a8105b3b1c200c655065517 virtus (2.0.0) sha256=8841dae4eb7fcc097320ba5ea516bf1839e5d056c61ee27138aa4bddd6e3d1c2 warden (1.2.9) sha256=46684f885d35a69dbb883deabf85a222c8e427a957804719e143005df7a1efd0 warden-basic_auth (0.2.1) sha256=bfc752e0109c0182c3e69e930284c5e1e81e7b4a354aeb2b5914ead1391f3c6e @@ -2323,8 +2323,8 @@ CHECKSUMS yabeda-puma-plugin (0.9.0) sha256=b78673ecc7ee30bc50691ddc41b7022c1c1801843900d5101418f4a14b550bc8 yabeda-rails (0.11.0) sha256=afa2581bd44c8f419cb3f2bbf9f6fb40f817c30476f7caf5d1c55c48d69a5b29 yaml (0.4.0) sha256=240e69d1e6ce3584d6085978719a0faa6218ae426e034d8f9b02fb54d3471942 - yard (0.9.43) sha256=cf8733a8f0485df2a162927e9b5f182215a61f6d22de096b8f402c726a1c5821 - zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd + yard (0.9.44) sha256=eb087e9b631ccd887b049f303d489963945452d5e2a7eb49a5a74a7cf6887f28 + zeitwerk (2.8.2) sha256=7212a61311083c604184b1ea2574b9aa05cd14f855a0841c06985cabe9181d12 RUBY VERSION ruby 4.0.2 diff --git a/app/components/_index.sass b/app/components/_index.sass index f1bf4c13736..de484ac1523 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -1,6 +1,4 @@ @import "enterprise_edition/banner_component" -@import "filter/filters_component" -@import "open_project/common/border_box_list_component" @import "op_primer/border_box_table_component" @import "op_primer/full_page_prompt_component" @import "op_primer/form_helpers" @@ -9,6 +7,7 @@ @import "open_project/common/attribute_help_text_component" @import "open_project/common/attribute_help_text_caption_component" @import "open_project/common/attribute_label_component" +@import "open_project/common/border_box_list_component" @import "open_project/common/inplace_edit_fields/index" @import "open_project/common/submenu_component" @import "open_project/common/main_menu_toggle_component" diff --git a/app/components/admin/custom_fields/custom_field_projects/row_component.html.erb b/app/components/admin/custom_fields/custom_field_projects/row_component.html.erb index cf694aa0c31..3792e1735e5 100644 --- a/app/components/admin/custom_fields/custom_field_projects/row_component.html.erb +++ b/app/components/admin/custom_fields/custom_field_projects/row_component.html.erb @@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= component_wrapper(tag: "tr", class: row_css_class, data: { turbo: true }) do %> +<%= component_wrapper(tag: "tr", class: row_css_class) do %> <% columns.each do |column| %> <%= column_value(column) %> diff --git a/app/components/admin/settings/working_days/confirm_dialog_component.html.erb b/app/components/admin/settings/working_days/confirm_dialog_component.html.erb new file mode 100644 index 00000000000..67df581f1d8 --- /dev/null +++ b/app/components/admin/settings/working_days/confirm_dialog_component.html.erb @@ -0,0 +1,39 @@ +<%= + render( + Primer::OpenProject::DangerDialog.new( + id: DIALOG_ID, + title: t("working_days.dialog.title"), + confirm_button_text: t("working_days.dialog.confirm_button"), + form_arguments:, + size: :medium_portrait + ) + ) do |dialog| + dialog.with_confirmation_message do |message| + message.with_heading(tag: :h2) { t("working_days.dialog.heading") } + message.with_description_content(t("working_days.dialog.description")) + end + + dialog.with_additional_details do + concat hidden_settings_fields + concat( + tag.div do + if removed_non_working_days.any? + concat tag.p(t("working_days.dialog.removed_title")) + concat( + render( + Primer::BaseComponent.new( + tag: :ul, + mb: 3 + ) + ) do + safe_join(removed_non_working_days.map { |non_working_day| tag.li(non_working_day) }) + end + ) + end + + concat tag.p(t("working_days.dialog.warning")) + end + ) + end + end +%> diff --git a/app/components/admin/settings/working_days/confirm_dialog_component.rb b/app/components/admin/settings/working_days/confirm_dialog_component.rb new file mode 100644 index 00000000000..ff380bff316 --- /dev/null +++ b/app/components/admin/settings/working_days/confirm_dialog_component.rb @@ -0,0 +1,78 @@ +# 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 + module Settings + module WorkingDays + class ConfirmDialogComponent < ApplicationComponent + include OpTurbo::Streamable + + DIALOG_ID = "working-days-change-dialog" + + attr_reader :form_values, :removed_non_working_days + + def initialize(form_values: {}, removed_non_working_days: []) + super + @form_values = form_values + @removed_non_working_days = removed_non_working_days + end + + private + + def form_arguments + { + action: helpers.admin_settings_working_days_and_hours_path, + method: :patch, + data: { turbo: false } + } + end + + def hidden_settings_fields + hidden_field_tags_for("settings", form_values) + end + + def hidden_field_tags_for(name, value) + case value + when Hash + safe_join( + value.flat_map do |key, nested_value| + hidden_field_tags_for("#{name}[#{key}]", nested_value) + end + ) + when Array + safe_join(value.map { |array_value| hidden_field_tag("#{name}[]", array_value) }) + else + hidden_field_tag(name, value) + end + end + end + end + end +end diff --git a/app/components/filter/filter_component.html.erb b/app/components/filter/filter_component.html.erb index 21a8d9e0475..fb10894fbbc 100644 --- a/app/components/filter/filter_component.html.erb +++ b/app/components/filter/filter_component.html.erb @@ -26,134 +26,30 @@ <% else %> <%= helpers.turbo_frame_tag TURBO_FRAME_ID do %> - <%= form_tag( - {}, + <%= primer_form_with( + url: {}, method: :get, data: { action: "submit->filter--filters-form#sendForm:prevent" } - ) do %> -
- <%= - render( - Primer::Beta::IconButton.new( - icon: :x, - scheme: :invisible, - classes: "advanced-filters--close", - tooltip_direction: :se, - aria: { label: t("js.close_form_title") }, - data: { action: "filter--filters-form#toggleDisplayFilters" } - ) - ) - %> - <%= t(:label_filter_plural) %> - -
+ <% end %> + <% end %> <% end %> <% end %> <% end %> diff --git a/app/components/filter/filter_component.rb b/app/components/filter/filter_component.rb index 9654b3b706c..92a4abd260e 100644 --- a/app/components/filter/filter_component.rb +++ b/app/components/filter/filter_component.rb @@ -29,7 +29,6 @@ # ++ module Filter class FilterComponent < ApplicationComponent - OPERATORS_WITHOUT_VALUES = %w[* !* t w].freeze TURBO_FRAME_ID = "filter_component" options :query @@ -38,29 +37,14 @@ module Filter options lazy_loaded_path: false options initially_expanded: false - # Returns filters, active and inactive. - # In case a filter is active, the active one will be preferred over the inactive one. - def each_filter - allowed_filters.each do |allowed_filter| - active_filter = query.find_active_filter(allowed_filter.name) - filter = active_filter || allowed_filter - - yield filter, active_filter.present?, additional_filter_attributes(filter) - end + def filter_form(form) + Filters::FilterFormComponent.new(builder: form, query:, allowed_filters:) end def allowed_filters query.available_advanced_filters end - def value_hidden_class(selected_operator) - operator_without_value?(selected_operator) ? "hidden" : "" - end - - def operator_without_value?(operator) - OPERATORS_WITHOUT_VALUES.include?(operator) - end - def lazy_loaded? = !!lazy_loaded_path def initially_expanded? = initially_expanded @@ -75,105 +59,16 @@ module Filter end def filter_classes - "op-filters-form op-filters-form_top-margin #{'-expanded' if initially_expanded?}" + [ + "op-filters-form", + "op-filters-form_top-margin", + ("-expanded" if initially_expanded?), + ("op-filters-form--with-footer" unless turbo_requests?) + ].compact.join(" ") end def lazy_turbo_frame_src - public_send(lazy_loaded_path, **params.permit(:filters, :columns, :sortBy, :id, :query_id)) - end - - protected - - # With this method we can pass additional options for each type of filter into the frontend. This is especially - # useful when we want to pass options for the autocompleter components. - # - # When the method is overwritten in a subclass, the subclass should call super(filter) to get the default attributes. - # - # @param filter [QueryFilter] the filter for which we want to pass additional attributes - # @return [Hash] the additional attributes for the filter, that will be yielded in the each_filter method - def additional_filter_attributes(filter) - case filter - when Queries::Filters::Shared::ProjectFilter::Required, - Queries::Filters::Shared::ProjectFilter::Optional - { autocomplete_options: project_autocomplete_options } - when Queries::Filters::Shared::CustomFields::User - { autocomplete_options: user_autocomplete_options } - when Queries::Filters::Shared::CustomFields::ListOptional - { autocomplete_options: custom_field_list_autocomplete_options(filter) } - when Queries::Filters::Shared::CustomFields::Hierarchy - { autocomplete_options: custom_field_hierarchy_autocomplete_options(filter) } - when Queries::Projects::Filters::ProjectStatusFilter, - Queries::Projects::Filters::TypeFilter - { autocomplete_options: list_autocomplete_options(filter) } - else - {} - end - end - - def custom_field_list_autocomplete_options(filter) - all_items = if filter.custom_field.version? - filter.allowed_values.map { |name, id, project_name| { name:, id:, project_name: } } - else - filter.allowed_values.map { |name, id| { name:, id: } } - end - selected = filter.values - options = { items: all_items } - options[:groupBy] = "project_name" if filter.custom_field.version? - autocomplete_options.merge(options).merge(model: all_items.select { |item| selected.include?(item[:id]) }) - end - - def custom_field_hierarchy_autocomplete_options(filter) - items = filter.allowed_values.map do |name, id| - path = name.split(" / ") - { name: path.last, id:, depth: path.length - 1 } - end - selected = filter.values - - autocomplete_options.merge({ items: }).merge(model: items.select { |item| selected.include?(item[:id]) }) - end - - def list_autocomplete_options(filter) - all_items = filter.allowed_values.map { |name, id| { name:, id: } } - selected = filter.values - autocomplete_options.merge( - items: all_items, - model: all_items.select { |item| selected.include?(item[:id]) } - ) - end - - def autocomplete_options - { - component: "opce-autocompleter", - bindValue: "id", - bindLabel: "name", - hideSelected: true - } - 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: "status", operator: "!", values: [Principal.statuses["locked"].to_s] } - ], - searchKey: "any_name_attribute", - focusDirectly: false - } + public_send(lazy_loaded_path, **params.permit(:filters, :columns, :sortBy, :id, :query_id, :project_id)) end end end diff --git a/app/components/filter/filters_component.sass b/app/components/filter/filters_component.sass deleted file mode 100644 index 6e7856e6301..00000000000 --- a/app/components/filter/filters_component.sass +++ /dev/null @@ -1,47 +0,0 @@ -.op-filters - &-form - display: none - &.-expanded - display: block - .advanced-filters--filter-value - // visibility based on operator type - &.hidden - visibility: hidden - height: 55px - - // visibility for list value selectors - .multi-select - display: none - .single-select - display: block - &.multi-value - .multi-select - display: block - .single-select - display: none - - // visibility for datetime_past value selectors - &.between-dates - >.on-date, - >.days - display: none - &.on-date - >.between-dates, - >.days - display: none - &.days - >.on-date, - >.between-dates - display: none - - // Special input field styles - &.between-dates - input[type="text"] - display: inline-block - max-width: 6rem - - .advanced-filters--controls - margin-top: 1rem - - &_top-margin - margin-top: 1rem diff --git a/app/components/filters/filter_form_component.html.erb b/app/components/filters/filter_form_component.html.erb new file mode 100644 index 00000000000..10fc8108366 --- /dev/null +++ b/app/components/filters/filter_form_component.html.erb @@ -0,0 +1,11 @@ +<%= + render( + Primer::ConditionalWrapper.new( + condition: @wrap_with_controller, + **@wrapper_arguments + ) + ) do +%> + <%= hidden_filters_input if @hidden_input_name %> + <%= render(form_list) %> +<% end %> diff --git a/app/components/filters/filter_form_component.rb b/app/components/filters/filter_form_component.rb new file mode 100644 index 00000000000..18754ee91e0 --- /dev/null +++ b/app/components/filters/filter_form_component.rb @@ -0,0 +1,172 @@ +# 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. +#++ + +# Renders the list of filter input fields (one row per available filter plus an +# "add filter" select) for a given query as part of a Primer form. +# +# The set of inputs depends on the query's available and active filters and is +# built dynamically at render time. The component receives the builder of the +# surrounding `primer_form_with` so that the emitted field names match what the +# controller expects (top-level `operator_` and `_value` fields). +# +# Embed it in any primer form: +# +# <%= primer_form_with(url: ...) do |f| %> +# <%= f.text_field(name: :title) %> +# <%= render(Filters::FilterFormComponent.new(builder: f, query: @query)) %> +# <% end %> +# +# Customise the set of advertised filters by passing `allowed_filters:` (used +# by `Filter::FilterComponent` subclasses that restrict or reorder the list). +# +# By default the component does *not* attach the `filter--filters-form` Stimulus +# controller, because in the standard layout (e.g. `Projects::IndexSubHeaderComponent`) +# the controller has to sit on a common ancestor of the advanced filter form +# *and* the inline quick filter input so that `sendForm()` can collect values +# from both. For standalone embeds with no co-located quick filter, pass +# `wrap_with_controller: true` and the component will emit its own controller +# wrapper. +# +# Pass `hidden_input_name:` (e.g. `"filters"`) to also emit a hidden input +# bound to the Stimulus controller's `filtersInput` target. The controller +# keeps the field value in sync with the serialized filter selections so +# that a normal form submit carries the canonical filter string in +# `params[:]` — no `sendForm` redirect needed. +# +# `output_format:` selects how the filter selection is serialized into the +# hidden field (and into the URL when `sendForm` redirects). Supported values: +# * `:params` (default) — URL-style string: `name ~ "foo"&login ! "bar"`. +# * `:json` — JSON array: `[{"name":{"operator":"~","values":["foo"]}}, ...]`. +# Only meaningful when this component owns the controller +# (`wrap_with_controller: true`); otherwise the host's controller wrapper +# decides. +# +# `autocomplete_append_to:` forwards an `appendTo` selector (or DOM reference +# string ng-select understands, e.g. `"#my-dialog"` or `"body"`) to every +# autocompleter the component renders. Use this when the component is embedded +# in a Primer dialog or another container that clips overflow, so the dropdown +# portal renders outside that container instead of being clipped. +class Filters::FilterFormComponent < ApplicationComponent + include OpPrimer::AttributesHelper + include Primer::FetchOrFallbackHelper + + OUTPUT_FORMATS = %i[params json].freeze + + def initialize(builder:, + query:, + allowed_filters: nil, + wrap_with_controller: false, + hidden_input_name: nil, + output_format: nil, + autocomplete_append_to: nil, + **wrapper_arguments) + super() + @builder = builder + @query = query + @allowed_filters = allowed_filters || query.available_advanced_filters + @wrap_with_controller = wrap_with_controller + @hidden_input_name = hidden_input_name + @output_format = fetch_or_fallback(OUTPUT_FORMATS, output_format.to_sym) if output_format + @autocomplete_append_to = autocomplete_append_to + @wrapper_arguments = wrapper_arguments + @wrapper_arguments[:tag] ||= :div + @wrapper_arguments[:classes] = class_names( + "op-filters-form -expanded", + @wrapper_arguments[:classes] + ) + @wrapper_arguments[:data] = merge_data( + @wrapper_arguments, + { + data: { + controller: "filter--filters-form", + filter__filters_form_output_format_value: @output_format&.to_s + } + } + ) + end + + private + + attr_reader :query, :allowed_filters + + def form_list + Primer::Forms::FormList.new(*sub_forms) + end + + def hidden_filters_input + hidden_field_tag( + @hidden_input_name, + "", + data: { filter__filters_form_target: "filtersInput" } + ) + end + + def sub_forms + forms = map_filter do |filter, active, additional_attributes| + filter_form_class(filter) + .new(@builder, filter:, additional_attributes:, active:) + end + + forms << Filters::Inputs::AddFilterForm.new( + @builder, + allowed_filters:, + active_filter_names: query.filters.map(&:name) + ) + end + + def map_filter + allowed_filters.map do |allowed_filter| + active_filter = query.find_active_filter(allowed_filter.name) + filter = active_filter || allowed_filter + + yield filter, active_filter.present?, additional_filter_attributes(filter) + end + end + + def additional_filter_attributes(filter) + opts = filter.autocomplete_options + opts = opts.merge(appendTo: @autocomplete_append_to) if @autocomplete_append_to + opts.any? ? { autocomplete_options: opts } : {} + end + + def filter_form_class(filter) + if filter.is_a?(Queries::Filters::Shared::BooleanFilter) + Filters::Inputs::BooleanForm + elsif filter.autocomplete_options.any? + Filters::Inputs::AutocompleteForm + elsif filter.type.in? %i[list list_optional list_all] + Filters::Inputs::ListForm + elsif filter.type.in? %i[datetime_past date] + Filters::Inputs::DateForm + else + Filters::Inputs::TextForm + end + end +end diff --git a/app/components/homescreen/blocks/favorite_projects.rb b/app/components/homescreen/blocks/favorite_projects.rb new file mode 100644 index 00000000000..a3fff4a4b7f --- /dev/null +++ b/app/components/homescreen/blocks/favorite_projects.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 Homescreen + module Blocks + class FavoriteProjects < Grids::WidgetComponent + def call + render(Grids::Widgets::FavoriteProjects.new(current_user:)) + end + end + end +end diff --git a/app/components/homescreen/blocks/projects.html.erb b/app/components/homescreen/blocks/projects.html.erb deleted file mode 100644 index 6e2918aed34..00000000000 --- a/app/components/homescreen/blocks/projects.html.erb +++ /dev/null @@ -1,57 +0,0 @@ -<%= widget_wrapper do %> - <% if @favorite_projects.any? %> -

<%= t("projects.lists.favorited") %>

- - <% end %> - - <% if @newest_projects.empty? %> -

- <%= t("homescreen.additional.no_visible_projects") %> -

- <% else %> -

<%= t("homescreen.additional.projects") %>

- - <% end %> - -
- <% if current_user.allowed_globally?(:add_project) %> - <%= link_to new_project_path, - { class: "button -primary", - aria: { label: t(:label_project_new) }, - title: t(:label_project_new) } do %> - <%= op_icon("button--icon icon-add") %> - <%= Project.model_name.human %> - <% end %> - <% end %> - - <%# If any project exists %> - <% unless @newest_projects.empty? %> - <%= link_to t(:label_project_view_all), projects_path, - class: "button -highlight-inverted", - title: t(:label_project_view_all) %> - <% end %> -
-<% end %> diff --git a/app/components/op_primer/attributes_helper.rb b/app/components/op_primer/attributes_helper.rb new file mode 100644 index 00000000000..4cc905747b0 --- /dev/null +++ b/app/components/op_primer/attributes_helper.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 OpPrimer + # Drop-in replacement for `Primer::AttributesHelper` that treats the Stimulus + # `controller` data attribute as plural. Stimulus supports multiple + # controllers on one element (space-separated `data-controller="a b"`), but + # upstream Primer omits `controller` from its plural data attributes, so + # `merge_data` would silently drop a caller's controller when a component + # merges in its own. Treating it as plural concatenates them instead. + module AttributesHelper + include Primer::AttributesHelper + + PLURAL_DATA_ATTRIBUTES = (Primer::AttributesHelper::PLURAL_DATA_ATTRIBUTES + %i[controller]).freeze + + def merge_data(*hashes) + merge_prefixed_attribute_hashes(*hashes, prefix: :data, plural_keys: PLURAL_DATA_ATTRIBUTES) + end + end +end diff --git a/app/components/op_primer/quick_filter/segmented_component.html.erb b/app/components/op_primer/quick_filter/segmented_component.html.erb index ba86fe5e682..52bc488c178 100644 --- a/app/components/op_primer/quick_filter/segmented_component.html.erb +++ b/app/components/op_primer/quick_filter/segmented_component.html.erb @@ -27,13 +27,15 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= render(Primer::Alpha::SegmentedControl.new("aria-label": @name, full_width: false)) do |control| %> - <% items.each do |item| %> - <% control.with_item( - tag: :a, - href: href_for(item.value), - label: item.label, - selected: current_value == item.value - ) %> +<%= component_wrapper do %> + <%= render(Primer::Alpha::SegmentedControl.new("aria-label": @name, full_width: false)) do |control| %> + <% items.each do |item| %> + <% control.with_item( + tag: :a, + href: href_for(item.value), + label: item.label, + selected: current_value == item.value + ) %> + <% end %> <% end %> <% end %> diff --git a/app/components/op_primer/quick_filter/segmented_component.rb b/app/components/op_primer/quick_filter/segmented_component.rb index 3ad3752ebec..0ead8fbb7fc 100644 --- a/app/components/op_primer/quick_filter/segmented_component.rb +++ b/app/components/op_primer/quick_filter/segmented_component.rb @@ -32,6 +32,7 @@ module OpPrimer module QuickFilter class SegmentedComponent < ApplicationComponent include ApplicationHelper + include OpTurbo::Streamable renders_many :items, Item @@ -45,9 +46,9 @@ module OpPrimer @orders = orders end - def render? - items.any? - end + def wrapper_key = "quick-filter-#{@filter_key}" + + def render? = items.any? private diff --git a/app/components/projects/index_sub_header_component.rb b/app/components/projects/index_sub_header_component.rb index e231720bed3..427a48a06ee 100644 --- a/app/components/projects/index_sub_header_component.rb +++ b/app/components/projects/index_sub_header_component.rb @@ -84,10 +84,8 @@ module Projects def allowed_new_workspace_types @allowed_new_workspace_types ||= [].tap do |types| - if OpenProject::FeatureDecisions.portfolio_models_active? - types << "portfolio" if @current_user.allowed_globally?(:add_portfolios) - types << "program" if @current_user.allowed_globally?(:add_programs) - end + types << "portfolio" if @current_user.allowed_globally?(:add_portfolios) + types << "program" if @current_user.allowed_globally?(:add_programs) types << "project" if @current_user.allowed_globally?(:add_project) end end diff --git a/app/components/projects/row_component.rb b/app/components/projects/row_component.rb index 8cd0697a812..38a47067611 100644 --- a/app/components/projects/row_component.rb +++ b/app/components/projects/row_component.rb @@ -172,7 +172,6 @@ module Projects end def workspace_type_badge - return unless OpenProject::FeatureDecisions.portfolio_models_active? # Only show icon and type for non-project workspaces return unless project.workspace_type.in?(["portfolio", "program"]) diff --git a/app/components/projects/settings/versions/related_issues_widget_component.rb b/app/components/projects/settings/versions/related_issues_widget_component.rb index 4ffe66717c5..506a423cd37 100644 --- a/app/components/projects/settings/versions/related_issues_widget_component.rb +++ b/app/components/projects/settings/versions/related_issues_widget_component.rb @@ -66,7 +66,7 @@ module Projects end def render_view_all_link(widget) - widget.with_row do + widget.with_footer do helpers.link_to( I18n.t("projects.settings.versions.show_work_packages"), helpers.project_work_packages_version_path(version) diff --git a/app/components/row_component.rb b/app/components/row_component.rb index 00ae80c0993..d9ae018c387 100644 --- a/app/components/row_component.rb +++ b/app/components/row_component.rb @@ -89,8 +89,12 @@ class RowComponent < ApplicationComponent :default end - def checkmark(condition) - if condition + def checkmark(condition, primerized: false) + return unless condition + + if primerized + render(Primer::Beta::Octicon.new(icon: :check)) + else helpers.op_icon "icon icon-checkmark" end end diff --git a/app/components/table_component.html.erb b/app/components/table_component.html.erb index 17e2a3410b9..27c53b728d6 100644 --- a/app/components/table_component.html.erb +++ b/app/components/table_component.html.erb @@ -42,7 +42,7 @@ See COPYRIGHT and LICENSE files for more details. <% if sortable_column?(name) %> <%= helpers.sort_header_tag(name, **options) %> <% else %> - +
diff --git a/app/components/users/user_filters_component.rb b/app/components/users/user_filters_component.rb index eb923be1ffa..710c9994a17 100644 --- a/app/components/users/user_filters_component.rb +++ b/app/components/users/user_filters_component.rb @@ -38,28 +38,5 @@ module Users .grep_v(Queries::Users::Filters::BlockedFilter) .sort_by(&:human_name) end - - protected - - def additional_filter_attributes(filter) - case filter - when Queries::Users::Filters::GroupFilter - { - autocomplete_options: { - component: "opce-user-autocompleter", - resource: "principals", - url: ::API::V3::Utilities::PathHelper::ApiV3Path.principals, - filters: [ - { name: "type", operator: "=", values: %w[Group] } - ], - searchKey: "any_name_attribute", - inputValue: filter.values, - bindValue: "id" - } - } - else - super - end - end end end diff --git a/app/contracts/attachments/create_contract.rb b/app/contracts/attachments/create_contract.rb index 9686efdac59..415364ad196 100644 --- a/app/contracts/attachments/create_contract.rb +++ b/app/contracts/attachments/create_contract.rb @@ -36,6 +36,7 @@ module Attachments attribute :digest attribute :description attribute :content_type + attribute :charset attribute :container attribute :container_type attribute :author diff --git a/app/contracts/oauth_clients/create_contract.rb b/app/contracts/oauth_clients/create_contract.rb index 83df00f5397..398a9a21f63 100644 --- a/app/contracts/oauth_clients/create_contract.rb +++ b/app/contracts/oauth_clients/create_contract.rb @@ -36,11 +36,9 @@ module OAuthClients validates :client_id, presence: true, length: { maximum: 255 } attribute :client_secret, writable: true - validates :client_secret, presence: true, if: :client_secret_required? + validates :client_secret, presence: true validates :client_secret, length: { maximum: 255 } - def client_secret_required? = true - attribute :integration_type, writable: true validates :integration_type, presence: true diff --git a/app/controllers/admin/settings/exports_settings_controller.rb b/app/controllers/admin/settings/exports_settings_controller.rb new file mode 100644 index 00000000000..1196c153d35 --- /dev/null +++ b/app/controllers/admin/settings/exports_settings_controller.rb @@ -0,0 +1,35 @@ +# 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 ExportsSettingsController < ::Admin::SettingsController + menu_item :settings_exports + end +end diff --git a/app/controllers/admin/settings/working_days_and_hours_settings_controller.rb b/app/controllers/admin/settings/working_days_and_hours_settings_controller.rb index 4a2d5a94af8..ed64a3b35bd 100644 --- a/app/controllers/admin/settings/working_days_and_hours_settings_controller.rb +++ b/app/controllers/admin/settings/working_days_and_hours_settings_controller.rb @@ -30,8 +30,25 @@ module Admin::Settings class WorkingDaysAndHoursSettingsController < ::Admin::SettingsController + include OpTurbo::ComponentStream + menu_item :working_days_and_hours + def confirm_changes + return update unless working_days_changed? || non_working_days_changed? + + removed_days = non_working_days_params + .select { |nwd| nwd["_destroy"].present? } + .filter_map { |nwd| removed_non_working_day_date(nwd) } + + component = Admin::Settings::WorkingDays::ConfirmDialogComponent.new( + form_values: params.expect(settings: {}).to_h, + removed_non_working_days: removed_days + ) + + respond_with_dialog(component) + end + def failure_callback(call) @modified_non_working_days = modified_non_working_days_for(call.result) flash[:error] = call.message || I18n.t(:notice_internal_server_error) @@ -53,15 +70,31 @@ module Admin::Settings private + def working_days_changed? + working_days_params(params.expect(settings: {})) != Setting.working_days.map(&:to_i) + end + + def non_working_days_changed? + non_working_days_params.any? + end + def working_days_params(settings) settings[:working_days] ? settings[:working_days].compact_blank.map(&:to_i).uniq : [] end def non_working_days_params - non_working_days = params[:settings].to_unsafe_hash[:non_working_days_attributes] || {} + non_working_days = params.expect(settings: {})[:non_working_days_attributes] || {} non_working_days.to_h.values end + def removed_non_working_day_date(non_working_day_params) + date = NonWorkingDay.find_by(id: non_working_day_params["id"])&.date || non_working_day_params["date"] + + I18n.l(date.to_date, format: :long) + rescue Date::Error, NoMethodError + nil + end + def modified_non_working_days_for(result) return if result.nil? diff --git a/app/controllers/openproject_metadata_controller.rb b/app/controllers/openproject_metadata_controller.rb new file mode 100644 index 00000000000..ca4907c02a3 --- /dev/null +++ b/app/controllers/openproject_metadata_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class OpenprojectMetadataController < ApplicationController + no_authorization_required! :show + + skip_before_action :check_if_login_required + + def show + render json: { + installation_uuid: Setting.installation_uuid + } + end +end diff --git a/app/controllers/portfolios/menus_controller.rb b/app/controllers/portfolios/menus_controller.rb index f301f114563..b8b5e7a2c8d 100644 --- a/app/controllers/portfolios/menus_controller.rb +++ b/app/controllers/portfolios/menus_controller.rb @@ -31,7 +31,6 @@ module Portfolios class MenusController < ApplicationController authorization_checked! :show - before_action :not_authorized_on_feature_flag_inactive before_action :authorize_portfolio_access, only: %i[show] def show @@ -47,9 +46,5 @@ module Portfolios render_403 unless User.current.allowed_globally?(:add_portfolios) || Project.portfolio.allowed_to(User.current, :view_project).any? end - - def not_authorized_on_feature_flag_inactive - render_403 unless OpenProject::FeatureDecisions.portfolio_models_active? - end end end diff --git a/app/controllers/portfolios_controller.rb b/app/controllers/portfolios_controller.rb index 883eee11563..9c23460bbcf 100644 --- a/app/controllers/portfolios_controller.rb +++ b/app/controllers/portfolios_controller.rb @@ -34,7 +34,6 @@ # projects in these cases. class PortfoliosController < ProjectsController before_action :authorize_portfolio_access, only: %i[index] - before_action :not_authorized_on_feature_flag_inactive skip_before_action :load_query_or_deny_access # skip using the superclass's before action because the next must be called first before_action :set_default_query, only: %i[index] # Must be called before `load_query_or_deny_access` diff --git a/app/controllers/programs_controller.rb b/app/controllers/programs_controller.rb index 582b9d7b369..b480a1022d3 100644 --- a/app/controllers/programs_controller.rb +++ b/app/controllers/programs_controller.rb @@ -29,5 +29,4 @@ # ++ class ProgramsController < ProjectsController - before_action :not_authorized_on_feature_flag_inactive end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 124afd6d046..915a21f4c0f 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -330,10 +330,6 @@ class ProjectsController < ApplicationController ::Exports::Register.list_formats(Project).map(&:to_s) end - def not_authorized_on_feature_flag_inactive - render_403 unless OpenProject::FeatureDecisions.portfolio_models_active? - end - def layout_for_new if portfolio_management_feature_missing? "global" diff --git a/app/forms/admin/settings/exports_settings_form.rb b/app/forms/admin/settings/exports_settings_form.rb new file mode 100644 index 00000000000..7f6f529ade6 --- /dev/null +++ b/app/forms/admin/settings/exports_settings_form.rb @@ -0,0 +1,49 @@ +# 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 + module Settings + class ExportsSettingsForm < ApplicationForm + settings_form do |sf| + sf.text_field( + name: :work_packages_projects_export_limit, + type: :number, + input_width: :xsmall, + caption: I18n.t(:setting_work_packages_projects_export_limit_text) + ) + + sf.check_box( + name: :csv_escape_formulas, + caption: I18n.t(:setting_csv_escape_formulas_text) + ) + end + end + end +end diff --git a/app/forms/admin/settings/general_settings_form.rb b/app/forms/admin/settings/general_settings_form.rb index a399108bba8..0f4653445e2 100644 --- a/app/forms/admin/settings/general_settings_form.rb +++ b/app/forms/admin/settings/general_settings_form.rb @@ -82,12 +82,6 @@ module Admin input_width: :xsmall ) - sf.text_field( - name: :work_packages_projects_export_limit, - type: :number, - input_width: :xsmall - ) - sf.text_field( name: :file_max_size_displayed, type: :number, diff --git a/app/forms/filters/inputs/add_filter_form.rb b/app/forms/filters/inputs/add_filter_form.rb new file mode 100644 index 00000000000..6f35b761ee8 --- /dev/null +++ b/app/forms/filters/inputs/add_filter_form.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Filters::Inputs::AddFilterForm < ApplicationForm + def initialize(allowed_filters:, active_filter_names:) + super() + @allowed_filters = allowed_filters + @active_filter_names = active_filter_names + end + + form do |form| + form.select_list( + name: :add_filter_select, + label: I18n.t(:label_filter_add), + scope_name_to_model: false, + prompt: I18n.t(:actionview_instancetag_blank_option), + data: { + "filter--filters-form-target": "addFilterSelect", + action: "change->filter--filters-form#addFilter:prevent" + } + ) do |select| + @allowed_filters.each do |filter| + select.option( + label: filter.human_name, + value: filter.name, + disabled: @active_filter_names.include?(filter.name) + ) + end + end + end +end diff --git a/app/forms/filters/inputs/autocomplete_form.rb b/app/forms/filters/inputs/autocomplete_form.rb new file mode 100644 index 00000000000..0303efae19e --- /dev/null +++ b/app/forms/filters/inputs/autocomplete_form.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Filters::Inputs::AutocompleteForm < Filters::Inputs::BaseFilterForm + def add_operand(group) + group.autocompleter( + name: operand_name, + label: @filter.human_name, + visually_hide_label: true, + required: true, + wrapper_classes: ["advanced-filters--filter-value"], + wrapper_data_attributes: { + "filter--filters-form-target": "filterValueContainer", + "filter-name": @filter.name, + "filter-autocomplete": "true" + }, + autocomplete_options: @additional_attributes[:autocomplete_options].merge( + id: operand_name, + multiple: true, + multipleAsSeparateInputs: false, + inputName: "value", + inputValue: @filter.values, + hiddenFieldAction: "change->filter--filters-form#autocompleteSendForm" + ) + ) + end +end diff --git a/app/forms/filters/inputs/base_filter_form.rb b/app/forms/filters/inputs/base_filter_form.rb new file mode 100644 index 00000000000..91ec5bf8920 --- /dev/null +++ b/app/forms/filters/inputs/base_filter_form.rb @@ -0,0 +1,135 @@ +# 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 Filters::Inputs::BaseFilterForm < ApplicationForm + def initialize(filter:, additional_attributes:, active:) + super() + @filter = filter + @additional_attributes = additional_attributes + @active = active + end + + def self.inherited(subclass) + super + subclass.form do |form| + form.group(**filter_row_arguments) do |group| + add_label(group) + add_operator(group) + add_operand(group) + add_delete_button(group) + end + end + end + + protected + + def add_label(group) + filter_human_name = @filter.human_name + for_id = operator_hidden? ? operand_input_id : "operator_#{@filter.name}" + group.html_content do + content_tag(:label, filter_human_name, class: "advanced-filters--filter-name", for: for_id) + end + end + + def operator_hidden? + @filter.is_a?(Queries::Filters::Shared::BooleanFilter) + end + + def operand_input_id + nil + end + + def add_operand(_group) + raise SubclassResponsibilityError + end + + def filter_row_arguments + args = { + layout: :horizontal, + classes: "advanced-filters--filter", + data: { + "filter--filters-form-target": "filter", + "filter-name": @filter.name, + "filter-type": @filter.type + } + } + args[:hidden] = "hidden" unless @active + args + end + + def operand_name + "#{@filter.name}_value" + end + + private + + def add_operator(group) + selected_operator = @filter.operator || @filter.default_operator.symbol + + group.select_list( + name: :"operator_#{@filter.name}", + label: @filter.human_name, + visually_hide_label: true, + scope_name_to_model: false, + hidden: @filter.is_a?(Queries::Filters::Shared::BooleanFilter), + data: { + action: "change->filter--filters-form#setValueVisibility", + "filter--filters-form-filter-name-param": @filter.name, + "filter--filters-form-target": "operator", + "filter-name": @filter.name + } + ) do |select| + @filter.available_operators.each do |operator| + select.option( + label: operator.human_name, + value: operator.symbol, + selected: operator.symbol == selected_operator, + **(operator.requires_value? ? {} : { "data-no-value": true }) + ) + end + end + end + + def add_delete_button(group) + filter_name = @filter.name + group.html_content do + render(Primer::Beta::IconButton.new( + icon: :x, + scheme: :invisible, + classes: "advanced-filters--remove-filter", + aria: { label: I18n.t("button_delete") }, + data: { + action: "click->filter--filters-form#removeFilter", + "filter--filters-form-filter-name-param": filter_name + } + )) + end + end +end diff --git a/app/forms/filters/inputs/boolean_form.rb b/app/forms/filters/inputs/boolean_form.rb new file mode 100644 index 00000000000..8cb564b997d --- /dev/null +++ b/app/forms/filters/inputs/boolean_form.rb @@ -0,0 +1,48 @@ +# 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 Filters::Inputs::BooleanForm < Filters::Inputs::BaseFilterForm + def add_operand(group) + group.segmented_control( + name: operand_name, + label: @filter.human_name, + visually_hide_label: true, + value: @filter.values.first, + items: [ + { value: "f", label: I18n.t("general_text_No") }, + { value: "t", label: I18n.t("general_text_Yes") } + ], + wrapper_data_attributes: { + "filter--filters-form-target": "filterValueContainer", + "filter-name": @filter.name + } + ) + end +end diff --git a/app/forms/filters/inputs/date_form.rb b/app/forms/filters/inputs/date_form.rb new file mode 100644 index 00000000000..7cd2b0410cb --- /dev/null +++ b/app/forms/filters/inputs/date_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. +#++ + +class Filters::Inputs::DateForm < Filters::Inputs::BaseFilterForm + DAYS_OPERATORS = %w[>t- t+ t+].freeze + + def add_operand(group) + filter_name = @filter.name + filter_values = @filter.values + fmt = date_format + + days_value = fmt == "days" ? filter_values.fetch(0, "") : nil + on_date_value = fmt == "on-date" ? filter_values.fetch(0, "") : nil + from_value = fmt == "between-dates" ? filter_values.fetch(0, "") : nil + to_value = fmt == "between-dates" ? filter_values.fetch(1, "") : nil + + # The multi name intentionally uses @filter.name (not operand_name) because + # parseDateFilterValue in filters-form.controller.ts locates the datepicker + # inputs via findTargetById(filterName, …), which matches the id derived from + # the multi name. Switching to operand_name would require migrating that + # lookup to findTargetByName and adding data-filter-name to the picker inputs. + group.multi(name: filter_name, label: filter_name, visually_hide_label: true, + class: ["advanced-filters--filter-value"], + data: { + "filter--filters-form-target": "filterValueContainer", + "filter-name": filter_name + }) do |builder| + days_div(builder, filter_name, days_value) + on_date_div(builder, filter_name, on_date_value) + between_dates_div(builder, filter_name, from_value, to_value) + end + end + + private + + def date_format + op = @filter.operator || @filter.default_operator.symbol + @date_format ||= if DAYS_OPERATORS.include?(op) + "days" + elsif op == "=d" + "on-date" + elsif op == "<>d" + "between-dates" + end + end + + def days_div(builder, filter_name, value) + field_arguments = { + name: :days, + label: I18n.t("datetime.units.day.other"), + visually_hide_label: true, + trailing_visual: { text: { text: I18n.t("datetime.units.day.other") } }, + scope_name_to_model: false, + value:, + hidden: value.nil?, + data: { + "filter--filters-form-target": "days", + "filter-name": filter_name + } + } + + builder.text_field(**field_arguments, type: :number, step: "any", class: "days") + end + + def on_date_div(builder, filter_name, value) + builder.single_date_picker( + name: :singleDay, + label: :singleDay, + hidden: value.nil?, + leading_visual: { icon: :calendar }, + value: value || "", + datepicker_options: { input_attributes: { "data-filter--filters-form-target" => "singleDay" } }, + data: { "filter-name": filter_name } + ) + end + + def between_dates_div(builder, filter_name, from_value, to_value) + value = [from_value, to_value].compact.join(" - ").presence + builder.range_date_picker( + name: :dateRange, + label: :dateRange, + hidden: value.nil?, + leading_visual: { icon: :calendar }, + value: value || "-", + datepicker_options: { input_attributes: { "data-filter--filters-form-target" => "dateRange" } }, + data: { "filter-name": filter_name } + ) + end +end diff --git a/app/forms/filters/inputs/list_form.rb b/app/forms/filters/inputs/list_form.rb new file mode 100644 index 00000000000..6e6a0709c84 --- /dev/null +++ b/app/forms/filters/inputs/list_form.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. +#++ + +class Filters::Inputs::ListForm < Filters::Inputs::BaseFilterForm + def add_operand(group) + filter_name = @filter.name + filter_values = @filter.values || [] + items = @filter.allowed_values.map { |name, id| { name:, id: } } + + group.autocompleter( + name: operand_name, + label: :value, + visually_hide_label: true, + wrapper_classes: ["advanced-filters--filter-value"], + wrapper_data_attributes: { + "filter--filters-form-target": "filterValueContainer", + "filter-name": filter_name, + "filter-autocomplete": "true" + }, + autocomplete_options: { + component: "opce-autocompleter", + id: operand_name, + multiple: true, + multipleAsSeparateInputs: false, + inputName: "value", + inputValue: filter_values, + items:, + model: items.select { |item| filter_values.include?(item[:id]) }, + bindLabel: "name", + bindValue: "id", + hideSelected: true, + defaultData: false, + hiddenFieldAction: "change->filter--filters-form#autocompleteSendForm" + }.merge(@additional_attributes[:autocomplete_options] || {}) + ) + end +end diff --git a/app/forms/filters/inputs/text_form.rb b/app/forms/filters/inputs/text_form.rb new file mode 100644 index 00000000000..1db8ada4fc1 --- /dev/null +++ b/app/forms/filters/inputs/text_form.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. +#++ + +class Filters::Inputs::TextForm < Filters::Inputs::BaseFilterForm + def add_operand(group) + field_arguments = { + name: operand_name, + label: @filter.human_name, + visually_hide_label: true, + scope_name_to_model: false, + value: @filter.values.first, + data: { + "filter--filters-form-target": "filterValueContainer simpleValue", + "filter-name": @filter.name + } + } + if @filter.type.in? %i[integer float] + group.text_field(**field_arguments, type: :number, step: "any") + else + group.text_field(**field_arguments) + end + end +end diff --git a/app/helpers/frontend_asset_helper.rb b/app/helpers/frontend_asset_helper.rb index a8288a34c07..1c5396baefc 100644 --- a/app/helpers/frontend_asset_helper.rb +++ b/app/helpers/frontend_asset_helper.rb @@ -49,8 +49,6 @@ module FrontendAssetHelper # or referencing the running CLI proxy that hosts the assets in memory. def include_frontend_assets capture do - concat nonced_javascript_include_tag variable_asset_path("jquery.js"), skip_pipeline: true - %w(polyfills.js main.js).each do |file| concat nonced_javascript_include_tag variable_asset_path(file), skip_pipeline: true, type: "module" end diff --git a/app/helpers/sort_helper.rb b/app/helpers/sort_helper.rb index 3fd55d4e29a..982b0524c57 100644 --- a/app/helpers/sort_helper.rb +++ b/app/helpers/sort_helper.rb @@ -338,7 +338,7 @@ module SortHelper # Extracts the given `options` and provides them to a block. # See #sort_header_tag and #sort_header_with_action_menu for usage examples. - def with_sort_header_options(column, allowed_params: nil, with_title: false, **options) + def with_sort_header_options(column, allowed_params: nil, with_title: false, **options) # rubocop:disable Metrics/AbcSize caption = get_caption(column, options) default_order = options.delete(:default_order) || "asc" @@ -349,6 +349,12 @@ module SortHelper options[:title] = sort_header_title(column, caption, options) if with_title options[:icon_only_header] = column == :favorited + # Give the header cell an explicit accessible name equal to the plain caption. + # Without it, the column header's accessible name is derived from its contents, + # which can include the action menu trigger text and is uppercased by the + # `text-transform` styling, neither of which should be part of the name. + options[:"aria-label"] = caption if caption.present? + within_sort_header_tag_hierarchy(options, sort_class(column)) do yield(column, caption, default_order, allowed_params:, param:, lang:, title: options[:title], sortable: options.fetch(:sortable, false), data:) diff --git a/app/models/attachment.rb b/app/models/attachment.rb index 0679e6caa55..4ba634a12bd 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -93,7 +93,9 @@ class Attachment < ApplicationRecord # specifically when using S3 for attachments. In the case of S3 the file name for the downloaded # file will still be correct as it's part of the URL before the query. def external_url_options(expires_in: nil) - { content_disposition: content_disposition(include_filename: false), expires_in: } + { content_disposition: content_disposition(include_filename: false), + content_type: served_content_type, + expires_in: } end def external_storage? @@ -119,6 +121,30 @@ class Attachment < ApplicationRecord end end + # Returns the Content-Type to use when serving this file inline in a browser. + # Text files are normalised to text/plain (prevents script execution) with an + # explicit charset. Non-inlineable files get application/octet-stream so the + # browser is forced to download them. + def served_content_type + if is_text? + "text/plain; charset=#{charset.presence || Setting.attachment_default_charset}" + elsif inlineable? + content_type + else + "application/octet-stream" + end + end + + # Returns the content type to use when serving the file to a browser. + # For text files, ensures a charset is always present so browsers don't + # fall back to ISO-8859-1. Preserves the real MIME subtype (e.g. text/x-ruby) + # unlike served_content_type which normalises to text/plain for security. + def serving_content_type + return content_type unless is_text? + + "#{content_type}; charset=#{charset.presence || Setting.attachment_default_charset}" + end + def visible?(user = User.current) allowed_or_author?(user) do container.attachments_visible?(user) @@ -227,7 +253,7 @@ class Attachment < ApplicationRecord end def set_content_type(file) - self.content_type = self.class.content_type_for(file.path) + self.content_type, self.charset = OpenProject::ContentTypeDetector.new(file.path).detect_with_charset end def set_digest(file) diff --git a/app/models/exports/concerns/csv.rb b/app/models/exports/concerns/csv.rb index c6898c261d0..cce6a0e2ca4 100644 --- a/app/models/exports/concerns/csv.rb +++ b/app/models/exports/concerns/csv.rb @@ -47,7 +47,8 @@ module Exports def encode_csv_columns(columns, encoding = I18n.t(:general_csv_encoding)) columns.map do |cell| - Redmine::CodesetUtil.from_utf8(cell.to_s, encoding) + sanitized = Exports::Concerns::CSVFormulaSanitization.sanitize(cell) + Redmine::CodesetUtil.from_utf8(sanitized, encoding) end end diff --git a/app/models/exports/concerns/csv_formula_sanitization.rb b/app/models/exports/concerns/csv_formula_sanitization.rb new file mode 100644 index 00000000000..8090c9d2238 --- /dev/null +++ b/app/models/exports/concerns/csv_formula_sanitization.rb @@ -0,0 +1,76 @@ +# 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 Exports + module Concerns + # Escape cells that begin with a spreadsheet formula-triggering character, + # if the +csv_escape_formulas+ setting is active. + module CSVFormulaSanitization + module_function + + # Leading characters that always indicate a formula and will always be escaped + ALWAYS_ESCAPE = %W[= @ \t \r].freeze + + # Leading characters that trigger formula evaluation but may also legitimately + # be a number (negative/positive values). + POSSIBLE_NUMBER_START = %w[- +].freeze + + # A single, optionally signed number as it appears in exported cells: + # thousands/decimal separators, optional surrounding whitespace and an + # optional currency symbol or percent sign (e.g. "-5.00", "-1.234,56 €"). + # Anything with an internal operator (e.g. "+1+1") or letters/parentheses + # fails this match and will be treated as a formula again + PLAIN_NUMBER = /\A[+-]?[\p{Sc}\s]*\d[\d.,'\s]*[\p{Sc}%]?\z/u + + # Escape a single CSV cell value if the setting is active + # + # @param value [Object] the raw cell value + # @return [String] the (possibly escaped) string value + def sanitize(value) + str = value.to_s + return str unless needs_escaping?(str) + + "'#{str}" + end + + def needs_escaping?(str) + return false unless Setting.csv_escape_formulas? + return false if str.empty? + + first = str[0] + return true if ALWAYS_ESCAPE.include?(first) + return false unless POSSIBLE_NUMBER_START.include?(first) + + # Leading - or +: only escape when the value is not a plain signed number. + !str.match?(PLAIN_NUMBER) + end + end + end +end diff --git a/app/models/persisted_view.rb b/app/models/persisted_view.rb index 9646870b784..9ac38611f59 100644 --- a/app/models/persisted_view.rb +++ b/app/models/persisted_view.rb @@ -31,7 +31,7 @@ class PersistedView < ApplicationRecord belongs_to :project, optional: true belongs_to :principal, optional: true, inverse_of: :persisted_views - belongs_to :query, polymorphic: true, optional: true + belongs_to :query, polymorphic: true, optional: true, autosave: true belongs_to :parent, class_name: "PersistedView", optional: true has_many :children, class_name: "PersistedView", foreign_key: "parent_id", dependent: :destroy, inverse_of: :parent @@ -49,6 +49,7 @@ class PersistedView < ApplicationRecord scope :public_views, -> { where(public: true) } scope :private_views, ->(principal = User.current) { where(public: false, principal_id: principal.id) } + scope :with_children, -> { includes(:children) } scope :visible, (lambda do |principal = User.current| public_views.or(private_views(principal)) @@ -68,6 +69,13 @@ class PersistedView < ApplicationRecord attr_writer :allowed_children end + # Resolves a (potentially user-supplied) class name to a view class, but only + # if it is an allowed child of this view. Returns nil for any unknown or + # forbidden name. + def self.allowed_child_class(name) + allowed_children.index_with(&:constantize)[name.to_s] + end + # Returns the query of this view or, if not set, the query of the parent view. def effective_query query || parent&.effective_query diff --git a/app/models/project.rb b/app/models/project.rb index 8d01e969e0c..ef5be9571b3 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -85,6 +85,8 @@ class Project < ApplicationRecord }, dependent: :destroy has_many :time_entries, dependent: :delete_all has_many :time_entry_activities_projects, dependent: :delete_all + has_many :cost_types_projects, dependent: :delete_all + has_many :cost_types, through: :cost_types_projects has_many :queries, dependent: :destroy has_many :news, -> { includes(:author) }, dependent: :destroy has_many :categories, -> { order("#{Category.table_name}.name") }, dependent: :delete_all @@ -193,7 +195,8 @@ class Project < ApplicationRecord scope :templated, -> { where(templated: true) } scopes :activated_time_activity, - :visible_with_activated_time_activity + :visible_with_activated_time_activity, + :available_cost_types enum :status_code, { on_track: 0, diff --git a/app/models/queries/filters/base.rb b/app/models/queries/filters/base.rb index 1b049ce549a..58d7140aa4a 100644 --- a/app/models/queries/filters/base.rb +++ b/app/models/queries/filters/base.rb @@ -182,6 +182,10 @@ class Queries::Filters::Base errors.full_message(human_name, messages) end + def autocomplete_options + {} + end + protected def type_strategy diff --git a/app/models/queries/filters/shared/custom_fields/hierarchy.rb b/app/models/queries/filters/shared/custom_fields/hierarchy.rb index c29e545b9be..2c927879812 100644 --- a/app/models/queries/filters/shared/custom_fields/hierarchy.rb +++ b/app/models/queries/filters/shared/custom_fields/hierarchy.rb @@ -37,6 +37,23 @@ module Queries true end + def autocomplete_options + items = allowed_values.map do |name, id| + path = name.split(" / ") + { name: path.last, id:, depth: path.length - 1 } + end + + { + component: "opce-autocompleter", + bindValue: "id", + bindLabel: "name", + hideSelected: true, + defaultData: false, + items:, + model: items.select { |item| values.include?(item[:id]) } + } + end + def value_objects CustomField::Hierarchy::Item .where(id: @values) diff --git a/app/models/queries/filters/shared/custom_fields/list_optional.rb b/app/models/queries/filters/shared/custom_fields/list_optional.rb index ea5a0f1db68..94ad7cdc889 100644 --- a/app/models/queries/filters/shared/custom_fields/list_optional.rb +++ b/app/models/queries/filters/shared/custom_fields/list_optional.rb @@ -59,6 +59,24 @@ module Queries::Filters::Shared :list_optional end + def autocomplete_options # rubocop:disable Metrics/AbcSize + all_items = if custom_field.version? + allowed_values.map { |name, id, project_name| { name:, id:, project_name: } } + else + allowed_values.map { |name, id| { name:, id: } } + end + options = { items: all_items } + options[:groupBy] = "project_name" if custom_field.version? + + { + component: "opce-autocompleter", + bindValue: "id", + bindLabel: "name", + hideSelected: true, + defaultData: false + }.merge(options).merge(model: all_items.select { |item| values.include?(item[:id]) }) + end + protected def condition @@ -75,8 +93,7 @@ module Queries::Filters::Shared end def customized_strategy? - operator_strategy == Queries::Operators::CustomFields::EqualsAll || - operator_strategy == Queries::Operators::CustomFields::NotEqualsAll + [Queries::Operators::CustomFields::EqualsAll, Queries::Operators::CustomFields::NotEqualsAll].include?(operator_strategy) end def type_strategy_class diff --git a/app/models/queries/filters/shared/custom_fields/user.rb b/app/models/queries/filters/shared/custom_fields/user.rb index 907f5eafcc0..89e87f85afa 100644 --- a/app/models/queries/filters/shared/custom_fields/user.rb +++ b/app/models/queries/filters/shared/custom_fields/user.rb @@ -48,6 +48,22 @@ module Queries::Filters::Shared vals + user_groups_added(vals) end + def 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: "status", operator: "!", values: [Principal.statuses["locked"].to_s] } + ], + searchKey: "any_name_attribute", + focusDirectly: false + } + end + private def group_members_added(vals) diff --git a/app/models/queries/filters/shared/project_filter/optional.rb b/app/models/queries/filters/shared/project_filter/optional.rb index ba92e84d8e9..95de9205222 100644 --- a/app/models/queries/filters/shared/project_filter/optional.rb +++ b/app/models/queries/filters/shared/project_filter/optional.rb @@ -51,6 +51,14 @@ module Queries::Filters::Shared::ProjectFilter::Optional # harm. @type_strategy ||= ::Queries::Filters::Strategies::IntegerListOptional.new(self) end + + def autocomplete_options + { + component: "opce-project-autocompleter", + resource: "projects", + filters: [{ name: "active", operator: "=", values: ["t"] }] + } + end end module ClassMethods diff --git a/app/models/queries/filters/shared/project_filter/required.rb b/app/models/queries/filters/shared/project_filter/required.rb index 951b2919c47..6ac038e857f 100644 --- a/app/models/queries/filters/shared/project_filter/required.rb +++ b/app/models/queries/filters/shared/project_filter/required.rb @@ -51,6 +51,14 @@ module Queries::Filters::Shared::ProjectFilter::Required # harm. @type_strategy ||= ::Queries::Filters::Strategies::IntegerList.new(self) end + + def autocomplete_options + { + component: "opce-project-autocompleter", + resource: "projects", + filters: [{ name: "active", operator: "=", values: ["t"] }] + } + end end module ClassMethods diff --git a/app/models/queries/projects/filters/project_status_filter.rb b/app/models/queries/projects/filters/project_status_filter.rb index 99fe4da849b..d23d264bf43 100644 --- a/app/models/queries/projects/filters/project_status_filter.rb +++ b/app/models/queries/projects/filters/project_status_filter.rb @@ -41,6 +41,19 @@ class Queries::Projects::Filters::ProjectStatusFilter < Queries::Projects::Filte :list_optional end + def autocomplete_options + all_items = allowed_values.map { |name, id| { name:, id: } } + { + component: "opce-autocompleter", + bindValue: "id", + bindLabel: "name", + hideSelected: true, + defaultData: false, + items: all_items, + model: all_items.select { |item| values.include?(item[:id]) } + } + end + def where operator_strategy.sql_for_field(values, model.table_name, :status_code) end diff --git a/app/models/queries/projects/filters/type_filter.rb b/app/models/queries/projects/filters/type_filter.rb index b5bbb363b02..05ceb9467e4 100644 --- a/app/models/queries/projects/filters/type_filter.rb +++ b/app/models/queries/projects/filters/type_filter.rb @@ -45,6 +45,19 @@ class Queries::Projects::Filters::TypeFilter < Queries::Projects::Filters::Base :list end + def autocomplete_options + all_items = allowed_values.map { |name, id| { name:, id: } } + { + component: "opce-autocompleter", + bindValue: "id", + bindLabel: "name", + hideSelected: true, + defaultData: false, + items: all_items, + model: all_items.select { |item| values.include?(item[:id]) } + } + end + def self.key :type_id end diff --git a/app/models/queries/users/filters/group_filter.rb b/app/models/queries/users/filters/group_filter.rb index 92ed4ad7a3e..e19be215199 100644 --- a/app/models/queries/users/filters/group_filter.rb +++ b/app/models/queries/users/filters/group_filter.rb @@ -34,4 +34,16 @@ class Queries::Users::Filters::GroupFilter < Queries::Users::Filters::UserFilter def human_name I18n.t(:label_group) end + + def autocomplete_options + { + component: "opce-user-autocompleter", + resource: "principals", + url: ::API::V3::Utilities::PathHelper::ApiV3Path.principals, + filters: [{ name: "type", operator: "=", values: %w[Group] }], + searchKey: "any_name_attribute", + inputValue: values, + bindValue: "id" + } + end end diff --git a/app/models/queries/work_packages/filter/filter_for_wp_mixin.rb b/app/models/queries/work_packages/filter/filter_for_wp_mixin.rb index 9ee968a8b8a..9e75a32abb2 100644 --- a/app/models/queries/work_packages/filter/filter_for_wp_mixin.rb +++ b/app/models/queries/work_packages/filter/filter_for_wp_mixin.rb @@ -37,6 +37,27 @@ module Queries::WorkPackages::Filter::FilterForWpMixin raise NotImplementedError, "There would be too many candidates" end + # Tell `Filters::FilterFormComponent`'s dispatch to render these filters with a + # server-side autocompleter (the candidate set is too large for an inline + # ` | null) { + this._inputAttrs = attrs ?? {}; + this.applyInputAttrs(); + } + + private _inputAttrs:Record = {}; + @ViewChild('input') input:ElementRef; mobile = false; @@ -116,6 +123,15 @@ export class OpBasicSingleDatePickerComponent implements ControlValueAccessor, O if (!this.mobile) { this.initializeDatePicker(); } + this.applyInputAttrs(); + } + + private applyInputAttrs():void { + const el = (this.input?.nativeElement as HTMLInputElement | null) + ?? (this.elementRef.nativeElement as HTMLElement).querySelector(`input[id="${this.id}"]`); + if (el) { + Object.entries(this._inputAttrs).forEach(([key, val]) => el.setAttribute(key, val)); + } } ngOnDestroy():void { @@ -220,7 +236,7 @@ export class OpBasicSingleDatePickerComponent implements ControlValueAccessor, O private appendToBodyOrDialog():HTMLElement|undefined { if (this.inDialog) { - return document.querySelector(`#${this.inDialog}`) as HTMLElement; + return document.querySelector(`#${this.inDialog}`)!; } return undefined; diff --git a/frontend/src/app/shared/components/editor/components/ckeditor/ckeditor-setup.service.ts b/frontend/src/app/shared/components/editor/components/ckeditor/ckeditor-setup.service.ts index 1dc058140f8..57137adf518 100644 --- a/frontend/src/app/shared/components/editor/components/ckeditor/ckeditor-setup.service.ts +++ b/frontend/src/app/shared/components/editor/components/ckeditor/ckeditor-setup.service.ts @@ -58,8 +58,11 @@ export class CKEditorSetupService { const editorClass = type === 'constrained' ? window.OPConstrainedEditor : window.OPClassicEditor; wrapper.classList.add(`ckeditor-type-${type}`); - const toolbarWrapper = wrapper.querySelector('.document-editor__toolbar')!; - const contentWrapper = wrapper.querySelector('.document-editor__editable') as HTMLElement; + const toolbarWrapper = wrapper.querySelector('.document-editor__toolbar'); + const contentWrapper = wrapper.querySelector('.document-editor__editable'); + if (!toolbarWrapper || !contentWrapper) { + throw new Error('Missing CKEditor wrapper elements.'); + } const config = this.createConfig(context, initialData); return this diff --git a/frontend/src/app/shared/components/fields/display/display-field.service.ts b/frontend/src/app/shared/components/fields/display/display-field.service.ts index 48bd727ab4b..bfeb26b3e91 100644 --- a/frontend/src/app/shared/components/fields/display/display-field.service.ts +++ b/frontend/src/app/shared/components/fields/display/display-field.service.ts @@ -91,27 +91,27 @@ export class DisplayFieldService extends AbstractFieldService { let field:WorkPackageIdDisplayField; @@ -37,7 +36,7 @@ describe('WorkPackageIdDisplayField', () => { const mockInjector = { get: (token:unknown, notFoundValue?:unknown) => serviceMap.get(token) ?? notFoundValue ?? {}, - } as unknown as Injector; + }; field = new WorkPackageIdDisplayField('id', { injector: mockInjector, diff --git a/frontend/src/app/shared/components/fields/display/field-types/wp-spent-time-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/wp-spent-time-display-field.module.ts index 1b3bb8798db..3aadc541460 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/wp-spent-time-display-field.module.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/wp-spent-time-display-field.module.ts @@ -78,7 +78,7 @@ export class WorkPackageSpentTimeDisplayField extends WorkDisplayField { // Link to the cost report having the work package filter preselected. No grouping. const href = URI( this.PathHelper.projectTimeEntriesPath( - project.identifier as string, + project.identifier, ), ) .search( diff --git a/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.component.spec.ts b/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.component.spec.ts new file mode 100644 index 00000000000..ba989760073 --- /dev/null +++ b/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.component.spec.ts @@ -0,0 +1,118 @@ +//-- 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. +//++ + +import { TestBed } from '@angular/core/testing'; +import { StateService, Transition, TransitionService } from '@uirouter/core'; +import * as Turbo from '@hotwired/turbo'; +import { ConfigurationService } from 'core-app/core/config/configuration.service'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { EditingPortalService } from 'core-app/shared/components/fields/edit/editing-portal/editing-portal-service'; +import { EditFieldHandler } from 'core-app/shared/components/fields/edit/editing-portal/edit-field-handler'; +import { EditFormRoutingService } from 'core-app/shared/components/fields/edit/edit-form/edit-form-routing.service'; +import { EditFormComponent } from 'core-app/shared/components/fields/edit/edit-form/edit-form.component'; +import { GlobalEditFormChangesTrackerService } from 'core-app/shared/components/fields/edit/services/global-edit-form-changes-tracker/global-edit-form-changes-tracker.service'; +import { vi } from 'vitest'; + +describe('EditFormComponent', () => { + let onBeforeCallback:(transition:Transition) => unknown; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + beforeEach(async () => { + await TestBed + .configureTestingModule({ + declarations: [ + EditFormComponent, + ], + providers: [ + { + provide: TransitionService, + useValue: { + onBefore: vi.fn((_criteria:unknown, callback:(transition:Transition) => unknown) => { + onBeforeCallback = callback; + return vi.fn(); + }), + }, + }, + { provide: ConfigurationService, useValue: { warnOnLeavingUnsaved: vi.fn().mockReturnValue(true) } }, + { provide: EditingPortalService, useValue: {} }, + { provide: StateService, useValue: {} }, + { provide: I18nService, useValue: { t: vi.fn().mockReturnValue('Leave edit mode?') } }, + { provide: EditFormRoutingService, useValue: { blockedTransition: vi.fn().mockReturnValue(true) } }, + { + provide: GlobalEditFormChangesTrackerService, + useValue: { + addToActiveForms: vi.fn(), + removeFromActiveForms: vi.fn(), + }, + }, + ], + }) + .compileComponents(); + }); + + it('restores a canceled browser Back transition without navigating forward', () => { + const fixture = TestBed.createComponent(EditFormComponent); + const component = fixture.componentInstance; + const urlRouterUpdate = vi.fn(); + const transition = { + options: vi.fn().mockReturnValue({ source: 'url' }), + from: vi.fn().mockReturnValue({ name: 'work-packages.partitioned.split' }), + params: vi.fn().mockReturnValue({ workPackageId: '46' }), + router: { + stateService: { + href: vi.fn().mockReturnValue('/work_packages/details/46/overview'), + }, + urlRouter: { + update: urlRouterUpdate, + }, + }, + } as unknown as Transition; + const turboPush = vi.spyOn( + Turbo.session.history, + 'push', + ).mockImplementation(() => undefined); + const historyForward = vi.spyOn(window.history, 'forward'); + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + const cancel = vi.spyOn(component, 'cancel'); + + component.activeFields = { + description: {} as EditFieldHandler, + }; + + expect(onBeforeCallback(transition)).toBe(false); + expect(confirm).toHaveBeenCalledWith('Leave edit mode?'); + expect(cancel).not.toHaveBeenCalled(); + expect(turboPush).toHaveBeenCalledOnce(); + expect(turboPush.mock.calls[0][0].pathname).toBe('/work_packages/details/46/overview'); + expect(urlRouterUpdate).toHaveBeenCalledWith(true); + expect(historyForward).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.component.ts b/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.component.ts index 5897a8faa79..83e5e54dc99 100644 --- a/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.component.ts +++ b/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.component.ts @@ -46,6 +46,7 @@ import { EditFormRoutingService } from 'core-app/shared/components/fields/edit/e import { ResourceChangesetCommit } from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service'; import { GlobalEditFormChangesTrackerService } from 'core-app/shared/components/fields/edit/services/global-edit-form-changes-tracker/global-edit-form-changes-tracker.service'; import { firstValueFrom } from 'rxjs'; +import * as Turbo from '@hotwired/turbo'; @Component({ selector: 'edit-form,[edit-form]', @@ -103,6 +104,7 @@ export class EditFormComponent extends EditForm implements OnInit, // that's not within the edit mode. if (!this.editFormRouting || this.editFormRouting.blockedTransition(transition)) { if (requiresConfirmation && !window.confirm(confirmText)) { + this.undoCanceledBrowserBackTransition(transition); return false; } @@ -113,6 +115,31 @@ export class EditFormComponent extends EditForm implements OnInit, }); } + private undoCanceledBrowserBackTransition(transition:Transition) { + if (transition.options().source !== 'url') { + return; + } + + const fromUrl = transition + .router + .stateService + .href(transition.from(), transition.params('from')); + + if (!fromUrl) { + return; + } + + // Restore the canceled Back URL without firing a real forward navigation, + // which would make Turbo restore a stale snapshot of the split view. + Turbo.session + .history + .push(new URL(fromUrl, window.location.origin)); + + // Keep UI-Router from replacing the restored browser history entry while + // it rolls back the aborted Back navigation. + transition.router.urlRouter.update(true); + } + ngOnInit() { this.editMode = this.initializeEditMode; this.globalEditFormChangesTrackerService.addToActiveForms(this); diff --git a/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.spec.ts b/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.spec.ts index f9993c7a396..e7cf25d086b 100644 --- a/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.spec.ts +++ b/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.spec.ts @@ -30,7 +30,7 @@ import { ApplicationRef, Injector } from '@angular/core'; import { EditForm } from 'core-app/shared/components/fields/edit/edit-form/edit-form'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { EditFieldHandler } from 'core-app/shared/components/fields/edit/editing-portal/edit-field-handler'; -import { IFieldSchema } from 'core-app/shared/components/fields/field.base'; +import { vi } from 'vitest'; class TestEditForm extends EditForm { constructor(injector:Injector, private readonly requireVisibleSpy:(fieldName:string) => Promise, private readonly activateFieldSpy:() => Promise, private readonly resetSpy:(fieldName:string, focus?:boolean) => void) { @@ -58,7 +58,7 @@ describe('EditForm', () => { it('does not require visibility twice for newly erroneous inactive fields', async () => { const tick = vi.fn(); const requireVisible = vi.fn().mockResolvedValue(undefined); - const activateField = vi.fn().mockResolvedValue({} as EditFieldHandler); + const activateField = vi.fn().mockResolvedValue({}); const reset = vi.fn(); const injector = { get: vi.fn().mockImplementation((token:unknown) => { @@ -68,7 +68,7 @@ describe('EditForm', () => { throw new Error(`Unexpected token: ${String(token)}`); }), - } as unknown as Injector; + }; const form = new TestEditForm(injector, requireVisible, activateField, reset); const change = { @@ -77,7 +77,7 @@ describe('EditForm', () => { ofProperty: vi.fn().mockReturnValue({ writable: true, name: 'Foo', - } as IFieldSchema), + }), }, getForm: vi.fn().mockResolvedValue(undefined), }; diff --git a/frontend/src/app/shared/components/fields/edit/field-handler/hal-resource-edit-field-handler.ts b/frontend/src/app/shared/components/fields/edit/field-handler/hal-resource-edit-field-handler.ts index 1983e828048..fcd2c48b0ab 100644 --- a/frontend/src/app/shared/components/fields/edit/field-handler/hal-resource-edit-field-handler.ts +++ b/frontend/src/app/shared/components/fields/edit/field-handler/hal-resource-edit-field-handler.ts @@ -98,7 +98,7 @@ export class HalResourceEditFieldHandler extends EditFieldHandler { } public focus(setClickOffset?:number) { - const target = this.element.querySelector('.inline-edit--field') as HTMLElement; + const target = this.element.querySelector('.inline-edit--field'); if (!target) { debugLog(`Tried to focus on ${this.fieldName}, but element does not (yet) exist.`); diff --git a/frontend/src/app/shared/components/fields/edit/field-types/project-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/project-edit-field.component.ts index f33d2c95cb7..a1bc16dd719 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/project-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/project-edit-field.component.ts @@ -53,7 +53,7 @@ export class ProjectEditFieldComponent extends EditFieldComponent implements OnI readonly http = inject(HttpClient); readonly halResourceService = inject(HalResourceService); - isNew = isNewResource(this.resource); + isNew = isNewResource(this.resource as { id:string | null }); url:string; @@ -84,9 +84,10 @@ export class ProjectEditFieldComponent extends EditFieldComponent implements OnI { name: 'active', operator: '=' as FilterOperator, values: ['t'] }, ]; - if (isNewResource(this.resource) && this.change.value('type')) { - const typeId = idFromLink((this.change.value('type') as { href:string }).href); - filters.push({ name: 'type_id', operator: '=' as FilterOperator, values: [typeId] }); + const type = this.change.value<{ href:string }|null>('type'); + if (isNewResource(this.resource as { id:string | null }) && type) { + const typeId = idFromLink(type.href); + filters.push({ name: 'type_id', operator: '=', values: [typeId] }); } return filters; diff --git a/frontend/src/app/shared/components/fields/edit/modal-with-turbo-content/modal-with-turbo-content.directive.ts b/frontend/src/app/shared/components/fields/edit/modal-with-turbo-content/modal-with-turbo-content.directive.ts index cb2192c2fb2..3789bd5c98f 100644 --- a/frontend/src/app/shared/components/fields/edit/modal-with-turbo-content/modal-with-turbo-content.directive.ts +++ b/frontend/src/app/shared/components/fields/edit/modal-with-turbo-content/modal-with-turbo-content.directive.ts @@ -27,13 +27,13 @@ //++ import { AfterViewInit, ChangeDetectorRef, Directive, ElementRef, EventEmitter, Input, OnDestroy, Output, inject } from '@angular/core'; -import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { HalEventsService } from 'core-app/features/hal/services/hal-events.service'; import { ToastService } from 'core-app/shared/components/toaster/toast.service'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset'; +import type { TurboBeforeFrameRenderEvent, TurboSubmitEndEvent } from '@hotwired/turbo'; @Directive({ selector: '[opModalWithTurboContent]', @@ -78,7 +78,7 @@ export class ModalWithTurboContentDirective implements AfterViewInit, OnDestroy .removeEventListener('cancelModalWithTurboContent', this.cancelListenerBound); } - private contextBasedListener(event:CustomEvent) { + private contextBasedListener(event:TurboSubmitEndEvent) { if (this.resource.id === 'new') { void this.propagateSuccessfulCreate(event); } else { @@ -86,10 +86,8 @@ export class ModalWithTurboContentDirective implements AfterViewInit, OnDestroy } } - private preserveSegmentAttributes(event:CustomEvent) { - const turboEvent = event as CustomEvent<{ newFrame?:HTMLElement }>; - - const element = turboEvent.detail?.newFrame?.querySelector('segmented-control'); + private preserveSegmentAttributes(event:TurboBeforeFrameRenderEvent) { + const element = event.detail?.newFrame?.querySelector('segmented-control'); if (!element) return; const connectedCallback = Object.getOwnPropertyDescriptor( @@ -114,13 +112,11 @@ export class ModalWithTurboContentDirective implements AfterViewInit, OnDestroy this.cancel.emit(); } - private async propagateSuccessfulCreate(event:CustomEvent) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + private async propagateSuccessfulCreate(event:TurboSubmitEndEvent) { const { fetchResponse } = event.detail; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (fetchResponse.succeeded) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access + if (fetchResponse?.succeeded) { + if (!fetchResponse.response.body) return; const JSONresponse:unknown = await this.extractJSONFromResponse(fetchResponse.response.body); this.successfulCreate.emit(JSONresponse); @@ -130,18 +126,16 @@ export class ModalWithTurboContentDirective implements AfterViewInit, OnDestroy } } - private propagateSuccessfulUpdate(event:CustomEvent) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + private propagateSuccessfulUpdate(event:TurboSubmitEndEvent) { const { fetchResponse } = event.detail; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (fetchResponse.succeeded) { + if (fetchResponse?.succeeded) { this.halEvents.push( - this.resource as WorkPackageResource, + this.resource, { eventType: 'updated' }, ); - void this.apiV3Service.work_packages.id(this.resource as WorkPackageResource).refresh(); + void this.apiV3Service.work_packages.id(this.resource).refresh(); this.successfulUpdate.emit(); diff --git a/frontend/src/app/shared/components/fields/edit/services/global-edit-form-changes-tracker/global-edit-form-changes-tracker.service.spec.ts b/frontend/src/app/shared/components/fields/edit/services/global-edit-form-changes-tracker/global-edit-form-changes-tracker.service.spec.ts index adf0d829c61..23da08df375 100644 --- a/frontend/src/app/shared/components/fields/edit/services/global-edit-form-changes-tracker/global-edit-form-changes-tracker.service.spec.ts +++ b/frontend/src/app/shared/components/fields/edit/services/global-edit-form-changes-tracker/global-edit-form-changes-tracker.service.spec.ts @@ -1,20 +1,40 @@ import { TestBed } from '@angular/core/testing'; import { EditFormComponent } from 'core-app/shared/components/fields/edit/edit-form/edit-form.component'; +import { OpenProject } from 'core-app/core/setup/globals/openproject'; import { GlobalEditFormChangesTrackerService } from './global-edit-form-changes-tracker.service'; describe('GlobalEditFormChangesTrackerService', () => { let service:GlobalEditFormChangesTrackerService; - const createForm = (changed?:boolean) => ({ + let originalOpenProject:OpenProject; + const createForm = ( + changed?:boolean, + inFlight = false, + resourceId:string|null = '1', + editing = Boolean(changed) || resourceId === null, + ) => ({ + editing, + resource: { + id: resourceId, + }, change: { + inFlight, isEmpty: () => !changed, }, } as EditFormComponent); beforeEach(() => { + originalOpenProject = window.OpenProject; + window.OpenProject = new OpenProject(); TestBed.configureTestingModule({}); service = TestBed.inject(GlobalEditFormChangesTrackerService); }); + afterEach(() => { + // eslint-disable-next-line @typescript-eslint/dot-notation + service['abortController'].abort(); + window.OpenProject = originalOpenProject; + }); + it('should be created', () => { expect(service).toBeTruthy(); }); @@ -60,6 +80,67 @@ describe('GlobalEditFormChangesTrackerService', () => { expect(service.thereAreFormsWithUnsavedChanges).toBe(true); }); + it('should report no changes when a changed form is no longer editing', () => { + const form = createForm(true, false, '1', false); + + service.addToActiveForms(form); + + expect(service.thereAreFormsWithUnsavedChanges).toBe(false); + }); + + it('should report no changes when one form is editing without changes', () => { + const form = { + ...createForm(), + editing: true, + } as EditFormComponent; + + service.addToActiveForms(form); + + expect(service.thereAreFormsWithUnsavedChanges).toBe(false); + }); + + it('should report changes when an unchanged form tracks a new resource', () => { + const form = createForm(false, false, null); + + service.addToActiveForms(form); + + expect(service.thereAreFormsWithUnsavedChanges).toBe(true); + }); + + it('should report no changes when a new resource form is no longer editing', () => { + const form = createForm(false, false, null, false); + + service.addToActiveForms(form); + + expect(service.thereAreFormsWithUnsavedChanges).toBe(false); + }); + + it('should report no changes when the only changed form is being saved', () => { + const form = createForm(true, true); + + service.addToActiveForms(form); + + expect(service.thereAreFormsWithUnsavedChanges).toBe(false); + }); + + it('should report no changes when a new resource is being saved', () => { + const form = createForm(false, true, null); + + service.addToActiveForms(form); + + expect(service.thereAreFormsWithUnsavedChanges).toBe(false); + }); + + it('should report changes when another form has unsaved changes while one is being saved', () => { + const savingForm = createForm(true, true); + const changedForm = createForm(true); + + service.addToActiveForms(savingForm); + service.addToActiveForms(changedForm); + + expect(service.thereAreFormsWithUnsavedChanges).toBe(true); + }); + it('should report forms with changes when multiple form have changes', () => { const form = createForm(true); const form2 = createForm(true); @@ -72,13 +153,92 @@ describe('GlobalEditFormChangesTrackerService', () => { expect(service.thereAreFormsWithUnsavedChanges).toBe(true); }); - it('should call thereAreFormsWithUnsavedChangesSpy on beforeunload', () => { - const thereAreFormsWithUnsavedChangesSpy = vi.spyOn(service, 'thereAreFormsWithUnsavedChanges', 'get'); + it('should prevent beforeunload when a tracked form has changes', () => { + const form = createForm(true); + const event = new Event('beforeunload', { cancelable: true }); - window.onbeforeunload = vi.fn(); + service.addToActiveForms(form); + window.dispatchEvent(event); - window.dispatchEvent(new Event('beforeunload')); + expect(event.defaultPrevented).toBe(true); + }); - expect(thereAreFormsWithUnsavedChangesSpy).toHaveBeenCalled(); + it('should not prevent beforeunload when the page was submitted', () => { + const form = createForm(true); + const event = new Event('beforeunload', { cancelable: true }); + + window.OpenProject.pageState = 'submitted'; + service.addToActiveForms(form); + window.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(false); + }); + + it('registers an OpenProject callback for edit form changes', () => { + const form = createForm(true); + + service.addToActiveForms(form); + + expect(window.OpenProject.editFormsContainUnsavedChanges()).toBe(true); + }); + + it('should prevent turbo:before-render for restoration visits when a tracked form has changes', () => { + const form = createForm(true); + const event = new Event('turbo:before-render', { cancelable: true }); + + service.addToActiveForms(form); + document.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + }); + + it('should not prevent turbo:before-render when no forms have changes', () => { + const event = new Event('turbo:before-render', { cancelable: true }); + + document.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(false); + }); + + it('should not prevent turbo:before-render after a non-restore turbo:visit', () => { + const form = createForm(true); + + service.addToActiveForms(form); + document.dispatchEvent(new CustomEvent('turbo:visit', { detail: { url: 'http://example.com', action: 'advance' } })); + + const event = new Event('turbo:before-render', { cancelable: true }); + document.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(false); + }); + + it('should prevent turbo:before-render after a restore turbo:visit', () => { + const form = createForm(true); + + service.addToActiveForms(form); + document.dispatchEvent(new CustomEvent('turbo:visit', { detail: { url: 'http://example.com', action: 'restore' } })); + + const event = new Event('turbo:before-render', { cancelable: true }); + document.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + }); + + it('should still block renders when a prior turbo:before-visit was canceled', () => { + const form = createForm(true); + + service.addToActiveForms(form); + + const canceledVisit = new CustomEvent('turbo:before-visit', { + detail: { url: 'http://example.com' }, + cancelable: true, + }); + canceledVisit.preventDefault(); + document.dispatchEvent(canceledVisit); + + const event = new Event('turbo:before-render', { cancelable: true }); + document.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); }); }); diff --git a/frontend/src/app/shared/components/fields/edit/services/global-edit-form-changes-tracker/global-edit-form-changes-tracker.service.ts b/frontend/src/app/shared/components/fields/edit/services/global-edit-form-changes-tracker/global-edit-form-changes-tracker.service.ts index 359dff8b6f0..c5b8ea06881 100644 --- a/frontend/src/app/shared/components/fields/edit/services/global-edit-form-changes-tracker/global-edit-form-changes-tracker.service.ts +++ b/frontend/src/app/shared/components/fields/edit/services/global-edit-form-changes-tracker/global-edit-form-changes-tracker.service.ts @@ -1,6 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { EditFormComponent } from 'core-app/shared/components/fields/edit/edit-form/edit-form.component'; import { I18nService } from 'core-app/core/i18n/i18n.service'; +import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; @Injectable({ providedIn: 'root', @@ -9,20 +10,51 @@ export class GlobalEditFormChangesTrackerService { private i18nService = inject(I18nService); private activeForms = new Map(); + private abortController = new AbortController(); + private visitApproved = false; get thereAreFormsWithUnsavedChanges() { - return Array.from(this.activeForms.keys()).some((form) => !form.change.isEmpty()); + return Array + .from(this.activeForms.keys()) + .some((form) => ( + form.editing + && !form.change.inFlight + && (isNewResource(form.resource) || !form.change.isEmpty()) + )); } constructor() { - // Global beforeunload hook to show a data loss warn - // when the user clicks on a link out of the Angular app - window.addEventListener('beforeunload', (event) => { - if (this.thereAreFormsWithUnsavedChanges) { + const { signal } = this.abortController; + + window.OpenProject.editFormsContainUnsavedChanges = () => this.thereAreFormsWithUnsavedChanges; + + // turbo:visit fires after a visit starts (canceled visits never + // reach it) and carries the visit action. Restoration visits + // have action "restore"; link clicks have "advance"/"replace". + document.addEventListener('turbo:visit', (event) => { + const { action } = (event as CustomEvent<{ action:string }>).detail; + this.visitApproved = action !== 'restore'; + }, { signal }); + + // Block Turbo restoration renders that would clobber Angular's + // DOM while an edit form is active. For restoration visits + // visitApproved is false, so the guard fires. + document.addEventListener('turbo:before-render', (event) => { + if (!this.visitApproved && this.thereAreFormsWithUnsavedChanges) { event.preventDefault(); - event.returnValue = this.i18nService.t('js.work_packages.confirm_edit_cancel'); } - }); + }, { signal }); + + // Show a data loss warning when the user closes the tab or + // navigates away from the Angular app entirely. + window.addEventListener('beforeunload', (event) => { + if (!window.OpenProject.pageWasSubmitted && this.thereAreFormsWithUnsavedChanges) { + const message = this.i18nService.t('js.work_packages.confirm_edit_cancel'); + + event.preventDefault(); + event.returnValue = message; + } + }, { signal }); } public addToActiveForms(form:EditFormComponent) { diff --git a/frontend/src/app/shared/components/fields/macros/attribute-model-loader.service.ts b/frontend/src/app/shared/components/fields/macros/attribute-model-loader.service.ts index d4bc4e548aa..3965bf5b741 100644 --- a/frontend/src/app/shared/components/fields/macros/attribute-model-loader.service.ts +++ b/frontend/src/app/shared/components/fields/macros/attribute-model-loader.service.ts @@ -98,7 +98,7 @@ export class AttributeModelLoaderService { shareReplay(1), ); - state.clearAndPutFromPromise(firstValueFrom(observable) as PromiseLike); + state.clearAndPutFromPromise(firstValueFrom(observable)); return observable; } diff --git a/frontend/src/app/shared/components/grids/grid/area.service.ts b/frontend/src/app/shared/components/grids/grid/area.service.ts index 6ed8e5a87d0..c1f733af355 100644 --- a/frontend/src/app/shared/components/grids/grid/area.service.ts +++ b/frontend/src/app/shared/components/grids/grid/area.service.ts @@ -399,7 +399,7 @@ export class GridAreaService { } public resetAreas(ignoredArea:GridWidgetArea|null = null) { - this.widgetAreas.filter((area) => !ignoredArea || area.guid !== ignoredArea.guid).forEach((area) => area.reset()); + this.widgetAreas.filter((area) => area.guid !== ignoredArea?.guid).forEach((area) => area.reset()); this.numRows = this.resource.rowCount; this.numColumns = this.resource.columnCount; diff --git a/frontend/src/app/shared/components/grids/grid/resize.service.ts b/frontend/src/app/shared/components/grids/grid/resize.service.ts index 38a6f7552ee..5677976d6f7 100644 --- a/frontend/src/app/shared/components/grids/grid/resize.service.ts +++ b/frontend/src/app/shared/components/grids/grid/resize.service.ts @@ -88,7 +88,7 @@ export class GridResizeService { } public isResized(area:GridWidgetArea) { - return this.resizedArea && this.resizedArea.guid === area.guid; + return this.resizedArea?.guid === area.guid; } public isPassive(area:GridWidgetArea) { diff --git a/frontend/src/app/shared/components/grids/openproject-grids.module.ts b/frontend/src/app/shared/components/grids/openproject-grids.module.ts index 030add9d191..fb6deb6be4f 100644 --- a/frontend/src/app/shared/components/grids/openproject-grids.module.ts +++ b/frontend/src/app/shared/components/grids/openproject-grids.module.ts @@ -79,8 +79,8 @@ import { TimeEntriesCurrentUserConfigurationModalComponent, } from './widgets/time-entries/current-user/configuration-modal/configuration.modal'; import { - WidgetProjectFavoritesComponent, -} from 'core-app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component'; + WidgetFavoriteProjectsComponent, +} from 'core-app/shared/components/grids/widgets/favorite-projects/widget-favorite-projects.component'; import { IconModule } from 'core-app/shared/components/icon/icon.module'; import { OpenprojectEnterpriseModule } from 'core-app/features/enterprise/openproject-enterprise.module'; import { ErrorBlankSlateComponent } from './widgets/error-blankslate/error-blankslate.component'; @@ -128,7 +128,7 @@ import { ErrorBlankSlateComponent } from './widgets/error-blankslate/error-blank WidgetProjectDescriptionComponent, WidgetProjectStatusComponent, WidgetSubprojectsComponent, - WidgetProjectFavoritesComponent, + WidgetFavoriteProjectsComponent, WidgetTimeEntriesCurrentUserComponent, WidgetTimeEntriesProjectComponent, diff --git a/frontend/src/app/shared/components/grids/widgets/abstract-widget.component.ts b/frontend/src/app/shared/components/grids/widgets/abstract-widget.component.ts index d875ec77f36..070c7fe4cd3 100644 --- a/frontend/src/app/shared/components/grids/widgets/abstract-widget.component.ts +++ b/frontend/src/app/shared/components/grids/widgets/abstract-widget.component.ts @@ -17,6 +17,8 @@ export abstract class AbstractWidgetComponent extends UntilDestroyedMixin { @HostBinding('style.grid-row-end') gridRowEnd:number; + @HostBinding('class.grid--widget-host') gridWidgetHost = true; + @Input() resource:GridWidgetResource; @Output() resourceChanged = new EventEmitter(); @@ -45,6 +47,7 @@ export abstract class AbstractWidgetComponent extends UntilDestroyedMixin { * We arbitrarily restrict this for some resources however, * whose component classes will set this to false. */ + // eslint-disable-next-line @typescript-eslint/class-literal-property-style public get isEditable() { return true; } diff --git a/frontend/src/app/shared/components/grids/widgets/favorite-projects/widget-favorite-projects.component.html b/frontend/src/app/shared/components/grids/widgets/favorite-projects/widget-favorite-projects.component.html new file mode 100644 index 00000000000..632156e7f88 --- /dev/null +++ b/frontend/src/app/shared/components/grids/widgets/favorite-projects/widget-favorite-projects.component.html @@ -0,0 +1,14 @@ + + + + + + + diff --git a/frontend/src/app/shared/components/grids/widgets/favorite-projects/widget-favorite-projects.component.ts b/frontend/src/app/shared/components/grids/widgets/favorite-projects/widget-favorite-projects.component.ts new file mode 100644 index 00000000000..30df2221711 --- /dev/null +++ b/frontend/src/app/shared/components/grids/widgets/favorite-projects/widget-favorite-projects.component.ts @@ -0,0 +1,19 @@ +import { + ChangeDetectionStrategy, + Component, + HostBinding, +} from '@angular/core'; +import { AbstractTurboWidgetComponent } from 'core-app/shared/components/grids/widgets/abstract-turbo-widget.component'; + +@Component({ + selector: 'op-favorite-projects-widget', + templateUrl: './widget-favorite-projects.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, +}) +export class WidgetFavoriteProjectsComponent extends AbstractTurboWidgetComponent { + @HostBinding('class.op-widget-favorite-projects') className = true; + + override frameId = 'grids-widgets-favorite-projects'; + override name = 'project_favorites'; +} diff --git a/frontend/src/app/shared/components/grids/widgets/members/members.component.html b/frontend/src/app/shared/components/grids/widgets/members/members.component.html index 848e4c13944..0a6f66af30e 100644 --- a/frontend/src/app/shared/components/grids/widgets/members/members.component.html +++ b/frontend/src/app/shared/components/grids/widgets/members/members.component.html @@ -10,15 +10,20 @@ [resource]="resource" /> -
- @if ({hasCapability: hasCapability$ | async}; as context) { - @if (context.hasCapability === false) { +@if ({hasCapability: hasCapability$ | async}; as context) { + @if (context.hasCapability === false) { +
- } - @if (context.hasCapability === true) { - - } +
} -
+ @if (context.hasCapability === true) { + + + } +} diff --git a/frontend/src/app/shared/components/grids/widgets/members/members.component.ts b/frontend/src/app/shared/components/grids/widgets/members/members.component.ts index 5efbe13ec14..69e9db587cf 100644 --- a/frontend/src/app/shared/components/grids/widgets/members/members.component.ts +++ b/frontend/src/app/shared/components/grids/widgets/members/members.component.ts @@ -1,21 +1,23 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + inject, +} from '@angular/core'; import { AbstractTurboWidgetComponent } from 'core-app/shared/components/grids/widgets/abstract-turbo-widget.component'; -import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; import { CurrentUserService } from 'core-app/core/current-user/current-user.service'; @Component({ selector: 'op-members-widget', templateUrl: './members.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - standalone: false + standalone: false, }) export class WidgetMembersComponent extends AbstractTurboWidgetComponent { - protected readonly currentProject = inject(CurrentProjectService); protected readonly currentUser = inject(CurrentUserService); - get text() { - return { missing_permission: this.i18n.t('js.grid.widgets.missing_permission') }; - } + text = { + missing_permission: this.i18n.t('js.grid.widgets.missing_permission'), + }; hasCapability$ = this.currentUser.hasCapabilities$('memberships/read', this.currentProject.id); diff --git a/frontend/src/app/shared/components/grids/widgets/news/news.component.html b/frontend/src/app/shared/components/grids/widgets/news/news.component.html index 4c2dd0b5ea7..33f1d209941 100644 --- a/frontend/src/app/shared/components/grids/widgets/news/news.component.html +++ b/frontend/src/app/shared/components/grids/widgets/news/news.component.html @@ -6,4 +6,4 @@ [resource]="resource" /> - + diff --git a/frontend/src/app/shared/components/grids/widgets/project-description/project-description.component.html b/frontend/src/app/shared/components/grids/widgets/project-description/project-description.component.html index f6a09c1544e..37bd5304288 100644 --- a/frontend/src/app/shared/components/grids/widgets/project-description/project-description.component.html +++ b/frontend/src/app/shared/components/grids/widgets/project-description/project-description.component.html @@ -10,4 +10,4 @@ [resource]="resource" /> - + diff --git a/frontend/src/app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component.html b/frontend/src/app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component.html deleted file mode 100644 index ad9434bb1e4..00000000000 --- a/frontend/src/app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - -@if ((projects$ | async); as projects) { -
- @if (projects.length === 0) { -
- -

- -
- -

-
- } - @else { -
    - @for (project of projects; track project) { -
  • - - - -
  • - } -
- } -
-} diff --git a/frontend/src/app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component.sass b/frontend/src/app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component.sass deleted file mode 100644 index a0e8b419fc3..00000000000 --- a/frontend/src/app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component.sass +++ /dev/null @@ -1,6 +0,0 @@ -.op-widget-project-favorites - &--list - list-style: none - - &--link - padding-left: 0.25rem diff --git a/frontend/src/app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component.ts b/frontend/src/app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component.ts deleted file mode 100644 index d70a58c2194..00000000000 --- a/frontend/src/app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { AbstractWidgetComponent } from 'core-app/shared/components/grids/widgets/abstract-widget.component'; -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, OnInit, ViewEncapsulation, inject } from '@angular/core'; -import { HalResourceService } from 'core-app/features/hal/services/hal-resource.service'; -import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; -import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; -import { ProjectResource } from 'core-app/features/hal/resources/project-resource'; -import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; -import { TimezoneService } from 'core-app/core/datetime/timezone.service'; -import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder'; -import { Observable } from 'rxjs'; - -@Component({ - templateUrl: './widget-project-favorites.component.html', - styleUrls: ['./widget-project-favorites.component.sass'], - encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: false, -}) -export class WidgetProjectFavoritesComponent extends AbstractWidgetComponent implements OnInit { - readonly halResource = inject(HalResourceService); - readonly pathHelper = inject(PathHelperService); - readonly timezone = inject(TimezoneService); - readonly apiV3Service = inject(ApiV3Service); - readonly currentProject = inject(CurrentProjectService); - readonly cdr = inject(ChangeDetectorRef); - - @HostBinding('class.op-widget-project-favorites') className = true; - - public text = { - no_favorites: this.i18n.t('js.favorite_projects.no_results'), - no_favorites_subtext: this.i18n.t('js.favorite_projects.no_results_subtext'), - }; - - public projects$:Observable; - - ngOnInit() { - const filters = new ApiV3FilterBuilder(); - filters.add('favorited', '=', true); - filters.add('active', '=', true); - - this.projects$ = this - .apiV3Service - .projects - .filtered(filters, { sortBy: '[["name","asc"]]', pageSize: '-1' }) - .getPaginatedResults(); - } - - projectPath(project:ProjectResource) { - return this.pathHelper.projectPath(project.identifier); - } -} diff --git a/frontend/src/app/shared/components/grids/widgets/project-status/project-status.component.html b/frontend/src/app/shared/components/grids/widgets/project-status/project-status.component.html index 2c8f9962519..006be62f64e 100644 --- a/frontend/src/app/shared/components/grids/widgets/project-status/project-status.component.html +++ b/frontend/src/app/shared/components/grids/widgets/project-status/project-status.component.html @@ -10,4 +10,4 @@ [resource]="resource" /> - + diff --git a/frontend/src/app/shared/components/grids/widgets/subprojects/subprojects.component.html b/frontend/src/app/shared/components/grids/widgets/subprojects/subprojects.component.html index 4c2dd0b5ea7..33f1d209941 100644 --- a/frontend/src/app/shared/components/grids/widgets/subprojects/subprojects.component.html +++ b/frontend/src/app/shared/components/grids/widgets/subprojects/subprojects.component.html @@ -6,4 +6,4 @@ [resource]="resource" /> - + diff --git a/frontend/src/app/shared/components/grids/widgets/widgets.service.ts b/frontend/src/app/shared/components/grids/widgets/widgets.service.ts index 22092b1fe25..920a31f9c07 100644 --- a/frontend/src/app/shared/components/grids/widgets/widgets.service.ts +++ b/frontend/src/app/shared/components/grids/widgets/widgets.service.ts @@ -26,8 +26,8 @@ import { } from 'core-app/shared/components/grids/widgets/project-status/project-status.component'; import { WidgetSubprojectsComponent } from 'core-app/shared/components/grids/widgets/subprojects/subprojects.component'; import { - WidgetProjectFavoritesComponent, -} from 'core-app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component'; + WidgetFavoriteProjectsComponent, +} from 'core-app/shared/components/grids/widgets/favorite-projects/widget-favorite-projects.component'; import { I18nService } from 'core-app/core/i18n/i18n.service'; @Injectable() @@ -237,7 +237,7 @@ export class GridWidgetsService { }, { identifier: 'project_favorites', - component: WidgetProjectFavoritesComponent, + component: WidgetFavoriteProjectsComponent, title: this.I18n.t('js.grid.widgets.project_favorites.title'), properties: { name: this.I18n.t('js.grid.widgets.project_favorites.title'), diff --git a/frontend/src/app/shared/components/header-project-select/header-project-select.component.html b/frontend/src/app/shared/components/header-project-select/header-project-select.component.html index 31bb8c79774..0e47fa61439 100644 --- a/frontend/src/app/shared/components/header-project-select/header-project-select.component.html +++ b/frontend/src/app/shared/components/header-project-select/header-project-select.component.html @@ -95,29 +95,6 @@ } @else { } - @if (!portfolioModelsEnabled) { -
-
- - - - - @if (canCreateNewProjects$ | async) { - - - - - } -
-
- }
} diff --git a/frontend/src/app/shared/components/header-project-select/header-project-select.component.ts b/frontend/src/app/shared/components/header-project-select/header-project-select.component.ts index 96eddcf953e..d61fffe3730 100644 --- a/frontend/src/app/shared/components/header-project-select/header-project-select.component.ts +++ b/frontend/src/app/shared/components/header-project-select/header-project-select.component.ts @@ -42,7 +42,6 @@ import { CurrentUserService } from 'core-app/core/current-user/current-user.serv import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; import { IProjectData } from 'core-app/shared/components/searchable-project-list/project-data'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; -import { ConfigurationService } from 'core-app/core/config/configuration.service'; @Component({ selector: 'opce-header-project-select', @@ -57,7 +56,6 @@ import { ConfigurationService } from 'core-app/core/config/configuration.service }) export class OpHeaderProjectSelectComponent extends UntilDestroyedMixin implements OnInit, OnDestroy, AfterViewInit { readonly pathHelper = inject(PathHelperService); - readonly configuration = inject(ConfigurationService); readonly I18n = inject(I18nService); readonly currentProject = inject(CurrentProjectService); readonly searchableProjectListService = inject(SearchableProjectListService); @@ -78,8 +76,6 @@ export class OpHeaderProjectSelectComponent extends UntilDestroyedMixin implemen public textFieldFocused = false; - public portfolioModelsEnabled = this.configuration.activeFeatureFlags.includes('portfolioModels'); - public canCreateNewProjects$ = this.currentUserService.hasCapabilities$('projects/create', 'global'); public projects$ = this.searchableProjectListService.allProjects$.pipe( @@ -134,14 +130,11 @@ export class OpHeaderProjectSelectComponent extends UntilDestroyedMixin implemen no_favorites: this.I18n.t('js.favorite_projects.no_results'), no_favorites_subtext: this.I18n.t('js.favorite_projects.no_results_subtext'), project: { - singular: this.I18n.t('js.label_project'), plural: this.I18n.t('js.label_project_plural'), - list: this.I18n.t('js.label_project_list'), select: this.I18n.t('js.label_all_projects'), search_placeholder: this.I18n.t('js.include_projects.search_placeholder') }, workspace: { - list: this.I18n.t('js.label_workspace_list'), search_placeholder: this.I18n.t('js.include_workspaces.search_placeholder') }, search_favorites_placeholder: this.I18n.t('js.include_projects.search_placeholder_favorites'), @@ -149,9 +142,8 @@ export class OpHeaderProjectSelectComponent extends UntilDestroyedMixin implemen no_favorite_results: this.I18n.t('js.include_projects.no_favorite_results') }; - // Computed text properties based on portfolio models feature flag public get currentText() { - return this.portfolioModelsEnabled ? this.text.workspace : this.text.project; + return this.text.workspace; } public displayMode:'all'|'favorited'; diff --git a/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.html b/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.html index e9de11e22b7..37784251c71 100644 --- a/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.html +++ b/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.html @@ -35,20 +35,18 @@ size="small" > } - @if (portfolioModelsEnabled) { - @switch (project._type) { - @case ('Portfolio') { - - - {{ this.I18n.t('js.include_workspaces.types.portfolio') }} - - } - @case ('Program') { - - - {{ this.I18n.t('js.include_workspaces.types.program') }} - - } + @switch (project._type) { + @case ('Portfolio') { + + + {{ this.I18n.t('js.include_workspaces.types.portfolio') }} + + } + @case ('Program') { + + + {{ this.I18n.t('js.include_workspaces.types.program') }} + } } @@ -79,20 +77,18 @@ data-test-selector="op-header-project-select--item-disabled-title" > {{ project.name }} - @if (portfolioModelsEnabled) { - @switch (project._type) { - @case ('Portfolio') { - - - {{ this.I18n.t('js.include_workspaces.types.portfolio') }} - - } - @case ('Program') { - - - {{ this.I18n.t('js.include_workspaces.types.program') }} - - } + @switch (project._type) { + @case ('Portfolio') { + + + {{ this.I18n.t('js.include_workspaces.types.portfolio') }} + + } + @case ('Program') { + + + {{ this.I18n.t('js.include_workspaces.types.program') }} + } } diff --git a/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.ts b/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.ts index 9d67f148811..83c15898858 100644 --- a/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.ts +++ b/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.ts @@ -5,7 +5,6 @@ import { } from 'core-app/shared/components/searchable-project-list/searchable-project-list.service'; import { IProjectData } from 'core-app/shared/components/searchable-project-list/project-data'; import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; -import { ConfigurationService } from 'core-app/core/config/configuration.service'; import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; import { getMetaContent } from 'core-app/core/setup/globals/global-helpers'; @@ -19,7 +18,6 @@ import { getMetaContent } from 'core-app/core/setup/globals/global-helpers'; export class OpHeaderProjectSelectListComponent implements OnInit, OnChanges { readonly I18n = inject(I18nService); readonly pathHelper = inject(PathHelperService); - readonly configuration = inject(ConfigurationService); readonly searchableProjectListService = inject(SearchableProjectListService); readonly elementRef = inject(ElementRef); readonly cdRef = inject(ChangeDetectorRef); @@ -58,8 +56,6 @@ export class OpHeaderProjectSelectListComponent implements OnInit, OnChanges { include_all_selected: this.I18n.t('js.include_projects.tooltip.include_all_selected') }; - public portfolioModelsEnabled = this.configuration.activeFeatureFlags.includes('portfolioModels'); - ngOnInit():void { if (this.root) { this.searchableProjectListService.selectedItemID$.subscribe((selectedItemID) => { diff --git a/frontend/src/app/shared/components/op-context-menu/handlers/wp-create-settings-menu.directive.ts b/frontend/src/app/shared/components/op-context-menu/handlers/wp-create-settings-menu.directive.ts index 407e97aa6af..5dd4f3dff8f 100644 --- a/frontend/src/app/shared/components/op-context-menu/handlers/wp-create-settings-menu.directive.ts +++ b/frontend/src/app/shared/components/op-context-menu/handlers/wp-create-settings-menu.directive.ts @@ -41,7 +41,7 @@ export class WorkPackageCreateSettingsMenuDirective extends OpContextMenuTrigger readonly halEditing = inject(HalResourceEditingService); override readonly placement = 'bottom-end'; - + protected open(evt:Event) { const wp = this.states.workPackages.get('new').value; diff --git a/frontend/src/app/shared/components/op-non-working-days-list/op-non-working-days-list.component.ts b/frontend/src/app/shared/components/op-non-working-days-list/op-non-working-days-list.component.ts index c545e4107d5..b3a711704f8 100644 --- a/frontend/src/app/shared/components/op-non-working-days-list/op-non-working-days-list.component.ts +++ b/frontend/src/app/shared/components/op-non-working-days-list/op-non-working-days-list.component.ts @@ -1,21 +1,14 @@ -import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostBinding, Injector, Input, OnInit, ViewChild, ViewEncapsulation, inject } from '@angular/core'; -import { BannersService } from 'core-app/core/enterprise/banners.service'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostBinding, Input, OnInit, ViewChild, ViewEncapsulation, inject } from '@angular/core'; import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { OpModalService } from '../modal/modal.service'; -import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; import { populateInputsFromDataset } from 'core-app/shared/components/dataset-inputs'; import { FullCalendarComponent } from '@fullcalendar/angular'; import { CalendarOptions, EventInput, EventSourceFuncArg } from '@fullcalendar/core'; import listPlugin from '@fullcalendar/list'; -import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; import { DayResourceService } from 'core-app/core/state/days/day.service'; import { IDay } from 'core-app/core/state/days/day.model'; import { CalendarViewEvent } from 'core-app/features/calendar/op-work-packages-calendar.service'; import { opIconElement } from 'core-app/shared/helpers/op-icon-builder'; -import { ConfirmDialogService } from 'core-app/shared/components/modals/confirm-dialog/confirm-dialog.service'; -import { ConfirmDialogOptions } from '../modals/confirm-dialog/confirm-dialog.modal'; import { ToastService } from 'core-app/shared/components/toaster/toast.service'; -import moment from 'moment-timezone'; import allLocales from '@fullcalendar/core/locales-all'; @@ -34,16 +27,10 @@ export interface INonWorkingDay { templateUrl: './op-non-working-days-list.component.html', standalone: false, }) -export class OpNonWorkingDaysListComponent implements OnInit, AfterViewInit { +export class OpNonWorkingDaysListComponent implements OnInit { readonly elementRef = inject>(ElementRef); protected I18n = inject(I18nService); - readonly bannersService = inject(BannersService); - readonly opModalService = inject(OpModalService); - readonly injector = inject(Injector); - readonly pathHelper = inject(PathHelperService); - readonly apiV3Service = inject(ApiV3Service); readonly dayService = inject(DayResourceService); - readonly confirmDialogService = inject(ConfirmDialogService); readonly toast = inject(ToastService); readonly cdRef = inject(ChangeDetectorRef); @@ -59,22 +46,13 @@ export class OpNonWorkingDaysListComponent implements OnInit, AfterViewInit { already_added_error: this.I18n.t('js.admin.working_days.already_added_error'), new_date: this.I18n.t('js.admin.working_days.calendar.new_date'), add_non_working_day: this.I18n.t('js.admin.working_days.add_non_working_day'), - change_description: this.I18n.t('js.admin.working_days.change_description'), - warning: this.I18n.t('js.admin.working_days.warning'), - change_button: this.I18n.t('js.admin.working_days.change_button'), - change_title: this.I18n.t('js.admin.working_days.change_title'), - removed_title: this.I18n.t('js.admin.working_days.removed_title'), non_working_day_name: this.I18n.t('js.modals.label_name'), add: this.I18n.t('js.button_add'), }; - form_submitted = false; - originalNonWorkingDays:INonWorkingDay[] = []; nonWorkingDays:INonWorkingDay[] = []; - originalWorkingDays:string[] = []; - datepickerOpened = false; selectedNonWorkingDayName = ''; @@ -100,15 +78,10 @@ export class OpNonWorkingDaysListComponent implements OnInit, AfterViewInit { anchor.classList.add('fc-list-day-side-text', 'op-non-working-days-list--delete-icon'); anchor.appendChild(opIconElement('icon', 'icon-delete')); - anchor.addEventListener('click', () => { - // Create 4 hidden inputs(id, name, date, _destroy) for the deleted NWD - this.nonWorkingDays = this.nonWorkingDays.map((item) => { - if (item.date === event.id) { - return { ...item, _destroy: true }; - } + anchor.addEventListener('click', (clickEvent:Event) => { + clickEvent.preventDefault(); - return item; - }); + this.markNonWorkingDayForRemoval(event.id); event.remove(); this.cdRef.detectChanges(); }); @@ -121,36 +94,15 @@ export class OpNonWorkingDaysListComponent implements OnInit, AfterViewInit { constructor() { populateInputsFromDataset(this); - this.listenToFormSubmit(); } - private listenToFormSubmit() { - const form = this.elementRef.nativeElement.closest('form')!; - form.addEventListener('submit', (evt:Event) => { - if (!this.form_submitted - && (this.nonWorkingDaysModified() || this.workingDaysModified())) { - this.form_submitted = true; - const target = evt.target as HTMLFormElement; - const options:ConfirmDialogOptions = { - text: { - text: this.text.change_description, - title: this.text.change_title, - button_continue: this.text.change_button, - }, - dangerHighlighting: true, - divideContent: true, - refreshOnCancel: true, - showListData: this.removedNonWorkingDays.length > 0, - warningText: this.text.warning, - passedData: this.removedNonWorkingDays, - listTitle: this.text.removed_title, - }; - evt.preventDefault(); - void this.confirmDialogService.confirm(options).then(() => { - this.form_submitted = false; - target.submit(); - }); + private markNonWorkingDayForRemoval(date:string):void { + this.nonWorkingDays = this.nonWorkingDays.map((item) => { + if (item.date === date) { + return { ...item, _destroy: true }; } + + return item; }); } @@ -163,23 +115,6 @@ export class OpNonWorkingDaysListComponent implements OnInit, AfterViewInit { this.cdRef.detectChanges(); } - ngAfterViewInit():void { - const form = this.elementRef.nativeElement.closest('form')!; - const workingDayCheckboxes = Array.from(form.querySelectorAll('input[name="settings[working_days][]"]')); - workingDayCheckboxes.forEach((checkbox:HTMLInputElement) => { - if (checkbox.checked) { - this.originalWorkingDays.push(checkbox.value); - } - }); - } - - public get removedNonWorkingDays():string[] { - return this - .nonWorkingDays - .filter((el) => el._destroy) - .map((el) => moment(el.date).format('MMMM DD, YYYY')); - } - // Initializes nonWorkingDays from the API public calendarEventsFunction( fetchInfo:EventSourceFuncArg, @@ -238,28 +173,4 @@ export class OpNonWorkingDaysListComponent implements OnInit, AfterViewInit { api.addEvent({ ...day, id: date }); } - private get workingDays():string[] { - const workingDays:string[] = []; - - const form = this.elementRef.nativeElement.closest('form')!; - const workingDayCheckboxes = Array.from(form.querySelectorAll('input[name="settings[working_days][]"]')); - workingDayCheckboxes.forEach((checkbox:HTMLInputElement) => { - if (checkbox.checked) { - workingDays.push(checkbox.value); - } - }); - - return workingDays; - } - - private nonWorkingDaysModified():boolean { - return this.removedNonWorkingDays.length > 0 - || this.modifiedNonWorkingDays.length > 0 - || this.nonWorkingDays.length > this.originalNonWorkingDays.length; - } - - private workingDaysModified():boolean { - return _.difference(this.workingDays, this.originalWorkingDays).length > 0 - || _.difference(this.originalWorkingDays, this.workingDays).length > 0; - } } diff --git a/frontend/src/app/shared/components/persistent-toggle/persistent-toggle.component.ts b/frontend/src/app/shared/components/persistent-toggle/persistent-toggle.component.ts index 4f55e5920b6..ece5ebe4c3f 100644 --- a/frontend/src/app/shared/components/persistent-toggle/persistent-toggle.component.ts +++ b/frontend/src/app/shared/components/persistent-toggle/persistent-toggle.component.ts @@ -88,7 +88,7 @@ export class PersistentToggleComponent implements OnInit { window.OpenProject.guardedLocalStorage(this.identifier, (!!isNowHidden).toString()); const targetNotification = this.targetNotification; - if (!targetNotification) return; + if (!targetNotification) return; if (isNowHidden) { slideUp(targetNotification, 400); diff --git a/frontend/src/app/shared/components/searchable-project-list/searchable-project-list.service.ts b/frontend/src/app/shared/components/searchable-project-list/searchable-project-list.service.ts index 04055898ec0..3a8a8528d34 100644 --- a/frontend/src/app/shared/components/searchable-project-list/searchable-project-list.service.ts +++ b/frontend/src/app/shared/components/searchable-project-list/searchable-project-list.service.ts @@ -102,11 +102,11 @@ export class SearchableProjectListService { // such as favorites or the preloaded projects (current project, selected projects) // in a filtered view, it's legitimate for them to be missing, thus we skip extra fetching if a search text is present if(!loadingEnabled || searchText.length > 0) { - return of([projects, false as boolean]); + return of([projects, false]); } return this.pipeConcatProjects(projects, this.preloadProjectIds.concat(favoriteIds)) - .pipe(map((p) => [p, true as boolean])); + .pipe(map((p) => [p, true])); }), switchMap(([projects, enhancePreloadedProjects]:[IProject[],boolean]) => { // These can be fetched in parallel to ancestors, since they share ancestors with preloadProjectIds entries and thus @@ -299,7 +299,7 @@ export class SearchableProjectListService { const listParent = findSearchableListParent(event.currentTarget as HTMLElement); const focused = document.activeElement; - (listParent?.querySelector('.spot-list--item-action_active') as HTMLElement)?.click(); + listParent?.querySelector('.spot-list--item-action_active')?.click(); (focused as HTMLElement)?.focus(); } diff --git a/frontend/src/app/shared/directives/search-highlight.directive.ts b/frontend/src/app/shared/directives/search-highlight.directive.ts index 68280c727a4..4f637d6b3f6 100644 --- a/frontend/src/app/shared/directives/search-highlight.directive.ts +++ b/frontend/src/app/shared/directives/search-highlight.directive.ts @@ -13,7 +13,7 @@ export class OpSearchHighlightDirective implements AfterViewChecked { let el = this.elementRef.nativeElement as HTMLElement; const highlightedElement = el.querySelector('.op-search-highlight'); - if (!!highlightedElement && this.query && highlightedElement.innerHTML.toLocaleLowerCase() === this.query.toLocaleLowerCase()) { + if (!!highlightedElement && highlightedElement.innerHTML.toLocaleLowerCase() === this.query?.toLocaleLowerCase()) { return; } diff --git a/frontend/src/app/shared/helpers/chronic_duration.spec.ts b/frontend/src/app/shared/helpers/chronic_duration.spec.ts index f0418c58c3e..4b598c1aee9 100644 --- a/frontend/src/app/shared/helpers/chronic_duration.spec.ts +++ b/frontend/src/app/shared/helpers/chronic_duration.spec.ts @@ -118,7 +118,7 @@ describe('parseChronicDuration', () => { }); /* The cecile case */ - it('it parses 2h15 correctly to 2h 15 minutes even when the default unit is hours', () => { + it('parses 2h15 correctly to 2h 15 minutes even when the default unit is hours', () => { expect(parseChronicDuration('2h15', { defaultUnit: 'hours' })).toBe(2 * 3600 + 15 * 60); }); diff --git a/frontend/src/app/shared/helpers/dom-helpers.spec.ts b/frontend/src/app/shared/helpers/dom-helpers.spec.ts index 6d73e5f4561..3bc01b2b7f3 100644 --- a/frontend/src/app/shared/helpers/dom-helpers.spec.ts +++ b/frontend/src/app/shared/helpers/dom-helpers.spec.ts @@ -26,7 +26,15 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { toggleElement, toggleElementByClass, toggleElementByVisibility, attributeTokenList, } from './dom-helpers'; +import { + toggleElement, + toggleElementByClass, + toggleElementByVisibility, + attributeTokenList, + toggleEnabled, + enableElement, + disableElement, +} from './dom-helpers'; describe('dom-helpers', () => { describe('toggleElement', () => { @@ -328,4 +336,205 @@ describe('dom-helpers', () => { expect(tokens).toEqual(['alpha', 'beta', 'gamma']); }); }); + + describe('toggleEnabled', () => { + describe('basic disabled toggling', () => { + it('toggles disabled property on input element when no value provided', () => { + const input = document.createElement('input'); + + expect(input.disabled).toBe(false); + + toggleEnabled(input); + + expect(input.disabled).toBe(true); + + toggleEnabled(input); + + expect(input.disabled).toBe(false); + }); + + it('enables element when value is true', () => { + const input = document.createElement('input'); + input.disabled = true; + + toggleEnabled(input, true); + + expect(input.disabled).toBe(false); + }); + + it('disables element when value is false', () => { + const input = document.createElement('input'); + input.disabled = false; + + toggleEnabled(input, false); + + expect(input.disabled).toBe(true); + }); + + it('works with select elements', () => { + const select = document.createElement('select'); + toggleEnabled(select, false); + + expect(select.disabled).toBe(true); + + toggleEnabled(select, true); + + expect(select.disabled).toBe(false); + }); + + it('works with textarea elements', () => { + const textarea = document.createElement('textarea'); + toggleEnabled(textarea, false); + + expect(textarea.disabled).toBe(true); + }); + + it('works with button elements', () => { + const button = document.createElement('button'); + toggleEnabled(button, false); + + expect(button.disabled).toBe(true); + }); + }); + + describe('with toggleHidden parameter', () => { + it('toggles both hidden and disabled when toggleHidden is true', () => { + const input = document.createElement('input'); + + expect(input.hidden).toBe(false); + expect(input.disabled).toBe(false); + + toggleEnabled(input, false, true); + + expect(input.hidden).toBe(true); + expect(input.disabled).toBe(true); + + toggleEnabled(input, true, true); + + expect(input.hidden).toBe(false); + expect(input.disabled).toBe(false); + }); + + it('toggles both properties when no value provided and toggleHidden is true', () => { + const input = document.createElement('input'); + + expect(input.hidden).toBe(false); + expect(input.disabled).toBe(false); + + toggleEnabled(input, undefined, true); + + expect(input.hidden).toBe(true); + expect(input.disabled).toBe(true); + + toggleEnabled(input, undefined, true); + + expect(input.hidden).toBe(false); + expect(input.disabled).toBe(false); + }); + + it('only toggles disabled when toggleHidden is false', () => { + const input = document.createElement('input'); + input.hidden = true; + + toggleEnabled(input, false, false); + + expect(input.hidden).toBe(true); // unchanged + expect(input.disabled).toBe(true); + }); + + it('only toggles disabled when toggleHidden is omitted', () => { + const input = document.createElement('input'); + input.hidden = true; + + toggleEnabled(input, false); + + expect(input.hidden).toBe(true); // unchanged + expect(input.disabled).toBe(true); + }); + + it('toggles hidden on non-form elements when toggleHidden is true', () => { + const div = document.createElement('div') as HTMLElement; + + expect(div.hidden).toBe(false); + + toggleEnabled(div, false, true); + + expect(div.hidden).toBe(true); + + toggleEnabled(div, true, true); + + expect(div.hidden).toBe(false); + }); + }); + + describe('fieldset recursion', () => { + it('recursively disables all child elements in a fieldset', () => { + const fieldset = document.createElement('fieldset'); + const input1 = document.createElement('input'); + const input2 = document.createElement('input'); + const select = document.createElement('select'); + + fieldset.appendChild(input1); + fieldset.appendChild(input2); + fieldset.appendChild(select); + + toggleEnabled(fieldset, false); + + expect(fieldset.disabled).toBe(true); + expect(input1.disabled).toBe(true); + expect(input2.disabled).toBe(true); + expect(select.disabled).toBe(true); + }); + + it('recursively enables all child elements in a fieldset', () => { + const fieldset = document.createElement('fieldset'); + const input = document.createElement('input'); + fieldset.appendChild(input); + + // First disable + toggleEnabled(fieldset, false); + + expect(fieldset.disabled).toBe(true); + expect(input.disabled).toBe(true); + + // Then enable + toggleEnabled(fieldset, true); + + expect(fieldset.disabled).toBe(false); + expect(input.disabled).toBe(false); + }); + + it('passes toggleHidden parameter to child elements', () => { + const fieldset = document.createElement('fieldset'); + const input = document.createElement('input'); + fieldset.appendChild(input); + + toggleEnabled(fieldset, false, true); + + expect(fieldset.hidden).toBe(true); + expect(fieldset.disabled).toBe(true); + expect(input.disabled).toBe(true); + }); + }); + + describe('convenience functions', () => { + it('enableElement sets disabled to false', () => { + const input = document.createElement('input'); + input.disabled = true; + + enableElement(input); + + expect(input.disabled).toBe(false); + }); + + it('disableElement sets disabled to true', () => { + const input = document.createElement('input'); + input.disabled = false; + + disableElement(input); + + expect(input.disabled).toBe(true); + }); + }); + }); }); diff --git a/frontend/src/app/shared/helpers/dom-helpers.ts b/frontend/src/app/shared/helpers/dom-helpers.ts index 635c2da2e9e..00c0e366152 100644 --- a/frontend/src/app/shared/helpers/dom-helpers.ts +++ b/frontend/src/app/shared/helpers/dom-helpers.ts @@ -243,3 +243,45 @@ export function attributeTokenList(element:HTMLElement, attribute:string):DOMTok return list as DOMTokenList; } /* eslint-enable */ + +/** + * Toggles the enabled/disabled state of form elements. + * For fieldsets, recursively applies to all child elements. + * + * @param element the element to toggle + * @param value force state (optional): `true` to enable/`false` to disable + * @param toggleHidden optionally toggle element.hidden inversely with disabled state + */ +export function toggleEnabled(element:HTMLElement, value?:boolean, toggleHidden?:boolean) { + // Handle hidden property if requested + if (toggleHidden) { + if (typeof value === 'undefined') { + element.hidden = !element.hidden; + } else { + element.hidden = !value; + } + } + + if ( + element instanceof HTMLInputElement || + element instanceof HTMLSelectElement || + element instanceof HTMLTextAreaElement || + element instanceof HTMLButtonElement || + element instanceof HTMLFieldSetElement + ) { + if (typeof value === 'undefined') { + element.disabled = !element.disabled; + } else { + element.disabled = !value; + } + } + + if (element instanceof HTMLFieldSetElement) { + Array.from(element.elements).forEach((child) => { + toggleEnabled(child as HTMLElement, !element.disabled, toggleHidden); + }); + } +} + +export const enableElement = (element:HTMLElement) => toggleEnabled(element, true); +export const disableElement = (element:HTMLElement) => toggleEnabled(element, false); diff --git a/frontend/src/app/shared/helpers/drag-and-drop/dom-autoscroll.service.ts b/frontend/src/app/shared/helpers/drag-and-drop/dom-autoscroll.service.ts index 35b3099177a..60d352eea2b 100644 --- a/frontend/src/app/shared/helpers/drag-and-drop/dom-autoscroll.service.ts +++ b/frontend/src/app/shared/helpers/drag-and-drop/dom-autoscroll.service.ts @@ -120,9 +120,9 @@ export class DomAutoscrollService { public getElementsUnderPoint():HTMLElement[] { const underPoint = []; - for (let i = 0; i < this.elements.length; i++) { - if (this.inside(this.point, this.elements[i])) { - underPoint.push(this.elements[i] as HTMLElement); + for (const element of this.elements) { + if (this.inside(this.point, element)) { + underPoint.push(element as HTMLElement); // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion } } diff --git a/frontend/src/app/spot/components/drop-modal/drop-modal.component.ts b/frontend/src/app/spot/components/drop-modal/drop-modal.component.ts index 111d7155064..4b07dd2e49b 100644 --- a/frontend/src/app/spot/components/drop-modal/drop-modal.component.ts +++ b/frontend/src/app/spot/components/drop-modal/drop-modal.component.ts @@ -183,7 +183,7 @@ export class SpotDropModalComponent implements OnDestroy { (this.focusGrabber.nativeElement as HTMLElement).focus(); } - private onGlobalClick = this.close.bind(this) as () => void; + private onGlobalClick = this.close.bind(this); ngOnDestroy():void { if (this.opened) { diff --git a/frontend/src/elements/block-note-element.ts b/frontend/src/elements/block-note-element.ts index 6ba9a5e5c2e..2dddfcee99c 100644 --- a/frontend/src/elements/block-note-element.ts +++ b/frontend/src/elements/block-note-element.ts @@ -53,12 +53,6 @@ class BlockNoteElement extends HTMLElement { if (browserSpecificClasses.length > 0) { this.editorRoot.classList.add(...browserSpecificClasses); } - // Clone the blank-target link description into the shadow DOM - // so aria-describedby references resolve for links inside the editor - const blankLinkDesc = document.getElementById('open-blank-target-link-description'); - if (blankLinkDesc) { - this.editorRoot.appendChild(blankLinkDesc.cloneNode(true)); - } this.editorMount = document.createElement('div'); this.editorRoot.appendChild(this.editorMount); diff --git a/frontend/src/global_styles/content/_accounts.sass b/frontend/src/global_styles/content/_accounts.sass index 32f7a1606ef..8681ee84e69 100644 --- a/frontend/src/global_styles/content/_accounts.sass +++ b/frontend/src/global_styles/content/_accounts.sass @@ -98,7 +98,7 @@ #nav-login-content .login-auth-providers h3 span - background: var(--body-background) + background: var(--overlay-bgColor) .login-auth-provider-list margin-top: 1em margin-bottom: 10px diff --git a/frontend/src/global_styles/content/_advanced_filters.sass b/frontend/src/global_styles/content/_advanced_filters.sass index 90db80632af..78b64d09d60 100644 --- a/frontend/src/global_styles/content/_advanced_filters.sass +++ b/frontend/src/global_styles/content/_advanced_filters.sass @@ -131,10 +131,69 @@ $advanced-filters--grid-gap: 10px height: 1px margin: 0.75rem 0 -.advanced-filters--spacer, -.advanced-filters--filter - &.hidden - display: none !important + +.op-filters-form + display: none + margin-top: 1rem + position: relative + + &.-expanded + display: block + + &--with-footer + padding-top: 0 + + .advanced-filters--actions + display: flex + justify-content: flex-end + align-items: center + gap: var(--base-size-8) + margin-top: var(--base-size-16) + + .FormControl-horizontalGroup.advanced-filters--filter + max-width: 752px + align-items: center + gap: $advanced-filters--grid-gap + + &:not([data-filter-type="boolean"]) + flex-wrap: wrap + + > .advanced-filters--filter-name + text-align: left + overflow: hidden + text-overflow: ellipsis + white-space: nowrap + + &:not([data-filter-type="boolean"]) > .advanced-filters--filter-name + flex: 0 0 100% + + &[data-filter-type="boolean"] > .advanced-filters--filter-name + flex: 0 0 $advanced-filters--label-size + + > .FormControl:not(.advanced-filters--filter-value) + flex: 0 0 $advanced-filters--operator-size + + > .FormControl.advanced-filters--filter-value + flex: 1 1 0 + min-width: 0 + + > .advanced-filters--remove-filter + margin-left: auto + flex-shrink: 0 + + .FormControl:has([data-filter--filters-form-target="addFilterSelect"]) + border-top: 1px solid var(--borderColor-muted) + margin-top: var(--base-size-8) + padding-top: var(--base-size-16) + max-width: 752px + + .advanced-filters--filter-value .FormControl-select-wrap + display: flex + align-items: center + gap: var(--base-size-4) + + .FormControl-select + flex: 1 1 auto .work-packages-embedded-view--container .advanced-filters--container margin: 0 0 1rem 0 diff --git a/frontend/src/global_styles/content/_forms.sass b/frontend/src/global_styles/content/_forms.sass index de166468bd3..1a6b0e8cf10 100644 --- a/frontend/src/global_styles/content/_forms.sass +++ b/frontend/src/global_styles/content/_forms.sass @@ -173,6 +173,11 @@ form .-columns-2 column-count: 2 + .form--field + break-inside: avoid + page-break-inside: avoid + -webkit-column-break-inside: avoid + // follows new board page columns width (which uses -wide class (500px) for its tiles) .boards-enterprise-banner-wide max-width: calc(2*500px + 1rem) diff --git a/frontend/src/global_styles/content/_grid.sass b/frontend/src/global_styles/content/_grid.sass index a680629903d..9f43dce4705 100644 --- a/frontend/src/global_styles/content/_grid.sass +++ b/frontend/src/global_styles/content/_grid.sass @@ -107,11 +107,16 @@ $grid--widget-padding: 20px 20px 20px 20px .grid--area-content height: 100% - ng-component, widget-wp-graph + .grid--widget-host display: flex flex-direction: column height: 100% + > .grid--widget-content + display: flex + flex-direction: column + flex: 1 1 auto + &.cdk-drag-preview overflow: hidden background: white diff --git a/frontend/src/global_styles/content/_widget_box.sass b/frontend/src/global_styles/content/_widget_box.sass index 946b7201d29..9292ae95d10 100644 --- a/frontend/src/global_styles/content/_widget_box.sass +++ b/frontend/src/global_styles/content/_widget_box.sass @@ -140,6 +140,10 @@ $widget-box--enumeration-width: 20px overflow-y: auto @include styled-scroll-bar + &.op-widget-box--empty + align-items: center + justify-content: center + // styles specific to the custom-text widget &.-custom-text a.inplace-editing--trigger-link @@ -217,6 +221,11 @@ $widget-box--enumeration-width: 20px border-bottom-right-radius: var(--borderRadius-medium) border-bottom-left-radius: var(--borderRadius-medium) +.op-widget-box--footer + margin-top: auto + padding: var(--stack-padding-normal) + border-top: var(--borderWidth-thin) solid var(--borderColor-muted) + .widget-box--arrow-links list-style: none margin: 0.5rem 0 1rem 0 diff --git a/frontend/src/global_styles/content/modules/_auth_plugins.sass b/frontend/src/global_styles/content/modules/_auth_plugins.sass index 0cebabf5484..857870ac73f 100644 --- a/frontend/src/global_styles/content/modules/_auth_plugins.sass +++ b/frontend/src/global_styles/content/modules/_auth_plugins.sass @@ -17,7 +17,6 @@ // // See doc/COPYRIGHT.md for more details. -.op-app-header #nav-login-content, #content, .login-auth-providers a.auth-provider background-repeat: no-repeat diff --git a/frontend/src/global_styles/content/reporting/_index.sass b/frontend/src/global_styles/content/reporting/_index.sass index 554b0e64863..b5b7132a39a 100644 --- a/frontend/src/global_styles/content/reporting/_index.sass +++ b/frontend/src/global_styles/content/reporting/_index.sass @@ -40,7 +40,6 @@ #query_form fieldset.header_collapsible.collapsible padding-bottom: 10px - // -------------------------- Save and Delete Reports -------------------------- #save_as_form, #delete_form z-index: 999 @@ -82,16 +81,15 @@ div.button_form padding-left: 5px padding-right: 5px -#add_filter_block +.add_filter_block margin-top: 6px -#add_filter_select +.add_filter_select margin-bottom: 10px .advanced-filters--filter-value.-binary display: flex - // -------------------------- Mobile -------------------------- @media only screen and (max-width: $breakpoint-sm) .group-by--control diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index f9f3869f6c0..3215f428457 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -38,7 +38,8 @@ &-input-wrap[invalid='true'] input:not(:focus) border-color: var(--control-borderColor-danger) - &-spacingWrapper > :empty + &-spacingWrapper > :empty, + &-spacingWrapper:not(:has(> :not([hidden]))) display: none .UnderlineNav diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 30459af0604..d5a763220f5 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,7 +1,6 @@ import { OpenProjectModule } from 'core-app/app.module'; import { enableProdMode, provideZonelessChangeDetection } from '@angular/core'; -import 'core-app/core/setup/init-jquery'; import 'core-app/core/setup/init-js-patches'; import { initializeLocale } from 'core-app/core/setup/init-locale'; diff --git a/frontend/src/react/components/OpBlockNoteEditor.tsx b/frontend/src/react/components/OpBlockNoteEditor.tsx index 0bf2ef36b9f..281f56a0369 100644 --- a/frontend/src/react/components/OpBlockNoteEditor.tsx +++ b/frontend/src/react/components/OpBlockNoteEditor.tsx @@ -29,6 +29,7 @@ */ import { BlockNoteEditorOptions, BlockNoteSchema } from '@blocknote/core'; +import { ExternalLinkA11yExtension } from '../extensions/external-link-a11y'; import { ExternalLinkCaptureExtension } from '../extensions/external-link-capture'; import { User } from '@blocknote/core/comments'; import { filterSuggestionItems } from '@blocknote/core/extensions'; @@ -116,11 +117,10 @@ export function OpBlockNoteEditor({ }), dictionary: localeDictionary, ...(attachmentsEnabled && { uploadFile }), - // When external link capture is enabled, intercept clicks on external - // links via a ProseMirror plugin and route through /external_redirect. - ...(captureExternalLinks && { - extensions: [ExternalLinkCaptureExtension], - }), + extensions: [ + ExternalLinkA11yExtension, + ...(captureExternalLinks ? [ExternalLinkCaptureExtension] : []), + ], }; }, [hocuspocusProvider, doc, activeUser, localeDictionary, attachmentsEnabled, uploadFile, captureExternalLinks]); diff --git a/frontend/src/react/extensions/external-link-a11y.ts b/frontend/src/react/extensions/external-link-a11y.ts new file mode 100644 index 00000000000..48b05b394ff --- /dev/null +++ b/frontend/src/react/extensions/external-link-a11y.ts @@ -0,0 +1,256 @@ +//-- 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. +//++ + +import { createExtension } from '@blocknote/core'; +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Decoration, DecorationSet } from 'prosemirror-view'; +import { + AddMarkStep, + RemoveMarkStep, + ReplaceStep, + ReplaceAroundStep, +} from 'prosemirror-transform'; +import type { Step } from 'prosemirror-transform'; +import type { Node as PmNode, Mark, Slice } from 'prosemirror-model'; +import { isHrefExternal } from 'core-stimulus/helpers/external-link-helpers'; + +// ProseMirror primer +// ------------------ +// • Transaction (`tr`): an immutable description of an edit. Editor state +// moves forward by applying transactions, not by direct DOM mutation. +// • Step: the atomic operation a transaction is built from. Relevant ones +// are ReplaceStep (replace a range with a Slice of content) and +// AddMarkStep / RemoveMarkStep (toggle a mark like `link` on a range). +// • Slice: the chunk of content carried by a ReplaceStep — what is being +// pasted, typed, or otherwise inserted. +// • Mapping (`tr.mapping`): ProseMirror's position translator. After an +// insert of N characters at position 10, mapping rewrites later +// positions to account for the shift. Decorations can be mapped through +// it to stay in sync without being rebuilt. +// • Decoration / DecorationSet: non-mutating overlays (widgets, attrs) +// rendered alongside the document. The sr-only hint here is a widget +// decoration — invisible to the model, visible (audible) in the DOM. + +const pluginKey = new PluginKey('externalLinkA11y'); +const DESCRIPTION_ID = 'open-blank-target-link-description'; + +// --- Decoration construction ------------------------------------------------ + +function findExternalLinkMark(node:PmNode):Mark|null { + for (const mark of node.marks) { + if (mark.type.name === 'link' && isHrefExternal(String(mark.attrs.href ?? ''))) { + return mark; + } + } + return null; +} + +// Detects whether the next inline node belongs to the same contiguous link +// run. Assumes every inline node inside a link carries the link mark. This +// holds for the current BlockNote schema, which has no inline custom nodes +// that opt out of marks. If a mention/inline-embed node is ever added that +// permits a link mark to wrap it without inheriting, revisit this — you will +// need to walk link runs explicitly instead of relying on nodeAfter. +function sameLinkContinues(next:PmNode|null|undefined, href:string):boolean { + if (!next) return false; + return next.marks.some( + (m) => m.type.name === 'link' && String(m.attrs.href ?? '') === href, + ); +} + +let missingDescriptionWarned = false; + +function readDescription():string { + const source = document.getElementById(DESCRIPTION_ID); + const text = source?.textContent?.trim() ?? ''; + if (!text && !missingDescriptionWarned) { + missingDescriptionWarned = true; + // The sr-only span is rendered in base.html.erb and also referenced by + // ExternalLinksController. If it goes missing, external-link hints silently + // become empty — warn once so the regression surfaces during development. + console.warn( + `[ExternalLinkA11yExtension] #${DESCRIPTION_ID} not found; external-link hints will be empty.`, + ); + } + return text; +} + +function buildWidget():HTMLElement { + const span = document.createElement('span'); + span.className = 'sr-only'; + // contenteditable=false keeps the hint inert inside ProseMirror's editable + // region, so users cannot place their caret inside it or delete it. + span.setAttribute('contenteditable', 'false'); + // Reuse the same translated string the body-level ExternalLinksController + // references via aria-describedby, keeping i18n centralised in Rails. + // Captured at decoration-creation time, so a mid-session locale change + // keeps stale text until the next rebuild. Leading NBSP is a separator + // for the link's computed accessible name — without it, AT can announce + // "Link textOpen link in a new tab" because descendant text-node + // concatenation isn't guaranteed to insert whitespace, especially in + // contenteditable. + span.textContent = `\u00A0${readDescription()}`; + return span; +} + +function buildDecorations(doc:PmNode):DecorationSet { + const decorations:Decoration[] = []; + + doc.descendants((node, pos) => { + const linkMark = findExternalLinkMark(node); + if (!linkMark) return; + + const href = String(linkMark.attrs.href ?? ''); + const end = pos + node.nodeSize; + const next = doc.resolve(end).nodeAfter; + + // Only emit the hint at the end of a contiguous link run. Adjacent inline + // nodes (e.g. text with an extra bold mark) carrying the same href are + // rendered as one , so we emit a single hint per link, not per node. + if (sameLinkContinues(next, href)) return; + + decorations.push( + Decoration.widget(end, buildWidget, { + // Wrap the widget in the link mark so the sr-only span is rendered + // INSIDE the tag. This makes the hint part of the link's + // accessible name — the only approach that is reliably announced by + // VoiceOver/NVDA in a contenteditable context, where aria-describedby + // is widely ignored. + marks: [linkMark], + // Negative side keeps the widget attached to the preceding link run + // when content is inserted at the same position. + side: -1, + ignoreSelection: true, + }), + ); + }); + + return DecorationSet.create(doc, decorations); +} + +// --- Transaction gating ----------------------------------------------------- + +function sliceContainsLinkMark(slice:Slice):boolean { + let found = false; + slice.content.descendants((node) => { + if (found) return false; + if (node.marks.some((m) => m.type.name === 'link')) { + found = true; + return false; + } + return true; + }); + return found; +} + +/** + * Decides whether a step warrants rebuilding the widget set, vs. just + * shifting existing decorations forward. + * + * Pure typing inserts content without touching link boundaries; the widget + * at the end of each run rides along correctly via decoration mapping, so + * the doc walk can be skipped. A rebuild is needed when: + * - a link mark is added, removed, or moved by a mark step; + * - an inserted slice itself carries a link mark (paste of linked HTML); + * - any range is deleted or replaced. Deletions whose right edge meets a + * link's trailing widget make `WidgetType.map` report the widget as + * deleted (PM's mapping forces `side=1` at the deletion's right edge, + * which clashes with our `assoc=-1`). The simplest robust answer is to + * reseat the widget set whenever a range is removed. + */ +function stepAffectsLinks(step:Step):boolean { + if (step instanceof AddMarkStep || step instanceof RemoveMarkStep) { + return step.mark.type.name === 'link'; + } + if (step instanceof ReplaceStep || step instanceof ReplaceAroundStep) { + if (step.from !== step.to) return true; + return sliceContainsLinkMark(step.slice); + } + return false; +} + +/** + * BlockNote extension that adds a screen-reader-only "opens in new tab" hint + * to external links inside the editor. + * + * The hint is injected as a ProseMirror widget decoration wrapped in the link + * mark, so the resulting DOM looks like: + * + * + * Link text + * Open link in a new tab + * + * + * Putting the text inside the anchor makes it part of the link's accessible + * name, which screen readers announce in every mode — including the edit mode + * they switch into inside contenteditable regions. The previous approach of + * using `aria-describedby` on an inline decoration span did not work in + * contenteditable: VoiceOver and NVDA ignore aria-describedby there, and the + * inline decoration landed the attribute on a generic span rather than the + * anchor anyway. + * + * Decorations never mutate the document model, so ProseMirror does not + * re-render and there is no DOMObserver mutation loop (the reason direct DOM + * rewriting was abandoned for this attribute). + */ +export const ExternalLinkA11yExtension = createExtension({ + key: 'externalLinkA11y', + + prosemirrorPlugins: [ + new Plugin({ + key: pluginKey, + state: { + init(_, { doc }) { + return buildDecorations(doc); + }, + // `apply` runs once per transaction to advance plugin state and is + // the hot path during typing. Three branches: + // 1. Doc unchanged: decorations are still valid as-is. + // 2. Doc changed but no step affects link runs: shift existing + // decorations forward through the transaction's position + // mapping. O(decoration count); no doc walk. + // 3. A step adds, removes, or moves a link mark: rebuild. + // + // The rebuild lives here in `apply` rather than in `appendTransaction` + // because dispatching a chained meta-only tx from there interferes + // with y-prosemirror's PM↔Y.Doc sync — paste content gets applied + // locally but never reaches the rendered view. + apply(tr, oldDecos) { + if (!tr.docChanged) return oldDecos; + if (tr.steps.some(stepAffectsLinks)) return buildDecorations(tr.doc); + return oldDecos.map(tr.mapping, tr.doc); + }, + }, + props: { + decorations(state) { + return pluginKey.getState(state) as DecorationSet; + }, + }, + }), + ], +}); diff --git a/frontend/src/stimulus/controllers/async-dialog.controller.ts b/frontend/src/stimulus/controllers/async-dialog.controller.ts index 6279b34b814..684d96a6d39 100644 --- a/frontend/src/stimulus/controllers/async-dialog.controller.ts +++ b/frontend/src/stimulus/controllers/async-dialog.controller.ts @@ -33,6 +33,12 @@ import { renderStreamMessage } from '@hotwired/turbo'; import { TurboHelpers } from 'core-turbo/helpers'; export default class AsyncDialogController extends ApplicationController { + static values = { disableDuringLoad: { type: Boolean, default: true } }; + + declare disableDuringLoadValue:boolean; + + private loading = false; + connect() { // Only bind events if we have an href to work with if (this.href) { @@ -55,6 +61,12 @@ export default class AsyncDialogController extends ApplicationController { } private triggerTurboStream(url:string):void { + if (this.disableDuringLoadValue && this.loading) return; + + if (this.disableDuringLoadValue) { + this.loading = true; + (this.element as HTMLElement).setAttribute('aria-disabled', 'true'); + } TurboHelpers.showProgressBar(); void fetch(url, { @@ -74,6 +86,10 @@ export default class AsyncDialogController extends ApplicationController { }).then((html) => { renderStreamMessage(html); }).finally(() => { + if (this.disableDuringLoadValue) { + this.loading = false; + (this.element as HTMLElement).removeAttribute('aria-disabled'); + } TurboHelpers.hideProgressBar(); }); } diff --git a/frontend/src/stimulus/controllers/beforeunload.controller.spec.ts b/frontend/src/stimulus/controllers/beforeunload.controller.spec.ts new file mode 100644 index 00000000000..282133eb8b6 --- /dev/null +++ b/frontend/src/stimulus/controllers/beforeunload.controller.spec.ts @@ -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. + * ++ + */ + +import { vi } from 'vitest'; +import { OpenProject } from 'core-app/core/setup/globals/openproject'; +import { BeforeunloadController } from './beforeunload.controller'; + +describe('BeforeunloadController', () => { + let originalOpenProject:OpenProject; + let controller:BeforeunloadController; + + beforeEach(() => { + originalOpenProject = window.OpenProject; + window.OpenProject = new OpenProject(); + vi.stubGlobal('I18n', { t: vi.fn().mockReturnValue('Leave page?') }); + controller = Object.create(BeforeunloadController.prototype) as BeforeunloadController; + }); + + afterEach(() => { + window.OpenProject = originalOpenProject; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + function turboBeforeVisit(url = 'http://example.com/projects') { + return new CustomEvent('turbo:before-visit', { + detail: { url }, + cancelable: true, + }); + } + + function handle(event:Event) { + controller.handleEvent(event); + + return event; + } + + it('shows confirm when Angular edit forms have unsaved changes', () => { + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + + window.OpenProject.editFormsContainUnsavedChanges = () => true; + const event = handle(turboBeforeVisit()); + + expect(confirm).toHaveBeenCalledWith('Leave page?'); + expect(event.defaultPrevented).toBe(true); + }); + + it('shows confirm when pageState is edited', () => { + window.OpenProject.pageState = 'edited'; + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + + const event = handle(turboBeforeVisit()); + + expect(confirm).toHaveBeenCalledWith('Leave page?'); + expect(event.defaultPrevented).toBe(true); + }); + + it('does not show confirm when nothing is dirty', () => { + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + + const event = handle(turboBeforeVisit()); + + expect(confirm).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); + + it('does not prevent navigation when user accepts confirm', () => { + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); + + window.OpenProject.editFormsContainUnsavedChanges = () => true; + const event = handle(turboBeforeVisit()); + + expect(confirm).toHaveBeenCalledWith('Leave page?'); + expect(event.defaultPrevented).toBe(false); + }); + + it('only checks pageWasEdited for native beforeunload', () => { + window.OpenProject.editFormsContainUnsavedChanges = () => true; + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + const event = new Event('beforeunload', { cancelable: true }); + + handle(event); + + expect(confirm).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); + + it('resets pageState to pristine on turbo:render', () => { + window.OpenProject.pageState = 'edited'; + + handle(new Event('turbo:render')); + + expect(window.OpenProject.pageState).toBe('pristine'); + }); + + it('sets pageState to submitted on form submit', () => { + handle(new Event('submit')); + + expect(window.OpenProject.pageState).toBe('submitted'); + }); +}); diff --git a/frontend/src/stimulus/controllers/beforeunload.controller.ts b/frontend/src/stimulus/controllers/beforeunload.controller.ts index 983aecbd0d1..833bfb7de6a 100644 --- a/frontend/src/stimulus/controllers/beforeunload.controller.ts +++ b/frontend/src/stimulus/controllers/beforeunload.controller.ts @@ -21,11 +21,11 @@ export class BeforeunloadController extends ApplicationController { this.abortController.abort(); } - handleEvent(evt:BeforeUnloadEvent|TurboBeforeVisitEvent|CustomEvent) { + handleEvent(evt:Event) { switch (evt.type) { case 'beforeunload': case 'turbo:before-visit': - this.beforeunloadHandler(evt as BeforeUnloadEvent|TurboBeforeVisitEvent); + this.beforeunloadHandler(evt); break; case 'turbo:submit-end': case 'turbo:load': @@ -41,7 +41,11 @@ export class BeforeunloadController extends ApplicationController { } private beforeunloadHandler(evt:BeforeUnloadEvent|TurboBeforeVisitEvent) { - if (window.OpenProject.pageState !== 'edited') { + const hasUnsavedChanges = evt.type === 'turbo:before-visit' + ? window.OpenProject.pageHasUnsavedChanges + : window.OpenProject.pageWasEdited; + + if (!hasUnsavedChanges) { return; } diff --git a/frontend/src/stimulus/controllers/check-all.controller.spec.ts b/frontend/src/stimulus/controllers/check-all.controller.spec.ts index ada71a74f5c..02a5093b67a 100644 --- a/frontend/src/stimulus/controllers/check-all.controller.spec.ts +++ b/frontend/src/stimulus/controllers/check-all.controller.spec.ts @@ -27,122 +27,105 @@ * See COPYRIGHT and LICENSE files for more details. * ++ */ -import { Application } from '@hotwired/stimulus'; +import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; import CheckAllController from './check-all.controller'; import CheckableController from './checkable.controller'; -const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); +const checkAllTemplate = ` +
+ + +
+`; + +const checkableTemplate = ` +
+ + + +
+`; describe('CheckAllController', () => { - let Stimulus:Application; - let fixturesElement:HTMLElement; - - beforeEach(() => { - fixturesElement = document.createElement('div'); - document.body.appendChild(fixturesElement); - }); + let ctx:StimulusTestContext; beforeEach(async () => { - Stimulus = Application.start(); - // Stimulus.debug = true; - Stimulus.handleError = (error, message, detail) => { - console.error(error, message, detail); - }; - Stimulus.register('checkable', CheckableController); - Stimulus.register('check-all', CheckAllController); - await nextFrame(); + ctx = await setupStimulusTest({ + controllers: { + 'check-all': CheckAllController, + checkable: CheckableController, + }, + }); }); - const checkAllTemplate = ` -
- - -
- `; - - const checkableTemplate = ` -
- - - -
- `; - - function appendTemplate(html:string) { - const template = document.createElement('template'); - template.innerHTML = html.trim(); - fixturesElement.appendChild(template.content.cloneNode(true)); - } + afterEach(() => ctx.dispose()); describe('without checkable controller', () => { beforeEach(async () => { - appendTemplate(checkAllTemplate); - await nextFrame(); + await ctx.mount(checkAllTemplate); }); it('does nothing and does not error', () => { expect(() => { - document.getElementById('check-all')!.click(); - document.getElementById('uncheck-all')!.click(); + ctx.screen.getByRole('button', { name: 'Check all' }).click(); + ctx.screen.getByRole('button', { name: 'Uncheck all' }).click(); }).not.toThrow(); }); }); describe('with checkable controller', () => { beforeEach(async () => { - appendTemplate(checkableTemplate); - appendTemplate(checkAllTemplate); - await nextFrame(); + await ctx.mount(` + ${checkableTemplate} + ${checkAllTemplate} + `); }); it('toggles checkboxes', async () => { - const inputs = Array.from(document.querySelectorAll('input[type="checkbox"]')); + const inputs = ctx.screen.getAllByRole('checkbox'); expect(inputs).toHaveLength(3); - expect(inputs.every((i) => !i.checked)).toBe(true); + inputs.forEach((input) => { + expect(input).not.toBeChecked(); + }); - document.getElementById('check-all')!.click(); - await nextFrame(); + ctx.screen.getByRole('button', { name: 'Check all' }).click(); + await ctx.nextFrame(); - expect(inputs.every((i) => i.checked)).toBe(true); + inputs.forEach((input) => { + expect(input).toBeChecked(); + }); - document.getElementById('uncheck-all')!.click(); - await nextFrame(); + ctx.screen.getByRole('button', { name: 'Uncheck all' }).click(); + await ctx.nextFrame(); - expect(inputs.every((i) => !i.checked)).toBe(true); + inputs.forEach((input) => { + expect(input).not.toBeChecked(); + }); }); it('applies aria-controls for connected outlet', () => { - const checkAllEl = document.querySelector('[data-controller="check-all"]')!; + const checkAllEl = ctx.container.querySelector('[data-controller="check-all"]')!; - expect(checkAllEl).toBeDefined(); + expect(checkAllEl).toHaveAttribute('aria-controls'); - const ariaControls = checkAllEl.getAttribute('aria-controls'); + const ariaControls = checkAllEl.getAttribute('aria-controls')!; - expect(ariaControls).toBeTruthy(); - expect(ariaControls!.split(/\s+/)).toContain('checkables'); + expect(ariaControls.split(/\s+/)).toContain('checkables'); }); it('removes aria-controls entry when outlet disconnects', async () => { - const checkAllEl = document.querySelector('[data-controller="check-all"]')!; + const checkAllEl = ctx.container.querySelector('[data-controller="check-all"]')!; const ariaBefore = checkAllEl.getAttribute('aria-controls') ?? ''; - // Scenarios with connected checkable outlets + expect(ariaBefore.split(/\s+/)).toContain('checkables'); - // Remove the outlet element to trigger outlet disconnect - document.getElementById('checkables')!.remove(); - - await nextFrame(); + ctx.container.querySelector('#checkables')!.remove(); + await ctx.nextFrame(); const ariaAfter = checkAllEl.getAttribute('aria-controls') ?? ''; expect(ariaAfter.split(/\s+/)).not.toContain('checkables'); }); }); - - afterEach(() => { - fixturesElement.remove(); - - Stimulus.stop(); - }); }); diff --git a/frontend/src/stimulus/controllers/checkable.controller.spec.ts b/frontend/src/stimulus/controllers/checkable.controller.spec.ts index 3b937c1771f..fc859a995ab 100644 --- a/frontend/src/stimulus/controllers/checkable.controller.spec.ts +++ b/frontend/src/stimulus/controllers/checkable.controller.spec.ts @@ -31,14 +31,14 @@ import { ActionEvent } from '@hotwired/stimulus'; import CheckableController from './checkable.controller'; +import { createControllerInstance } from 'core-stimulus/test-helpers'; describe('CheckableController', () => { let controller:any; let inputs:HTMLInputElement[]; beforeEach(() => { - // Create a plain object that uses the controller prototype so we can call methods - controller = Object.create(CheckableController.prototype); + controller = createControllerInstance(CheckableController); inputs = [0, 1, 2].map(() => { const input = document.createElement('input'); @@ -103,7 +103,6 @@ describe('CheckableController', () => { }); describe('toggleSelection', () => { - // Helper to create an ActionEvent-like object with params function createActionEvent(params:ActionEvent['params']):ActionEvent { const event = new Event('click') as ActionEvent; event.params = params; @@ -129,7 +128,6 @@ describe('CheckableController', () => { }); it('toggles only checkboxes matching the key/value pair', () => { - // Add data attributes to checkboxes inputs[0].dataset.role = 'admin'; inputs[1].dataset.role = 'member'; inputs[2].dataset.role = 'admin'; @@ -138,7 +136,6 @@ describe('CheckableController', () => { controller.toggleSelection(event); - // Only admin checkboxes should be checked expect(inputs[0].checked).toBe(true); expect(inputs[1].checked).toBe(false); expect(inputs[2].checked).toBe(true); @@ -156,7 +153,6 @@ describe('CheckableController', () => { controller.toggleSelection(event); - // Only admin checkboxes should be unchecked expect(inputs[0].checked).toBe(false); expect(inputs[1].checked).toBe(true); // member stays checked expect(inputs[2].checked).toBe(false); diff --git a/frontend/src/stimulus/controllers/disable-when-checked.controller.spec.ts b/frontend/src/stimulus/controllers/disable-when-checked.controller.spec.ts new file mode 100644 index 00000000000..f5784d53eca --- /dev/null +++ b/frontend/src/stimulus/controllers/disable-when-checked.controller.spec.ts @@ -0,0 +1,152 @@ +/* + * -- 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. + * ++ + */ + +import OpDisableWhenCheckedController from './disable-when-checked.controller'; +import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; + +describe('OpDisableWhenCheckedController', () => { + let ctx:StimulusTestContext; + + beforeEach(async () => { + ctx = await setupStimulusTest({ + controllers: { 'disable-when-checked': OpDisableWhenCheckedController }, + }); + }); + + afterEach(() => ctx.dispose()); + + describe('basic disable on check', () => { + beforeEach(async () => { + await ctx.mount(` +
+ + +
+ `); + }); + + it('disables effect targets when cause is checked', async () => { + const checkbox = ctx.screen.getByRole('checkbox', { name: 'Toggle' }); + const textField = ctx.screen.getByRole('textbox', { name: 'Text field' }); + + expect(textField).toBeEnabled(); + + checkbox.click(); + await ctx.nextFrame(); + + expect(textField).toBeDisabled(); + }); + + it('re-enables effect targets when cause is unchecked', async () => { + const checkbox = ctx.screen.getByRole('checkbox', { name: 'Toggle' }); + const textField = ctx.screen.getByRole('textbox', { name: 'Text field' }); + + checkbox.click(); + await ctx.nextFrame(); + + expect(textField).toBeDisabled(); + + checkbox.click(); + await ctx.nextFrame(); + + expect(textField).toBeEnabled(); + }); + }); + + describe('reversed mode', () => { + beforeEach(async () => { + await ctx.mount(` +
+ + +
+ `); + }); + + it('enables effect targets when cause is checked', async () => { + const checkbox = ctx.screen.getByRole('checkbox', { name: 'Toggle' }); + const textField = ctx.screen.getByRole('textbox', { name: 'Text field' }); + + checkbox.click(); + await ctx.nextFrame(); + + expect(textField).toBeEnabled(); + }); + }); + + describe('select option handling', () => { + it('resets select value when selected option becomes disabled', async () => { + await ctx.mount(` +
+ + +
+ `); + + const select = ctx.container.querySelector('select[aria-label="Options"]')!; + + expect(select.value).toBe('a'); + + ctx.screen.getByRole('checkbox', { name: 'Toggle' }).click(); + await ctx.nextFrame(); + + expect(select.value).toBe(''); + }); + }); +}); diff --git a/frontend/src/stimulus/controllers/disable-when-clicked.controller.spec.ts b/frontend/src/stimulus/controllers/disable-when-clicked.controller.spec.ts new file mode 100644 index 00000000000..95128cde290 --- /dev/null +++ b/frontend/src/stimulus/controllers/disable-when-clicked.controller.spec.ts @@ -0,0 +1,77 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ + +import DisableWhenClickedController from './disable-when-clicked.controller'; +import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; + +describe('DisableWhenClickedController', () => { + let ctx:StimulusTestContext; + + beforeEach(async () => { + ctx = await setupStimulusTest({ + controllers: { 'disable-when-clicked': DisableWhenClickedController }, + }); + }); + + afterEach(() => ctx.dispose()); + + it('disables button after click', async () => { + await ctx.mount(` + + `); + + const button = ctx.screen.getByRole('button', { name: 'Submit' }); + + button.click(); + // setTimeout(fn) defers by one task; nextFrame (rAF) fires after + await ctx.nextFrame(); + + expect(button).toBeDisabled(); + }); + + it('replaces button text when text value is set', async () => { + await ctx.mount(` + + `); + + const button = ctx.screen.getByRole('button', { name: 'Submit' }); + + button.click(); + await ctx.nextFrame(); + + expect(button).toHaveTextContent('Processing...'); + expect(button).toBeDisabled(); + }); +}); diff --git a/frontend/src/stimulus/controllers/disable-when-value-selected.controller.ts b/frontend/src/stimulus/controllers/disable-when-value-selected.controller.ts new file mode 100644 index 00000000000..61582c71715 --- /dev/null +++ b/frontend/src/stimulus/controllers/disable-when-value-selected.controller.ts @@ -0,0 +1,69 @@ +/* + * -- 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. + * ++ + */ + +import { toggleEnabled } from 'core-app/shared/helpers/dom-helpers'; +import { ApplicationController } from 'stimulus-use'; + +export default class OpDisableWhenValueSelectedController extends ApplicationController { + static targets = ['cause', 'effect']; + + declare readonly effectTargets:(HTMLInputElement|HTMLFieldSetElement)[]; + + private boundListener = this.toggleDisabled.bind(this); + + causeTargetConnected(target:HTMLElement) { + target.addEventListener('change', this.boundListener); + } + + causeTargetDisconnected(target:HTMLElement) { + target.removeEventListener('change', this.boundListener); + } + + private toggleDisabled(evt:Event):void { + const input = evt.target as HTMLInputElement; + const targetName = input.dataset.targetName; + + this + .effectTargets + .filter((el) => targetName === el.dataset.targetName) + .forEach((el) => { + const disabled = this.willDisable(el, input.value); + toggleEnabled(el, !disabled); + }); + } + + private willDisable(el:HTMLElement, value:string):boolean { + if (el.dataset.notValue) { + return el.dataset.notValue === value; + } + + return !(el.dataset.value === value); + } +} diff --git a/frontend/src/stimulus/controllers/dynamic/admin/custom-fields.controller.ts b/frontend/src/stimulus/controllers/dynamic/admin/custom-fields.controller.ts index e9039f7336f..c3935cb76bf 100644 --- a/frontend/src/stimulus/controllers/dynamic/admin/custom-fields.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/admin/custom-fields.controller.ts @@ -117,9 +117,12 @@ export default class CustomFieldsController extends Controller { addOption() { const count = this.customOptionRowTargets.length; const last = this.customOptionRowTargets[count - 1]; + if (!last) { return false; } + const dup = last.cloneNode(true) as HTMLElement; - const input = dup.querySelector('.custom-option-value input') as HTMLInputElement; + const input = dup.querySelector('.custom-option-value input'); + if (!input) { return false; } input.setAttribute('name', `custom_field[custom_options_attributes][${count}][value]`); input.setAttribute('id', `custom_field_custom_options_attributes_${count}_value`); @@ -129,8 +132,9 @@ export default class CustomFieldsController extends Controller { .querySelector('.custom-option-id') ?.remove(); - const defaultValueCheckbox = dup.querySelector('input[type="checkbox"]') as HTMLInputElement; - const defaultValueHidden = dup.querySelector('input[type="hidden"]') as HTMLInputElement; + const defaultValueCheckbox = dup.querySelector('input[type="checkbox"]'); + const defaultValueHidden = dup.querySelector('input[type="hidden"]'); + if (!defaultValueCheckbox || !defaultValueHidden) { return false; } defaultValueHidden.setAttribute('name', `custom_field[custom_options_attributes][${count}][default_value]`); defaultValueHidden.removeAttribute('id'); diff --git a/frontend/src/stimulus/controllers/dynamic/admin/hierarchy-item.controller.ts b/frontend/src/stimulus/controllers/dynamic/admin/hierarchy-item.controller.ts index bfee93d82c5..028713cbd75 100644 --- a/frontend/src/stimulus/controllers/dynamic/admin/hierarchy-item.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/admin/hierarchy-item.controller.ts @@ -85,10 +85,12 @@ export default class HierarchyItemController extends Controller { if (event.dataTransfer) { const origin = event.dataTransfer.getData('application/dragkey'); - const originElement = document.querySelector(`[data-hierarchy-item-id='${origin}']`) as HTMLElement; + const originElement = document.querySelector(`[data-hierarchy-item-id='${origin}']`); + if (!originElement) { return; } + originElement.style.opacity = '1'; - if (targetElement.dataset.hierarchyItemId === (originElement as HTMLElement).dataset.hierarchyItemId) { + if (targetElement.dataset.hierarchyItemId === originElement.dataset.hierarchyItemId) { return; } diff --git a/frontend/src/stimulus/controllers/dynamic/admin/progress-tracking.controller.ts b/frontend/src/stimulus/controllers/dynamic/admin/progress-tracking.controller.ts index 0778fb4ca23..c514ca3a0b8 100644 --- a/frontend/src/stimulus/controllers/dynamic/admin/progress-tracking.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/admin/progress-tracking.controller.ts @@ -83,8 +83,8 @@ export default class ProgressTrackingController extends Controller { } getSelectedMode() { - const checkedRadio = this.progressCalculationModeRadioGroupTarget.querySelector('input:checked') as HTMLInputElement; - return checkedRadio?.value || ''; + const checkedRadio = this.progressCalculationModeRadioGroupTarget.querySelector('input:checked'); + return checkedRadio?.value ?? ''; } getWarningMessageHtml():string { diff --git a/frontend/src/stimulus/controllers/dynamic/admin/registration.controller.ts b/frontend/src/stimulus/controllers/dynamic/admin/registration.controller.ts index fdd323321d7..9f2f2b63d1a 100644 --- a/frontend/src/stimulus/controllers/dynamic/admin/registration.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/admin/registration.controller.ts @@ -65,7 +65,7 @@ export default class RegistrationController extends Controller { } getSelectedOption() { - const checkedRadio = this.selfRegistrationRadioGroupTarget.querySelector('input[type="radio"]:checked') as HTMLInputElement; - return checkedRadio?.value || ''; + const checkedRadio = this.selfRegistrationRadioGroupTarget.querySelector('input[type="radio"]:checked'); + return checkedRadio?.value ?? ''; } } diff --git a/frontend/src/stimulus/controllers/dynamic/admin/roles.controller.ts b/frontend/src/stimulus/controllers/dynamic/admin/roles.controller.ts index da3b6092fde..f9817d667dc 100644 --- a/frontend/src/stimulus/controllers/dynamic/admin/roles.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/admin/roles.controller.ts @@ -29,6 +29,7 @@ */ import { Controller } from '@hotwired/stimulus'; +import { toggleEnabled } from 'core-app/shared/helpers/dom-helpers'; export default class RolesController extends Controller { static targets = [ @@ -54,17 +55,15 @@ export default class RolesController extends Controller { } globalRoleValueChanged() { - this.toggleEnabled(this.memberAttributesTarget, !this.globalRoleValue); - this.toggleEnabled(this.memberPermissionsTarget, !this.globalRoleValue); - this.toggleEnabled(this.globalPermissionsTarget, this.globalRoleValue); + this.togglePermissionSection(this.memberAttributesTarget, !this.globalRoleValue); + this.togglePermissionSection(this.memberPermissionsTarget, !this.globalRoleValue); + this.togglePermissionSection(this.globalPermissionsTarget, this.globalRoleValue); } - toggleEnabled(target:HTMLElement, enabled:boolean) { - target.hidden = !enabled; + private togglePermissionSection(target:HTMLElement, enabled:boolean) { + toggleEnabled(target, enabled, true); target .querySelectorAll('input,select') - .forEach((input:HTMLInputElement) => { - input.disabled = !enabled; - }); + .forEach((input:HTMLInputElement) => toggleEnabled(input, enabled)); } } diff --git a/frontend/src/stimulus/controllers/dynamic/admin/users.controller.ts b/frontend/src/stimulus/controllers/dynamic/admin/users.controller.ts index d456813bc0a..91581604191 100644 --- a/frontend/src/stimulus/controllers/dynamic/admin/users.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/admin/users.controller.ts @@ -1,4 +1,5 @@ import { Controller } from '@hotwired/stimulus'; +import { toggleEnabled } from 'core-app/shared/helpers/dom-helpers'; export default class UsersController extends Controller { static targets = [ @@ -34,10 +35,8 @@ export default class UsersController extends Controller { } private toggleHiddenAndDisabled(target:HTMLElement, hiddenAndDisabled:boolean) { - target.hidden = hiddenAndDisabled; + toggleEnabled(target, !hiddenAndDisabled, true); target.querySelectorAll('input') - .forEach((el:HTMLInputElement) => { - el.disabled = hiddenAndDisabled; - }); + .forEach((el:HTMLInputElement) => toggleEnabled(el, !hiddenAndDisabled)); } } diff --git a/frontend/src/stimulus/controllers/dynamic/costs/budget-subform.controller.ts b/frontend/src/stimulus/controllers/dynamic/costs/budget-subform.controller.ts index fd1128458f5..2ff5b9d8623 100644 --- a/frontend/src/stimulus/controllers/dynamic/costs/budget-subform.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/costs/budget-subform.controller.ts @@ -129,7 +129,8 @@ export default class BudgetSubformController extends Controller { const row = this.element.querySelector(`#${row_identifier}`)!; const body = new FormData(); body.append('element_id', row_identifier); - body.append('fixed_date', (document.querySelector('#budget_fixed_date') as HTMLInputElement).value); + const fixedDateInput = document.querySelector('#budget_fixed_date'); + body.append('fixed_date', fixedDateInput?.value ?? ''); row.querySelectorAll('.budget-item-value').forEach((itemValue:HTMLInputElement|HTMLSelectElement) => { body.append(itemValue.dataset.requestKey!, (itemValue.value || '0')); diff --git a/frontend/src/stimulus/controllers/dynamic/filter/filters-form.controller.ts b/frontend/src/stimulus/controllers/dynamic/filter/filters-form.controller.ts index 7250e3cbcdc..80dd29e81e7 100644 --- a/frontend/src/stimulus/controllers/dynamic/filter/filters-form.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/filter/filters-form.controller.ts @@ -36,6 +36,7 @@ import { hideElement, showElement, } from 'core-app/shared/helpers/dom-helpers'; +import { PrimerMultiInputElement } from '@primer/view-components/app/lib/primer/forms/primer_multi_input'; interface PrimerTextFieldElement extends HTMLElement { inputElement:HTMLInputElement; @@ -56,13 +57,13 @@ export default class FiltersFormController extends Controller { 'simpleFilter', 'filter', 'addFilterSelect', - 'spacer', 'operator', 'filterValueContainer', - 'filterValueSelect', 'days', 'singleDay', + 'dateRange', 'simpleValue', + 'filtersInput', ]; declare readonly filterFormToggleTarget:HTMLButtonElement; @@ -70,15 +71,16 @@ export default class FiltersFormController extends Controller { declare readonly simpleFilterTargets:HTMLElement[]; declare readonly filterTargets:HTMLElement[]; declare readonly addFilterSelectTarget:HTMLSelectElement; - declare readonly spacerTarget:HTMLElement; declare readonly operatorTargets:HTMLSelectElement[]; declare readonly filterValueContainerTargets:HTMLElement[]; - declare readonly filterValueSelectTargets:HTMLSelectElement[]; declare readonly daysTargets:HTMLInputElement[]; declare readonly singleDayTargets:HTMLInputElement[]; + declare readonly dateRangeTargets:HTMLInputElement[]; declare readonly simpleValueTargets:HTMLInputElement[]; + declare readonly filtersInputTarget:HTMLInputElement; declare readonly hasFilterFormToggleTarget:boolean; + declare readonly hasFiltersInputTarget:boolean; static values = { displayFilters: { type: Boolean, default: false }, @@ -102,20 +104,22 @@ export default class FiltersFormController extends Controller { }); private boundListener:() => void; + private boundClearListener:(event:MouseEvent) => void; initialize() { // Initialize runs anytime an element with a controller connected to the DOM for the first time this.boundListener = debounce(this.sendForm.bind(this), 300); + this.boundClearListener = (event:MouseEvent) => this.clearInputWithButton(event); } connect() { const clearButton = document.getElementById(this.clearButtonIdValue); - clearButton?.addEventListener('click', (event:MouseEvent) => this.clearInputWithButton(event)); + clearButton?.addEventListener('click', this.boundClearListener); } disconnect() { const clearButton = document.getElementById(this.clearButtonIdValue); - clearButton?.removeEventListener('click', (event:MouseEvent) => this.clearInputWithButton(event)); + clearButton?.removeEventListener('click', this.boundClearListener); } addFilterSelectTargetConnected() { @@ -125,6 +129,12 @@ export default class FiltersFormController extends Controller { this.formLoadedResolver(); this.formLoadedResolver = null; } + + // Populate the hidden field with the current serialized filters so the + // initial form state is submittable without any user interaction. + if (this.hasFiltersInputTarget) { + this.writeFiltersToHiddenInput(); + } } // Register and deregister change/input listeners on input elements to reload the page's frames on user input. @@ -140,10 +150,13 @@ export default class FiltersFormController extends Controller { filterValueContainerTargetConnected(target:HTMLElement) { this.addChangeListener(target); - } - - filterValueSelectTargetConnected(target:HTMLElement) { - this.addChangeListener(target); + const filterName = target.getAttribute('data-filter-name'); + if (filterName) { + const operator = this.findTargetByName(filterName, this.operatorTargets); + if (operator && this.operatorRequiresNoValue(operator)) { + target.setAttribute('hidden', ''); + } + } } daysTargetConnected(target:HTMLElement) { @@ -152,6 +165,31 @@ export default class FiltersFormController extends Controller { singleDayTargetConnected(target:HTMLElement) { this.addChangeListener(target); + this.registerAngularPickerWithMultiInput(target, 'opce-basic-single-date-picker'); + } + + dateRangeTargetConnected(target:HTMLElement) { + this.addChangeListener(target); + this.registerAngularPickerWithMultiInput(target, 'opce-range-date-picker'); + } + + // angular_component_tag serialises @Input() bindings as JSON in data-* attributes, so the + // Angular date picker wrapper's data-name holds a JSON-encoded value that primer-multi-input + // cannot use to identify its child fields. date_picker.html.erb therefore also sets + // data-multi-input-name as a plain string with the intended field name. + // This method is called when the Stimulus target connects, i.e. after Angular has already + // bootstrapped the component and consumed its data-* attributes, so we can safely overwrite + // data-name with the plain string from data-multi-input-name without interfering with Angular. + // primer-multi-input.activateField() then uses data-name to show/hide the correct picker + // when the filter operator changes. + private registerAngularPickerWithMultiInput(target:HTMLElement, tagName:string) { + const wrapper = target.closest(tagName); + if (wrapper?.closest('primer-multi-input')) { + const multiInputName = wrapper.getAttribute('data-multi-input-name'); + if (multiInputName) { + wrapper.setAttribute('data-name', multiInputName); + } + } } simpleValueTargetDisconnected(target:HTMLElement) { @@ -166,10 +204,6 @@ export default class FiltersFormController extends Controller { this.removeChangeListener(target); } - filterValueSelectTargetDisconnected(target:HTMLElement) { - this.removeChangeListener(target); - } - daysTargetDisconnected(target:HTMLElement) { this.removeChangeListener(target); } @@ -178,6 +212,10 @@ export default class FiltersFormController extends Controller { this.removeChangeListener(target); } + dateRangeTargetDisconnected(target:HTMLElement) { + this.removeChangeListener(target); + } + filterFormTargetConnected() { // Didn't really change, but we need to ensure that the visibility of the target is correct. // This is caused by there first being a skeleton form, which allows the user to already @@ -215,24 +253,12 @@ export default class FiltersFormController extends Controller { } } - toggleMultiSelect({ params: { filterName } }:{ params:{ filterName:string } }) { - const valueContainer = this.findTargetByName(filterName, this.filterValueContainerTargets); - const singleSelect = this.findTargetByName(filterName, this.filterValueSelectTargets, (selectField) => !selectField.multiple); - const multiSelect = this.findTargetByName(filterName, this.filterValueSelectTargets, (selectField) => selectField.multiple); - if (valueContainer && singleSelect && multiSelect) { - if (valueContainer.classList.contains('multi-value')) { - const valueToSelect = this.getValueToSelect(multiSelect); - this.setSelectOptions(singleSelect, valueToSelect); - } else { - const valueToSelect = this.getValueToSelect(singleSelect); - this.setSelectOptions(multiSelect, valueToSelect); - } - valueContainer.classList.toggle('multi-value'); - } + private get liveUpdatesEnabled():boolean { + return this.performTurboRequestsValue || this.hasFiltersInputTarget; } private addChangeListener(target:HTMLElement) { - if (!this.performTurboRequestsValue) { return; } + if (!this.liveUpdatesEnabled) { return; } if (target instanceof HTMLInputElement) { target.addEventListener('input', this.boundListener); @@ -242,7 +268,7 @@ export default class FiltersFormController extends Controller { } private removeChangeListener(target:HTMLElement) { - if (!this.performTurboRequestsValue) { return; } + if (!this.liveUpdatesEnabled) { return; } if (target instanceof HTMLInputElement) { target.removeEventListener('input', this.boundListener); @@ -251,16 +277,6 @@ export default class FiltersFormController extends Controller { } } - private getValueToSelect(selectElement:HTMLSelectElement) { - return selectElement.selectedOptions[0]?.value; - } - - private setSelectOptions(selectElement:HTMLSelectElement, selectedValue:string) { - Array.from(selectElement.options).forEach((option) => { - option.selected = option.value === selectedValue; - }); - } - addFilter(event:Event) { const filterName = (event.target as HTMLSelectElement).value; this.addFilterByName(filterName); @@ -269,15 +285,14 @@ export default class FiltersFormController extends Controller { addFilterByName(filterName:string) { const selectedFilter = this.findTargetByName(filterName, this.filterTargets); if (selectedFilter) { - selectedFilter.classList.remove('hidden'); + selectedFilter.removeAttribute('hidden'); } this.addFilterSelectTarget.selectedOptions[0].disabled = true; this.addFilterSelectTarget.selectedIndex = 0; - this.setSpacerVisibility(); this.focusFilterValueIfPossible(selectedFilter); - if (this.performTurboRequestsValue) { + if (this.liveUpdatesEnabled) { this.sendForm(); } } @@ -313,14 +328,13 @@ export default class FiltersFormController extends Controller { removeFilter({ params: { filterName } }:{ params:{ filterName:string } }) { const filterToRemove = this.findTargetByName(filterName, this.filterTargets); - filterToRemove?.classList.add('hidden'); + filterToRemove?.setAttribute('hidden', ''); const selectOptions = Array.from(this.addFilterSelectTarget.options); const removedFilterOption = selectOptions.find((option) => option.value === filterName); removedFilterOption?.removeAttribute('disabled'); - this.setSpacerVisibility(); - if (this.performTurboRequestsValue) { + if (this.liveUpdatesEnabled) { this.sendForm(); } } @@ -340,56 +354,58 @@ export default class FiltersFormController extends Controller { inputElement.dispatchEvent(inputEvent); } - private setSpacerVisibility() { - if (this.anyFiltersStillVisible()) { - this.spacerTarget.classList.remove('hidden'); - } else { - this.spacerTarget.classList.add('hidden'); - } - } - - private anyFiltersStillVisible() { - return this.filterTargets.some((filter) => !filter.classList.contains('hidden')); - } - - private readonly noValueOperators = ['*', '!*', 't', 'w']; private readonly daysOperators = ['>t-', 't+', 't+']; private readonly onDateOperator = '=d'; private readonly betweenDatesOperator = '<>d'; + // Whether the operator currently selected in `operatorElement` declares + // itself value-less. Driven by the `data-no-value` attribute that + // `Filters::Inputs::BaseFilterForm#add_operator` emits on each `