mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
Merge remote-tracking branch 'origin/dev' into merge-release/17.4-20260512044520
This commit is contained in:
@@ -5,6 +5,7 @@ inherit_gem:
|
||||
- config/accessibility.yml
|
||||
exclude:
|
||||
- '**/frontend/**/*'
|
||||
- 'lookbook/previews/open_project/deprecated/**/*'
|
||||
- '**/node_modules/**/*'
|
||||
- '**/vendor/**/*'
|
||||
linters:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -51,4 +51,4 @@ jobs:
|
||||
|
||||
- name: Test (Angular)
|
||||
id: npm-test
|
||||
run: npm test -- --code-coverage
|
||||
run: npm test
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
@@ -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
|
||||
%>
|
||||
+29
-29
@@ -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
|
||||
+14
-14
@@ -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
|
||||
+18
-22
@@ -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
|
||||
+24
-9
@@ -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
-1
@@ -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
|
||||
+41
-15
@@ -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
|
||||
+11
-9
@@ -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
|
||||
@@ -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"),
|
||||
|
||||
+1
-1
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 != ''
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+6
-14
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user