From 4aa4f5221e38c72c1069fc482e033c27d17f8b5c Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Thu, 2 Apr 2026 14:49:05 +0200 Subject: [PATCH 1/3] Remove uiRouter from Boards module --- frontend/src/app/app.module.ts | 8 ++ .../core/path-helper/path-helper.service.ts | 4 + .../app/core/routing/openproject.routes.ts | 6 - .../add-list-modal.component.ts | 8 +- .../board-filter/board-filter.component.ts | 14 ++- .../board/board-list/board-list.component.ts | 19 ++-- .../board-entry.component.ts} | 59 ++++++++-- .../board-list-container.component.ts | 22 ++-- .../board-partitioned-page.component.html | 48 ++++++++ .../board-partitioned-page.component.ts | 84 ++++++-------- .../boards/openproject-boards.module.ts | 12 +- .../boards/openproject-boards.routes.ts | 105 ------------------ .../routing/wp-split-view/wp-split-view.html | 2 +- .../controllers/boards/boards_controller.rb | 26 ++++- .../app/views/boards/boards/show.html.erb | 11 +- modules/boards/config/routes.rb | 7 ++ .../boards/lib/open_project/boards/engine.rb | 2 +- 17 files changed, 222 insertions(+), 215 deletions(-) rename frontend/src/app/features/boards/{boards-root/boards-root.component.ts => board/board-partitioned-page/board-entry.component.ts} (51%) create mode 100644 frontend/src/app/features/boards/board/board-partitioned-page/board-partitioned-page.component.html delete mode 100644 frontend/src/app/features/boards/openproject-boards.routes.ts diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index d3edb6674eb..e2984de7e10 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -39,6 +39,7 @@ import { OpSharedModule } from 'core-app/shared/shared.module'; import { OpSpotModule } from 'core-app/spot/spot.module'; import { OpDragScrollDirective } from 'core-app/shared/directives/op-drag-scroll/op-drag-scroll.directive'; import { OpenprojectWorkPackagesModule } from 'core-app/features/work-packages/openproject-work-packages.module'; +import { OpenprojectBoardsModule } from 'core-app/features/boards/openproject-boards.module'; import { OpenprojectAttachmentsModule } from 'core-app/shared/components/attachments/openproject-attachments.module'; import { OpenprojectEditorModule } from 'core-app/shared/components/editor/openproject-editor.module'; import { OpenprojectGridsModule } from 'core-app/shared/components/grids/openproject-grids.module'; @@ -149,6 +150,9 @@ import { import { WorkPackageSplitViewEntryComponent, } from 'core-app/features/work-packages/routing/wp-split-view/wp-split-view-entry.component'; +import { + BoardEntryComponent, +} from 'core-app/features/boards/board/board-partitioned-page/board-entry.component'; import { StorageLoginButtonComponent, } from 'core-app/shared/components/storages/storage-login-button/storage-login-button.component'; @@ -295,6 +299,9 @@ export function runBootstrap(appRef:ApplicationRef) { OpenprojectWorkPackagesModule, OpenprojectWorkPackageRoutesModule, + // Boards + OpenprojectBoardsModule, + // Work packages in graph representation OpenprojectWorkPackageGraphsModule, // Calendar module @@ -394,6 +401,7 @@ export class OpenProjectModule implements DoBootstrap { registerCustomElement('opce-reminder-settings', ReminderSettingsPageComponent, { injector }); registerCustomElement('opce-notification-center', InAppNotificationCenterComponent, { injector }); registerCustomElement('opce-wp-split-view', WorkPackageSplitViewEntryComponent, { injector }); + registerCustomElement('opce-board-view', BoardEntryComponent, { injector }); registerCustomElement('opce-wp-full-view', WorkPackageFullViewEntryComponent, { injector }); registerCustomElement('opce-wp-full-create', WorkPackageFullCreateEntryComponent, { injector }); registerCustomElement('opce-wp-full-copy', WorkPackageFullCopyEntryComponent, { injector }); diff --git a/frontend/src/app/core/path-helper/path-helper.service.ts b/frontend/src/app/core/path-helper/path-helper.service.ts index 5c30f35a952..dcc9a2a571a 100644 --- a/frontend/src/app/core/path-helper/path-helper.service.ts +++ b/frontend/src/app/core/path-helper/path-helper.service.ts @@ -247,6 +247,10 @@ export class PathHelperService { return `${this.boardsPath(projectIdentifier)}/new`; } + public boardDetailsPath(projectIdentifier:string|null, boardId:string|number, workPackageId:string|number) { + return `${this.boardsPath(projectIdentifier)}/${boardId}/details/${workPackageId}`; + } + public projectDashboardsPath(projectIdentifier:string) { return `${this.projectPath(projectIdentifier)}/dashboards`; } diff --git a/frontend/src/app/core/routing/openproject.routes.ts b/frontend/src/app/core/routing/openproject.routes.ts index 25ff234b031..945a93303bc 100644 --- a/frontend/src/app/core/routing/openproject.routes.ts +++ b/frontend/src/app/core/routing/openproject.routes.ts @@ -68,12 +68,6 @@ export const OPENPROJECT_ROUTES:Ng2StateDeclaration[] = [ '!$default': { component: ApplicationBaseComponent }, }, }, - { - name: 'boards.**', - parent: 'optional_project', - url: '/boards', - loadChildren: () => import('../../features/boards/openproject-boards.module').then((m) => m.OpenprojectBoardsModule), - }, { name: 'bim.**', parent: 'optional_project', diff --git a/frontend/src/app/features/boards/board/add-list-modal/add-list-modal.component.ts b/frontend/src/app/features/boards/board/add-list-modal/add-list-modal.component.ts index 585510250fc..e1d100c4cd4 100644 --- a/frontend/src/app/features/boards/board/add-list-modal/add-list-modal.component.ts +++ b/frontend/src/app/features/boards/board/add-list-modal/add-list-modal.component.ts @@ -34,7 +34,6 @@ import { OpModalComponent } from 'core-app/shared/components/modal/modal.compone import { OpModalLocalsToken } from 'core-app/shared/components/modal/modal.service'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { Board } from 'core-app/features/boards/board/board'; -import { StateService } from '@uirouter/core'; import { BoardService } from 'core-app/features/boards/board/board.service'; import { BoardActionsRegistryService } from 'core-app/features/boards/board/board-actions/board-actions-registry.service'; import { BoardActionService } from 'core-app/features/boards/board/board-actions/board-action.service'; @@ -42,6 +41,7 @@ import { HalResourceNotificationService } from 'core-app/features/hal/services/h import { tap } from 'rxjs/operators'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; import { firstValueFrom, Observable, @@ -113,11 +113,11 @@ export class AddListModalComponent extends OpModalComponent implements OnInit { readonly cdRef:ChangeDetectorRef, readonly boardActions:BoardActionsRegistryService, readonly halNotification:HalResourceNotificationService, - readonly state:StateService, readonly boardService:BoardService, readonly I18n:I18nService, readonly apiV3Service:ApiV3Service, - readonly currentProject:CurrentProjectService) { + readonly currentProject:CurrentProjectService, + readonly pathHelper:PathHelperService) { super(locals, cdRef, elementRef); } @@ -141,7 +141,7 @@ export class AddListModalComponent extends OpModalComponent implements OnInit { .then((board) => { this.inFlight = false; this.closeMe(); - void this.state.go('boards.partitioned.show', { board_id: board.id, isNew: true }); + void Turbo.visit(`${this.pathHelper.boardsPath(this.currentProject.identifier)}/${board.id}`); }) .catch(() => (this.inFlight = false)); } diff --git a/frontend/src/app/features/boards/board/board-filter/board-filter.component.ts b/frontend/src/app/features/boards/board/board-filter/board-filter.component.ts index 6c960942039..2d34ca92487 100644 --- a/frontend/src/app/features/boards/board/board-filter/board-filter.component.ts +++ b/frontend/src/app/features/boards/board/board-filter/board-filter.component.ts @@ -7,7 +7,6 @@ import { HalResourceService } from 'core-app/features/hal/services/hal-resource. import { WorkPackageViewFiltersService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-filters.service'; import { QueryFilterInstanceResource } from 'core-app/features/hal/resources/query-filter-instance-resource'; import { UrlParamsHelperService } from 'core-app/features/work-packages/components/wp-query/url-params-helper'; -import { StateService } from '@uirouter/core'; import { debounceTime, skip, take } from 'rxjs/operators'; import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; import { Observable } from 'rxjs'; @@ -32,8 +31,7 @@ export class BoardFilterComponent extends UntilDestroyedMixin implements AfterVi private readonly wpStatesInitialization:WorkPackageStatesInitializationService, private readonly wpTableFilters:WorkPackageViewFiltersService, private readonly urlParamsHelper:UrlParamsHelperService, - private readonly boardFilters:BoardFiltersService, - private readonly $state:StateService) { + private readonly boardFilters:BoardFiltersService) { super(); } @@ -71,9 +69,15 @@ export class BoardFilterComponent extends UntilDestroyedMixin implements AfterVi const filterHash = this.urlParamsHelper.buildV3GetFilters(filters); const query_props = JSON.stringify(filterHash); - this.boardFilters.filters.putValue(filterHash); + const url = new URL(window.location.href); + if (query_props) { + url.searchParams.set('query_props', query_props); + } else { + url.searchParams.delete('query_props'); + } + window.history.pushState({}, '', url); - this.$state.go('.', { query_props }, { custom: { notify: false } }); + this.boardFilters.filters.putValue(filterHash); }); } diff --git a/frontend/src/app/features/boards/board/board-list/board-list.component.ts b/frontend/src/app/features/boards/board/board-list/board-list.component.ts index 52406a60191..baa4f648b35 100644 --- a/frontend/src/app/features/boards/board/board-list/board-list.component.ts +++ b/frontend/src/app/features/boards/board/board-list/board-list.component.ts @@ -185,9 +185,9 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni this.resource.isNewWidget = false; // Set initial selection if split view open - if (this.state.includes(`${this.state.current.data.baseRoute}.details`)) { - const wpId = this.state.params.workPackageId; - this.wpViewSelectionService.initializeSelection([wpId]); + const detailsMatch = window.location.pathname.match(/\/details\/(\d+)/); + if (detailsMatch) { + this.wpViewSelectionService.initializeSelection([detailsMatch[1]]); } // If this query space changes its focused or selected @@ -495,15 +495,20 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni } openStateLink(event:{ workPackageId:string; requestedState:string }) { - const params = { workPackageId: event.workPackageId }; - if (event.requestedState === 'split') { - this.keepTab.goCurrentDetailsState(params); + this.goToSplitView(event.workPackageId); } else { - this.keepTab.goCurrentShowState(params.workPackageId); + this.keepTab.goCurrentShowState(event.workPackageId); } } + private goToSplitView(workPackageId:string):void { + const base = this.pathHelper.boardDetailsPath(this.currentProject.identifier, this.board.id!, workPackageId); + const search = window.location.search; + const link = search ? `${base}${search}` : base; + Turbo.visit(link, { frame: 'content-bodyRight', action: 'advance' }); + } + private schema(workPackage:WorkPackageResource) { return this.schemaCache.of(workPackage); } diff --git a/frontend/src/app/features/boards/boards-root/boards-root.component.ts b/frontend/src/app/features/boards/board/board-partitioned-page/board-entry.component.ts similarity index 51% rename from frontend/src/app/features/boards/boards-root/boards-root.component.ts rename to frontend/src/app/features/boards/board/board-partitioned-page/board-entry.component.ts index 65eb30d4516..0503484d44b 100644 --- a/frontend/src/app/features/boards/boards-root/boards-root.component.ts +++ b/frontend/src/app/features/boards/board/board-partitioned-page/board-entry.component.ts @@ -1,20 +1,50 @@ -import { Component, Injector } from '@angular/core'; +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { ChangeDetectionStrategy, Component, ElementRef, Injector, Input } from '@angular/core'; +import { populateInputsFromDataset } from 'core-app/shared/components/dataset-inputs'; +import { + WorkPackageIsolatedQuerySpaceDirective, +} from 'core-app/features/work-packages/directives/query-space/wp-isolated-query-space.directive'; import { BoardConfigurationService } from 'core-app/features/boards/board/configuration-modal/board-configuration.service'; import { BoardActionsRegistryService } from 'core-app/features/boards/board/board-actions/board-actions-registry.service'; import { BoardStatusActionService } from 'core-app/features/boards/board/board-actions/status/status-action.service'; import { BoardVersionActionService } from 'core-app/features/boards/board/board-actions/version/version-action.service'; -import { QueryUpdatedService } from 'core-app/features/boards/board/query-updated/query-updated.service'; import { BoardAssigneeActionService } from 'core-app/features/boards/board/board-actions/assignee/assignee-action.service'; import { BoardSubprojectActionService } from 'core-app/features/boards/board/board-actions/subproject/subproject-action.service'; import { BoardSubtasksActionService } from 'core-app/features/boards/board/board-actions/subtasks/board-subtasks-action.service'; -import { - WorkPackageIsolatedQuerySpaceDirective, -} from 'core-app/features/work-packages/directives/query-space/wp-isolated-query-space.directive'; +import { QueryUpdatedService } from 'core-app/features/boards/board/query-updated/query-updated.service'; @Component({ - selector: 'boards-entry', + selector: 'board-entry', hostDirectives: [WorkPackageIsolatedQuerySpaceDirective], - template: '', + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, providers: [ BoardConfigurationService, BoardStatusActionService, @@ -26,11 +56,18 @@ import { ], standalone: false, }) -export class BoardsRootComponent { - constructor(readonly injector:Injector) { - // Register action services - const registry = injector.get(BoardActionsRegistryService); +export class BoardEntryComponent { + @Input() boardId:string; + constructor( + readonly elementRef:ElementRef, + readonly injector:Injector, + ) { + populateInputsFromDataset(this); + + document.body.classList.add('router--boards-full-view'); + + const registry = injector.get(BoardActionsRegistryService); registry.add('status', injector.get(BoardStatusActionService)); registry.add('assignee', injector.get(BoardAssigneeActionService)); registry.add('version', injector.get(BoardVersionActionService)); diff --git a/frontend/src/app/features/boards/board/board-partitioned-page/board-list-container.component.ts b/frontend/src/app/features/boards/board/board-partitioned-page/board-list-container.component.ts index cecc9501023..c7274956a89 100644 --- a/frontend/src/app/features/boards/board/board-partitioned-page/board-list-container.component.ts +++ b/frontend/src/app/features/boards/board/board-partitioned-page/board-list-container.component.ts @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, ElementRef, + Input, Injector, OnInit, QueryList, @@ -36,11 +37,11 @@ import { BoardActionsRegistryService, } from 'core-app/features/boards/board/board-actions/board-actions-registry.service'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; -import { - WorkPackageStatesInitializationService, -} from 'core-app/features/work-packages/components/wp-list/wp-states-initialization.service'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; +import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; @Component({ + selector: 'board-list-container', templateUrl: './board-list-container.component.html', styleUrls: ['./board-list-container.component.sass'], providers: [ @@ -50,6 +51,7 @@ import { standalone: false, }) export class BoardListContainerComponent extends UntilDestroyedMixin implements OnInit { + @Input() boardId:string; text = { delete: this.I18n.t('js.button_delete'), areYouSure: this.I18n.t('js.text_are_you_sure'), @@ -90,7 +92,7 @@ export class BoardListContainerComponent extends UntilDestroyedMixin implements private currentQueryUpdatedMonitoring:Subscription; constructor( -readonly I18n:I18nService, + readonly I18n:I18nService, readonly state:StateService, readonly toastService:ToastService, readonly halNotification:HalResourceNotificationService, @@ -102,16 +104,17 @@ readonly I18n:I18nService, readonly apiV3Service:ApiV3Service, readonly Boards:BoardService, readonly boardListCrossSelectionService:BoardListCrossSelectionService, - readonly wpStatesInitialization:WorkPackageStatesInitializationService, readonly Drag:DragAndDropService, readonly apiv3Service:ApiV3Service, readonly QueryUpdated:QueryUpdatedService, + readonly pathHelper:PathHelperService, + readonly currentProject:CurrentProjectService, ) { super(); } ngOnInit():void { - const id:string = this.state.params.board_id.toString(); + const id:string = this.boardId || this.state.params.board_id?.toString(); this.board$ = this .apiV3Service .boards @@ -128,10 +131,11 @@ readonly I18n:I18nService, .pipe( this.untilDestroyed(), filter((state) => state.focusedWorkPackage !== null), - filter(() => this.state.includes(`${this.state.current.data.baseRoute}.details`)), + filter(() => window.location.pathname.includes('/details/')), ).subscribe((selection) => { - // Update split screen - this.state.go(`${this.state.current.data.baseRoute}.details`, { workPackageId: selection.focusedWorkPackage }); + // Update split screen + const link = this.pathHelper.boardDetailsPath(this.currentProject.identifier, id, selection.focusedWorkPackage!); + Turbo.visit(link, { frame: 'content-bodyRight', action: 'advance' }); }); } diff --git a/frontend/src/app/features/boards/board/board-partitioned-page/board-partitioned-page.component.html b/frontend/src/app/features/boards/board/board-partitioned-page/board-partitioned-page.component.html new file mode 100644 index 00000000000..a0ee9d566e0 --- /dev/null +++ b/frontend/src/app/features/boards/board/board-partitioned-page/board-partitioned-page.component.html @@ -0,0 +1,48 @@ +
+ +
+
+

+ +

+ @if (showToolbar) { +
    + @for (definition of toolbarButtonComponents; track definition) { + @if (!definition.show || definition.show()) { +
  • + +
  • + } + } +
+ } +
+
+ +
+ @if (filterContainerDefinition) { + + } +
+ +
+ + +
+
diff --git a/frontend/src/app/features/boards/board/board-partitioned-page/board-partitioned-page.component.ts b/frontend/src/app/features/boards/board/board-partitioned-page/board-partitioned-page.component.ts index 85c123a2d7d..7baa0da7bb6 100644 --- a/frontend/src/app/features/boards/board/board-partitioned-page/board-partitioned-page.component.ts +++ b/frontend/src/app/features/boards/board/board-partitioned-page/board-partitioned-page.component.ts @@ -2,7 +2,9 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - Injector, OnInit, OnDestroy, + Injector, + Input, + OnInit, } from '@angular/core'; import { DynamicComponentDefinition, @@ -11,7 +13,6 @@ import { } from 'core-app/features/work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component'; import { StateService, - TransitionService, } from '@uirouter/core'; import { BoardFilterComponent } from 'core-app/features/boards/board/board-filter/board-filter.component'; import { ToastService } from 'core-app/shared/components/toaster/toast.service'; @@ -24,17 +25,18 @@ import { BoardsMenuButtonComponent } from 'core-app/features/boards/board/toolba import { catchError, finalize, + skip, take, } from 'rxjs/operators'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; import { QueryResource } from 'core-app/features/hal/resources/query-resource'; -import { Ng2StateDeclaration } from '@uirouter/angular'; +import { Board } from 'core-app/features/boards/board/board'; import { BoardFiltersService } from 'core-app/features/boards/board/board-filter/board-filters.service'; import { CardViewHandlerRegistry } from 'core-app/features/work-packages/components/wp-card-view/event-handler/card-view-handler-registry'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; import { OpTitleService } from 'core-app/core/html/op-title.service'; -import { EMPTY } from 'rxjs'; +import { EMPTY, ReplaySubject } from 'rxjs'; import { SubmenuService } from 'core-app/core/main-menu/submenu.service'; import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; @@ -44,7 +46,8 @@ export function boardCardViewHandlerFactory(injector:Injector) { } @Component({ - templateUrl: '../../../work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.html', + selector: 'board-partitioned-page', + templateUrl: './board-partitioned-page.component.html', styleUrls: [ '../../../work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.sass', './board-partitioned-page.component.sass', @@ -56,7 +59,8 @@ export function boardCardViewHandlerFactory(injector:Injector) { ], standalone: false, }) -export class BoardPartitionedPageComponent extends UntilDestroyedMixin implements OnInit, OnDestroy { +export class BoardPartitionedPageComponent extends UntilDestroyedMixin implements OnInit { + @Input() boardId:string; text = { button_more: this.I18n.t('js.button_more'), delete: this.I18n.t('js.button_delete'), @@ -71,15 +75,8 @@ export class BoardPartitionedPageComponent extends UntilDestroyedMixin implement unnamed_list: this.I18n.t('js.boards.label_unnamed_list'), }; - /** Board observable */ - board$ = this - .apiV3Service - .boards - .id(this.state.params.board_id.toString()) - .observe(); - - /** Whether this is a new board just created */ - isNew = !!this.state.params.isNew; + /** Board subject */ + board$ = new ReplaySubject(1); /** Whether the board is editable */ editable:boolean; @@ -95,10 +92,6 @@ export class BoardPartitionedPageComponent extends UntilDestroyedMixin implement /** Do we currently have query props ? */ showToolbarSaveButton:boolean; - /** Listener callbacks */ - // eslint-disable-next-line @typescript-eslint/ban-types - removeTransitionSubscription:Function; - /** Show a toolbar */ showToolbar = true; @@ -139,7 +132,6 @@ export class BoardPartitionedPageComponent extends UntilDestroyedMixin implement constructor( readonly I18n:I18nService, readonly cdRef:ChangeDetectorRef, - readonly $transitions:TransitionService, readonly state:StateService, readonly toastService:ToastService, readonly halNotification:HalResourceNotificationService, @@ -159,29 +151,28 @@ export class BoardPartitionedPageComponent extends UntilDestroyedMixin implement // Ensure board is being loaded this.Boards.loadAllBoards(); - this.removeTransitionSubscription = this.$transitions.onSuccess({}, (transition):any => { - const toState = transition.to(); - const params = transition.params('to'); + const boardId = this.boardId || this.state.params.board_id?.toString(); + this.apiV3Service.boards.id(boardId).observe() + .pipe(this.untilDestroyed()) + .subscribe((board) => this.board$.next(board)); - this.showToolbarSaveButton = !!params.query_props; - this.setPartition(toState); - - this - .board$ - .pipe(take(1)) - .subscribe((board) => { - this.titleService.setFirstPart(board.name); - }); - - this.cdRef.detectChanges(); - }); + // React to filter changes (board-filter updates boardFilters after pushing URL) + this.boardFilters.filters.values$() + .pipe( + this.untilDestroyed(), + skip(1), // skip the initial empty default value + ) + .subscribe(() => { + this.showToolbarSaveButton = !!new URLSearchParams(window.location.search).get('query_props'); + this.cdRef.detectChanges(); + }); this.board$ .pipe( this.untilDestroyed(), ) .subscribe((board) => { - const queryProps = this.state.params.query_props; + const queryProps = new URLSearchParams(window.location.search).get('query_props'); this.editable = board.editable; this.selectedTitle = board.name; this.titleService.setFirstPart(board.name); @@ -191,11 +182,6 @@ export class BoardPartitionedPageComponent extends UntilDestroyedMixin implement }); } - ngOnDestroy():void { - super.ngOnDestroy(); - this.removeTransitionSubscription(); - } - breadcrumbItems() { return [ { href: this.pathHelperService.projectPath(this.currentProject.identifier!), text: (this.currentProject.name) }, @@ -213,8 +199,10 @@ export class BoardPartitionedPageComponent extends UntilDestroyedMixin implement board.name = newName; board.filters = this.boardFilters.current; - const params = { isNew: false, query_props: null }; - this.state.go('.', params, { custom: { notify: false } }); + const url = new URL(window.location.href); + url.searchParams.delete('query_props'); + window.history.pushState({}, '', url); + this.showToolbarSaveButton = false; this.toolbarDisabled = true; this.Boards @@ -245,16 +233,6 @@ export class BoardPartitionedPageComponent extends UntilDestroyedMixin implement return this.editable; } - /** - * We need to set the current partition to the grid to ensure - * either side gets expanded to full width if we're not in '-split' mode. - * - * @param state The current or entering state - */ - protected setPartition(state:Ng2StateDeclaration) { - this.currentPartition = (state.data?.partition) ? state.data.partition : '-split'; - } - private reloadSidemenu():void { this.submenuService.reloadSubmenu(null); } diff --git a/frontend/src/app/features/boards/openproject-boards.module.ts b/frontend/src/app/features/boards/openproject-boards.module.ts index 65847007c8d..586034fcb15 100644 --- a/frontend/src/app/features/boards/openproject-boards.module.ts +++ b/frontend/src/app/features/boards/openproject-boards.module.ts @@ -30,9 +30,7 @@ import { NgModule } from '@angular/core'; import { OpSharedModule } from 'core-app/shared/shared.module'; import { OpenprojectWorkPackagesModule } from 'core-app/features/work-packages/openproject-work-packages.module'; import { OpenprojectModalModule } from 'core-app/shared/components/modal/modal.module'; -import { UIRouterModule } from '@uirouter/angular'; import { BoardListComponent } from 'core-app/features/boards/board/board-list/board-list.component'; -import { BoardsRootComponent } from 'core-app/features/boards/boards-root/boards-root.component'; import { BoardInlineAddAutocompleterComponent } from 'core-app/features/boards/board/inline-add/board-inline-add-autocompleter.component'; import { BoardsToolbarMenuDirective } from 'core-app/features/boards/board/toolbar-menu/boards-toolbar-menu.directive'; import { BoardConfigurationModalComponent } from 'core-app/features/boards/board/configuration-modal/board-configuration.modal'; @@ -43,9 +41,9 @@ import { BoardFilterComponent } from 'core-app/features/boards/board/board-filte import { BoardListMenuComponent } from 'core-app/features/boards/board/board-list/board-list-menu.component'; import { VersionBoardHeaderComponent } from 'core-app/features/boards/board/board-actions/version/version-board-header.component'; import { DynamicModule } from 'ng-dynamic-component'; -import { BOARDS_ROUTES, uiRouterBoardsConfiguration } from 'core-app/features/boards/openproject-boards.routes'; import { BoardPartitionedPageComponent } from 'core-app/features/boards/board/board-partitioned-page/board-partitioned-page.component'; import { BoardListContainerComponent } from 'core-app/features/boards/board/board-partitioned-page/board-list-container.component'; +import { BoardEntryComponent } from 'core-app/features/boards/board/board-partitioned-page/board-entry.component'; import { BoardsMenuButtonComponent } from 'core-app/features/boards/board/toolbar-menu/boards-menu-button.component'; import { AssigneeBoardHeaderComponent } from 'core-app/features/boards/board/board-actions/assignee/assignee-board-header.component'; import { SubprojectBoardHeaderComponent } from 'core-app/features/boards/board/board-actions/subproject/subproject-board-header.component'; @@ -64,18 +62,12 @@ import { OpenprojectEnterpriseModule } from 'core-app/features/enterprise/openpr // Dynamic Module for actions DynamicModule, - - // Routes for /boards - UIRouterModule.forChild({ - states: BOARDS_ROUTES, - config: uiRouterBoardsConfiguration, - }), ], declarations: [ BoardPartitionedPageComponent, BoardListContainerComponent, + BoardEntryComponent, BoardListComponent, - BoardsRootComponent, BoardInlineAddAutocompleterComponent, BoardHighlightingTabComponent, BoardConfigurationModalComponent, diff --git a/frontend/src/app/features/boards/openproject-boards.routes.ts b/frontend/src/app/features/boards/openproject-boards.routes.ts deleted file mode 100644 index 85f44ea998e..00000000000 --- a/frontend/src/app/features/boards/openproject-boards.routes.ts +++ /dev/null @@ -1,105 +0,0 @@ -//-- copyright -// OpenProject is an open source project management software. -// Copyright (C) the OpenProject GmbH -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License version 3. -// -// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -// Copyright (C) 2006-2013 Jean-Philippe Lang -// Copyright (C) 2010-2013 the ChiliProject Team -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License -// as published by the Free Software Foundation; either version 2 -// of the License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -// -// See COPYRIGHT and LICENSE files for more details. -//++ - -import { Ng2StateDeclaration, UIRouter } from '@uirouter/angular'; -import { BoardsRootComponent } from 'core-app/features/boards/boards-root/boards-root.component'; -import { BoardPartitionedPageComponent } from 'core-app/features/boards/board/board-partitioned-page/board-partitioned-page.component'; -import { BoardListContainerComponent } from 'core-app/features/boards/board/board-partitioned-page/board-list-container.component'; -import { makeSplitViewRoutes } from 'core-app/features/work-packages/routing/split-view-routes.template'; -import { WorkPackageSplitViewComponent } from 'core-app/features/work-packages/routing/wp-split-view/wp-split-view.component'; - -export const menuItemClass = 'boards-menu-item'; - -export const sidemenuId = 'boards_sidemenu'; -export const sideMenuOptions = { - sidemenuId, - hardReloadOnBaseRoute: true, -}; - -export const BOARDS_ROUTES:Ng2StateDeclaration[] = [ - { - name: 'boards', - parent: 'optional_project', - // The trailing slash is important - // cf., https://community.openproject.com/wp/29754 - url: '/boards/?query_props', - data: { - bodyClasses: 'router--boards-view-base', - menuItem: menuItemClass, - sideMenuOptions, - }, - params: { - // Use custom encoder/decoder that ensures validity of URL string - query_props: { type: 'opQueryString', dynamic: true }, - }, - component: BoardsRootComponent, - }, - { - name: 'boards.partitioned', - url: '{board_id}', - params: { - board_id: { type: 'int' }, - isNew: { type: 'bool', inherit: false, dynamic: true }, - }, - data: { - parent: 'boards', - bodyClasses: 'router--boards-full-view', - menuItem: menuItemClass, - sideMenuOptions, - }, - reloadOnSearch: false, - component: BoardPartitionedPageComponent, - redirectTo: 'boards.partitioned.show', - }, - { - name: 'boards.partitioned.show', - url: '', - data: { - baseRoute: 'boards.partitioned.show', - sideMenuOptions, - }, - views: { - 'content-left': { component: BoardListContainerComponent }, - }, - }, - ...makeSplitViewRoutes( - 'boards.partitioned.show', - menuItemClass, - WorkPackageSplitViewComponent, - ), -]; - -export function uiRouterBoardsConfiguration(uiRouter:UIRouter) { - // Ensure boards/ are being redirected correctly - // cf., https://community.openproject.com/wp/29754 - uiRouter.urlService.rules - .when( - new RegExp('^/projects/(.*)/boards$'), - (match) => `/projects/${match[1]}/boards/`, - ); -} diff --git a/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.html b/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.html index f662ff05abb..27c5c0e46f4 100644 --- a/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.html +++ b/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.html @@ -48,7 +48,7 @@ data-notification-selector='notification-scroll-container' > + [ndcDynamicInputs]="{ workPackage: workPackage }" /> } diff --git a/modules/boards/app/controllers/boards/boards_controller.rb b/modules/boards/app/controllers/boards/boards_controller.rb index 808107f50d6..914eed69e8d 100644 --- a/modules/boards/app/controllers/boards/boards_controller.rb +++ b/modules/boards/app/controllers/boards/boards_controller.rb @@ -3,13 +3,15 @@ module ::Boards class BoardsController < BaseController include Layout + include WorkPackages::WithSplitView before_action :load_and_authorize_in_optional_project before_action :find_board_for_deletion, only: %i[destroy] + before_action :find_board, only: %i[show split_view] # The boards permission alone does not suffice # to view work packages - before_action :authorize_work_package_permission, only: %i[show] + before_action :authorize_work_package_permission, only: %i[show split_view] before_action :build_board_grid, only: %i[new] before_action :load_query, only: %i[index] @@ -21,7 +23,19 @@ module ::Boards end def show - render layout: "angular/angular" + render + end + + def split_view + respond_to do |format| + format.html do + if turbo_frame_request? + render "work_packages/split_view", layout: false + else + render :show + end + end + end end def new; end @@ -56,6 +70,10 @@ module ::Boards private + def split_view_base_route + project_work_package_board_path(@project, params[:id], request.query_parameters) + end + def load_query projects = @project || Project.allowed_to(User.current, :show_board_views) @@ -64,6 +82,10 @@ module ::Boards .where(project: projects) end + def find_board + @board_grid = Boards::Grid.find_by!(id: params[:id], project: @project) + end + def find_board_for_deletion @board_grid = Boards::Grid.find_by!(id: params[:id], project: @project) end diff --git a/modules/boards/app/views/boards/boards/show.html.erb b/modules/boards/app/views/boards/boards/show.html.erb index 649a066695a..7b8705cd00d 100644 --- a/modules/boards/app/views/boards/boards/show.html.erb +++ b/modules/boards/app/views/boards/boards/show.html.erb @@ -27,4 +27,13 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<% html_title(t("boards.label_boards")) -%> +<% html_title(@board_grid.name) -%> + +<% content_for :content_body do %> + <%= angular_component_tag "opce-board-view", inputs: { boardId: @board_grid.id.to_s } %> +<% end %> + +<% content_for :content_body_right do %> + <%= turbo_stream.set_title(title: page_title(*html_title_parts)) if turbo_frame_request? %> + <%= render(split_view_instance) if render_work_package_split_view? %> +<% end %> diff --git a/modules/boards/config/routes.rb b/modules/boards/config/routes.rb index a850b469bd3..86e2394c262 100644 --- a/modules/boards/config/routes.rb +++ b/modules/boards/config/routes.rb @@ -12,6 +12,13 @@ Rails.application.routes.draw do collection do get "menu" => "boards/menus#show" end + member do + get "details/:work_package_id(/:tab)", + action: :split_view, + defaults: { tab: :overview }, + as: :details, + work_package_split_view: true + end get "(/*state)" => "boards/boards#show", on: :member, as: "", constraints: { id: /\d+/ } end end diff --git a/modules/boards/lib/open_project/boards/engine.rb b/modules/boards/lib/open_project/boards/engine.rb index 46da7d7360a..9c3ce11acde 100644 --- a/modules/boards/lib/open_project/boards/engine.rb +++ b/modules/boards/lib/open_project/boards/engine.rb @@ -28,7 +28,7 @@ module OpenProject::Boards settings: {} do project_module :board_view, dependencies: :work_package_tracking, order: 80 do permission :show_board_views, - { "boards/boards": %i[index show], + { "boards/boards": %i[index show split_view], "boards/menus": %i[show] }, permissible_on: :project, dependencies: :view_work_packages, From bd14f7600b7fba86dc81665dd6829dbd768204aa Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Tue, 7 Apr 2026 12:22:11 +0200 Subject: [PATCH 2/3] Update selection logic and adapt tests to new routing without uiRouter --- .../src/app/core/main-menu/submenu.service.ts | 6 +++--- .../version/version-action.service.ts | 7 ++----- .../board-partitioned-page.component.ts | 4 ++-- .../wp-single-card.component.ts | 13 +++++++----- .../spec/features/board_navigation_spec.rb | 20 ++++++++++--------- .../pages/work_packages/work_package_card.rb | 8 ++++++-- 6 files changed, 32 insertions(+), 26 deletions(-) diff --git a/frontend/src/app/core/main-menu/submenu.service.ts b/frontend/src/app/core/main-menu/submenu.service.ts index c0374eab1d9..dff74195f52 100644 --- a/frontend/src/app/core/main-menu/submenu.service.ts +++ b/frontend/src/app/core/main-menu/submenu.service.ts @@ -6,9 +6,9 @@ import { StateService } from '@uirouter/core'; export class SubmenuService { constructor(protected $state:StateService) {} - reloadSubmenu(selectedQueryId:string|null):void { + reloadSubmenu(selectedQueryId:string|null, sidemenuId?:string):void { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment - const menuIdentifier:string|undefined = this.$state.current.data.sideMenuOptions?.sidemenuId; + const menuIdentifier:string|undefined = sidemenuId ?? this.$state.current.data?.sideMenuOptions?.sidemenuId; if (menuIdentifier) { const menu = document.getElementById(menuIdentifier) as FrameElement; @@ -18,7 +18,7 @@ export class SubmenuService { if (currentSrc && menu) { const frameUrl = new URL(currentSrc, window.location.origin); - const defaultQuery = sideMenuOptions.defaultQuery; + const defaultQuery = sideMenuOptions?.defaultQuery; if (selectedQueryId) { // If there is a default query passed in the route definition, it means that id passed as argument and not as parameter, diff --git a/frontend/src/app/features/boards/board/board-actions/version/version-action.service.ts b/frontend/src/app/features/boards/board/board-actions/version/version-action.service.ts index 90992070755..c090f3bb58d 100644 --- a/frontend/src/app/features/boards/board/board-actions/version/version-action.service.ts +++ b/frontend/src/app/features/boards/board/board-actions/version/version-action.service.ts @@ -4,7 +4,6 @@ import { QueryResource } from 'core-app/features/hal/resources/query-resource'; import { VersionResource } from 'core-app/features/hal/resources/version-resource'; import { OpContextMenuItem } from 'core-app/shared/components/op-context-menu/op-context-menu.types'; import { isClickedWithModifier } from 'core-app/shared/helpers/link-handling/link-handling'; -import { StateService } from '@uirouter/core'; import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service'; import { VersionBoardHeaderComponent } from 'core-app/features/boards/board/board-actions/version/version-board-header.component'; import { FormResource } from 'core-app/features/hal/resources/form-resource'; @@ -22,8 +21,6 @@ import { map } from 'rxjs/operators'; @Injectable() export class BoardVersionActionService extends CachedBoardActionService { - @InjectField() state:StateService; - @InjectField() halNotification:HalResourceNotificationService; filterName = 'version'; @@ -119,8 +116,8 @@ export class BoardVersionActionService extends CachedBoardActionService { .id(version) .patch({ status: newStatus }) .subscribe( - (version) => { - this.state.go('.', {}, { reload: true }); + () => { + Turbo.visit(window.location.href, { action: 'replace' }); }, (error) => this.halNotification.handleRawError(error), ); diff --git a/frontend/src/app/features/boards/board/board-partitioned-page/board-partitioned-page.component.ts b/frontend/src/app/features/boards/board/board-partitioned-page/board-partitioned-page.component.ts index 7baa0da7bb6..9d33220ca41 100644 --- a/frontend/src/app/features/boards/board/board-partitioned-page/board-partitioned-page.component.ts +++ b/frontend/src/app/features/boards/board/board-partitioned-page/board-partitioned-page.component.ts @@ -214,8 +214,8 @@ export class BoardPartitionedPageComponent extends UntilDestroyedMixin implement }), finalize(() => { this.toolbarDisabled = false; - this.reloadSidemenu(); this.cdRef.detectChanges(); + this.reloadSidemenu(); }), ).subscribe(() => { this.toastService.addSuccess(this.text.updateSuccessful); @@ -234,6 +234,6 @@ export class BoardPartitionedPageComponent extends UntilDestroyedMixin implement } private reloadSidemenu():void { - this.submenuService.reloadSubmenu(null); + this.submenuService.reloadSubmenu(null, 'boards_sidemenu'); } } diff --git a/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts b/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts index 4c0ab445b3d..70ea0c0272f 100644 --- a/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts @@ -35,7 +35,7 @@ import { isClickedWithModifier } from 'core-app/shared/helpers/link-handling/lin import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; import { TimezoneService } from 'core-app/core/datetime/timezone.service'; import { StatusResource } from 'core-app/features/hal/resources/status-resource'; -import { combineLatest } from 'rxjs'; +import { EMPTY, merge } from 'rxjs'; import { map } from 'rxjs/operators'; import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; import SpotDropAlignmentOption from 'core-app/spot/drop-alignment-options'; @@ -131,10 +131,13 @@ export class WorkPackageSingleCardComponent extends UntilDestroyedMixin implemen ngOnInit():void { // Update selection state - combineLatest([ + // Use merge instead of combineLatest: params$ only emits on uiRouter transitions and + // may never emit on pages that don't use uiRouter (e.g. boards). With merge, any + // emission from either source triggers re-evaluation of the selection state. + merge( this.wpTableSelection.live$(), - this.uiRouterGlobals.params$, - ]) + this.uiRouterGlobals.params$ ?? EMPTY, + ) .pipe( this.untilDestroyed(), map(() => { @@ -145,7 +148,7 @@ export class WorkPackageSingleCardComponent extends UntilDestroyedMixin implemen return this.wpTableSelection.isSelected(this.workPackage.id!); }), ) - .subscribe((selected) => { + .subscribe((selected:boolean) => { this.selected = selected; this.cdRef.detectChanges(); }); diff --git a/modules/boards/spec/features/board_navigation_spec.rb b/modules/boards/spec/features/board_navigation_spec.rb index f3096892dce..53525ce29cb 100644 --- a/modules/boards/spec/features/board_navigation_spec.rb +++ b/modules/boards/spec/features/board_navigation_spec.rb @@ -82,10 +82,10 @@ RSpec.describe "Work Package boards spec", # Open the details page with the info icon card = board_page.card_for(wp) - split_view = card.open_details_view + split_view = card.open_details_view(primerized: true) split_view.expect_subject - expect(page).to have_current_path /details\/#{wp.id}\/overview/ + expect(page).to have_current_path /details\/#{wp.id}/ card.expect_selected split_view.close @@ -101,7 +101,7 @@ RSpec.describe "Work Package boards spec", # Click on the card again card.open_details_view - expect(page).to have_current_path /details\/#{wp.id}\/overview/ + expect(page).to have_current_path /details\/#{wp.id}/ end it "navigates correctly the path from overview page to the boards page", @@ -139,7 +139,7 @@ RSpec.describe "Work Package boards spec", expect(wp.subject).to eq "Task 1" # Open the details page with the info icon card = board_page.card_for(wp) - split_view = card.open_details_view + split_view = card.open_details_view(primerized: true) split_view.expect_subject split_view.switch_to_tab tab: :relations expect(page).to have_current_path /details\/#{wp.id}\/relations/ @@ -170,7 +170,7 @@ RSpec.describe "Work Package boards spec", expect(wp.subject).to eq "Task 1" # Open the details page with the info icon card = board_page.card_for(wp) - split_view = card.open_details_view + split_view = card.open_details_view(primerized: true) split_view.expect_subject page.driver.refresh @@ -195,14 +195,16 @@ RSpec.describe "Work Package boards spec", # Open the details page with the info icon card = board_page.card_for(wp) - split_view = card.open_details_view + split_view = card.open_details_view(primerized: true) split_view.expect_subject # Go to full view of WP split_view.switch_to_fullscreen - wait_for_network_idle - find_by_id("action-show-more-dropdown-menu").click - click_link(I18n.t("js.button_delete")) + wait_for_turbo do + split_view.wait_for_activity_tab + find_by_id("action-show-more-dropdown-menu").click + click_link(I18n.t("js.button_delete")) + end # Delete the WP destroy_modal.expect_listed(wp) diff --git a/spec/support/pages/work_packages/work_package_card.rb b/spec/support/pages/work_packages/work_package_card.rb index 0bcb1bd9f28..a21864b496f 100644 --- a/spec/support/pages/work_packages/work_package_card.rb +++ b/spec/support/pages/work_packages/work_package_card.rb @@ -63,11 +63,15 @@ module Pages end end - def open_details_view + def open_details_view(primerized: false) card_element.hover card_element.find('[data-test-selector="op-wp-single-card--details-button"]').click - ::Pages::SplitWorkPackage.new work_package + if primerized + Pages::PrimerizedSplitWorkPackage.new work_package + else + ::Pages::SplitWorkPackage.new work_package + end end end end From eb4d69b9ebb041b35c41d5a50312e5ca8d0b1ff0 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Tue, 7 Apr 2026 13:21:52 +0200 Subject: [PATCH 3/3] Add missing parameters on split screen change --- .../board-partitioned-page/board-list-container.component.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/features/boards/board/board-partitioned-page/board-list-container.component.ts b/frontend/src/app/features/boards/board/board-partitioned-page/board-list-container.component.ts index c7274956a89..fba32f8b51d 100644 --- a/frontend/src/app/features/boards/board/board-partitioned-page/board-list-container.component.ts +++ b/frontend/src/app/features/boards/board/board-partitioned-page/board-list-container.component.ts @@ -134,8 +134,9 @@ export class BoardListContainerComponent extends UntilDestroyedMixin implements filter(() => window.location.pathname.includes('/details/')), ).subscribe((selection) => { // Update split screen - const link = this.pathHelper.boardDetailsPath(this.currentProject.identifier, id, selection.focusedWorkPackage!); - Turbo.visit(link, { frame: 'content-bodyRight', action: 'advance' }); + const base = this.pathHelper.boardDetailsPath(this.currentProject.identifier, id, selection.focusedWorkPackage!); + const search = window.location.search; + Turbo.visit(search ? `${base}${search}` : base, { frame: 'content-bodyRight', action: 'advance' }); }); }