Merge remote-tracking branch 'origin/dev' into merge-release/17.4-20260512044520

This commit is contained in:
ulferts
2026-05-12 08:32:13 +02:00
1537 changed files with 51391 additions and 24277 deletions
+1
View File
@@ -5,6 +5,7 @@ inherit_gem:
- config/accessibility.yml
exclude:
- '**/frontend/**/*'
- 'lookbook/previews/open_project/deprecated/**/*'
- '**/node_modules/**/*'
- '**/vendor/**/*'
linters:
+9 -2
View File
@@ -6,7 +6,7 @@ updates:
interval: "daily"
target-branch: "dev"
cooldown:
default-days: 5
default-days: 7
semver-major-days: 30
semver-minor-days: 14
semver-patch-days: 5
@@ -48,7 +48,7 @@ updates:
interval: "daily"
target-branch: "dev"
cooldown:
default-days: 5
default-days: 7
semver-major-days: 30
semver-minor-days: 14
semver-patch-days: 5
@@ -65,6 +65,11 @@ updates:
schedule:
interval: "weekly"
target-branch: "dev"
cooldown:
default-days: 7
semver-major-days: 30
semver-minor-days: 14
semver-patch-days: 5
commit-message:
prefix: "deps(hocuspocus)"
labels:
@@ -81,3 +86,5 @@ updates:
schedule:
interval: "weekly"
target-branch: "dev"
cooldown:
default-days: 7
+3 -1
View File
@@ -89,7 +89,9 @@ jobs:
ehassan01,
JohannaStriebing,
fereshtehnm,
thykel
thykel,
j-racsko,
FeliciaMundhenke2904
# the followings are the optional inputs - If the optional inputs are not given, then default values will be taken
remote-organization-name: opf
remote-repository-name: legal
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
- name: 'Checkout repository'
uses: actions/checkout@v6
- name: 'Dependency Review'
uses: actions/dependency-review-action@v4
uses: actions/dependency-review-action@v4.9.0
# Commonly enabled options, see https://github.com/actions/dependency-review-action#configuration-options for all available options.
with:
comment-summary-in-pr: on-failure
+5 -1
View File
@@ -51,7 +51,11 @@ jobs:
with:
branch: ${{ needs.compute-inputs.outputs.branch }}
tag: ${{ needs.compute-inputs.outputs.tag }}
secrets: inherit
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DEPLOY_APP_PRIVATE_KEY: ${{ secrets.DEPLOY_APP_PRIVATE_KEY }}
OPS_MAIL_SMTP_TOKEN: ${{ secrets.OPS_MAIL_SMTP_TOKEN }}
trigger_helm_release:
if: ${{ github.repository == 'opf/openproject' }}
+10 -2
View File
@@ -13,7 +13,11 @@ jobs:
with:
branch: dev
tag: dev
secrets: inherit
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DEPLOY_APP_PRIVATE_KEY: ${{ secrets.DEPLOY_APP_PRIVATE_KEY }}
OPS_MAIL_SMTP_TOKEN: ${{ secrets.OPS_MAIL_SMTP_TOKEN }}
build-release-candidate:
if: github.repository == 'opf/openproject'
# References to release/X.Y and X.Y-rc are being
@@ -22,4 +26,8 @@ jobs:
with:
branch: release/17.4
tag: 17.4-rc
secrets: inherit
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DEPLOY_APP_PRIVATE_KEY: ${{ secrets.DEPLOY_APP_PRIVATE_KEY }}
OPS_MAIL_SMTP_TOKEN: ${{ secrets.OPS_MAIL_SMTP_TOKEN }}
+26 -9
View File
@@ -17,6 +17,15 @@ on:
required: false
type: boolean
default: false
secrets:
DOCKER_USERNAME:
required: true
DOCKER_PASSWORD:
required: true
DEPLOY_APP_PRIVATE_KEY:
required: true
OPS_MAIL_SMTP_TOKEN:
required: true
workflow_dispatch:
inputs:
branch:
@@ -44,7 +53,10 @@ jobs:
use_test_registry: ${{ inputs.use_test_registry }}
branch: ${{ inputs.branch }}
tag: ${{ inputs.tag }}
secrets: inherit
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DEPLOY_APP_PRIVATE_KEY: ${{ secrets.DEPLOY_APP_PRIVATE_KEY }}
setup:
needs: [build-hocuspocus]
runs-on: ubuntu-latest
@@ -55,17 +67,21 @@ jobs:
ref: ${{ inputs.branch }}
- name: Set version outputs and convert tags
id: extract_version
env:
INPUT_TAG: ${{ inputs.tag }}
INPUT_USE_TEST_REGISTRY: ${{ inputs.use_test_registry }}
REGISTRY_IMAGE: ${{ vars.REGISTRY_IMAGE }}
run: |
set -e
echo "Processing tag: ${{ inputs.tag }}"
./script/gh/docker-tags.rb "${{ inputs.tag }}" --version
./script/gh/docker-tags.rb "${{ inputs.tag }}" --format-for-docker
echo "Processing tag: $INPUT_TAG"
./script/gh/docker-tags.rb "$INPUT_TAG" --version
./script/gh/docker-tags.rb "$INPUT_TAG" --format-for-docker
# Determine registry image based on workflow_dispatch input
if [ "${{ inputs.use_test_registry }}" = "true" ]; then
if [ "$INPUT_USE_TEST_REGISTRY" = "true" ]; then
echo "registry_image=openproject/openproject-test" >> "$GITHUB_OUTPUT"
else
echo "registry_image=${{ vars.REGISTRY_IMAGE }}" >> "$GITHUB_OUTPUT"
echo "registry_image=$REGISTRY_IMAGE" >> "$GITHUB_OUTPUT"
fi
- name: Verify outputs
run: |
@@ -85,7 +101,7 @@ jobs:
echo "✓ docker_tags: ${{ steps.extract_version.outputs.docker_tags }}"
echo "✓ registry_image: ${{ steps.extract_version.outputs.registry_image }}"
- name: Cache NPM
uses: runs-on/cache@v4
uses: runs-on/cache@v5
with:
path: |
frontend/node_modules
@@ -93,7 +109,7 @@ jobs:
key: nodejs-x64-${{ hashFiles('**/package-lock.json') }}
restore-keys: nodejs-x64-
- name: Cache angular
uses: runs-on/cache@v4
uses: runs-on/cache@v5
with:
path: frontend/.angular
key: angular-${{ github.ref }}
@@ -351,7 +367,8 @@ jobs:
needs: [setup, build, merge]
if: ${{ always() && contains(needs.*.result, 'failure') }}
uses: ./.github/workflows/email-notification.yml
secrets: inherit
secrets:
OPS_MAIL_SMTP_TOKEN: ${{ secrets.OPS_MAIL_SMTP_TOKEN }}
with:
subject: "Docker build failed"
body: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+18 -5
View File
@@ -42,6 +42,13 @@ on:
required: false
type: boolean
default: false
secrets:
DOCKER_USERNAME:
required: true
DOCKER_PASSWORD:
required: true
DEPLOY_APP_PRIVATE_KEY:
required: true
jobs:
build:
@@ -62,19 +69,25 @@ jobs:
echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
- name: Determine tags
id: tags
env:
INPUT_USE_TEST_REGISTRY: ${{ inputs.use_test_registry }}
INPUT_BRANCH: ${{ inputs.branch }}
FALLBACK_BRANCH: ${{ github.ref_name }}
INPUT_TAG: ${{ inputs.tag }}
SHORT_SHA: ${{ steps.short-sha.outputs.short_sha }}
run: |
REGISTRY=openproject/hocuspocus
if [ "${{ inputs.use_test_registry }}" = "true" ]; then
if [ "$INPUT_USE_TEST_REGISTRY" = "true" ]; then
REGISTRY=openproject/hocuspocus-test
fi
BRANCH=$(echo '${{ inputs.branch || github.ref_name }}' | tr / -)
SPECIFIC_TAG=$BRANCH-${{ steps.short-sha.outputs.short_sha }}
BRANCH=$(echo "${INPUT_BRANCH:-$FALLBACK_BRANCH}" | tr / -)
SPECIFIC_TAG=$BRANCH-$SHORT_SHA
LATEST_TAG=$BRANCH-latest
if [ -n "${{ inputs.tag }}" ]; then
SPECIFIC_TAG=$(echo '${{ inputs.tag }}' | tr -d v)
if [ -n "$INPUT_TAG" ]; then
SPECIFIC_TAG=$(echo "$INPUT_TAG" | tr -d v)
LATEST_TAG=latest
fi
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
compose_files: docker-compose.pullpreview.yml
provider: hetzner
region: fsn1
instance_type: cx53
instance_type: cpx42
ports: 80,443
default_port: 443
ttl: 10d
+2 -1
View File
@@ -115,7 +115,8 @@ jobs:
needs: [prepare, seed]
if: ${{ always() && contains(needs.*.result, 'failure') }}
uses: ./.github/workflows/email-notification.yml
secrets: inherit
secrets:
OPS_MAIL_SMTP_TOKEN: ${{ secrets.OPS_MAIL_SMTP_TOKEN }}
with:
to: operations@openproject.com
subject: "Seeding with some locales failed on ${{ needs.prepare.outputs.ref }}"
+5 -5
View File
@@ -38,7 +38,7 @@ jobs:
- uses: actions/checkout@v6
- name: Cache DOCKER
id: cache_docker
uses: runs-on/cache@v4
uses: runs-on/cache@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@v4
uses: runs-on/cache@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@v4
uses: runs-on/cache@v5
with:
path: cache/node
key: node-${{ hashFiles('package.json', 'frontend/package-lock.json') }}
restore-keys: |
node-
- name: Cache ANGULAR
uses: runs-on/cache@v4
uses: runs-on/cache@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@v4
uses: runs-on/cache@v5
with:
path: cache/runtime-logs
key: runtime-logs-${{ github.head_ref || github.ref }}-${{ github.sha }}
+1 -1
View File
@@ -51,4 +51,4 @@ jobs:
- name: Test (Angular)
id: npm-test
run: npm test -- --code-coverage
run: npm test
+2 -2
View File
@@ -32,7 +32,7 @@ jobs:
- name: Add comment if versions differ
if: steps.version-check.outputs.version_mismatch == 'true'
uses: marocchino/sticky-pull-request-comment@v2
uses: marocchino/sticky-pull-request-comment@v3
with:
header: version-mismatch-comment
message: |
@@ -50,7 +50,7 @@ jobs:
- The work package version OR your pull request target branch is correct
- name: Version check passed
if: steps.version-check.outputs.version_mismatch != 'true'
uses: marocchino/sticky-pull-request-comment@v2
uses: marocchino/sticky-pull-request-comment@v3
with:
header: version-mismatch-comment
delete: true
+12 -5
View File
@@ -60,7 +60,8 @@ Layout/MultilineOperationIndentation:
Enabled: false
Lint/AmbiguousBlockAssociation:
AllowedMethods: [change]
AllowedMethods:
- change
Lint/AmbiguousOperator:
Enabled: false
@@ -147,9 +148,9 @@ Naming/PredicatePrefix:
Naming/VariableNumber:
AllowedPatterns:
- '\w_20\d\d' # allow dates like christmas_2022 or date_2034_04_12
- '\w\d++(_\d++)+' # allow hierarchical data like child1_2_5 (second + in regex is possessive qualifier)
- 'custom_field_\d+' # allow custom field method names to be called with send :custom_field_1001
- "\\w_20\\d\\d" # allow dates like christmas_2022 or date_2034_04_12
- "\\w\\d++(_\\d++)+" # allow hierarchical data like child1_2_5 (second + in regex is possessive qualifier)
- "custom_field_\\d+" # allow custom field method names to be called with send :custom_field_1001
OpenProject/AddPreviewForViewComponent:
Include:
@@ -181,6 +182,10 @@ Rails/DynamicFindBy:
- find_by_login
- find_by_mail
- find_by_plaintext_value
- find_by_rss_key
- find_by_unique
- find_by_unique!
- find_by_api_key
# Allow reorder to prevent find each cop triggering
Rails/FindEach:
@@ -333,6 +338,7 @@ RSpec/SpecFilePathFormat:
CustomTransform:
OpenIDConnect: openid_connect
OAuthClients: oauth_clients
XWiki: xwiki
XWikiProviders: xwiki_providers
EnforcedInflector: active_support
IgnoreMethods: true
@@ -402,7 +408,8 @@ Style/FormatString:
Enabled: false
Style/FormatStringToken:
AllowedMethods: [redirect]
AllowedMethods:
- redirect
Style/FrozenStringLiteralComment:
Enabled: true
+18 -168
View File
@@ -20,8 +20,6 @@
- Node: `^22.21.0` (see `package.json` engines)
- Bundler: Latest 2.x
OpenProject supports two development setups: **Local** and **Docker**. Choose one based on your preference.
### Local Development Setup
```bash
@@ -34,59 +32,24 @@ bin/dev # Start all services (Rails, frontend, Good Job
### Docker Development Setup
The Docker development environment uses configurations in `docker/dev/` and the `bin/compose` wrapper script.
```bash
# Initial setup (first time only)
bin/compose setup # Installs backend and frontend dependencies
# Starting services
bin/compose start # Start backend and frontend in background
bin/compose run # Start frontend in background, backend in foreground (for debugging with pry)
# Running tests
bin/compose rspec spec/models/user_spec.rb # Run specific tests in backend-test container
# Other operations
bin/compose reset # Remove all containers and volumes (requires setup again)
bin/compose <command> # Pass any docker-compose command directly
```
**Important Docker Notes:**
- **CRITICAL**: `config/database.yml` must NOT exist when using Docker (rename or delete it)
- Most developers use a local `docker-compose.override.yml` for custom port mappings and configurations
- Copy `docker-compose.override.example.yml` to `docker-compose.override.yml` and customize as needed
- Default ports: Backend at http://localhost:3000 (or 4200 for frontend dev server)
- Services: `backend`, `frontend`, `worker`, `db`, `db-test`, `backend-test`, `cache`
- Persisted volumes: `pgdata`, `bundle`, `npm`, `tmp`, `opdata` (data survives container restarts)
- Docker build context: Uses Dockerfiles in `docker/dev/backend/` and `docker/dev/frontend/`
See [`docker/dev/AGENTS.md`](docker/dev/AGENTS.md) for full Docker setup and commands.
## Project Structure
### Key Directories
- `app/` - Rails application code
- `app/components/` - ViewComponent-based UI components (Ruby + ERB)
- `app/contracts/` - Validation and authorization contracts
- `app/controllers/` - Rails controllers
- `app/models/` - ActiveRecord models
- `app/services/` - Service objects (business logic)
- `app/workers/` - Background job workers
- `config/` - Rails configuration, routes, locales
- `db/` - Database migrations and seeds
- `frontend/src/` - Frontend code
- `frontend/src/app/` - Legacy Angular modules/components
- `frontend/src/stimulus/` - Stimulus controllers
- `frontend/src/turbo/` - Turbo integration
- `lib/` - Ruby libraries and extensions
- `lookbook/` - ViewComponent previews (https://qa.openproject-edge.com/lookbook/)
- `modules/` - OpenProject plugin modules
- `spec/` - RSpec test suite
- `spec/features/` - System/feature tests (Capybara)
- `spec/models/` - Model unit tests
- `spec/requests/` - API/integration tests
- `spec/services/` - Service tests
- `app/` — Rails application code
- `config/` — Rails configuration, routes, locales
- `db/` — Database migrations and seeds
- `docker/dev/` — Docker development environment
- `frontend/` — TypeScript/Angular/Stimulus frontend
- `lib/` — Ruby libraries and extensions
- `lookbook/` — ViewComponent previews (<https://qa.openproject-edge.com/lookbook/>)
- `modules/` — OpenProject plugin modules
- `spec/` — RSpec test suite
### Configuration Files
- `.ruby-version` - Ruby version
- `.rubocop.yml` - Ruby linting rules
- `.erb_lint.yml` - ERB template linting
@@ -95,8 +58,6 @@ bin/compose <command> # Pass any docker-compose command dire
- `package.json` / `frontend/package.json` - Node.js dependencies
- `lefthook.yml` - Git hooks configuration
## Building and Testing
### Linting (Run Before Committing)
```bash
@@ -114,126 +75,15 @@ erb_lint {files}
bundle exec lefthook install
```
### Running Tests
```bash
# Backend (RSpec) - prefer specific tests over running all
bundle exec rspec spec/models/user_spec.rb # Single file
bundle exec rspec spec/models/user_spec.rb:42 # Single line
bundle exec rspec spec/features # Directory
bundle exec rake parallel:spec # Parallel execution
# Frontend (Jasmine/Karma)
cd frontend && npm test && cd ..
```
### Debugging CI Failures
```bash
./script/github_pr_errors | xargs bundle exec rspec # Run failed tests from CI
./script/bulk_run_rspec spec/path/to/flaky_spec.rb # Run tests multiple times
```
## Code Style Guidelines
### Ruby
- Follow [Ruby community style guide](https://github.com/bbatsov/ruby-style-guide)
- Use service objects for complex business logic (return `ServiceResult`)
- Use contracts for validation and authorization
- Keep controllers thin, models focused
- Document with [YARD](https://yardoc.org/)
- Write RSpec tests for all new features
- **Work package identifiers**: `WorkPackage.find("PROJ-42")` resolves semantic identifiers transparently. Use `find_by_display_id` only when input could legitimately be numeric OR semantic (controllers, URL-driven components, macro resolvers). Low-level code (queries, filters, services) should stick to `find_by(id:)` with primary keys. See `app/models/work_package/semantic_identifier/finder_methods.rb`.
### JavaScript/TypeScript
- **New development**: Use Hotwire (Turbo + Stimulus) with server-rendered HTML
- **Legacy code**: Follow ESLint rules
- Prefer TypeScript over JavaScript
- Use [Primer Design System](https://primer.style/product/) via ViewComponent
### Templates
- Use ERB for server-rendered views
- Use ViewComponents for reusable UI (with Lookbook previews)
- Lint with erb_lint before committing
### Database Migrations
- Follow Rails migration conventions
- Migrations are "squashed" between major releases (see `docs/development/migrations/`)
### Translations
- UI strings must use translation keys (never hard-coded)
- Source translations in `**/config/locales/en.yml` can be modified directly
- Other translations managed via Crowdin
### Commit Messages
## Commit Messages
- First line: < 72 characters, then blank line, then detailed description
- Reference work packages when applicable
- Merge strategy: "Merge pull request" (not squash), except single-commit PRs can use "Rebase and merge"
## Important Commands Reference
### Local Development Commands
```bash
# Setup
bin/setup # Initial Rails setup
bin/setup_dev # Full dev environment setup
# Database
bundle exec rails g migration MigrationName # Generate a migration
bundle exec rails db:migrate # Run migrations
bundle exec rails db:rollback # Rollback last migration
bundle exec rails db:seed # Seed sample data
# Development
bin/dev # Start all services
bundle exec rails console # Rails console
bundle exec rails routes # List routes
# Testing
bundle exec rspec # Run RSpec tests
bundle exec rails parallel:spec # Parallel tests
cd frontend && npm test # Frontend tests
# Linting
bundle exec rubocop # Ruby linting
cd frontend && npx eslint src/ # JS/TS linting
erb_lint {files} # ERB linting
```
### Docker Development Commands
```bash
# Setup and lifecycle
bin/compose setup # Setup Docker environment (first time)
bin/compose start # Start all services in background
bin/compose run # Start frontend in background, backend in foreground
bin/compose reset # Remove all containers and volumes
bin/compose stop # Stop all services
bin/compose down # Stop and remove containers
# Testing
bin/compose rspec spec/models/user_spec.rb # Run specific tests
bin/compose exec backend bundle exec rspec # Run tests directly in backend container
# Development
bin/compose exec backend bundle exec rails console # Rails console
bin/compose logs backend # View backend logs
bin/compose logs -f backend # Follow backend logs
bin/compose ps # List running containers
# Database
bin/compose exec backend bundle exec rails db:migrate # Run migrations
bin/compose exec backend bundle exec rails db:seed # Seed data
# Direct docker-compose commands
bin/compose up -d # Start services
bin/compose restart backend # Restart backend service
```
## Additional Documentation
- `docs/development/` - Development documentation
- `docs/development/running-tests/` - Testing guide
- `docs/development/code-review-guidelines/` - Code review standards
- `CONTRIBUTING.md` - Contribution workflow
- `.github/copilot-instructions.md` - Extended agent instructions with troubleshooting
- `docs/development/` Development documentation
- `docs/development/running-tests/` Testing guide
- `docs/development/code-review-guidelines/` Code review standards
- `CONTRIBUTING.md` Contribution workflow
- `.github/copilot-instructions.md` Extended agent instructions with troubleshooting
+1 -1
View File
@@ -58,4 +58,4 @@ 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.
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+1 -1
View File
@@ -20,6 +20,6 @@ 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.
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See COPYRIGHT and LICENSE files for more details.
+10 -13
View File
@@ -124,10 +124,10 @@ gem "sys-filesystem", "~> 1.5.0", require: false
gem "bcrypt", "~> 3.1.22"
gem "multi_json", "~> 1.20.0"
gem "oj", "~> 3.16.16"
gem "oj", "~> 3.17.0"
gem "daemons"
gem "good_job", "~> 4.14.2" # update should be done manually in sync with saas-openproject version.
gem "good_job", "~> 4.18.2" # update should be done manually in sync with saas-openproject version.
gem "rack-protection", "~> 3.2.0"
@@ -161,7 +161,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.10.0"
gem "mcp", "~> 0.14.0"
gem "meta-tags", "~> 2.23.0"
@@ -193,7 +193,7 @@ gem "rails-i18n", "~> 8.1.0"
gem "sprockets", "~> 3.7.2" # lock sprockets below 4.0
gem "sprockets-rails", "~> 3.5.1"
gem "puma", "~> 7.1"
gem "puma", "~> 8.0"
gem "puma-plugin-statsd", "~> 2.7"
gem "rack-timeout", "~> 0.7.0", require: "rack/timeout/base"
@@ -237,10 +237,10 @@ gem "yabeda-rails"
# opentelemetry
gem "opentelemetry-exporter-otlp", "~> 0.33.0", require: false
gem "opentelemetry-instrumentation-all", "~> 0.91.0", require: false
gem "opentelemetry-instrumentation-all", "~> 0.93.0", require: false
gem "opentelemetry-sdk", "~> 1.10", require: false
gem "view_component", "~> 4.6.0"
gem "view_component", "~> 4.9.0"
# Lookbook
gem "lookbook", "2.3.14"
@@ -264,10 +264,11 @@ 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: "with-patches"
gem "turbo_tests", github: "opf/turbo_tests", ref: "2_2_5_with_patches"
gem "rack_session_access"
gem "rspec", "~> 3.13.2"
@@ -316,8 +317,6 @@ 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
@@ -384,8 +383,6 @@ group :development, :test do
gem "active_record_doctor", "~> 2.0.1"
end
gem "bootsnap", "~> 1.23.0", require: false
# API gems
gem "grape", "~> 3.2.0"
gem "grape_logging", "~> 3.0.0"
@@ -405,7 +402,7 @@ gem "disposable", "~> 0.6.2"
gem "dentaku", "~> 3.5"
# Used for more powerful counter caches
gem "counter_culture", "~> 3.11"
gem "counter_culture", "~> 3.13"
group :postgres do
gem "pg", "~> 1.6.2"
@@ -432,4 +429,4 @@ end
gem "openproject-octicons", "~>19.34.0"
gem "openproject-octicons_helper", "~>19.34.0"
gem "openproject-primer_view_components", "~>0.84.5"
gem "openproject-primer_view_components", "~>0.85.0"
+262 -260
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -39,6 +39,7 @@ group :opf_plugins do
gem 'openproject-team_planner', path: 'modules/team_planner'
gem 'openproject-gantt', path: 'modules/gantt'
gem 'openproject-calendar', path: 'modules/calendar'
gem 'openproject-resource_management', path: 'modules/resource_management'
gem 'openproject-storages', path: 'modules/storages'
gem 'openproject-wikis', path: 'modules/wikis'
gem 'openproject-documents', path: 'modules/documents'
+30
View File
@@ -0,0 +1,30 @@
# App
## Directory Structure
- `app/components/` - ViewComponent-based UI components (Ruby + ERB)
- `app/contracts/` - Validation and authorization contracts
- `app/controllers/` - Rails controllers
- `app/models/` - ActiveRecord models
- `app/services/` - Service objects (business logic)
- `app/workers/` - Background job workers
## Code Style Guidelines
### Ruby
- Follow [Ruby community style guide](https://github.com/bbatsov/ruby-style-guide)
- Use service objects for complex business logic (return `ServiceResult`)
- Use contracts for validation and authorization
- Keep controllers thin, models focused
- Document with [YARD](https://yardoc.org/)
- Write RSpec tests for all new features
- **Work package identifiers**: `WorkPackage.find("PROJ-42")` resolves semantic identifiers transparently. Use `find_by_display_id` only when input could legitimately be numeric OR semantic (controllers, URL-driven components, macro resolvers). Low-level code (queries, filters, services) should stick to `find_by(id:)` with primary keys. See `app/models/work_package/semantic_identifier/finder_methods.rb`.
### Templates
- Use ERB for server-rendered views
- Use ViewComponents for reusable UI (with Lookbook previews)
- Lint with erb_lint before committing
## Translations
- UI strings must use translation keys (never hard-coded)
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+3
View File
@@ -11,6 +11,9 @@
@import "open_project/common/inplace_edit_fields/index"
@import "open_project/common/submenu_component"
@import "open_project/common/main_menu_toggle_component"
@import "open_project/common/work_package_card_list_component"
@import "open_project/common/work_package_card_list_component/header"
@import "open_project/common/work_package_card_component"
@import "portfolios/details_component"
@import "projects/row_component"
@import "projects/phases/hover_card_component"
@@ -0,0 +1,76 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2013 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See COPYRIGHT and LICENSE files for more details.
++#%>
<%=
component_wrapper do
flex_layout do |report_container|
report_container.with_row do
concat(render(Primer::Beta::Octicon.new(mr: 2, **summary_icon(report.tally))))
concat(render(Primer::Beta::Text.new(font_weight: :bold)) { humanize_summary(report.tally) })
end
report_container.with_row(mt: 2) do
render(Primer::Beta::Text.new) do
if report.healthy?
t(".summary.success")
elsif report.unhealthy?
t(".summary.failure")
else
t(".summary.warning")
end
end
end
report.results.each do |result_group|
report_container.with_row(mt: 3) do
render(Primer::Beta::BorderBox.new(test_selector: "op-health-report--result-group")) do |box|
box.with_header do
flex_layout(justify_content: :space_between, classes: "flex-wrap") do |header|
header.with_column do
render(Primer::Beta::Text.new(font_weight: :bold)) { I18n.t("#{result_group.key}.header", scope: i18n_scope) }
end
header.with_column do
concat(render(Primer::Beta::Octicon.new(mr: 2, **summary_icon(result_group.tally))))
concat(render(Primer::Beta::Text.new) { humanize_summary(result_group.tally) })
end
end
end
result_group.results.each do |value|
box.with_row do
render(HealthReports::ResultComponent.new(group: result_group.key, result: value, i18n_scope:))
end
end
end
end
end
end
end
%>
@@ -23,49 +23,49 @@
#
# 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.
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module Backlogs
class BacklogBucketHeaderComponent < ApplicationComponent
module HealthReports
class ReportComponent < ApplicationComponent
include OpPrimer::ComponentHelpers
include OpTurbo::Streamable
include Primer::FetchOrFallbackHelper
include Redmine::I18n
include CommonHelper
attr_reader :backlog_bucket, :project, :work_packages, :collapsed, :current_user
alias report model
def initialize(
backlog_bucket:,
project:,
work_packages:,
folded: false,
current_user: User.current
)
super()
@backlog_bucket = backlog_bucket
@project = project
@work_packages = work_packages
@collapsed = folded
@current_user = current_user
end
def wrapper_uniq_by
backlog_bucket.id
# The i18n_scope parameter defines the I18n scope that should be used to resolve
# names of groups, checks and error messages indicated by the results.
def initialize(*, i18n_scope:, **)
super(*, **)
@i18n_scope = i18n_scope
end
private
def story_points
@story_points ||= work_packages.sum { it.story_points || 0 }
attr_reader :i18n_scope
def summary_icon(check_tally)
case check_tally
in { failure: 1.. }
{ icon: :alert, color: :danger }
in { warning: 1.. }
{ icon: :alert, color: :attention }
else
{ icon: :"check-circle", color: :success }
end
end
def work_package_count
@work_package_count ||= work_packages.size
def humanize_summary(check_tally)
case check_tally
in { failure: 1.. }
t(".checks.failures", count: check_tally[:failure])
in { warning: 1.. }
t(".checks.warnings", count: check_tally[:warning])
else
t(".checks.success")
end
end
end
end
@@ -21,7 +21,7 @@ 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.
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See COPYRIGHT and LICENSE files for more details.
@@ -31,23 +31,23 @@ See COPYRIGHT and LICENSE files for more details.
flex_layout do |cell|
cell.with_row do
flex_layout(justify_content: :space_between, classes: "flex-wrap") do |row|
row.with_column(flex_layout: true, classes: "flex-wrap") do |text|
text.with_column(mr: 2) do
render(Primer::Beta::Text.new(font_weight: :bold)) { data[:text] }
row.with_column(flex_layout: true, classes: "flex-wrap") do |line|
line.with_column(mr: 2) do
render(Primer::Beta::Text.new(font_weight: :bold)) { text }
end
text.with_column(mr: 2) do
render(Primer::Beta::Text.new(font_size: :small, color: data[:status_color])) { data[:status_text] }
line.with_column(mr: 2) do
render(Primer::Beta::Text.new(font_size: :small, color: status_color)) { status_text }
end
if data[:error_code].present?
text.with_column do
render(Primer::Beta::Label.new(scheme: data[:status_color])) { data[:error_code] }
if error_code.present?
line.with_column do
render(Primer::Beta::Label.new(scheme: status_color)) { error_code }
end
end
end
if data[:error_code].present?
if error_code.present?
row.with_column do
helpers.static_link_to(href: data[:docs_href],
helpers.static_link_to(href: docs_href,
label: I18n.t(:label_more_information),
underline: true)
end
@@ -55,10 +55,10 @@ See COPYRIGHT and LICENSE files for more details.
end
end
if data[:error_text].present?
if error_text.present?
cell.with_row(mt: 1) do
render(Primer::Beta::Text.new(test_selector: "op-storages--health-status-check-information")) do
data[:error_text]
render(Primer::Beta::Text.new(test_selector: "op-health-report--result-status")) do
error_text
end
end
end
@@ -0,0 +1,88 @@
# 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 HealthReports
class ResultComponent < ApplicationComponent
include OpPrimer::ComponentHelpers
def initialize(group:, result:, i18n_scope:)
super(result)
@group = group
@i18n_scope = i18n_scope
end
private
def text = I18n.t("#{@group}.#{model.key}", scope: @i18n_scope)
def error_text
return nil if model.code.nil?
# TODO: fix translation namespace
I18n.t("errors.#{model.code}", scope: @i18n_scope, **model.context&.symbolize_keys)
end
def docs_href = ::OpenProject::Static::Links.url_for(:storage_docs, :health_status)
def error_code
if model.failure?
"ERR_#{model.code.upcase}"
elsif model.warning?
"WRN_#{model.code.upcase}"
end
end
def status_color
if model.success?
:success
elsif model.failure?
:danger
elsif model.warning? || model.skipped?
:attention
else
raise ArgumentError, "invalid check result state"
end
end
def status_text
if model.success?
t(".status.passed")
elsif model.failure?
t(".status.failed")
elsif model.warning?
t(".status.warning")
elsif model.skipped?
t(".status.skipped")
else
raise ArgumentError, "invalid check result state"
end
end
end
end
@@ -112,7 +112,7 @@ See COPYRIGHT and LICENSE files for more details.
<% end %>
<li class="simple-filters--filter">
<label class='simple-filters--filter-name' for='name'><%= User.human_attribute_name :name %>:</label>
<%= text_field_tag "name", params[:name], class: "simple-filters--filter-value" %>
<%= text_field_tag "name", params[:name], class: "simple-filters--filter-value", autocomplete: "off" %>
</li>
<li class="simple-filters--controls">
<%= submit_tag t(:button_apply), class: "button -primary -small", name: nil %>
@@ -46,9 +46,13 @@ module My
end
end
def active_token_count
oauth_application_tokens.count { |t| !t.expired? && !t.revoked? }
end
def active_tokens
render(Primer::Beta::Text.new(test_selector: "oauth-application-#{oauth_application.id}-active-tokens")) do
oauth_application_tokens.count { |t| !t.expired? && !t.revoked? }.to_s
active_token_count.to_s
end
end
@@ -74,11 +78,8 @@ module My
data: {
turbo_method: :post,
turbo_confirm: t(
"oauth.revoke_my_application_confirmation",
token_count: t(
"oauth.x_active_tokens",
count: oauth_application_tokens.count
)
"oauth.confirm_revoke_my_application",
count: active_token_count
)
}
))
@@ -60,6 +60,8 @@ module My
end
def expires_on
return I18n.t(:label_never) if client_token.expires_in.blank?
helpers.format_time(client_token.updated_at + client_token.expires_in.seconds)
end
@@ -28,6 +28,8 @@ See COPYRIGHT and LICENSE files for more details.
++#%>
<%= render(Primer::BaseComponent.new(tag: :span, **@system_arguments)) do %>
<%= leading_visual_icon %>
<%= content %>
<%= render(Primer::BaseComponent.new(tag: :span, display: :inline_flex, align_items: :center)) do %>
<%= leading_visual_icon %>
<%= content %>
<% end %>
<% end %>
@@ -31,7 +31,7 @@
module OpPrimer
class InlineMacroComponent < Primer::Component
renders_one :leading_visual_icon, ->(icon:, color: :muted) do
Primer::Beta::Octicon.new(icon:, color:, mr: 2, vertical_align: :middle)
Primer::Beta::Octicon.new(icon:, color:, size: :xsmall, mr: 2)
end
def initialize(**system_arguments)
@@ -1,7 +1,2 @@
@media screen
.op-inline-macro
display: inline
background: var(--bgColor-muted)
border: 1px solid transparent
border-radius: var(--borderRadius-medium)
padding: 4px 8px
.op-inline-macro
@include macro--text-style
@@ -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.
# ++
module OpPrimer
module QuickFilter
class BooleanComponent < SegmentedComponent
def initialize(name:, query:, filter_key:, path_args:, true_label: t(:general_text_Yes), false_label: t(:general_text_No),
orders: nil)
super(name:, query:, filter_key:, path_args:, orders:)
@true_label = true_label
@false_label = false_label
end
def before_render
with_item(label: @true_label, value: "t")
with_item(label: @false_label, value: "f")
end
end
end
end
@@ -0,0 +1,39 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2013 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See COPYRIGHT and LICENSE files for more details.
++#%>
<%= 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 %>
@@ -0,0 +1,89 @@
# 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
module QuickFilter
class SegmentedComponent < ApplicationComponent
include ApplicationHelper
renders_many :items, Item
def initialize(name:, query:, filter_key:, path_args:, orders: nil)
super
@name = name
@query = query
@filter_key = filter_key
@path_args = path_args
@orders = orders
end
def render?
items.any?
end
private
def current_value
@query.find_active_filter(@filter_key)&.values&.first
end
def href_for(value)
params = {}
filters = filters_params(value)
params[:filters] = filters.to_json if filters.any?
sort = sort_params(value)
params[:sortBy] = sort.to_json if sort.any?
polymorphic_path(@path_args, params)
end
def sort_params(value)
order_override = @orders && @orders[value]
if order_override
order_override.map { |attribute, direction| [attribute.to_s, direction.to_s] }
else
@query.orders.map { |order| [order.name, order.direction.to_s] }
end
end
def filters_params(value)
filters = @query.filters
.reject { |f| f.name == @filter_key }
.map { |f| { f.class.key.to_s => { "operator" => f.operator.to_s, "values" => f.values } } }
filters << { @filter_key.to_s => { "operator" => "=", "values" => [value] } } if value
filters
end
end
end
end
@@ -0,0 +1,46 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module OpPrimer
module QuickFilter
class SegmentedComponent < ApplicationComponent
class Item < ApplicationComponent
attr_reader :label, :value
def initialize(label:, value:)
super
@label = label
@value = value
end
end
end
end
end
@@ -27,41 +27,37 @@ See COPYRIGHT and LICENSE files for more details.
++# %>
<%= grid_layout("op-backlogs-story", tag: :article) do |grid| %>
<%= grid_layout(
"op-work-package-card",
tag: :article,
classes: { "op-work-package-card_with-metric": metric? }
) do |grid| %>
<% grid.with_area(:info_line) do %>
<%= render(WorkPackages::InfoLineComponent.new(work_package: story)) %>
<%# TODO(73089): allow callers to pass arguments through to InfoLineComponent (e.g. status presentation, variants). %>
<%= render(WorkPackages::InfoLineComponent.new(work_package:)) %>
<% end %>
<% grid.with_area(:points) do %>
<%= render(Primer::Beta::Text.new(color: :subtle)) do %>
<%= story_points %>
<span class="op-backlogs-points-label"> <%= t(:"backlogs.points_label", count: story_points) %></span>
<% if metric? %>
<% grid.with_area(:metric) do %>
<%= metric %>
<% end %>
<% end %>
<% grid.with_area(:menu) do %>
<% if menu_src.present? %>
<% if menu? %>
<%= menu %>
<% else %>
<%= render(
Primer::Alpha::ActionMenu.new(
menu_id: dom_target(story, :menu),
OpenProject::Common::WorkPackageCardComponent::Menu.new(
work_package:,
src: menu_src,
anchor_align: :end,
classes: "hide-when-print"
button_aria_label: t(".menu.label_actions")
)
) do |menu| %>
<% menu.with_show_button(
scheme: :invisible,
icon: :"kebab-horizontal",
"aria-label": t(".label_actions"),
tooltip_direction: :se
) %>
<% end %>
) %>
<% end %>
<% end %>
<% grid.with_area(:subject) do %>
<%= render(Primer::Beta::Text.new(font_weight: :semibold)) do %>
<%= story.subject %>
<% end %>
<%= render(Primer::Beta::Text.new(font_weight: :semibold)) { work_package.subject } %>
<% end %>
<% end %>
@@ -0,0 +1,60 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module OpenProject
module Common
class WorkPackageCardComponent < ApplicationComponent
include Primer::ClassNameHelper
include OpPrimer::ComponentHelpers
renders_one :metric, Primer::Content
renders_one :menu, ->(src: nil, button_aria_label: nil, **system_arguments) {
Menu.new(
work_package:,
src:,
button_aria_label:,
**system_arguments
)
}
attr_reader :work_package, :menu_src
# @param work_package [WorkPackage] the work package this card represents.
# @param menu_src [String, NilClass] optional lazy menu source. Prefer the
# `with_menu(src:)` slot for new call sites.
def initialize(work_package:, menu_src: nil)
super()
@work_package = work_package
@menu_src = menu_src
end
end
end
end
@@ -26,13 +26,28 @@
// See COPYRIGHT and LICENSE files for more details.
//++
import { Ng2StateDeclaration } from '@uirouter/angular';
.op-work-package-card
display: grid
grid-template-columns: 1fr auto
grid-template-rows: auto auto
grid-template-areas: "info_line menu" "subject subject"
align-items: center
margin-top: calc(-1 * var(--base-size-4))
margin-bottom: var(--base-size-4)
export const CALENDAR_LAZY_ROUTES:Ng2StateDeclaration[] = [
{
name: 'calendar.**',
parent: 'optional_project',
url: '/calendars',
loadChildren: () => import('./openproject-calendar.module').then((m) => m.OpenprojectCalendarModule),
},
];
.op-work-package-card_with-metric
grid-template-columns: 1fr minmax(2rem, max-content) auto
grid-template-areas: "info_line metric menu" "subject subject subject"
.op-work-package-card--metric
margin-left: var(--stack-gap-normal)
font-variant-numeric: tabular-nums
text-align: right
.op-work-package-card--menu
margin-left: var(--stack-gap-normal)
.op-work-package-card--subject
align-self: start // Align to top of second row
word-wrap: break-word
overflow-wrap: break-word
@@ -0,0 +1,37 @@
<%# -- copyright
OpenProject is an open source project management software.
Copyright (C) the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2013 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See COPYRIGHT and LICENSE files for more details.
++# %>
<%= render(@menu) do |menu| %>
<% menu.with_show_button(
scheme: :invisible,
icon: :"kebab-horizontal",
"aria-label": button_aria_label || t(".label_actions"),
tooltip_direction: :se
) %>
<% end %>
@@ -0,0 +1,65 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module OpenProject
module Common
class WorkPackageCardComponent
class Menu < ApplicationComponent
include OpPrimer::ComponentHelpers
delegate :with_item, :with_avatar_item, :with_divider, :with_group, :with_sub_menu_item, to: :@menu
attr_reader :work_package, :button_aria_label
def initialize(work_package:, src: nil, button_aria_label: nil, **system_arguments)
super()
@work_package = work_package
@button_aria_label = button_aria_label
system_arguments[:menu_id] ||= dom_target(work_package, :menu)
system_arguments[:src] = src
system_arguments[:anchor_align] ||= :end
system_arguments[:classes] = class_names(
system_arguments[:classes],
"hide-when-print"
)
@menu = Primer::Alpha::ActionMenu.new(**system_arguments)
end
private
def before_render
content
end
end
end
end
end
@@ -0,0 +1,54 @@
<%# -- copyright
OpenProject is an open source project management software.
Copyright (C) the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2013 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See COPYRIGHT and LICENSE files for more details.
++# %>
<%= render(Primer::Beta::BorderBox.new(**@system_arguments)) do |border_box| %>
<% if header? %>
<% border_box.with_header(id: header_id) do %>
<%= header %>
<% end %>
<% end %>
<% if items.empty? %>
<% border_box.with_row(data: { empty_list_item: true }) do %>
<%= empty_state %>
<% end %>
<% else %>
<% items.each do |item| %>
<% border_box.with_row(**item.row_args) do %>
<%= render(item.card) %>
<% end %>
<% end %>
<% end %>
<% if footer? %>
<% border_box.with_row(scheme: :neutral) do %>
<%= footer %>
<% end %>
<% end %>
<% end %>
@@ -0,0 +1,309 @@
# 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 OpenProject
module Common
class WorkPackageCardListComponent < ApplicationComponent
include Primer::AttributesHelper
include OpPrimer::ComponentHelpers
# Renders a `Header` above the card list with the title, count badge, and
# consumer-provided actions/menu/description.
#
# @param title [String] heading text rendered inside the collapsible header.
# @param count [Integer, NilClass] optional count badge displayed alongside
# the title; hidden when zero or nil.
renders_one :header, ->(title:, count: nil) {
Header.new(title:, count:, container:, list_id:, collapsed: folded?)
}
# Renders a `Primer::Beta::Blankslate` when no items are produced — that
# is, when `items.empty?` after slot resolution and automatic item builds.
# The slot is required unless the caller provides manual items, and is
# silently ignored whenever `items` is non-empty.
#
# @param title [String] blankslate heading.
# @param description [String, NilClass] optional secondary text.
# @param icon [Symbol, NilClass] optional Octicon name.
# @param system_arguments [Hash] forwarded to `Primer::Beta::Blankslate`.
renders_one :empty_state, ->(title:, description: nil, icon: nil, **system_arguments) {
system_arguments[:role] = "status"
system_arguments[:aria] = merge_aria(
system_arguments,
aria: { live: "polite" }
)
blankslate = Primer::Beta::Blankslate.new(**system_arguments)
blankslate.with_heading(tag: :h4).with_content(title)
blankslate.with_description_content(description) if description
blankslate.with_visual_icon(icon:) if icon
blankslate
}
# @!parse
# # Adds a work package item row to the list. When at least one item
# # is added manually, the list does not build rows from
# # `work_packages:`.
# #
# # @param work_package [WorkPackage] the work package rendered in the row.
# # @param component_klass [Class] row bridge class used instead of the
# # default item class. Defaults to the list's configured
# # `item_component_klass`. It must accept the arguments documented on
# # `#build_item`, expose `#row_args` with valid
# # `Primer::Beta::BorderBox#with_row` keyword arguments, and expose
# # `#card` returning a renderable object.
# # @param system_arguments [Hash] forwarded to the item class.
# def with_work_package_item(
# work_package:,
# component_klass: Item,
# **system_arguments,
# &block
# )
# end
# @!parse
# # Adds a custom empty item row to the list. This can be used instead of
# # the `empty_state` slot when the caller owns item iteration. It cannot
# # be combined with `work_packages:`, `with_work_package_item`, or
# # `with_item`.
# #
# # @param system_arguments [Hash] forwarded to
# # `Primer::Beta::BorderBox#with_row`.
# def with_empty_item(**system_arguments, &block)
# end
# @!parse
# # Adds a generic item to the list. When at least one item is added
# # manually, the list does not build rows from `work_packages:`.
# #
# # @param system_arguments [Hash] forwarded to
# # `Primer::Beta::BorderBox#with_row`.
# def with_item(**system_arguments, &block)
# end
renders_many :items, types: {
work_package_item: {
renders: lambda { |work_package:, **system_arguments, &block|
build_item(work_package:, **system_arguments).tap do |item|
capture(item, &block) if block
end
},
as: :work_package_item
},
empty_item: {
renders: lambda { |**system_arguments, &block|
build_content_item(EmptyItem, **system_arguments, &block)
},
as: :empty_item
},
item: {
renders: lambda { |**system_arguments, &block|
build_content_item(ContentItem, **system_arguments, &block)
},
as: :item
}
}
# Renders a free-form footer row below the card list.
renders_one :footer
attr_reader :work_packages,
:project,
:container,
:drag_and_drop,
:item_component_klass,
:params,
:current_user
# @param project [Project] the project this card list is rendered in. May
# differ from individual `work_package.project` values when sprints or
# buckets are shared across projects.
# @param container [Symbol, String, Class, ApplicationRecord] drives the
# list DOM id and related ids via `dom_target`.
# @param work_packages [Enumerable<WorkPackage>] the work packages to render
# as cards.
# @param drag_and_drop [Hash, NilClass] optional generic drag-and-drop
# target data. Requires `:target_id` and `:allowed_drag_type` when set.
# @param item_component_klass [Class] item class used for automatically
# built work package items.
# @param params [Hash] optional URL params passed to work package items
# when deriving row arguments.
# @param current_user [User] passed through to each item for permission
# checks; defaults to `User.current`.
# @param system_arguments [Hash] forwarded to the underlying
# `Primer::Beta::BorderBox`.
def initialize(
project:,
container:,
work_packages: [],
drag_and_drop: nil,
item_component_klass: Item,
params: {},
current_user: User.current,
**system_arguments
)
super()
@work_packages = work_packages
@project = project
@container = container
@drag_and_drop = drag_and_drop
@item_component_klass = item_component_klass
@params = params
@current_user = current_user
@automatic_items = false
@system_arguments = system_arguments
@system_arguments[:id] = container_id
@system_arguments[:list_id] = list_id
@system_arguments[:padding] = :condensed
merge_drag_and_drop_data! if drag_and_drop
end
def before_render
# Content must be loaded before mode validation and automatic item builds
# so slot calls have already populated `items`.
content
validate_item_mode!
build_automatic_items if build_automatic_items?
validate_empty_state!
end
# Builds a new work package item without adding it to the list. Use this
# instead of the `#with_work_package_item` slot when rendering additional
# items outside this list, such as in a separately-loaded page.
#
# @param work_package [WorkPackage] the work package rendered in the row.
# @param component_klass [Class] item class used instead of the configured
# default item class. It must accept `work_package:`, `project:`,
# `container:`, `params:`, `current_user:`, and `**system_arguments`.
# @param system_arguments [Hash] forwarded to the item class.
def build_item(
work_package:,
component_klass: item_component_klass,
**system_arguments
)
component_klass.new(
work_package:,
project:,
container:,
params:,
current_user:,
**system_arguments
)
end
private
def folded?
current_user.pref[:backlogs_versions_default_fold_state] == "closed"
end
def build_automatic_items?
non_empty_items.empty? && work_packages.any?
end
def build_automatic_items
@automatic_items = true
work_packages.each do |work_package|
with_work_package_item(work_package:)
end
end
def build_content_item(item_class, **system_arguments, &block)
item_class.new(**system_arguments).tap do |item|
item.with_content(capture(&block)) if block
end
end
def automatic_items?
@automatic_items
end
def validate_item_mode!
return unless empty_items.any?
if work_packages.any?
raise ArgumentError, "empty_item cannot be combined with work_packages"
end
if non_empty_items.any?
raise ArgumentError, "empty_item cannot be combined with other items"
end
end
def validate_empty_state!
return unless items.empty? && !empty_state?
raise ArgumentError, "empty_state slot is required when no work package items are rendered"
end
def container_id
dom_target(container)
end
def list_id
dom_target(container, :list)
end
def header_id
dom_target(container, :header)
end
def empty_items
items.select { |item| item.respond_to?(:empty_item?) && item.empty_item? }
end
def non_empty_items
items - empty_items
end
def merge_drag_and_drop_data!
@system_arguments[:data] = merge_data(
{
data: drag_and_drop_data
},
@system_arguments
)
end
def drag_and_drop_data
{
# Existing callers share one mirror container target on the page until
# parent-specific DnD handling is extracted in follow-up work.
generic_drag_and_drop_target: "container",
target_container_accessor: ":scope > ul",
target_id: drag_and_drop.fetch(:target_id),
target_allowed_drag_type: drag_and_drop.fetch(:allowed_drag_type)
}
end
end
end
end
@@ -1,4 +1,4 @@
.backlogs-inbox-component
.op-work-package-card-list
&--show-more-row
border-top: var(--borderWidth-thick) solid var(--borderColor-default)
border-bottom: var(--borderWidth-thick) solid var(--borderColor-default)
@@ -0,0 +1,58 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module OpenProject
module Common
class WorkPackageCardListComponent
# Item bridge for caller-provided content.
class ContentItem < ApplicationComponent
def initialize(**system_arguments)
super()
@system_arguments = system_arguments
end
def row_args
@system_arguments.deep_dup
end
def card
self
end
def empty_item? = false
def call
content
end
end
end
end
end
@@ -0,0 +1,51 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module OpenProject
module Common
class WorkPackageCardListComponent
# Row bridge for caller-provided empty content.
class EmptyItem < ContentItem
include Primer::AttributesHelper
def row_args
system_arguments = @system_arguments.deep_dup
system_arguments[:data] = merge_data(
{ data: { empty_list_item: true } },
system_arguments
)
system_arguments
end
def empty_item? = true
end
end
end
end
@@ -27,22 +27,48 @@ See COPYRIGHT and LICENSE files for more details.
++# %>
<%= component_wrapper(tag: :section) do %>
<%= render(Primer::Beta::BorderBox.new(**@system_arguments)) do |border_box| %>
<% border_box.with_header(id: dom_target(backlog_bucket, :header)) do %>
<%= render(Backlogs::BacklogBucketHeaderComponent.new(backlog_bucket:, project:, work_packages:, folded: folded?)) %>
<% end %>
<% if work_packages.empty? %>
<% border_box.with_row(data: { empty_list_item: true }) do %>
<!-- TODO: separate inbox component also to have different blankslate titles? -->
<%=
render Primer::Beta::Blankslate.new(role: "status", aria: { live: "polite" }) do |blankslate|
blankslate.with_heading(tag: :h4).with_content(t(".blankslate_title"))
blankslate.with_description_content(t(".blankslate_description"))
end
%>
<%= grid_layout("op-work-package-card-list-header", tag: :div) do |grid| %>
<% grid.with_area(:collapsible) do %>
<%=
render(
Primer::OpenProject::BorderBox::CollapsibleHeader.new(
collapsible_id: list_id,
collapsed:,
multi_line: true
)
) do |collapsible|
%>
<% collapsible.with_title(tag: :h4) { title } %>
<% if count %>
<% collapsible.with_count(
scheme: :primary,
count: count,
round: true,
limit: 1_000,
hide_if_zero: true,
aria: {
label: t(".label_work_package_count", count: count),
live: "polite"
}
) %>
<% end %>
<% if description? %>
<% collapsible.with_description do %>
<%= description %>
<% end %>
<% end %>
<% end %>
<%= render Backlogs::BacklogBucketItemComponent.with_collection(work_packages, container: border_box, project:) %>
<% end %>
<% if actions? %>
<% grid.with_area(:actions) do %>
<% actions.each do |action| %>
<%= action %>
<% end %>
<% end %>
<% end %>
<% grid.with_area(:menu) do %>
<%= menu %>
<% end %>
<% end %>
@@ -0,0 +1,79 @@
# 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 OpenProject
module Common
class WorkPackageCardListComponent
class Header < ApplicationComponent
include OpPrimer::ComponentHelpers
renders_one :description
renders_many :actions, types: {
button: ->(**system_arguments) do
Primer::Beta::Button.new(**system_arguments)
end
}
renders_one :menu, ->(menu_id: nil, button_aria_label: nil, **system_arguments) do
system_arguments[:classes] = class_names(
system_arguments[:classes],
"hide-when-print"
)
menu = Primer::Alpha::ActionMenu.new(
menu_id: menu_id || dom_target(container, :menu),
anchor_align: :end,
**system_arguments
)
menu.with_show_button(
scheme: :invisible,
icon: :"kebab-horizontal",
"aria-label": button_aria_label || t(".label_actions"),
tooltip_direction: :se
)
menu
end
attr_reader :title, :container, :list_id, :collapsed, :count
def initialize(title:, container:, list_id:, collapsed: false, count: nil)
super()
@title = title
@container = container
@list_id = list_id
@collapsed = collapsed
@count = count
end
end
end
end
end
@@ -26,13 +26,15 @@
// See COPYRIGHT and LICENSE files for more details.
//++
import { Ng2StateDeclaration } from '@uirouter/angular';
.op-work-package-card-list-header
display: grid
grid-template-columns: 1fr minmax(5rem, max-content) auto
grid-template-areas: "collapsible actions menu"
align-items: center
export const TEAM_PLANNER_LAZY_ROUTES:Ng2StateDeclaration[] = [
{
name: 'team_planner.**',
parent: 'optional_project',
url: '/team_planner',
loadChildren: () => import('./team-planner.module').then((m) => m.TeamPlannerModule),
},
];
&--actions,
&--menu
margin-left: var(--stack-gap-normal)
align-self: flex-start
// Unfortunately, the invisible button style bites us here again.
margin-top: -6px
@@ -0,0 +1,117 @@
# 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 OpenProject
module Common
class WorkPackageCardListComponent
# Internal row bridge between the card list and the visual card. It owns
# the surrounding BorderBox row arguments while `WorkPackageCardComponent`
# renders the card body.
class Item < ApplicationComponent
include ActionView::RecordIdentifier
include Primer::ClassNameHelper
include Primer::AttributesHelper
attr_reader :work_package,
:project,
:container,
:params,
:current_user
delegate :with_metric, to: :card
def initialize(
work_package:,
project:,
container:,
params: {},
current_user: User.current,
**system_arguments
)
super()
@work_package = work_package
@project = project
@container = container
@params = params
@current_user = current_user
@system_arguments = system_arguments
end
def row_args
row_arguments = @system_arguments.deep_dup
row_arguments[:id] ||= dom_id(work_package)
row_arguments[:tabindex] ||= 0
row_arguments[:classes] = class_names(row_classes, row_arguments[:classes])
row_arguments[:data] = merge_data(
{ data: row_data },
row_arguments
)
row_arguments
end
def card
@card ||= WorkPackageCardComponent.new(work_package:)
end
def render? = false
def empty_item? = false
private
def row_classes
class_names(
"Box-row--hover-blue",
"Box-row--focus-gray",
"Box-row--clickable",
"Box-row--draggable" => draggable?
)
end
def row_data
data = {
test_selector: "work-package-#{work_package.id}"
}
draggable? ? data.merge(draggable_data) : data
end
def draggable?
false
end
def draggable_data
{}
end
end
end
end
end
+1 -1
View File
@@ -55,7 +55,7 @@ class UserFilterComponent < IndividualPrincipalBaseFilterComponent
end
def base_query
Queries::Users::UserQuery
UserQuery
end
protected
@@ -75,7 +75,7 @@ See COPYRIGHT and LICENSE files for more details.
}
) do |menu|
if @editable
if User.current.allowed_in_project?(:protect_wiki_pages, @project)
if User.current.allowed_in_project?(:manage_wiki, @project)
menu.with_item(
label: lock_data[:label],
tag: :a,
@@ -87,7 +87,7 @@ See COPYRIGHT and LICENSE files for more details.
end
end
if @page.current_version?
if User.current.allowed_in_project?(:rename_wiki_pages, @project)
if User.current.allowed_in_project?(:edit_wiki_pages, @project)
menu.with_item(
label: t(:button_rename),
tag: :a,
@@ -98,7 +98,7 @@ See COPYRIGHT and LICENSE files for more details.
item.with_leading_visual_icon(icon: "arrow-switch")
end
end
if User.current.allowed_in_project?(:change_wiki_parent_page, @project)
if User.current.allowed_in_project?(:manage_wiki, @project)
menu.with_item(
label: t(:button_change_parent_page),
tag: :a,
@@ -110,7 +110,7 @@ See COPYRIGHT and LICENSE files for more details.
end
end
if User.current.allowed_in_project?(:delete_wiki_pages, @project)
if User.current.allowed_in_project?(:manage_wiki, @project)
menu.with_item(
label: t(:button_delete),
scheme: :danger,
@@ -140,7 +140,7 @@ See COPYRIGHT and LICENSE files for more details.
item.with_leading_visual_icon(icon: :history)
end
end
if User.current.allowed_in_project?(:manage_wiki_menu, @project)
if User.current.allowed_in_project?(:manage_wiki, @project)
menu.with_item(
label: t(:button_manage_menu_entry),
href: url_for(controller: "/wiki_menu_items", action: "edit", project_id: @project.identifier, id: @page),
@@ -161,7 +161,7 @@ See COPYRIGHT and LICENSE files for more details.
) do |item|
item.with_leading_visual_icon(icon: "op-printer")
end
if User.current.allowed_in_project?(:export_wiki_pages, @project)
if User.current.allowed_in_project?(:view_wiki_pages, @project)
menu.with_sub_menu_item(
tag: :a,
label: t("js.label_export"),
@@ -77,7 +77,7 @@
scheme: :primary,
type: :submit,
form: form_id,
hidden: show_autofix_section?,
hidden: true,
data: { admin__work_packages_identifier_target: "saveButton" }
)
) { t("button_save") } %>
@@ -101,7 +101,8 @@ module WorkPackages
{
data: {
controller: "admin--work-packages-identifier",
admin__work_packages_identifier_has_problematic_projects_value: has_problematic_projects?
admin__work_packages_identifier_has_problematic_projects_value: has_problematic_projects?,
admin__work_packages_identifier_current_value_value: Setting[:work_packages_identifier]
}
}
end
@@ -70,7 +70,7 @@
icon: :"screen-full",
tag: :a,
classes: "hidden-for-small-laptops",
href: work_package_path(work_package.id, full_screen_tab),
href: work_package_path(work_package.display_id, full_screen_tab),
target: "_top",
scheme: :invisible,
test_selector: "wp-details-tab-component--full-screen",
@@ -16,7 +16,7 @@
font_size: @font_size,
color: :muted
)
) { "##{@work_package.id}" }
) { @work_package.formatted_id }
end
if @show_status
@@ -0,0 +1,2 @@
<%= helpers.angular_component_tag "opce-wp-split-create",
inputs: { projectIdentifier: @project_identifier } %>
@@ -0,0 +1,37 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
class WorkPackages::SplitCreateComponent < ApplicationComponent
def initialize(project_identifier:)
super
@project_identifier = project_identifier
end
end
@@ -32,7 +32,7 @@ See COPYRIGHT and LICENSE files for more details.
blankslate.with_heading(tag: :h2).with_content(t("admin.workflows.blankslate.title"))
blankslate.with_description_content(t("admin.workflows.blankslate.description"))
blankslate.with_primary_action(
href: helpers.status_dialog_workflow_tab_path(@type, @tab, role_id: @role.id),
href: helpers.status_dialog_workflow_tab_path(@type, @tab, role_ids: @roles.map(&:id)),
scheme: :secondary,
data: { controller: "async-dialog" }
) do |button|
@@ -32,9 +32,9 @@ module Workflows
class BlankslateComponent < ApplicationComponent
include OpPrimer::ComponentHelpers
def initialize(role:, type:, tab:)
def initialize(roles:, type:, tab:)
super
@role = role
@roles = roles
@type = type
@tab = tab
end
@@ -30,7 +30,7 @@
module Workflows::PageHeaders
class EditComponent < BaseComponent
options :tabs, :role
options :tabs, :roles
def type = model
@@ -49,7 +49,7 @@ module Workflows::PageHeaders
mobile_icon: :copy,
mobile_label: t(:button_copy),
size: :medium,
href: new_workflow_copy_path(type, source_role_id: role&.id),
href: new_workflow_copy_path(type, source_role_id: roles&.first&.id),
aria: { label: helpers.t(:button_copy) },
title: helpers.t(:button_copy)
) do |button|
@@ -42,7 +42,7 @@ See COPYRIGHT and LICENSE files for more details.
Workflows::StatusFormComponent.new(
all_statuses: @all_statuses,
current_statuses: @current_statuses,
role: @role,
roles: @roles,
type: @type,
tab: @tab
)
@@ -35,11 +35,11 @@ module Workflows
DIALOG_ID = "workflows-status-dialog"
def initialize(all_statuses:, current_statuses:, role:, type:, tab:)
def initialize(all_statuses:, current_statuses:, roles:, type:, tab:)
super
@all_statuses = all_statuses
@current_statuses = current_statuses
@role = role
@roles = roles
@type = type
@tab = tab
end
@@ -29,7 +29,7 @@ See COPYRIGHT and LICENSE files for more details.
<%=
primer_form_with(
url: helpers.confirm_statuses_workflow_tab_path(@type, @tab, role_id: @role.id),
url: helpers.confirm_statuses_workflow_tab_path(@type, @tab, role_ids: @roles.map(&:id)),
method: :post,
id: FORM_ID,
data: { turbo_frame: "workflow-table" }
@@ -39,7 +39,6 @@ See COPYRIGHT and LICENSE files for more details.
f,
all_statuses: @all_statuses,
current_statuses: @current_statuses,
role: @role,
type: @type,
tab: @tab,
dialog_id:
@@ -32,11 +32,11 @@ module Workflows
class StatusFormComponent < ApplicationComponent
FORM_ID = "status-selection-form"
def initialize(all_statuses:, current_statuses:, role:, type:, tab:)
def initialize(all_statuses:, current_statuses:, roles:, type:, tab:)
super
@all_statuses = all_statuses
@current_statuses = current_statuses
@role = role
@roles = roles
@type = type
@tab = tab
end
@@ -32,21 +32,39 @@ See COPYRIGHT and LICENSE files for more details.
render Primer::OpenProject::SubHeader.new do |subheader|
if @type && @available_roles.any?
subheader.with_filter_component do
render(Primer::Alpha::ActionMenu.new(select_variant: :single)) do |menu|
menu.with_show_button(scheme: :secondary) do |button|
render(
Primer::Alpha::SelectPanel.new(
select_variant: :multiple,
fetch_strategy: :local,
title: t("admin.workflows.role_selector.title"),
data: data_attributes
)
) do |panel|
panel.with_show_button(scheme: :secondary) do |button|
button.with_trailing_visual_icon(icon: :"triangle-down")
@role ? t("admin.workflows.role_selector.label", role: @role.name) : t("admin.workflows.role_selector.no_role")
if @roles.many?
t("admin.workflows.role_selector.roles", count: @roles.size)
elsif @roles.one?
t("admin.workflows.role_selector.label", role: @roles.first.name)
else
t("admin.workflows.role_selector.no_role")
end
end
@available_roles.each do |available_role|
menu.with_item(
panel.with_item(
label: available_role.name,
active: available_role == @role,
tag: :a,
href: helpers.edit_workflow_tab_path(@type, @tab, role_id: available_role.id),
content_arguments: { data: { "admin--workflow-checkbox-state-confirmation-trigger": "click",
turbo_action: "advance" } }
active: @roles.include?(available_role),
item_id: available_role.id
)
end
panel.with_footer(show_divider: true) do
render(
Primer::Beta::Button.new(
scheme: :primary,
data: { action: "click->admin--workflow-role-select#apply" }
)
) { t(:button_apply) }
end
end
end
end
@@ -56,7 +74,7 @@ See COPYRIGHT and LICENSE files for more details.
scheme: :secondary,
leading_icon: :plus,
label: t("admin.workflows.status_button"),
href: helpers.status_dialog_workflow_tab_path(@type, @tab, role_id: @role&.id, status_ids: @statuses.pluck(:id).presence),
href: helpers.status_dialog_workflow_tab_path(@type, @tab, role_ids: @roles.map(&:id), status_ids: @statuses.pluck(:id).presence),
data: { controller: "async-dialog" }
) do
t("admin.workflows.status_button")
@@ -67,7 +85,7 @@ See COPYRIGHT and LICENSE files for more details.
<% if @statuses.any? %>
<%= form_tag(
workflow_tab_path(@type),
id: "workflow_form",
id: form_id,
method: :patch,
autocomplete: "off",
data: {
@@ -76,7 +94,9 @@ See COPYRIGHT and LICENSE files for more details.
}
) do %>
<%= hidden_field_tag "type_id", @type.id %>
<%= hidden_field_tag "role_id", @role.id %>
<% @roles.each do |role| %>
<%= hidden_field_tag "role_ids[]", role.id %>
<% end %>
<%= hidden_field_tag "tab", @tab %>
<%= helpers.render_tabs helpers.workflow_tabs(@type) %>
@@ -94,7 +114,7 @@ See COPYRIGHT and LICENSE files for more details.
Primer::OpenProject::FeedbackDialog.new(
title: t("admin.workflows.leave_confirmation.title"),
data: {
"admin--workflow-checkbox-state-target": "confirmationDialog",
"admin--workflow-checkbox-state-target": "confirmationDialog"
}
)
) do |dialog|
@@ -118,6 +138,6 @@ See COPYRIGHT and LICENSE files for more details.
%>
<% end %>
<% else %>
<%= render Workflows::BlankslateComponent.new(role: @role, type: @type, tab: @tab) %>
<%= render Workflows::BlankslateComponent.new(roles: @roles, type: @type, tab: @tab) %>
<% end %>
<% end %>
@@ -33,14 +33,29 @@ module Workflows
include OpTurbo::Streamable
include OpPrimer::ComponentHelpers
def initialize(tab:, role:, type:, available_roles:, statuses:, has_status_changes:)
FORM_ID = "workflow_form"
def initialize(tab:, roles:, type:, available_roles:, statuses:, has_status_changes:)
super
@tab = tab
@role = role
@roles = roles
@type = type
@available_roles = available_roles
@statuses = statuses
@has_status_changes = has_status_changes
end
private
def form_id = FORM_ID
def data_attributes
{
controller: "admin--workflow-role-select",
"admin--workflow-role-select-base-url-value": helpers.edit_workflow_tab_path(@type, @tab),
"admin--workflow-role-select-current-role-ids-value": @roles.map(&:id),
"admin--workflow-role-select-admin--workflow-checkbox-state-outlet": "##{form_id}"
}
end
end
end
@@ -46,7 +46,7 @@ See COPYRIGHT and LICENSE files for more details.
# The reason this is done here is because the submit is not a DELETE, and GET form submissions
# strip url params
dialog.with_additional_details do
concat(hidden_field_tag(:role_id, @role.id))
@roles.each { |role| concat(hidden_field_tag("role_ids[]", role.id)) }
@status_ids.each { |id| concat(hidden_field_tag("status_ids[]", id)) }
end
end
@@ -35,9 +35,9 @@ module Workflows
DIALOG_ID = "workflows-status-removal-dialog"
def initialize(role:, type:, tab:, status_ids:, removed_count:)
def initialize(roles:, type:, tab:, status_ids:, removed_count:)
super
@role = role
@roles = roles
@type = type
@tab = tab
@status_ids = Array(status_ids).flatten.map(&:to_i)
@@ -35,6 +35,7 @@ module CustomActions
validates :work_package_id, presence: true
validate :work_package_visible
validate :custom_action_conditions_fulfilled
private
@@ -45,5 +46,17 @@ module CustomActions
errors.add(:work_package_id, :does_not_exist)
end
end
def custom_action_conditions_fulfilled
return unless model.work_package_id
return unless options[:custom_action]
work_package = WorkPackage.visible(user).find_by(id: model.work_package_id)
return unless work_package
unless options[:custom_action].conditions_fulfilled?(work_package, user)
errors.add(:base, :error_unauthorized)
end
end
end
end
+34
View File
@@ -34,6 +34,40 @@ require_relative "base_contract"
# Model contract for AR records that
# support change tracking
class ModelContract < BaseContract
# Declares an attribute backed by the `store_attribute` gem (a virtual
# accessor for a key inside a JSONB column). store_attribute marks both the
# virtual attribute and the underlying column as dirty, which would otherwise
# trip the readonly check on the column. This DSL registers the virtual
# attribute as writable and, on first use per store column, declares the
# column itself as writable iff every dirty key in it is one that has been
# registered via `stored_attribute`.
def self.stored_attribute(name, store:)
store = store.to_sym
register_store_column(store) unless stored_keys_per_store.key?(store)
stored_keys_per_store[store] << name.to_s
attribute name
end
def self.register_store_column(store)
contract_class = self
attribute store, writable: -> {
allowed = contract_class.allowed_stored_keys_for(store)
model.public_send(:"#{store}_change")&.none? { |hash| hash.except(*allowed).any? }
}
end
def self.stored_keys_per_store
@stored_keys_per_store ||= Hash.new { |h, k| h[k] = Set.new }
end
def self.allowed_stored_keys_for(store)
ancestors
.select { |a| a.respond_to?(:stored_keys_per_store, true) }
.flat_map { |a| a.stored_keys_per_store.key?(store) ? a.stored_keys_per_store[store].to_a : [] }
.uniq
end
# Runs all the specified validations and returns +true+ if no errors were
# added otherwise +false+.
# Validations on the model as well as on the contract are run.
@@ -36,7 +36,10 @@ module OAuthClients
validates :client_id, presence: true, length: { maximum: 255 }
attribute :client_secret, writable: true
validates :client_secret, presence: true, length: { maximum: 255 }
validates :client_secret, presence: true, if: :client_secret_required?
validates :client_secret, length: { maximum: 255 }
def client_secret_required? = true
attribute :integration_type, writable: true
validates :integration_type, presence: true
+5
View File
@@ -73,6 +73,11 @@ module Roles
[]
end
# For now, we also remove all permissions related to resource management as this module is still behind FF
unless Rails.env.local?
permissions_to_remove += OpenProject::AccessControl.module_permissions(:resource_management)
end
OpenProject::AccessControl.project_permissions - permissions_to_remove
end
-2
View File
@@ -115,13 +115,11 @@ module Users
errors.add(:identity_url, :error_readonly) if model.user_auth_provider_links.any?(&:changed?)
end
# rubocop:disable Rails/DynamicFindBy
def existing_auth_source
if ldap_auth_source_id && LdapAuthSource.find_by_unique(ldap_auth_source_id).nil?
errors.add :auth_source, :error_not_found
end
end
# rubocop:enable Rails/DynamicFindBy
def can_create_or_manage_users?
user.allowed_globally?(:manage_user) || user.allowed_globally?(:create_user)
+2 -2
View File
@@ -48,7 +48,7 @@ module WikiPages
def validate_user_edit_allowed
if (model.project && !user.allowed_in_project?(:edit_wiki_pages, model.project)) ||
(model.protected_was && !user.allowed_in_project?(:protect_wiki_pages, model.project))
(model.protected_was && !user.allowed_in_project?(:manage_wiki, model.project))
errors.add :base, :error_unauthorized
end
end
@@ -62,7 +62,7 @@ module WikiPages
end
def validate_user_protect_permission
if model.protected_changed? && !user.allowed_in_project?(:protect_wiki_pages, model.project)
if model.protected_changed? && !user.allowed_in_project?(:manage_wiki, model.project)
errors.add :protected, :error_unauthorized
end
end
+5 -3
View File
@@ -268,10 +268,12 @@ class ApplicationController < ActionController::Base
@project = @object.project
end
# Filter for bulk work package operations
# Filter for bulk work package operations. Either :work_package_id (single-WP
# routes) or :ids (bulk routes) may carry numeric or semantic identifiers
# ("PROJ-42") since both originate from human-facing URLs or forms.
def find_work_packages
@work_packages = WorkPackage.includes(:project)
.where(id: params[:work_package_id] || params[:ids])
@work_packages = WorkPackage.where_display_id_in(params[:work_package_id] || params[:ids])
.includes(:project)
.order("id ASC")
fail ActiveRecord::RecordNotFound if @work_packages.empty?
+1 -1
View File
@@ -52,7 +52,7 @@ module My
token = cookies[OpenProject::Configuration["autologin_cookie_name"]]
if token
@current_token = @autologin_tokens.find_by_plaintext_value(token) # rubocop:disable Rails/DynamicFindBy
@current_token = @autologin_tokens.find_by_plaintext_value(token)
end
end
+12 -10
View File
@@ -74,22 +74,24 @@ class OAuthClientsController < ApplicationController
# rubocop:disable Metrics/AbcSize
def ensure_connection
client_id = params.fetch(:oauth_client_id)
storage_id = params.fetch(:storage_id)
oauth_client = OAuthClient.find_by(client_id:, integration_id: storage_id)
integration_id = params.fetch(:integration_id)
oauth_client = OAuthClient.find_by(client_id:, integration_id:)
handle_absent_oauth_client unless oauth_client
return handle_absent_oauth_client unless oauth_client
storage = oauth_client.integration
# check if the origin is the same
integration = oauth_client.integration
destination_url = destination_url(params.fetch(:destination_url, ""))
auth_state = ::Storages::Adapters::Authentication.authorization_state(storage:, user: User.current)
configuration = integration.oauth_configuration
connection = ::OAuthClients::ConnectionManager.new(user: User.current, configuration:)
.get_access_token
if auth_state == :connected
if connection.success?
redirect_to(destination_url)
else
nonce = SecureRandom.uuid
cookies["oauth_state_#{nonce}"] = { value: { href: destination_url, storageId: storage_id }.to_json, expires: 1.hour }
redirect_to(storage.oauth_configuration.authorization_uri(state: nonce), allow_other_host: true)
cookies["oauth_state_#{nonce}"] = { value: { href: destination_url, integrationId: integration_id }.to_json,
expires: 1.hour }
redirect_to(configuration.authorization_uri(state: nonce), allow_other_host: true)
end
end
@@ -198,7 +200,7 @@ class OAuthClientsController < ApplicationController
# This must be fixed in #50872.
state_value = MultiJson.load(cookie, symbolize_keys: true)
@oauth_client = OAuthClient.find_by(client_id: params[:oauth_client_id],
integration_id: state_value[:storageId])
integration_id: state_value[:integrationId])
end
@oauth_client = oauth_client_from_cookie.call
+15 -16
View File
@@ -86,7 +86,7 @@ class WikiController < ApplicationController
end
# display a page (in editing mode if it doesn't exist)
def show
def show # rubocop:disable Metrics/AbcSize
# Set the related page ID to make it the parent of new links
flash[:_related_wiki_page_id] = @page.id
@@ -94,7 +94,7 @@ class WikiController < ApplicationController
@page = ::WikiPages::AtVersion.new(@page, version)
if params[:format] == "markdown" && User.current.allowed_in_project?(:export_wiki_pages, @project)
if params[:format] == "markdown" && User.current.allowed_in_project?(:view_wiki_pages, @project)
send_data(@page.text, type: "text/plain", filename: "#{@page.title}.md")
return
end
@@ -182,7 +182,7 @@ class WikiController < ApplicationController
end
# rename a page
def rename
def rename # rubocop:disable Metrics/AbcSize
return render_403 unless editable?
@page.redirect_existing_links = true
@@ -222,7 +222,7 @@ class WikiController < ApplicationController
def wiki_root_menu_items
MenuItems::WikiMenuItem
.main_items(@wiki.id)
.map { |it| OpenStruct.new name: it.name, caption: it.title, item: it }
.map { OpenStruct.new name: it.name, caption: it.title, item: it }
end
def edit_parent_page
@@ -252,12 +252,11 @@ class WikiController < ApplicationController
# show page history
def history
# don't load text
@versions = @page
.journals
.select(:id, :user_id, :notes, :created_at, :version)
.order(Arel.sql("version DESC"))
.page(page_param)
.per_page(per_page_param)
@versions = @page.journals
.select(:id, :user_id, :notes, :created_at, :version)
.order(version: :desc)
.page(page_param)
.per_page(per_page_param)
render layout: !request.xhr?
end
@@ -307,7 +306,7 @@ class WikiController < ApplicationController
return
end
end
@page.destroy
@page.destroy!
flash[:notice] = I18n.t(:notice_successful_delete)
if page = @wiki.find_page(@wiki.start_page) || @wiki.pages.first
@@ -319,7 +318,7 @@ class WikiController < ApplicationController
# Export wiki to a single html file
def export
if User.current.allowed_in_project?(:export_wiki_pages, @project)
if User.current.allowed_in_project?(:view_wiki_pages, @project)
@pages = @wiki.pages.order(Arel.sql("title"))
export = render_to_string action: "export_multiple", layout: false
send_data(export, type: "text/html", filename: "wiki.html")
@@ -358,7 +357,7 @@ class WikiController < ApplicationController
def page_for_menu_item(page)
if page == :parent_page
page = send(:page)
page = page.parent if page && page.parent
page = page.parent if page&.parent
else
page = send(page)
end
@@ -406,9 +405,9 @@ class WikiController < ApplicationController
# Using the empty contract here as we use the method to instantiate the model, not to save it (new and new_child action).
# Errors are expected here as the user has not yet entered any data.
@page = WikiPages::SetAttributesService
.new(model: WikiPage.new, user: current_user, contract_class: EmptyContract)
.call(wiki: @wiki, title: wiki_page_title.presence, parent_id: flash[:_related_wiki_page_id])
.result
.new(model: WikiPage.new, user: current_user, contract_class: EmptyContract)
.call(wiki: @wiki, title: wiki_page_title.presence, parent_id: flash[:_related_wiki_page_id])
.result
end
# Returns true if the current user is allowed to edit the page, otherwise false
+67 -25
View File
@@ -38,27 +38,37 @@ class Workflows::TabsController < ApplicationController
before_action :set_type
before_action :set_tab
before_action :set_eligible_roles
before_action :set_role
before_action :set_roles
def edit
unless turbo_frame_request?
redirect_to edit_workflow_path(@type, role_id: params[:role_id], tab: @tab)
redirect_to edit_workflow_path(@type, role_ids: params[:role_ids], tab: @tab)
return
end
statuses_for_form
if @type && @role && @statuses.any?
if @type && @roles.any? && @statuses.any?
workflows_for_form
end
end
def update
call = Workflows::BulkUpdateService
.new(role: @role, type: @type, tab: @tab)
.call(permitted_status_params)
def update # rubocop:disable Metrics/AbcSize
success = false
Workflow.transaction do
success = true
base_params = permitted_status_params
indeterminate = permitted_indeterminate_params
@roles.each do |role|
role_params = indeterminate.empty? ? base_params : role_specific_params(base_params, indeterminate, role)
result = Workflows::BulkUpdateService.new(role:, type: @type, tab: @tab)
.call(role_params)
success = false unless result.success?
end
raise ActiveRecord::Rollback unless success
end
if call.success?
if success
render_flash_message_via_turbo_stream(
message: I18n.t(:notice_successful_update),
scheme: :success
@@ -69,7 +79,7 @@ class Workflows::TabsController < ApplicationController
update_via_turbo_stream(
component: Workflows::StatusMatrixFormComponent.new(
tab: @tab,
role: @role,
roles: @roles,
type: @type,
available_roles: @eligible_roles,
statuses:,
@@ -92,8 +102,8 @@ class Workflows::TabsController < ApplicationController
all_statuses = Status.order(:position)
current_statuses = if params[:status_ids].present?
Status.where(id: params[:status_ids].map(&:to_i)).order(:position)
elsif @type && @role
statuses_for_role_and_type
elsif @type && @roles.any?
statuses_for_roles_and_type
else
Status.none
end
@@ -101,7 +111,7 @@ class Workflows::TabsController < ApplicationController
respond_with_dialog Workflows::StatusDialogComponent.new(
all_statuses:,
current_statuses:,
role: @role,
roles: @roles,
type: @type,
tab: @tab
)
@@ -114,7 +124,7 @@ class Workflows::TabsController < ApplicationController
if removed_count > 0
respond_with_dialog Workflows::StatusRemovalDangerDialogComponent.new(
role: @role,
roles: @roles,
type: @type,
tab: @tab,
status_ids: current_status_ids,
@@ -125,7 +135,7 @@ class Workflows::TabsController < ApplicationController
update_via_turbo_stream(
component: Workflows::StatusMatrixFormComponent.new(
tab: @tab,
role: @role,
roles: @roles,
type: @type,
available_roles: @eligible_roles,
statuses:,
@@ -150,8 +160,9 @@ class Workflows::TabsController < ApplicationController
@eligible_roles = Workflow.eligible_roles.order(:builtin, :position)
end
def set_role
@role = @eligible_roles.find(params[:role_id])
def set_roles
@roles = @eligible_roles.where(id: params[:role_ids])
@roles = [@eligible_roles.first] if @roles.empty?
end
def statuses_for_form
@@ -159,8 +170,8 @@ class Workflows::TabsController < ApplicationController
@has_status_changes = false
@statuses = if @type && params[:status_ids].present?
statuses_from_params
elsif @type && @role
statuses_for_role_and_type
elsif @type && @roles.any?
statuses_for_roles_and_type
elsif @type
@type.statuses
else
@@ -170,18 +181,19 @@ class Workflows::TabsController < ApplicationController
def statuses_from_params
status_ids = params[:status_ids].map(&:to_i)
saved_ids = statuses_for_role_and_type.pluck(:id)
saved_ids = statuses_for_roles_and_type.pluck(:id)
@added_status_ids = status_ids - saved_ids
@has_status_changes = @added_status_ids.any? || (saved_ids - status_ids).any?
Status.where(id: status_ids).order(:position)
end
def statuses_for_role_and_type
@type.statuses(role: @role, tab: @tab)
def statuses_for_roles_and_type
status_ids = @roles.map { |role| @type.statuses(role:, tab: @tab).pluck(:id) }.flatten.uniq
Status.where(id: status_ids)
end
def workflows_for_form
workflows = Workflow.where(role_id: @role.id, type_id: @type.id)
workflows = Workflow.where(role_id: @roles.map(&:id), type_id: @type.id)
@workflows = {}
@workflows["always"] = workflows.select { |w| !w.author && !w.assignee }
@workflows["author"] = workflows.select(&:author)
@@ -189,10 +201,40 @@ class Workflows::TabsController < ApplicationController
end
def permitted_status_params
return {} if params["status"].blank?
status_params("status")
end
params["status"]
def permitted_indeterminate_params
status_params("indeterminate_status")
end
def status_params(key)
return {} if params[key].blank?
params[key]
.to_unsafe_h
.select { |key, value| /\A\d+\z/.match?(key) && value.keys.all? { /\A\d+\z/.match?(it) } }
.select { |k, value| /\A\d+\z/.match?(k) && value.keys.all? { /\A\d+\z/.match?(it) } }
end
def role_specific_params(base_params, indeterminate, role)
params = base_params.deep_dup
indeterminate.each do |old_id, new_ids|
new_ids.each_key do |new_id|
# Restore from DB so that it isn't overwritten by indeterminate state (unchecked)
had_transition = Workflow.exists?(
role_id: role.id,
type_id: @type.id,
old_status_id: old_id.to_i,
new_status_id: new_id.to_i,
author: @tab == "author",
assignee: @tab == "assignee"
)
if had_transition
params[old_id] ||= {}
params[old_id][new_id] = "1"
end
end
end
params
end
end
+5 -7
View File
@@ -38,7 +38,7 @@ class WorkflowsController < ApplicationController
before_action :find_types, only: %i[index]
before_action :find_type, only: %i[edit]
before_action :find_optional_role, only: %i[edit]
before_action :find_optional_roles, only: %i[edit]
def index; end
@@ -56,16 +56,14 @@ class WorkflowsController < ApplicationController
@types = ::Type.order(:position)
end
def find_role
@role = eligible_roles.find(params[:role_id])
end
def find_type
@type = ::Type.find(params[:type_id])
end
def find_optional_role
@role = eligible_roles.find_by(id: params[:role_id]) || eligible_roles.order(:builtin, :position).first
def find_optional_roles
ordered = eligible_roles.order(:builtin, :position)
@roles = ordered.where(id: params[:role_ids])
@roles = [ordered.first] if @roles.empty?
end
def eligible_roles
@@ -39,6 +39,8 @@ module Admin
visually_hide_label: true
) do |group|
available_feature_flags.each do |(label, name)|
next if !setting_value(name) && setting_disabled?(name)
group.check_box(
name:,
label:,
@@ -51,19 +51,42 @@ module Settings
f.select_list(
name: :new_project_user_role_id,
label: I18n.t(:setting_new_project_user_role_id),
caption: I18n.t(:setting_new_project_user_role_id_caption),
input_width: :medium,
include_blank: I18n.t(:actionview_instancetag_blank_option)
include_blank: false
) do |select|
ProjectRole.givable.each do |role|
select.option(
value: role.id.to_s,
label: role.name,
selected: Setting.new_project_user_role_id == role.id
)
end
build_new_project_user_role_options(select)
end
f.submit
end
# Adds the role options to the new_project_user_role_id select. Roles that pass the
# `assignable_to_project_creator` filter are listed first; the currently configured role is
# always included even when it has lost required permissions (with a label suffix), so the
# admin can see and change the current selection.
def build_new_project_user_role_options(select)
assignable = ProjectRole.assignable_to_project_creator.to_a
assignable.each { |role| add_assignable_role_option(select, role) }
configured = ProjectRole.givable.find_by(id: Setting.new_project_user_role_id)
add_non_qualifying_role_option(select, configured) if configured && assignable.exclude?(configured)
end
def add_assignable_role_option(select, role)
select.option(
value: role.id.to_s,
label: role.name,
selected: Setting.new_project_user_role_id == role.id
)
end
def add_non_qualifying_role_option(select, role)
select.option(
value: role.id.to_s,
label: I18n.t(:label_role_missing_permissions, role: role.name),
selected: true
)
end
end
end
+1 -3
View File
@@ -30,18 +30,16 @@
module Workflows
class StatusSelectForm < ApplicationForm
def initialize(all_statuses:, current_statuses:, role:, type:, tab:, dialog_id:)
def initialize(all_statuses:, current_statuses:, type:, tab:, dialog_id:)
super()
@all_statuses = all_statuses
@current_statuses = current_statuses
@role = role
@type = type
@tab = tab
@dialog_id = dialog_id
end
form do |f|
f.hidden(name: :role_id, value: @role.id)
f.hidden(name: :type_id, value: @type.id)
f.hidden(name: :tab, value: @tab || "always")
@current_statuses.each { |status| f.hidden(name: "original_status_ids[]", value: status.id) }
@@ -33,6 +33,14 @@ module WorkPackages::SplitViewHelper
params[:work_package_split_view].present?
end
def render_work_package_split_create?
params[:work_package_split_create].present?
end
def split_create_instance
WorkPackages::SplitCreateComponent.new(project_identifier: params[:project_id])
end
def split_view_instance
WorkPackages::SplitViewComponent.new(id: params[:work_package_id],
tab: params[:tab],
+1 -1
View File
@@ -37,7 +37,7 @@ module WorkflowHelper
].map do |tab|
tab.merge(
partial: "workflows/form",
path: edit_workflow_tab_path(type, tab[:name], params.permit(:role_id)),
path: edit_workflow_tab_path(type, tab[:name], params.permit(role_ids: [])),
data: { "admin--workflow-checkbox-state-confirmation-trigger": "click",
turbo_frame: "workflow-table",
turbo_action: "advance" }
+1 -1
View File
@@ -75,7 +75,7 @@ class Reminders::NotificationMailer < ApplicationMailer
end
def work_package_subject_text_wrapper
"=" * ("# #{@work_package.id}#{@work_package.subject}".length + 4)
"=" * ("#{@work_package.formatted_id} #{@work_package.subject}".length + 4)
end
def text_email_wrapper
+1 -1
View File
@@ -49,7 +49,7 @@ class SharingMailer < ApplicationMailer
send_localized_mail(@shared_with_user) do
@role_rights = derive_role_rights(role)
@allowed_work_package_actions = derive_allowed_work_package_actions(role)
I18n.t("mail.sharing.work_packages.subject", id: @work_package.id)
I18n.t("mail.sharing.work_packages.subject", id: @work_package.formatted_id)
end
end
+2 -2
View File
@@ -47,7 +47,7 @@ class WorkPackageMailer < ApplicationMailer
send_localized_mail(recipient) do
I18n.t(:"mail.mention.subject",
user_name: author.name,
id: @work_package.id,
id: @work_package.formatted_id,
subject: @work_package.subject)
end
end
@@ -73,7 +73,7 @@ class WorkPackageMailer < ApplicationMailer
def subject_for_work_package(work_package)
"#{work_package.project.name} - #{work_package.status.name} #{work_package.type.name} " +
"##{work_package.id}: #{work_package.subject}"
"#{work_package.formatted_id}: #{work_package.subject}"
end
def set_work_package_headers(work_package)
+3 -3
View File
@@ -113,16 +113,16 @@ module CustomField::OrderStatements
# ) cf_order_NNN ON cf_order_NNN.customized_id = …
#
def join_for_order_sql(value:, add_select: nil, join: nil, multi_value: false)
<<-SQL.squish
<<~SQL.squish
LEFT OUTER JOIN (
SELECT
#{multi_value ? '' : 'DISTINCT ON (cv.customized_id)'}
#{'DISTINCT ON (cv.customized_id)' unless multi_value}
cv.customized_id
, #{value} "value"
#{", #{add_select}" if add_select}
FROM #{CustomValue.quoted_table_name} cv
#{join}
WHERE cv.customized_type = #{CustomValue.connection.quote(self.class.customized_class.name)}
WHERE cv.customized_type = #{CustomValue.connection.quote(self.class.customized_class.base_class.name)}
AND cv.custom_field_id = #{id}
AND cv.value IS NOT NULL
AND cv.value != ''
+51
View File
@@ -0,0 +1,51 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
class HealthReport < ApplicationRecord
belongs_to :subject, polymorphic: true
serialize :results, coder: HealthReport::ResultGroup
def healthy? = results.all?(&:success?)
def unhealthy? = results.any?(&:failure?)
def warning? = results.any?(&:warning?)
def group(key)
results.find { |group| group.key == key }
end
def tally
results.reduce({}) do |tally, group|
tally.merge(group.tally) { |_, v1, v2| v1 + v2 }
end
end
end
+82
View File
@@ -0,0 +1,82 @@
# 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 HealthReport
class Result
class << self
def skipped(key)
new(key:, state: :skipped, code: nil, context: nil)
end
def success(key)
new(key:, state: :success, code: nil, context: nil)
end
def failure(key, code, context)
new(key:, state: :failure, code:, context:)
end
def warning(key, code, context)
new(key:, state: :warning, code:, context:)
end
# Used for deserialization
def load(parsed_json)
new(
key: parsed_json.fetch("key"),
state: parsed_json.fetch("state"),
code: parsed_json.fetch("code"),
context: parsed_json.fetch("context")
)
end
end
attr_reader :key, :state, :code, :context
def initialize(key:, state:, code:, context:)
@key = key
@state = state.to_sym
@code = code
@context = context
end
def success? = state == :success
def failure? = state == :failure
def warning? = state == :warning
def skipped? = state == :skipped
def to_h
{ key:, state:, code:, context: }
end
end
end
+82
View File
@@ -0,0 +1,82 @@
# 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 HealthReport
class ResultGroup
class << self
# Used for serialization in health report
# Note: Because we deserialize from jsonb, we don't expect a string
# but already parsed json
def load(parsed_json)
Array(parsed_json).map { |h| new(key: h.fetch("key"), results: h.fetch("results").map { |r| Result.load(r) }) }
end
# Used for serialization in health report
# Note: Because we serialize into jsonb, we don't return a string (JSON.dump)
# but return a hash/array directly.
def dump(value)
if value.is_a?(Array)
value.map(&:to_h)
else
value.to_h
end
end
end
attr_reader :key, :results
def initialize(key:, results: [])
@key = key
@results = results
end
def success? = results.all?(&:success?)
def non_failure? = results.none?(&:failure?)
def failure? = results.any?(&:failure?)
def warning? = results.any?(&:warning?)
def result_for(key)
results.find { |r| r.key == key }
end
alias [] result_for
def tally
results.map(&:state).tally
end
def to_h
{ key:, results: results.map(&:to_h) }
end
end
end
+1 -1
View File
@@ -36,7 +36,7 @@ class OAuthClientToken < ApplicationRecord
validates :user, uniqueness: { scope: :oauth_client }
validates :access_token, presence: true
validates :refresh_token, presence: true
validates :refresh_token, presence: true, if: -> { expires_in.present? }
scope :for_user_and_client, ->(user, client) { where(user:, oauth_client: client) }
end
@@ -28,20 +28,12 @@
# See COPYRIGHT and LICENSE files for more details.
#++
module OpenProject::Backlogs::Patches::API::V3::Utilities::ResourceLinkGeneratorPatch
extend ActiveSupport::Concern
class OrderedPersistedQueryEntity < ApplicationRecord
belongs_to :persisted_query, optional: false
belongs_to :entity, polymorphic: true, optional: false
included do
singleton_class.prepend(ClassMethods)
end
validates :position, presence: true
validates :entity_id, uniqueness: { scope: %i[persisted_query_id entity_type] }
module ClassMethods
private
def determine_path_method(record)
return :sprint if record.is_a?(Agile::Sprint)
super
end
end
default_scope { order(position: :asc) }
end
+79
View File
@@ -0,0 +1,79 @@
# 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 PersistedQuery < ApplicationRecord
include Queries::BaseQuery
include Queries::Serialization::Hash
include ::Scopes::Scoped
belongs_to :project, optional: true
belongs_to :principal, optional: true, inverse_of: :persisted_queries
has_many :views, class_name: "PersistedView",
as: :query,
dependent: :restrict_with_error,
inverse_of: :query
has_many :ordered_entities, -> { order(position: :asc) },
class_name: "OrderedPersistedQueryEntity",
dependent: :destroy,
inverse_of: :persisted_query
validates :name, length: { maximum: 255, allow_nil: true }
def self.inherited(subclass)
super
subclass.serialize :filters, coder: Queries::Serialization::Filters.new(subclass)
subclass.serialize :orders, coder: Queries::Serialization::Orders.new(subclass)
subclass.serialize :selects, coder: Queries::Serialization::Selects.new(subclass)
end
def self.register_query(&)
Queries::Register.register(self, &)
end
def user
principal if principal.is_a?(User)
end
def user=(user)
self.principal = user
end
# Returns the query results, bypassing filters and orders when the query has
# manually-added entities — in that case they are returned in the order
# stored on the join records.
def results
return super if ordered_entities.empty?
entity_ids = ordered_entities.pluck(:entity_id)
self.class.model.where(id: entity_ids).in_order_of(:id, entity_ids)
end
end
+103
View File
@@ -0,0 +1,103 @@
# 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 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 :parent, class_name: "PersistedView", optional: true
has_many :children, class_name: "PersistedView", foreign_key: "parent_id", dependent: :destroy, inverse_of: :parent
acts_as_favoritable
enum :category, {
work_package: "work_package",
project: "project",
resource_management: "resource_management"
}, validate: { allow_nil: true }
validates :name, presence: true, length: { maximum: 255 }
validate :parent_allows_this_child_class
scope :public_views, -> { where(public: true) }
scope :private_views, ->(principal = User.current) { where(public: false, principal_id: principal.id) }
scope :visible, (lambda do |principal = User.current|
public_views.or(private_views(principal))
end)
after_destroy :destroy_query_if_orphaned
# Class names of view types that can be created as direct children of this
# view. Each subclass gets its own list (no inheritance, no shared array)
# so subclasses can safely `<<` without leaking into PersistedView or
# sibling classes.
def self.allowed_children
@allowed_children ||= []
end
class << self
attr_writer :allowed_children
end
# Returns the query of this view or, if not set, the query of the parent view.
def effective_query
query || parent&.effective_query
end
# Whether the given user is permitted to see this view. Visibility rules
# depend on the concrete view type (e.g. project membership, sharing,
# public flag), so subclasses must implement this.
def visible?(_user)
raise SubclassResponsibilityError
end
private
def parent_allows_this_child_class
return if parent.nil?
unless parent.class.allowed_children.include?(self.class.name)
errors.add(:parent, :invalid_child_for_parent)
end
end
# When this view is destroyed, also destroy its query unless another public
# view still references it. Views belonging to the same owner that are also
# going away (e.g. during user deletion) do not count as "still referencing"
# since only public views keep a query alive.
def destroy_query_if_orphaned
return if query.nil?
return if PersistedView.exists?(query:, public: true)
query.destroy!
end
end
+3
View File
@@ -77,6 +77,9 @@ class Principal < ApplicationRecord
inverse_of: :principal
has_many :auth_providers, through: :user_auth_provider_links
has_many :persisted_views, inverse_of: :principal, dependent: :nullify
has_many :persisted_queries, inverse_of: :principal, dependent: :nullify
has_paper_trail
scopes :like,
@@ -41,7 +41,7 @@ module Principals::Scopes
def ordered_by_name(desc: false)
direction = desc ? "DESC" : "ASC"
order_case = Arel.sql <<~SQL
order_case = Arel.sql(<<~SQL.squish)
CASE
WHEN users.type = 'User' THEN LOWER(#{user_concat_sql})
WHEN users.type != 'User' THEN LOWER(users.lastname)

Some files were not shown because too many files have changed in this diff Show More