diff --git a/app/components/work_packages/split_create_component.html.erb b/app/components/work_packages/split_create_component.html.erb new file mode 100644 index 000000000000..8e389eff5816 --- /dev/null +++ b/app/components/work_packages/split_create_component.html.erb @@ -0,0 +1,2 @@ +<%= helpers.angular_component_tag "opce-wp-split-create", + inputs: { projectIdentifier: @project_identifier } %> diff --git a/app/components/work_packages/split_create_component.rb b/app/components/work_packages/split_create_component.rb new file mode 100644 index 000000000000..b33309649ed6 --- /dev/null +++ b/app/components/work_packages/split_create_component.rb @@ -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 diff --git a/app/helpers/work_packages/split_view_helper.rb b/app/helpers/work_packages/split_view_helper.rb index 9fc30f347abc..5b12485489a1 100644 --- a/app/helpers/work_packages/split_view_helper.rb +++ b/app/helpers/work_packages/split_view_helper.rb @@ -33,6 +33,14 @@ def render_work_package_split_view? 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], diff --git a/app/views/work_packages/split_create.html.erb b/app/views/work_packages/split_create.html.erb new file mode 100644 index 000000000000..f6140ad4c93b --- /dev/null +++ b/app/views/work_packages/split_create.html.erb @@ -0,0 +1,3 @@ +<%= turbo_frame_tag "content-bodyRight" do %> + <%= render(split_create_instance) %> +<% end %> diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index a002c9df85ef..1bca3a907a7b 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -144,9 +144,15 @@ import { import { WorkPackageSplitViewEntryComponent, } from 'core-app/features/work-packages/routing/wp-split-view/wp-split-view-entry.component'; +import { + WorkPackageSplitCreateEntryComponent, +} from 'core-app/features/work-packages/routing/wp-split-create/wp-split-create-entry.component'; import { BoardEntryComponent, } from 'core-app/features/boards/board/board-partitioned-page/board-entry.component'; +import { CalendarEntryComponent } from 'core-app/features/calendar/calendar-entry.component'; +import { TeamPlannerEntryComponent } from 'core-app/features/team-planner/team-planner/team-planner-entry.component'; +import { TeamPlannerModule } from 'core-app/features/team-planner/team-planner/team-planner.module'; import { StorageLoginButtonComponent, } from 'core-app/shared/components/storages/storage-login-button/storage-login-button.component'; @@ -300,6 +306,8 @@ export function runBootstrap(appRef:ApplicationRef) { OpenprojectWorkPackageGraphsModule, // Calendar module OpenprojectCalendarModule, + // Team Planner module + TeamPlannerModule, // MyPage OpenprojectMyPageModule, @@ -392,7 +400,10 @@ export class OpenProjectModule implements DoBootstrap { registerCustomElement('opce-notification-center', InAppNotificationCenterComponent, { injector }); registerCustomElement('opce-wp-split-view', WorkPackageSplitViewEntryComponent, { injector }); + registerCustomElement('opce-wp-split-create', WorkPackageSplitCreateEntryComponent, { injector }); registerCustomElement('opce-board-view', BoardEntryComponent, { injector }); + registerCustomElement('opce-calendar-view', CalendarEntryComponent, { injector }); + registerCustomElement('opce-team-planner-view', TeamPlannerEntryComponent, { 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/main-menu/submenu.service.ts b/frontend/src/app/core/main-menu/submenu.service.ts index dff74195f527..5ad6678b2607 100644 --- a/frontend/src/app/core/main-menu/submenu.service.ts +++ b/frontend/src/app/core/main-menu/submenu.service.ts @@ -9,31 +9,31 @@ export class SubmenuService { 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 = sidemenuId ?? this.$state.current.data?.sideMenuOptions?.sidemenuId; + if (!menuIdentifier) { return; } - if (menuIdentifier) { - const menu = document.getElementById(menuIdentifier) as FrameElement; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const sideMenuOptions = this.$state.$current.data?.sideMenuOptions as { hardReloadOnBaseRoute?:boolean, defaultQuery?:string }; - const currentSrc = menu.getAttribute('src'); + const menu = document.getElementById(menuIdentifier) as FrameElement|null; + const currentSrc = menu?.getAttribute('src'); + if (!currentSrc || !menu) { return; } - if (currentSrc && menu) { - const frameUrl = new URL(currentSrc, window.location.origin); - const defaultQuery = sideMenuOptions?.defaultQuery; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const sideMenuOptions = this.$state.$current.data?.sideMenuOptions as { hardReloadOnBaseRoute?:boolean, defaultQuery?:string }; + const frameUrl = new URL(currentSrc, window.location.origin); - if (selectedQueryId) { - // If there is a default query passed in the route definition, it means that id passed as argument and not as parameter, - // e.g. calendars/:id, team_planner/:id, ... - // Otherwise, we will just replace the params - if (defaultQuery) { - frameUrl.search = `?id=${selectedQueryId}`; - } else { - frameUrl.search = `?query_id=${selectedQueryId}`; - } - } + if (selectedQueryId) { + // Prefer the data attribute on the frame, then fall back to route sideMenuOptions, + // then default to 'query_id'. Modules with path-based IDs (e.g. calendars/:id) + // set data-query-param="id" on the frame. + const queryParam = menu.getAttribute('data-query-param') + ?? (sideMenuOptions?.defaultQuery ? 'id' : 'query_id'); - // Override the frame src to enforce a reload - menu.setAttribute('src', frameUrl.href); - } + frameUrl.search = `?${queryParam}=${selectedQueryId}`; + } + + const newSrc = frameUrl.href; + if (menu.getAttribute('src') !== newSrc) { + menu.setAttribute('src', newSrc); + } else { + void menu.reload(); } } } diff --git a/frontend/src/app/core/routing/openproject.routes.ts b/frontend/src/app/core/routing/openproject.routes.ts index fb01324276bf..e36af84a3806 100644 --- a/frontend/src/app/core/routing/openproject.routes.ts +++ b/frontend/src/app/core/routing/openproject.routes.ts @@ -39,8 +39,6 @@ import { mobileGuardActivated, redirectToMobileAlternative, } from 'core-app/shared/helpers/routing/mobile-guard.helper'; -import { TEAM_PLANNER_LAZY_ROUTES } from 'core-app/features/team-planner/team-planner/team-planner.lazy-routes'; -import { CALENDAR_LAZY_ROUTES } from 'core-app/features/calendar/calendar.lazy-routes'; export const OPENPROJECT_ROUTES:Ng2StateDeclaration[] = [ { @@ -74,8 +72,6 @@ export const OPENPROJECT_ROUTES:Ng2StateDeclaration[] = [ url: '/bcf', loadChildren: () => import('../../features/bim/ifc_models/openproject-ifc-models.module').then((m) => m.OpenprojectIFCModelsModule), }, - ...TEAM_PLANNER_LAZY_ROUTES, - ...CALENDAR_LAZY_ROUTES, ]; /** diff --git a/frontend/src/app/features/boards/board/board-partitioned-page/board-entry.component.ts b/frontend/src/app/features/boards/board/board-partitioned-page/board-entry.component.ts index 0503484d44b5..1fe536a15ebd 100644 --- a/frontend/src/app/features/boards/board/board-partitioned-page/board-entry.component.ts +++ b/frontend/src/app/features/boards/board/board-partitioned-page/board-entry.component.ts @@ -26,7 +26,14 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { ChangeDetectionStrategy, Component, ElementRef, Injector, Input } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + Injector, + Input, + OnDestroy, +} from '@angular/core'; import { populateInputsFromDataset } from 'core-app/shared/components/dataset-inputs'; import { WorkPackageIsolatedQuerySpaceDirective, @@ -43,7 +50,7 @@ import { QueryUpdatedService } from 'core-app/features/boards/board/query-update @Component({ selector: 'board-entry', hostDirectives: [WorkPackageIsolatedQuerySpaceDirective], - template: ``, + template: '', changeDetection: ChangeDetectionStrategy.OnPush, providers: [ BoardConfigurationService, @@ -56,7 +63,7 @@ import { QueryUpdatedService } from 'core-app/features/boards/board/query-update ], standalone: false, }) -export class BoardEntryComponent { +export class BoardEntryComponent implements OnDestroy { @Input() boardId:string; constructor( @@ -74,4 +81,8 @@ export class BoardEntryComponent { registry.add('subproject', injector.get(BoardSubprojectActionService)); registry.add('subtasks', injector.get(BoardSubtasksActionService)); } + + ngOnDestroy() { + document.body.classList.remove('router--boards-full-view'); + } } 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 9d33220ca413..464c05c4a7a7 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 @@ -47,7 +47,7 @@ export function boardCardViewHandlerFactory(injector:Injector) { @Component({ selector: 'board-partitioned-page', - templateUrl: './board-partitioned-page.component.html', + templateUrl: '../../../work-packages/routing/partitioned-query-space-page/primerized-partitioned-query-space-page.component.html', styleUrls: [ '../../../work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.sass', './board-partitioned-page.component.sass', diff --git a/frontend/src/app/features/calendar/calendar.lazy-routes.ts b/frontend/src/app/features/calendar/calendar-entry.component.ts similarity index 57% rename from frontend/src/app/features/calendar/calendar.lazy-routes.ts rename to frontend/src/app/features/calendar/calendar-entry.component.ts index 70e8ab18acc0..645a7efd35d7 100644 --- a/frontend/src/app/features/calendar/calendar.lazy-routes.ts +++ b/frontend/src/app/features/calendar/calendar-entry.component.ts @@ -26,13 +26,33 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { Ng2StateDeclaration } from '@uirouter/angular'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + Input, + OnDestroy, +} 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'; -export const CALENDAR_LAZY_ROUTES:Ng2StateDeclaration[] = [ - { - name: 'calendar.**', - parent: 'optional_project', - url: '/calendars', - loadChildren: () => import('./openproject-calendar.module').then((m) => m.OpenprojectCalendarModule), - }, -]; +@Component({ + hostDirectives: [WorkPackageIsolatedQuerySpaceDirective], + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, +}) +export class CalendarEntryComponent implements OnDestroy { + @Input() queryId:string; + + constructor(readonly elementRef:ElementRef) { + populateInputsFromDataset(this); + document.body.classList.add('router--calendar'); + } + + ngOnDestroy() { + document.body.classList.add('router--calendar'); + } +} diff --git a/frontend/src/app/features/calendar/calendar.routes.ts b/frontend/src/app/features/calendar/calendar.routes.ts deleted file mode 100644 index 4a2e1c34d1cf..000000000000 --- a/frontend/src/app/features/calendar/calendar.routes.ts +++ /dev/null @@ -1,84 +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 } from '@uirouter/angular'; -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'; -import { WorkPackagesBaseComponent } from 'core-app/features/work-packages/routing/wp-base/wp--base.component'; -import { WorkPackagesCalendarComponent } from 'core-app/features/calendar/wp-calendar/wp-calendar.component'; -import { WorkPackagesCalendarPageComponent } from 'core-app/features/calendar/wp-calendar-page/wp-calendar-page.component'; - -export const sidemenuId = 'calendar_sidemenu'; -export const sideMenuOptions = { - sidemenuId, - hardReloadOnBaseRoute: true, - defaultQuery: 'new', -}; - -export const CALENDAR_ROUTES:Ng2StateDeclaration[] = [ - { - name: 'calendar', - parent: 'optional_project', - url: '/calendars/:query_id?&query_props&cdate&cview', - redirectTo: 'calendar.page', - views: { - '!$default': { component: WorkPackagesBaseComponent }, - }, - params: { - query_id: { type: 'opQueryId', dynamic: true }, - cdate: { type: 'string', dynamic: true }, - cview: { type: 'string', dynamic: true }, - // Use custom encoder/decoder that ensures validity of URL string - query_props: { type: 'opQueryString' }, - }, - }, - { - name: 'calendar.page', - component: WorkPackagesCalendarPageComponent, - redirectTo: 'calendar.page.show', - data: { - bodyClasses: 'router--calendar', - sideMenuOptions, - }, - }, - { - name: 'calendar.page.show', - data: { - baseRoute: 'calendar.page.show', - sideMenuOptions, - }, - views: { - 'content-left': { component: WorkPackagesCalendarComponent }, - }, - }, - ...makeSplitViewRoutes( - 'calendar.page.show', - undefined, - WorkPackageSplitViewComponent, - ), -]; diff --git a/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts b/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts index 86dbd0c0d09d..51dc9246bf8a 100644 --- a/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts +++ b/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts @@ -15,9 +15,8 @@ import { ConfigurationService } from 'core-app/core/config/configuration.service import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { DomSanitizer } from '@angular/platform-browser'; import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; -import { splitViewRoute } from 'core-app/features/work-packages/routing/split-view-routes.helper'; -import { StateService } from '@uirouter/angular'; import { WorkPackageCollectionResource } from 'core-app/features/hal/resources/wp-collection-resource'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; import { ToastService } from 'core-app/shared/components/toaster/toast.service'; import { firstValueFrom, Observable } from 'rxjs'; import { @@ -34,7 +33,6 @@ import { UrlParamsHelperService, } from 'core-app/features/work-packages/components/wp-query/url-params-helper'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; -import { UIRouterGlobals } from '@uirouter/core'; import { TimezoneService } from 'core-app/core/datetime/timezone.service'; import { WorkPackagesListChecksumService, @@ -93,7 +91,6 @@ export class OpWorkPackagesCalendarService extends UntilDestroyedMixin { private I18n:I18nService, private configuration:ConfigurationService, private sanitizer:DomSanitizer, - private $state:StateService, readonly injector:Injector, readonly schemaCache:SchemaCacheService, readonly toastService:ToastService, @@ -104,8 +101,8 @@ export class OpWorkPackagesCalendarService extends UntilDestroyedMixin { readonly querySpace:IsolatedQuerySpace, readonly apiV3Service:ApiV3Service, readonly halResourceService:HalResourceService, - readonly uiRouterGlobals:UIRouterGlobals, readonly timezoneService:TimezoneService, + readonly pathHelper:PathHelperService, readonly halEditing:HalResourceEditingService, readonly wpTableSelection:WorkPackageViewSelectionService, readonly contextMenuService:OPContextMenuService, @@ -283,23 +280,29 @@ export class OpWorkPackagesCalendarService extends UntilDestroyedMixin { this.wpTableSelection.setSelection(id, -1); // Only open the split view if already open, otherwise only clicking the details opens - if (onlyWhenOpen && !this.$state.includes('**.details.*')) { + if (onlyWhenOpen && !window.location.pathname.includes('/details/')) { return; } - void this.$state.go( - `${splitViewRoute(this.$state)}.tabs`, - { workPackageId: id, tabIdentifier: 'overview' }, - ); + this.visitSplitViewLink(id); + } + + public openSplitCreate(extraParams?:Record):void { + this.visitSplitViewLink('new', extraParams); + } + + private visitSplitViewLink(id:string, extraParams?:Record):void { + const basePath = window.location.pathname.replace(/\/details\/.*$/, ''); + const params = new URLSearchParams(window.location.search); + if (extraParams) { + Object.entries(extraParams).forEach(([key, value]) => params.set(key, value)); + } + Turbo.visit(`${basePath}/details/${id}?${params.toString()}`, { frame: 'content-bodyRight', action: 'advance' }); } public openFullView(id:string):void { this.wpTableSelection.setSelection(id, -1); - - void this.$state.go( - 'work-packages.show', - { workPackageId: id }, - ); + Turbo.visit(this.pathHelper.workPackagePath(id)); } public onCardClicked({ workPackageId, event }:{ workPackageId:string, event:MouseEvent }):void { @@ -399,8 +402,22 @@ export class OpWorkPackagesCalendarService extends UntilDestroyedMixin { && !this.urlParams.query_props; } - public get urlParams() { - return this.uiRouterGlobals.params; + public get urlParams():{ + query_id?:string; + query_props?:string; + cdate?:string; + cview?:string; + } { + const search = new URLSearchParams(window.location.search); + // Extract query_id from path-based routing (e.g. /calendars/, /team_planners/). + const match = /\/(?:calendars|team_planners)\/([^/]+)/.exec(window.location.pathname); + const rawId = match?.[1]; + return { + query_id: rawId === 'new' ? undefined : rawId, + query_props: search.get('query_props') ?? undefined, + cdate: search.get('cdate') ?? undefined, + cview: search.get('cview') ?? undefined, + }; } private get areFiltersEmpty():boolean { @@ -417,17 +434,27 @@ export class OpWorkPackagesCalendarService extends UntilDestroyedMixin { } private updateDateParam(dates:DatesSetArg) { - void this.$state.go( - '.', - { - cdate: this.timezoneService.formattedISODate(dates.view.calendar.getDate()), - // v6.beta3 fails to have type on the ViewAPI - cview: (dates.view as unknown as { type:string }).type, - }, - { - custom: { notify: false }, - }, - ); + const url = new URL(window.location.href); + + // Don't push a history entry when a split view is open: the date params are already + // encoded in the details URL, and pushing here would add a spurious details-URL entry + // that browser-back would restore (with the split view still visible). + if (url.pathname.includes('/details/')) { + return; + } + + const newDate = this.timezoneService.formattedISODate(dates.view.calendar.getDate()); + const newView = (dates.view as unknown as { type:string }).type; + + if (url.searchParams.get('cdate') === newDate && url.searchParams.get('cview') === newView) { + return; + } + + url.searchParams.set('cdate', newDate); + url.searchParams.set('cview', newView); + // Use a Turbo-compatible state so that browser history.back() triggers Turbo's + // restoration visit (full page reload), which correctly resets any open split view frame. + window.history.pushState({ turbo: { restorationIdentifier: crypto.randomUUID() } }, '', url); } updateDates(resizeInfo:EventResizeDoneArg|EventDropArg|EventReceiveArg, dragged?:boolean):ResourceChangeset { diff --git a/frontend/src/app/features/calendar/openproject-calendar.module.ts b/frontend/src/app/features/calendar/openproject-calendar.module.ts index ead83646fc54..fde87c840c55 100644 --- a/frontend/src/app/features/calendar/openproject-calendar.module.ts +++ b/frontend/src/app/features/calendar/openproject-calendar.module.ts @@ -32,12 +32,11 @@ import { ReactiveFormsModule } from '@angular/forms'; import { FullCalendarModule } from '@fullcalendar/angular'; import { WorkPackagesCalendarComponent } from 'core-app/features/calendar/wp-calendar/wp-calendar.component'; import { OpenprojectWorkPackagesModule } from 'core-app/features/work-packages/openproject-work-packages.module'; -import { UIRouterModule } from '@uirouter/angular'; import { TimeEntryCalendarComponent } from 'core-app/features/calendar/te-calendar/te-calendar.component'; import { OpenprojectFieldsModule } from 'core-app/shared/components/fields/openproject-fields.module'; import { OpenprojectTimeEntriesModule } from 'core-app/shared/components/time_entries/openproject-time-entries.module'; import { WorkPackagesCalendarPageComponent } from 'core-app/features/calendar/wp-calendar-page/wp-calendar-page.component'; -import { CALENDAR_ROUTES } from 'core-app/features/calendar/calendar.routes'; +import { CalendarEntryComponent } from 'core-app/features/calendar/calendar-entry.component'; import { QueryGetIcalUrlModalComponent } from 'core-app/shared/components/modals/get-ical-url-modal/query-get-ical-url.modal'; @NgModule({ @@ -45,9 +44,6 @@ import { QueryGetIcalUrlModalComponent } from 'core-app/shared/components/modals // Commons OpSharedModule, - // Routes for /calendar - UIRouterModule.forChild({ states: CALENDAR_ROUTES }), - // Work Package module OpenprojectWorkPackagesModule, @@ -65,6 +61,7 @@ import { QueryGetIcalUrlModalComponent } from 'core-app/shared/components/modals ], declarations: [ // Work package calendars + CalendarEntryComponent, WorkPackagesCalendarPageComponent, WorkPackagesCalendarComponent, TimeEntryCalendarComponent, diff --git a/frontend/src/app/features/calendar/wp-calendar-page/wp-calendar-page.component.ts b/frontend/src/app/features/calendar/wp-calendar-page/wp-calendar-page.component.ts index 06bc7e78440e..f307e19bb0bd 100644 --- a/frontend/src/app/features/calendar/wp-calendar-page/wp-calendar-page.component.ts +++ b/frontend/src/app/features/calendar/wp-calendar-page/wp-calendar-page.component.ts @@ -29,6 +29,8 @@ import { ChangeDetectionStrategy, Component, + Input, + OnInit, ViewChild, } from '@angular/core'; import { WorkPackagesCalendarComponent } from 'core-app/features/calendar/wp-calendar/wp-calendar.component'; @@ -50,7 +52,8 @@ import { ActionsService } from 'core-app/core/state/actions/actions.service'; import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; @Component({ - templateUrl: '../../work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.html', + selector: 'op-wp-calendar-page', + templateUrl: '../../work-packages/routing/partitioned-query-space-page/primerized-partitioned-query-space-page.component.html', styleUrls: [ '../../work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.sass', ], @@ -60,7 +63,9 @@ import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decora ], standalone: false, }) -export class WorkPackagesCalendarPageComponent extends PartitionedQuerySpacePageComponent { +export class WorkPackagesCalendarPageComponent extends PartitionedQuerySpacePageComponent implements OnInit { + @Input() queryId:string; + @InjectField(ActionsService) actions$:ActionsService; @ViewChild(WorkPackagesCalendarComponent, { static: true }) calendarElement:WorkPackagesCalendarComponent; @@ -121,6 +126,20 @@ export class WorkPackagesCalendarPageComponent extends PartitionedQuerySpacePage }, ]; + override ngOnInit():void { + super.ngOnInit(); + // Fix showToolbarSaveButton from actual URL params (not uiRouter state) + this.showToolbarSaveButton = !!new URLSearchParams(window.location.search).get('query_props'); + + // Update save button reactively when query_props changes via pushState (non-uiRouter pages) + this.wpListChecksumService.visibleChecksum$ + .pipe(this.untilDestroyed()) + .subscribe((checksum) => { + this.showToolbarSaveButton = !!checksum; + this.cdRef.detectChanges(); + }); + } + /** * 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. diff --git a/frontend/src/app/features/calendar/wp-calendar/wp-calendar.component.ts b/frontend/src/app/features/calendar/wp-calendar/wp-calendar.component.ts index e0f6f2f5f2cf..8cd1568c57e5 100644 --- a/frontend/src/app/features/calendar/wp-calendar/wp-calendar.component.ts +++ b/frontend/src/app/features/calendar/wp-calendar/wp-calendar.component.ts @@ -57,7 +57,6 @@ import { WorkPackageViewFiltersService, } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-filters.service'; import { WorkPackagesListService } from 'core-app/features/work-packages/components/wp-list/wp-list.service'; -import { StateService } from '@uirouter/core'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { ToastService } from 'core-app/shared/components/toaster/toast.service'; import { DomSanitizer } from '@angular/platform-browser'; @@ -70,7 +69,7 @@ import { HalResourceEditingService, } from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service'; import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service'; -import { splitViewRoute } from 'core-app/features/work-packages/routing/split-view-routes.helper'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; import { CalendarViewEvent, OpWorkPackagesCalendarService, @@ -88,7 +87,6 @@ import { import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder'; import allLocales from '@fullcalendar/core/locales-all'; -import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; import { MeetingResource } from 'core-app/features/hal/resources/meeting-resource'; import { TimezoneService } from 'core-app/core/datetime/timezone.service'; @@ -128,7 +126,6 @@ export class WorkPackagesCalendarComponent extends UntilDestroyedMixin implement constructor( readonly actions$:ActionsService, readonly states:States, - readonly $state:StateService, readonly wpTableFilters:WorkPackageViewFiltersService, readonly wpListService:WorkPackagesListService, readonly querySpace:IsolatedQuerySpace, @@ -345,13 +342,10 @@ export class WorkPackagesCalendarComponent extends UntilDestroyedMixin implement const workPackageId = (evt.event.extendedProps.workPackage as WorkPackageResource).id!; // Currently the calendar widget is shown on multiple pages, // but only the calendar module itself is a partitioned query space which can deal with a split screen request - if (this.$state.includes('calendar')) { + if (window.location.pathname.includes('/calendars/')) { this.workPackagesCalendar.openSplitView(workPackageId); } else { - void this.$state.go( - 'work-packages.show', - { workPackageId }, - ); + window.location.href = this.pathHelper.workPackagePath(workPackageId); } } }, @@ -414,7 +408,7 @@ export class WorkPackagesCalendarComponent extends UntilDestroyedMixin implement durationEditable: this.workPackagesCalendar.eventDurationEditable(workPackage), end: exclusiveEnd, allDay: true, - className: `fc-event-clickable __hl_background_type_${workPackage.type.id || ''}`, + className: `fc-event-clickable __hl_background_type_${workPackage.type.id ?? ''}`, workPackage, }; }); @@ -444,13 +438,14 @@ export class WorkPackagesCalendarComponent extends UntilDestroyedMixin implement ignoreNonWorkingDays: nonWorkingDays, }; - void this.$state.go( - splitViewRoute(this.$state, 'new'), - { - defaults, - tabIdentifier: 'overview', - }, - ); + if (window.location.pathname.includes('/calendars/')) { + const extraParams:Record = { + startDate: defaults.startDate, + dueDate: defaults.dueDate, + ...(defaults.ignoreNonWorkingDays ? { ignoreNonWorkingDays: 'true' } : {}), + }; + this.workPackagesCalendar.openSplitCreate(extraParams); + } } @EffectCallback(calendarRefreshRequest) diff --git a/frontend/src/app/features/team-planner/team-planner/add-work-packages/add-existing-pane.component.ts b/frontend/src/app/features/team-planner/team-planner/add-work-packages/add-existing-pane.component.ts index 1636b2e96c37..6ef92b88a1bb 100644 --- a/frontend/src/app/features/team-planner/team-planner/add-work-packages/add-existing-pane.component.ts +++ b/frontend/src/app/features/team-planner/team-planner/add-work-packages/add-existing-pane.component.ts @@ -32,12 +32,9 @@ import { UrlParamsHelperService } from 'core-app/features/work-packages/componen import { IsolatedQuerySpace } from 'core-app/features/work-packages/directives/query-space/isolated-query-space'; import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; import { CalendarDragDropService } from 'core-app/features/team-planner/team-planner/calendar-drag-drop.service'; -import { splitViewRoute } from 'core-app/features/work-packages/routing/split-view-routes.helper'; -import { StateService } from '@uirouter/core'; import { ActionsService } from 'core-app/core/state/actions/actions.service'; import { teamPlannerEventRemoved } from 'core-app/features/team-planner/team-planner/planner/team-planner.actions'; import { WorkPackageViewFiltersService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-filters.service'; -import { OpCalendarService } from 'core-app/features/calendar/op-calendar.service'; import { OpWorkPackagesCalendarService } from 'core-app/features/calendar/op-work-packages-calendar.service'; @Component({ @@ -121,7 +118,6 @@ export class AddExistingPaneComponent extends UntilDestroyedMixin implements OnI private readonly urlParamsHelper:UrlParamsHelperService, private readonly workPackagesCalendar:OpWorkPackagesCalendarService, private readonly calendarDrag:CalendarDragDropService, - private readonly $state:StateService, private readonly actions$:ActionsService, private readonly wpFilters:WorkPackageViewFiltersService, ) { @@ -214,10 +210,7 @@ export class AddExistingPaneComponent extends UntilDestroyedMixin implements OnI } openStateLink(event:{ workPackageId:string; requestedState:string }):void { - void this.$state.go( - `${splitViewRoute(this.$state)}.tabs`, - { workPackageId: event.workPackageId, tabIdentifier: 'overview' }, - ); + this.workPackagesCalendar.openSplitView(event.workPackageId); } private addExistingFilters(filters:ApiV3FilterBuilder) { diff --git a/frontend/src/app/features/team-planner/team-planner/page/team-planner-page.component.ts b/frontend/src/app/features/team-planner/team-planner/page/team-planner-page.component.ts index d77dd42af8af..9cb3167c4972 100644 --- a/frontend/src/app/features/team-planner/team-planner/page/team-planner-page.component.ts +++ b/frontend/src/app/features/team-planner/team-planner/page/team-planner-page.component.ts @@ -31,7 +31,8 @@ import { OpWorkPackagesCalendarService } from 'core-app/features/calendar/op-wor import { OpCalendarService } from 'core-app/features/calendar/op-calendar.service'; @Component({ - templateUrl: '../../../work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.html', + selector: 'op-team-planner-page', + templateUrl: '../../../work-packages/routing/partitioned-query-space-page/primerized-partitioned-query-space-page.component.html', styleUrls: [ '../../../work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.sass', ], @@ -98,6 +99,17 @@ export class TeamPlannerPageComponent extends PartitionedQuerySpacePageComponent public ngOnInit():void { super.ngOnInit(); + // Fix showToolbarSaveButton from actual URL params (not uiRouter state) + this.showToolbarSaveButton = !!new URLSearchParams(window.location.search).get('query_props'); + + // Update save button reactively when query_props changes via pushState + this.wpListChecksumService.visibleChecksum$ + .pipe(this.untilDestroyed()) + .subscribe((checksum) => { + this.showToolbarSaveButton = !!checksum; + this.cdRef.detectChanges(); + }); + registerEffectCallbacks(this, this.untilDestroyed()); this.wpTableFilters.hidden.push( diff --git a/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.html b/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.html index 3ebc827c247a..e43d51c67258 100644 --- a/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.html +++ b/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.html @@ -98,7 +98,7 @@ [showStartDate]="!isWpStartDateInCurrentView(wp)" [showEndDate]="!isWpEndDateInCurrentView(wp)" (stateLinkClicked)="openStateLink($event)" - (cardClicked)="workPackagesCalendar.onCardClicked($event)" + (cardClicked)="onCardClicked($event)" (cardDblClicked)="workPackagesCalendar.onCardDblClicked($event)" (cardContextMenu)="workPackagesCalendar.showEventContextMenu($event)" /> diff --git a/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts b/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts index 14a923737539..569a70aac640 100644 --- a/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts +++ b/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts @@ -63,7 +63,6 @@ import { take, withLatestFrom, } from 'rxjs/operators'; -import { StateService } from '@uirouter/angular'; import resourceTimelinePlugin from '@fullcalendar/resource-timeline'; import interactionPlugin, { EventDragStartArg, @@ -77,7 +76,6 @@ import { ConfigurationService } from 'core-app/core/config/configuration.service import { WorkPackageViewFiltersService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-filters.service'; import { IsolatedQuerySpace } from 'core-app/features/work-packages/directives/query-space/isolated-query-space'; import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; -import { splitViewRoute } from 'core-app/features/work-packages/routing/split-view-routes.helper'; import { QueryFilterInstanceResource } from 'core-app/features/hal/resources/query-filter-instance-resource'; import { PrincipalsResourceService } from 'core-app/core/state/principals/principals.service'; import { @@ -96,7 +94,6 @@ import { MAGIC_PAGE_NUMBER } from 'core-app/core/apiv3/helpers/get-paginated-res import { CalendarDragDropService } from 'core-app/features/team-planner/team-planner/calendar-drag-drop.service'; import { StatusResource } from 'core-app/features/hal/resources/status-resource'; import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset'; -import { KeepTabService } from 'core-app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service'; import { HalError } from 'core-app/features/hal/services/hal-error'; import { ActionsService } from 'core-app/core/state/actions/actions.service'; import { @@ -407,7 +404,6 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, }; constructor( - private $state:StateService, private configuration:ConfigurationService, private principalsResourceService:PrincipalsResourceService, private capabilitiesResourceService:CapabilitiesResourceService, @@ -423,7 +419,6 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, readonly schemaCache:SchemaCacheService, readonly apiV3Service:ApiV3Service, readonly calendarDrag:CalendarDragDropService, - readonly keepTab:KeepTabService, readonly actions$:ActionsService, readonly toastService:ToastService, readonly loadingIndicatorService:LoadingIndicatorService, @@ -460,6 +455,7 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, .pipe( this.untilDestroyed(), debounceTime(0), + filter(() => !!this.ucCalendar), ) .subscribe(([principals, showAddAssignee]) => { const api = this.ucCalendar.getApi(); @@ -827,36 +823,31 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, ['$event.detail.start', '$event.detail.end', '$event.detail.assignee'], ) openNewSplitCreate(start:string, end:string, resourceHref:string, nonWorkingDays?:boolean):void { - const defaults = { - startDate: start, - dueDate: end, - _links: { - assignee: { - href: resourceHref, - }, - }, - ignoreNonWorkingDays: nonWorkingDays, - }; - - void this.$state.go( - splitViewRoute(this.$state, 'new'), - { - defaults, - tabIdentifier: 'overview', - }, - ); + const basePath = window.location.pathname.replace(/\/details\/.*$/, ''); + const search = new URLSearchParams(window.location.search); + search.set('startDate', start); + search.set('dueDate', end); + if (resourceHref) { + search.set('assignee_href', resourceHref); + } + if (nonWorkingDays) { + search.set('ignoreNonWorkingDays', 'true'); + } + Turbo.visit(`${basePath}/details/new?${search.toString()}`, { frame: 'content-bodyRight', action: 'advance' }); } openStateLink(event:{ workPackageId:string; requestedState:string }):void { - const params = { workPackageId: event.workPackageId }; - if (event.requestedState === 'split') { - this.keepTab.goCurrentDetailsState(params); + this.workPackagesCalendar.openSplitView(event.workPackageId); } else { - this.keepTab.goCurrentShowState(params.workPackageId); + this.workPackagesCalendar.openFullView(event.workPackageId); } } + onCardClicked({ workPackageId, event }:{ workPackageId:string, event:MouseEvent }):void { + this.workPackagesCalendar.onCardClicked({ workPackageId, event }); + } + shouldShowAsGhost(id:string, globalDraggingId:string|undefined):boolean { if (globalDraggingId === undefined) { return false; diff --git a/frontend/src/app/features/team-planner/team-planner/team-planner-entry.component.ts b/frontend/src/app/features/team-planner/team-planner/team-planner-entry.component.ts new file mode 100644 index 000000000000..639ef01f5b00 --- /dev/null +++ b/frontend/src/app/features/team-planner/team-planner/team-planner-entry.component.ts @@ -0,0 +1,27 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + OnDestroy, +} 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'; + +@Component({ + hostDirectives: [WorkPackageIsolatedQuerySpaceDirective], + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, +}) +export class TeamPlannerEntryComponent implements OnDestroy { + constructor(readonly elementRef:ElementRef) { + populateInputsFromDataset(this); + document.body.classList.add('router--team-planner'); + } + + ngOnDestroy():void { + document.body.classList.remove('router--team-planner'); + } +} diff --git a/frontend/src/app/features/team-planner/team-planner/team-planner.lazy-routes.ts b/frontend/src/app/features/team-planner/team-planner/team-planner.lazy-routes.ts deleted file mode 100644 index d9449ae243a8..000000000000 --- a/frontend/src/app/features/team-planner/team-planner/team-planner.lazy-routes.ts +++ /dev/null @@ -1,38 +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 } from '@uirouter/angular'; - -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), - }, -]; diff --git a/frontend/src/app/features/team-planner/team-planner/team-planner.module.ts b/frontend/src/app/features/team-planner/team-planner/team-planner.module.ts index 3d3d2c1e744f..769e12592208 100644 --- a/frontend/src/app/features/team-planner/team-planner/team-planner.module.ts +++ b/frontend/src/app/features/team-planner/team-planner/team-planner.module.ts @@ -1,16 +1,15 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { UIRouterModule } from '@uirouter/angular'; import { DynamicModule } from 'ng-dynamic-component'; import { FullCalendarModule } from '@fullcalendar/angular'; import { IconModule } from 'core-app/shared/components/icon/icon.module'; import { OpenprojectAutocompleterModule } from 'core-app/shared/components/autocompleter/openproject-autocompleter.module'; import { OpenprojectPrincipalRenderingModule } from 'core-app/shared/components/principal/principal-rendering.module'; import { OpenprojectWorkPackagesModule } from 'core-app/features/work-packages/openproject-work-packages.module'; -import { TEAM_PLANNER_ROUTES } from 'core-app/features/team-planner/team-planner/team-planner.routes'; import { TeamPlannerComponent } from 'core-app/features/team-planner/team-planner/planner/team-planner.component'; import { AddAssigneeComponent } from 'core-app/features/team-planner/team-planner/assignee/add-assignee.component'; import { TeamPlannerPageComponent } from 'core-app/features/team-planner/team-planner/page/team-planner-page.component'; +import { TeamPlannerEntryComponent } from 'core-app/features/team-planner/team-planner/team-planner-entry.component'; import { OpSharedModule } from 'core-app/shared/shared.module'; import { AddExistingPaneComponent } from './add-work-packages/add-existing-pane.component'; import { OpenprojectContentLoaderModule } from 'core-app/shared/components/op-content-loader/openproject-content-loader.module'; @@ -20,16 +19,13 @@ import { TeamPlannerViewSelectMenuDirective } from 'core-app/features/team-plann declarations: [ TeamPlannerComponent, TeamPlannerPageComponent, + TeamPlannerEntryComponent, AddAssigneeComponent, AddExistingPaneComponent, TeamPlannerViewSelectMenuDirective, ], imports: [ OpSharedModule, - // Routes for /team_planner - UIRouterModule.forChild({ - states: TEAM_PLANNER_ROUTES, - }), DynamicModule, CommonModule, IconModule, diff --git a/frontend/src/app/features/team-planner/team-planner/team-planner.routes.ts b/frontend/src/app/features/team-planner/team-planner/team-planner.routes.ts deleted file mode 100644 index 405fe948a813..000000000000 --- a/frontend/src/app/features/team-planner/team-planner/team-planner.routes.ts +++ /dev/null @@ -1,84 +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 } from '@uirouter/angular'; -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'; -import { WorkPackagesBaseComponent } from 'core-app/features/work-packages/routing/wp-base/wp--base.component'; -import { TeamPlannerPageComponent } from 'core-app/features/team-planner/team-planner/page/team-planner-page.component'; -import { TeamPlannerComponent } from 'core-app/features/team-planner/team-planner/planner/team-planner.component'; - -export const sidemenuId = 'team_planner_sidemenu'; -export const sideMenuOptions = { - sidemenuId, - hardReloadOnBaseRoute: true, - defaultQuery: 'new', -}; - -export const TEAM_PLANNER_ROUTES:Ng2StateDeclaration[] = [ - { - name: 'team_planner', - parent: 'optional_project', - url: '/team_planners/:query_id?query_props&cdate&cview', - redirectTo: 'team_planner.page', - views: { - '!$default': { component: WorkPackagesBaseComponent }, - }, - params: { - query_id: { type: 'opQueryId', dynamic: true }, - cdate: { type: 'string', dynamic: true }, - cview: { type: 'string', dynamic: true }, - // Use custom encoder/decoder that ensures validity of URL string - query_props: { type: 'opQueryString' }, - }, - }, - { - name: 'team_planner.page', - component: TeamPlannerPageComponent, - redirectTo: 'team_planner.page.show', - data: { - bodyClasses: 'router--team-planner', - sideMenuOptions, - }, - }, - { - name: 'team_planner.page.show', - data: { - baseRoute: 'team_planner.page.show', - sideMenuOptions, - }, - views: { - 'content-left': { component: TeamPlannerComponent }, - }, - }, - ...makeSplitViewRoutes( - 'team_planner.page.show', - undefined, - WorkPackageSplitViewComponent, - ), -]; 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 70ea0c0272fd..a266bc68d60d 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,8 +35,8 @@ 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 { EMPTY, merge } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { EMPTY, fromEvent, merge } from 'rxjs'; +import { distinctUntilChanged, map } from 'rxjs/operators'; import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; import SpotDropAlignmentOption from 'core-app/spot/drop-alignment-options'; import { BaselineMode, getBaselineState } from 'core-app/features/work-packages/components/wp-baseline/baseline-helpers'; @@ -134,19 +134,33 @@ export class WorkPackageSingleCardComponent extends UntilDestroyedMixin implemen // 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. + // turbo:frame-load is included so that URL-based detection updates when the split + // view opens or closes via Turbo frame navigation. merge( this.wpTableSelection.live$(), this.uiRouterGlobals.params$ ?? EMPTY, + fromEvent(document, 'turbo:frame-load'), ) .pipe( this.untilDestroyed(), map(() => { if (this.selectedWhenOpen) { - return this.uiRouterGlobals.params.workPackageId === this.workPackage.id; + // In uiRouter views, use the route param directly. + const wpIdFromRoute = this.uiRouterGlobals.params.workPackageId as string|undefined; + if (wpIdFromRoute) { + return wpIdFromRoute === this.workPackage.id; + } + + // In non-router views (e.g. Team Planner, Calendar): + // Use URL-based detection so that closing the split view (which changes the URL + // but does not clear the selection service) correctly deselects the card. + const urlMatch = /\/details\/(\d+)/.exec(window.location.pathname); + return urlMatch?.[1] === this.workPackage.id; } return this.wpTableSelection.isSelected(this.workPackage.id!); }), + distinctUntilChanged(), ) .subscribe((selected:boolean) => { this.selected = selected; diff --git a/frontend/src/app/features/work-packages/components/wp-list/wp-list-checksum.service.ts b/frontend/src/app/features/work-packages/components/wp-list/wp-list-checksum.service.ts index 45bcd15c26a3..8b214522f5f2 100644 --- a/frontend/src/app/features/work-packages/components/wp-list/wp-list-checksum.service.ts +++ b/frontend/src/app/features/work-packages/components/wp-list/wp-list-checksum.service.ts @@ -31,6 +31,7 @@ import { UrlParamsHelperService } from 'core-app/features/work-packages/componen import { Injectable } from '@angular/core'; import { WorkPackageViewPagination } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-table-pagination'; import { QueryResource } from 'core-app/features/hal/resources/query-resource'; +import { Subject } from 'rxjs'; @Injectable() export class WorkPackagesListChecksumService { @@ -44,6 +45,9 @@ export class WorkPackagesListChecksumService { public visibleChecksum:string|null; + /** Emits whenever visibleChecksum changes (useful for non-uiRouter pages to react to URL param changes) */ + public readonly visibleChecksum$ = new Subject(); + public updateIfDifferent(query:QueryResource, pagination:WorkPackageViewPagination):Promise { const newQueryChecksum = this.getNewChecksum(query, pagination); @@ -153,6 +157,28 @@ export class WorkPackagesListChecksumService { private maintainUrlQueryState(id:string|null, checksum:string|null):TransitionPromise { this.visibleChecksum = checksum; + this.visibleChecksum$.next(checksum); + + // When uiRouter is not managing the current page (e.g. calendar after Turbo migration), + // $state.current.name is empty and state.go('.') does nothing. Fall back to pushState. + if (!this.$state.current.name) { + const url = new URL(window.location.href); + + if (checksum) { + url.searchParams.set('query_props', checksum); + } else { + url.searchParams.delete('query_props'); + } + + if (id) { + url.searchParams.set('query_id', id); + } else { + url.searchParams.delete('query_id'); + } + + window.history.pushState({}, '', url.toString()); + return Promise.resolve() as unknown as TransitionPromise; + } return this.$state.go( '.', diff --git a/frontend/src/app/features/work-packages/components/wp-list/wp-list.service.ts b/frontend/src/app/features/work-packages/components/wp-list/wp-list.service.ts index 063442197c88..6b47e92c2b65 100644 --- a/frontend/src/app/features/work-packages/components/wp-list/wp-list.service.ts +++ b/frontend/src/app/features/work-packages/components/wp-list/wp-list.service.ts @@ -261,6 +261,7 @@ export class WorkPackagesListService { // Reload the query, and then reload the menu this.reloadQuery(createdQuery).subscribe(() => { + this.navigateToQueryOnNonRouterPage(createdQuery.id); this.states.changes.queries.next(createdQuery.id); this.reloadSidemenu(createdQuery.id); }); @@ -311,7 +312,11 @@ export class WorkPackagesListService { this.toastService.addSuccess(this.I18n.t('js.notice_successful_update')); const queryAccessibleByUser = query.public || query.user.id === this.currentUser.userId; if (queryAccessibleByUser) { - void this.$state.go('.', { query_id: query.id, query_props: null }, { reload: true }); + if (!this.$state.current.name) { + this.navigateToQueryOnNonRouterPage(query.id); + } else { + void this.$state.go('.', { query_id: query.id, query_props: null }, { reload: true }); + } this.states.changes.queries.next(query.id); this.reloadSidemenu(query.id); } else { @@ -463,7 +468,27 @@ export class WorkPackagesListService { } } + private navigateToQueryOnNonRouterPage(queryId:string|null):void { + if (this.$state.current.name) { return; } + + // update the URL path to reflect the saved query ID so subsequent refetches use the correct query_id. + const url = new URL(window.location.href); + url.pathname = url.pathname.replace(/\/[^/]+$/, `/${queryId}`); + url.searchParams.delete('query_id'); + url.searchParams.delete('query_props'); + window.history.pushState({}, '', url.toString()); + } + private reloadSidemenu(selectedQueryId:string|null):void { - this.submenuService.reloadSubmenu(selectedQueryId); + const sidemenuId = !this.$state.current.name ? this.getNonRouterSidemenuId() : undefined; + this.submenuService.reloadSubmenu(selectedQueryId, sidemenuId); + } + + private getNonRouterSidemenuId():string|undefined { + const { pathname } = window.location; + if (pathname.includes('/calendars')) return 'calendar_sidemenu'; + if (pathname.includes('/team_planners')) return 'team_planner_sidemenu'; + if (pathname.includes('/ifc_models')) return 'bim_sidemenu'; + return undefined; } } diff --git a/frontend/src/app/features/work-packages/components/wp-list/wp-query-view.service.ts b/frontend/src/app/features/work-packages/components/wp-list/wp-query-view.service.ts index 27ff2c11f0e3..e9b7d05c6e01 100644 --- a/frontend/src/app/features/work-packages/components/wp-list/wp-query-view.service.ts +++ b/frontend/src/app/features/work-packages/components/wp-list/wp-query-view.service.ts @@ -34,20 +34,20 @@ export class WorkPackagesQueryViewService { } private get viewType() { - if (this.$state.includes('work-packages')) { - return 'work_packages_table'; - } - if (this.$state.includes('team_planner')) { - return 'team_planner'; - } - if (this.$state.includes('bim')) { - return 'bim'; - } - if (this.$state.includes('calendar')) { - return 'work_packages_calendar'; - } - if (this.$state.includes('gantt')) { - return 'gantt'; + if (this.$state.current.name) { + if (this.$state.includes('work-packages')) { return 'work_packages_table'; } + if (this.$state.includes('team_planner')) { return 'team_planner'; } + if (this.$state.includes('bim')) { return 'bim'; } + if (this.$state.includes('calendar')) { return 'work_packages_calendar'; } + if (this.$state.includes('gantt')) { return 'gantt'; } + } else { + // Non-uiRouter page — derive view type from URL path + const { pathname } = window.location; + if (pathname.includes('/calendars')) { return 'work_packages_calendar'; } + if (pathname.includes('/team_planners')) { return 'team_planner'; } + if (pathname.includes('/ifc_models')) { return 'bim'; } + if (pathname.includes('/gantt')) { return 'gantt'; } + if (pathname.includes('/work_packages')) { return 'work_packages_table'; } } throw new Error('Not on a path defined for query views'); diff --git a/frontend/src/app/features/work-packages/components/wp-new/wp-new-split-view.component.ts b/frontend/src/app/features/work-packages/components/wp-new/wp-new-split-view.component.ts index fb4b9b426303..e75f6e4ba297 100644 --- a/frontend/src/app/features/work-packages/components/wp-new/wp-new-split-view.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-new/wp-new-split-view.component.ts @@ -27,7 +27,10 @@ //++ import { WorkPackageCreateComponent } from 'core-app/features/work-packages/components/wp-new/wp-create.component'; -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { WorkPackagesListService } from 'core-app/features/work-packages/components/wp-list/wp-list.service'; @Component({ selector: 'wp-new-split-view', @@ -36,4 +39,92 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; standalone: false, }) export class WorkPackageNewSplitViewComponent extends WorkPackageCreateComponent { + private readonly wpListService = inject(WorkPackagesListService); + + /** + * Before creating the new WP form, load the current query (with its active filters) + * into the isolated query space so that WorkPackageCreateService.defaultsFromFilters() + * can pre-populate the form fields automatically — no manual filter mapping needed. + */ + protected override async createdWorkPackage() { + if (!this.routedFromAngular) { + const params = new URLSearchParams(window.location.search); + + // Load the active query into the isolated query space so that + // WorkPackageCreateService.defaultsFromFilters() can pre-populate filter-based fields. + const queryId = params.get('query_id'); + const queryProps = params.get('query_props'); + if (queryId || queryProps) { + await firstValueFrom( + this.wpListService.fromQueryParams( + { query_id: queryId ?? undefined, query_props: queryProps ?? undefined }, + this.currentProjectService.identifier ?? undefined, + ), + ); + } + + // Apply defaults passed via URL params (e.g. when dragging to create on the calendar/team planner). + const startDate = params.get('startDate'); + const dueDate = params.get('dueDate'); + const ignoreNonWorkingDays = params.get('ignoreNonWorkingDays'); + const assigneeHref = params.get('assignee_href'); + const parentId = params.get('parent_id'); + if (startDate || dueDate || ignoreNonWorkingDays || assigneeHref || parentId) { + const existingDefaults = this.stateParams?.defaults; + this.stateParams = { + ...this.stateParams, + ...(parentId ? { parent_id: parentId } : {}), + defaults: { + _links: {}, + ...existingDefaults, + ...(startDate ? { startDate } : {}), + ...(dueDate ? { dueDate } : {}), + ...(ignoreNonWorkingDays ? { ignoreNonWorkingDays: true } : {}), + ...(assigneeHref ? { + _links: { + ...(existingDefaults?._links || {}), + assignee: { href: assigneeHref }, + }, + } : {}), + }, + }; + } + } + + return super.createdWorkPackage(); + } + + public override cancelAndBack():void { + if (this.routedFromAngular) { + super.cancelAndBack(); + return; + } + + this.wpCreate.cancelCreation(); + + // Close the split panel by navigating to the base URL (strips /details/new), + // replacing the history entry so back-navigation skips the create state. + const basePath = window.location.pathname.replace(/\/details\/.*$/, ''); + Turbo.visit(basePath + window.location.search, { frame: 'content-bodyRight', action: 'replace' }); + } + + public override onSaved(params:{ savedResource:WorkPackageResource, isInitial:boolean }):void { + if (this.routedFromAngular) { + super.onSaved(params); + return; + } + + const { savedResource, isInitial } = params; + this.editForm?.cancel(false); + + this.notificationService.showSave(savedResource, isInitial); + window.OpenProject.pageState = 'submitted'; + + // Open the newly created WP in the split panel. + const basePath = window.location.pathname.replace(/\/details\/.*$/, ''); + Turbo.visit(`${basePath}/details/${savedResource.id}${window.location.search}`, { + frame: 'content-bodyRight', + action: 'advance', + }); + } } diff --git a/frontend/src/app/features/work-packages/openproject-work-packages.module.ts b/frontend/src/app/features/work-packages/openproject-work-packages.module.ts index 021d9d994fdf..282110ac0dc9 100644 --- a/frontend/src/app/features/work-packages/openproject-work-packages.module.ts +++ b/frontend/src/app/features/work-packages/openproject-work-packages.module.ts @@ -407,6 +407,9 @@ import { import { WorkPackageFullCopyEntryComponent } from 'core-app/features/work-packages/routing/wp-full-copy/wp-full-copy-entry.component'; import { WorkPackageFullCreateEntryComponent } from 'core-app/features/work-packages/routing/wp-full-create/wp-full-create-entry.component'; import { WorkPackageFullViewEntryComponent } from 'core-app/features/work-packages/routing/wp-full-view/wp-full-view-entry.component'; +import { + WorkPackageSplitCreateEntryComponent, +} from 'core-app/features/work-packages/routing/wp-split-create/wp-split-create-entry.component'; @NgModule({ imports: [ @@ -591,6 +594,7 @@ import { WorkPackageFullViewEntryComponent } from 'core-app/features/work-packag WorkPackageDetailsViewButtonComponent, WorkPackageSplitViewComponent, WorkPackageSplitViewEntryComponent, + WorkPackageSplitCreateEntryComponent, WorkPackageBreadcrumbComponent, WorkPackageSplitViewToolbarComponent, WorkPackageWatcherButtonComponent, diff --git a/frontend/src/app/features/boards/board/board-partitioned-page/board-partitioned-page.component.html b/frontend/src/app/features/work-packages/routing/partitioned-query-space-page/primerized-partitioned-query-space-page.component.html similarity index 88% rename from frontend/src/app/features/boards/board/board-partitioned-page/board-partitioned-page.component.html rename to frontend/src/app/features/work-packages/routing/partitioned-query-space-page/primerized-partitioned-query-space-page.component.html index a0ee9d566e08..cf85df4fc70e 100644 --- a/frontend/src/app/features/boards/board/board-partitioned-page/board-partitioned-page.component.html +++ b/frontend/src/app/features/work-packages/routing/partitioned-query-space-page/primerized-partitioned-query-space-page.component.html @@ -14,8 +14,7 @@

[editable]="titleEditingEnabled" />

@if (showToolbar) { -
    +
      @for (definition of toolbarButtonComponents; track definition) { @if (!definition.show || definition.show()) {
    • - - +
      + +
      diff --git a/frontend/src/app/features/work-packages/routing/wp-split-create/wp-split-create-entry.component.ts b/frontend/src/app/features/work-packages/routing/wp-split-create/wp-split-create-entry.component.ts new file mode 100644 index 000000000000..29da7e5f034c --- /dev/null +++ b/frontend/src/app/features/work-packages/routing/wp-split-create/wp-split-create-entry.component.ts @@ -0,0 +1,78 @@ +//-- 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 { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + Input, + OnDestroy, +} from '@angular/core'; +import { + WorkPackageIsolatedQuerySpaceDirective, +} from 'core-app/features/work-packages/directives/query-space/wp-isolated-query-space.directive'; +import { populateInputsFromDataset } from 'core-app/shared/components/dataset-inputs'; + +/** + * An entry component to be rendered by Rails which opens an isolated query space + * for the work package split create (create form in the split panel). + */ +@Component({ + hostDirectives: [WorkPackageIsolatedQuerySpaceDirective], + standalone: false, + template: ` +
      + +
      + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class WorkPackageSplitCreateEntryComponent implements AfterViewInit, OnDestroy { + @Input() projectIdentifier?:string; + @Input() type?:string; + + constructor(readonly elementRef:ElementRef) { + populateInputsFromDataset(this); + document.body.classList.add('router--work-packages-partitioned-split-view-new'); + } + + ngAfterViewInit():void { + // wp-new-split-view sets pageState = 'edited' unconditionally on mount, + // which would block Turbo navigation in the split panel context. + // Reset it here after all children have initialized. + window.OpenProject.pageState = 'pristine'; + } + + ngOnDestroy():void { + document.body.classList.remove('router--work-packages-partitioned-split-view-new'); + } +} diff --git a/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive.ts b/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive.ts index f4e6953a8fe9..7c72a75a3af9 100644 --- a/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive.ts +++ b/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive.ts @@ -65,7 +65,13 @@ export class WorkPackageViewContextMenu extends OpContextMenuHandler { ); // Get the base route for the current route to ensure we always link correctly - protected baseRoute = this.$state.current.data.baseRoute || this.$state.current.name; + protected baseRoute = this.$state.current.data?.baseRoute ?? this.$state.current.name; + + // Whether we are running inside a uiRouter context (e.g. work packages list/board). + // Calendar and Team Planner render without uiRouter and rely on Turbo navigation instead. + protected get hasUiRouterContext():boolean { + return this.$state.current.name !== ''; + } protected items = this.buildItems(); @@ -126,7 +132,14 @@ export class WorkPackageViewContextMenu extends OpContextMenuHandler { break; case 'relation-new-child': - this.wpRelationsHierarchyService.addNewChildWp(this.baseRoute, this.workPackage); + if (this.hasUiRouterContext) { + this.wpRelationsHierarchyService.addNewChildWp(this.baseRoute, this.workPackage); + } else { + const newChildPath = `${window.location.pathname.replace(/\/details\/.*$/, '')}/details/new`; + const childParams = new URLSearchParams(window.location.search); + childParams.set('parent_id', id); + Turbo.visit(`${newChildPath}?${childParams.toString()}`, { frame: 'content-bodyRight', action: 'advance' }); + } break; case 'log_time': @@ -138,10 +151,15 @@ export class WorkPackageViewContextMenu extends OpContextMenuHandler { break; case 'relations': - void this.$state.go( - `${splitViewRoute(this.$state)}.tabs`, - { workPackageId: this.workPackageId, tabIdentifier: 'relations' }, - ); + if (this.hasUiRouterContext) { + void this.$state.go( + `${splitViewRoute(this.$state)}.tabs`, + { workPackageId: this.workPackageId, tabIdentifier: 'relations' }, + ); + } else { + const relationsPath = `${window.location.pathname.replace(/\/details\/.*$/, '')}/details/${this.workPackageId}${window.location.search}`; + Turbo.visit(relationsPath, { frame: 'content-bodyRight', action: 'advance' }); + } break; default: @@ -234,24 +252,32 @@ export class WorkPackageViewContextMenu extends OpContextMenuHandler { }); if (selected.length === 1 && this.allowSplitScreenActions) { + const splitViewHref = this.hasUiRouterContext + ? this.$state.href( + `${splitViewRoute(this.$state)}.tabs`, + { workPackageId: this.workPackageId, tabIdentifier: 'overview' }, + ) + : `${window.location.pathname.replace(/\/details\/.*$/, '')}/details/${this.workPackageId}${window.location.search}`; + items.unshift({ disabled: false, icon: 'icon-view-split', class: 'detailsViewMenuItem', - href: this.$state.href( - `${splitViewRoute(this.$state)}.tabs`, - { workPackageId: this.workPackageId, tabIdentifier: 'overview' }, - ), + href: splitViewHref, linkText: I18n.t('js.button_open_details'), onClick: (event) => { if (isClickedWithModifier(event)) { return false; } - this.$state.go( - `${splitViewRoute(this.$state)}.tabs`, - { workPackageId: this.workPackageId, tabIdentifier: 'overview' }, - ); + if (this.hasUiRouterContext) { + this.$state.go( + `${splitViewRoute(this.$state)}.tabs`, + { workPackageId: this.workPackageId, tabIdentifier: 'overview' }, + ); + } else { + Turbo.visit(splitViewHref, { frame: 'content-bodyRight', action: 'advance' }); + } return true; }, }); diff --git a/modules/boards/app/views/boards/boards/show.html.erb b/modules/boards/app/views/boards/boards/show.html.erb index 7b8705cd00db..8b4d6fb62b55 100644 --- a/modules/boards/app/views/boards/boards/show.html.erb +++ b/modules/boards/app/views/boards/boards/show.html.erb @@ -34,6 +34,5 @@ See COPYRIGHT and LICENSE files for more details. <% 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/calendar/app/components/calendar/add_button_component.rb b/modules/calendar/app/components/calendar/add_button_component.rb index c825653bccea..1cad85460dff 100644 --- a/modules/calendar/app/components/calendar/add_button_component.rb +++ b/modules/calendar/app/components/calendar/add_button_component.rb @@ -41,7 +41,7 @@ def render? def dynamic_path if current_project - new_project_calendars_path(current_project) + new_project_calendar_path(current_project) else new_calendar_path end diff --git a/modules/calendar/app/controllers/calendar/calendars_controller.rb b/modules/calendar/app/controllers/calendar/calendars_controller.rb index 4d34662cecee..1b216e1cf9bd 100644 --- a/modules/calendar/app/controllers/calendar/calendars_controller.rb +++ b/modules/calendar/app/controllers/calendar/calendars_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -31,14 +33,18 @@ class CalendarsController < ApplicationController before_action :load_and_authorize_in_optional_project before_action :build_calendar_view, only: %i[new] before_action :authorize, except: %i[index new create] - before_action :authorize_global, only: %i[index new create] + before_action :authorize_global, only: %i[index create] + before_action :authorize_new, only: %i[new] + authorization_checked! :new + authorize_with_permission :add_work_packages, only: %i[split_create] - before_action :find_calendar, only: %i[destroy] + before_action :find_calendar, only: %i[show split_view destroy] menu_item :calendar_view include Layout include PaginationHelper include SortHelper + include WorkPackages::WithSplitView def index @views = visible_views @@ -46,10 +52,38 @@ def index 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 + def split_create + respond_to do |format| + format.html do + if turbo_frame_request? + render "work_packages/split_create", layout: false + else + render :show + end + end + end + end + + def new + # In a project context, show the calendar view with an unsaved query. + # In the global context (no project), show the form so the user can select a project. + render :show if @project + end def create service_result = create_service_class.new(user: User.current) @@ -59,7 +93,7 @@ def create if service_result.success? flash[:notice] = I18n.t(:notice_successful_create) - redirect_to project_calendar_path(@project, @view.query) + redirect_to project_calendar_path(@project || @view.query.project, @view.query) else render action: :new, status: :unprocessable_entity end @@ -77,6 +111,23 @@ def destroy private + # In project context, `new` renders the calendar view and needs the same project-level + # permission as `show`. In global context (no project), it shows the creation form. + def authorize_new + @project ? authorize : authorize_global + end + + def split_view_base_route + # Unsaved calendars use the /new path (no :id). + # In that case @view is nil and we return the /new path as the base route + # so that the split view close button navigates back correctly. + if @view + project_calendar_path(@project, @view, request.query_parameters) + else + new_project_calendar_path(@project, request.query_parameters) + end + end + def build_calendar_view @view = Query.new end @@ -86,7 +137,7 @@ def create_service_class end def calendar_view_params - params.require(:query).permit(:name, :public, :starred).merge(project_id: @project&.id) + params.expect(query: %i[name public starred project_id]) end def visible_views @@ -103,6 +154,11 @@ def visible_views end def find_calendar + # split_view is also reachable via the /new collection path + # (e.g. /calendars/new/details/:wp_id) which carries no :id. + # In that case @view remains nil and split_view_base_route handles it. + return if params[:id].blank? + @view = Query .visible(current_user) .find(params[:id]) diff --git a/modules/calendar/app/controllers/calendar/menus_controller.rb b/modules/calendar/app/controllers/calendar/menus_controller.rb index d0a735356cce..bf2ff10413be 100644 --- a/modules/calendar/app/controllers/calendar/menus_controller.rb +++ b/modules/calendar/app/controllers/calendar/menus_controller.rb @@ -33,7 +33,7 @@ class MenusController < ApplicationController def show @submenu_menu_items = ::Calendar::Menu.new(project: @project, params:).menu_items @create_btn_options = if User.current.allowed_in_project?(:manage_calendars, @project) - { href: new_project_calendars_path(@project), module_key: "calendar" } + { href: new_project_calendar_path(@project), module_key: "calendar" } end render layout: nil diff --git a/modules/calendar/app/views/calendar/calendars/_form.html.erb b/modules/calendar/app/views/calendar/calendars/_form.html.erb deleted file mode 100644 index 1e082372b43c..000000000000 --- a/modules/calendar/app/views/calendar/calendars/_form.html.erb +++ /dev/null @@ -1,89 +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. - -++#%> - -<%= error_messages_for @view %> - -
      - -
      - <%= f.text_field :name, - label: t(:label_title), - required: true, - size: 60, - container_class: "-wide" %> -
      - -
      - -
      - <%= angular_component_tag "opce-project-autocompleter", - inputs: { - filters: [{ name: :user_action, operator: "=", values: ["calendars/create"] }], - inputName: "project_id", - inputValue: @project&.id - }, - id: "project_id", - class: "form--select-container -wide remote-field--input", - data: { - "test-selector": "project_id" - } %> -
      -
      -

      <%= t "help_texts.views.project", - singular: t(:label_calendar).downcase, - plural: t(:label_calendar_plural) %>

      -
      -
      - -
      - <%= styled_label_tag "query[public]", - Query.human_attribute_name(:public) %> -
      - <%= styled_check_box_tag "query[public]", - "1", - @view.public %> -
      -
      -

      <%= t "help_texts.views.public" %>

      -
      -
      - -
      - <%= styled_label_tag "query[starred]", - t(:label_favorite) %> -
      - <%= styled_check_box_tag "query[starred]", - "1", - @view.starred %> -
      -
      -

      <%= t "help_texts.views.favoured" %>

      -
      -
      -
      diff --git a/modules/calendar/app/views/calendar/calendars/new.html.erb b/modules/calendar/app/views/calendar/calendars/new.html.erb index 0de4281096f4..78979bb28bc9 100644 --- a/modules/calendar/app/views/calendar/calendars/new.html.erb +++ b/modules/calendar/app/views/calendar/calendars/new.html.erb @@ -39,9 +39,48 @@ See COPYRIGHT and LICENSE files for more details. end %> -<%= labelled_tabular_form_for @view, url: { controller: "/calendar/calendars", action: "create" }, html: { id: "calendar-form" } do |f| -%> - <%= render partial: "form", locals: { f: f } %> - <%= styled_button_tag t(:button_create), class: "-primary" %> - <%= link_to t(:button_cancel), { controller: "calendar/calendars", action: "index" }, - class: "button" %> +<%= primer_form_with(model: @view, scope: :query, url: calendars_path) do |f| %> + <%= render_inline_form(f) do |form| + project_id_value = @project&.id + form.text_field( + name: :name, + label: helpers.t(:label_title), + required: true, + input_width: :large + ) + + form.project_autocompleter( + name: :project_id, + label: Query.human_attribute_name(:project), + required: true, + caption: helpers.t("help_texts.views.project", + singular: helpers.t(:label_calendar).downcase, + plural: helpers.t(:label_calendar_plural)), + input_width: :large, + autocomplete_options: { + focusDirectly: false, + dropdownPosition: "bottom", + inputValue: project_id_value, + filters: [{ name: "user_action", operator: "=", values: ["calendars/create"] }], + data: { "test-selector": "project_id" } + } + ) + + form.check_box( + name: :public, + label: Query.human_attribute_name(:public), + caption: helpers.t("help_texts.views.public") + ) + + form.check_box( + name: :starred, + label: helpers.t(:label_favorite), + caption: helpers.t("help_texts.views.favoured") + ) + + form.group(layout: :horizontal) do |button_group| + button_group.submit(label: helpers.t(:button_create), name: :submit, scheme: :primary) + button_group.button(tag: :a, href: helpers.calendars_path, label: helpers.t(:button_cancel), name: :cancel) + end + end %> <% end %> diff --git a/modules/calendar/app/views/calendar/calendars/show.html.erb b/modules/calendar/app/views/calendar/calendars/show.html.erb index 71d247033717..8672b487dccc 100644 --- a/modules/calendar/app/views/calendar/calendars/show.html.erb +++ b/modules/calendar/app/views/calendar/calendars/show.html.erb @@ -27,4 +27,13 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<% html_title(t(:label_calendar_plural)) -%> +<% html_title(@view&.name.presence || t(:label_calendar_plural)) -%> + +<% content_for :content_body do %> + <%= angular_component_tag "opce-calendar-view", inputs: { queryId: @view&.id.to_s } %> +<% end %> + +<% content_for :content_body_right do %> + <%= render(split_view_instance) if render_work_package_split_view? %> + <%= render(split_create_instance) if render_work_package_split_create? %> +<% end %> diff --git a/modules/calendar/app/views/calendar/menus/_menu.html.erb b/modules/calendar/app/views/calendar/menus/_menu.html.erb index 4ecb73e0680d..dfce2ac8225d 100644 --- a/modules/calendar/app/views/calendar/menus/_menu.html.erb +++ b/modules/calendar/app/views/calendar/menus/_menu.html.erb @@ -1,5 +1,5 @@ <%= turbo_frame_tag "calendar_sidemenu", src: menu_project_calendars_path(@project, **params.permit(:id)), target: "_top", - data: { turbo: false }, + data: { turbo: false, query_param: "id" }, loading: :lazy %> diff --git a/modules/calendar/config/routes.rb b/modules/calendar/config/routes.rb index ec673b259fc0..1a3090c2fc76 100644 --- a/modules/calendar/config/routes.rb +++ b/modules/calendar/config/routes.rb @@ -2,14 +2,30 @@ scope "projects/:project_id", as: "project" do resources :calendars, controller: "calendar/calendars", - only: %i[index destroy], + only: %i[index show new create destroy], as: :calendars do collection do get "menu" => "calendar/menus#show" + get "new/details/new", + action: :split_create, + work_package_split_create: true + get "new/details/:work_package_id(/:tab)", + action: :split_view, + defaults: { tab: :overview }, + work_package_split_view: true end - get "/new" => "calendar/calendars#show", on: :collection, as: "new" get "/ical" => "calendar/ical#show", on: :member, as: "ical" - get "(/*state)" => "calendar/calendars#show", on: :member, as: "" + member do + get "details/new", + action: :split_create, + as: :split_create, + work_package_split_create: true + get "details/:work_package_id(/:tab)", + action: :split_view, + defaults: { tab: :overview }, + as: :details, + work_package_split_view: true + end end end diff --git a/modules/calendar/lib/open_project/calendar/engine.rb b/modules/calendar/lib/open_project/calendar/engine.rb index fc0f08a0e75a..031234fdd6d5 100644 --- a/modules/calendar/lib/open_project/calendar/engine.rb +++ b/modules/calendar/lib/open_project/calendar/engine.rb @@ -28,7 +28,7 @@ class Engine < ::Rails::Engine settings: {} do project_module :calendar_view, dependencies: :work_package_tracking do permission :view_calendar, - { "calendar/calendars": %i[index show], + { "calendar/calendars": %i[index show split_view split_create new], "calendar/menus": %i[show] }, permissible_on: :project, dependencies: %i[view_work_packages], diff --git a/modules/calendar/spec/features/calendars_spec.rb b/modules/calendar/spec/features/calendars_spec.rb index 9c559cc5606e..8f32f4f2aa5b 100644 --- a/modules/calendar/spec/features/calendars_spec.rb +++ b/modules/calendar/spec/features/calendars_spec.rb @@ -63,7 +63,7 @@ due_date: Time.zone.today.at_beginning_of_month.next_month + 18.days) end let(:filters) { Components::WorkPackages::Filters.new } - let(:current_wp_split_screen) { Pages::SplitWorkPackage.new(current_work_package, project) } + let(:current_wp_split_screen) { Pages::PrimerizedSplitWorkPackage.new(current_work_package, project) } before do login_as(user) @@ -83,10 +83,11 @@ find('[data-test-selector="add-calendar-button"]', text: "Calendar").click loading_indicator_saveguard + expect_angular_frontend_initialized # should open the calendar with the current month displayed expect(page) - .to have_css ".fc-event-title", text: current_work_package.subject + .to have_css ".fc-event-title", text: current_work_package.subject, wait: 20 expect(page) .to have_css ".fc-event-title", text: another_current_work_package.subject expect(page) @@ -172,8 +173,9 @@ # go back a month by using the browser back functionality page.execute_script("window.history.back()") + expect_angular_frontend_initialized expect(page) - .to have_css ".fc-event-title", text: current_work_package.subject + .to have_css ".fc-event-title", text: current_work_package.subject, wait: 20 expect(page) .to have_css ".fc-event-title", text: another_current_work_package.subject expect(page) @@ -183,21 +185,30 @@ # click goes to work package split screen page.find(".fc-event-title", text: current_work_package.subject).click - current_wp_split_screen.expect_open + + wait_for_turbo_frame do + expect(page).to have_current_path("/projects/#{project.identifier}/calendars/new/details/#{current_work_package.id}", ignore_query: true) + current_wp_split_screen.expect_open + end # Going back in browser history will lead us back to the calendar # Regression #29664 - page.go_back - expect(page) - .to have_css(".fc-event-title", text: current_work_package.subject, wait: 20) - current_wp_split_screen.expect_closed + retry_block do + page.go_back + expect_angular_frontend_initialized + expect(page) + .to have_css(".fc-event-title", text: current_work_package.subject, wait: 20) + current_wp_split_screen.expect_closed + end # After go_back, the app may not be fully initialized even though the # calendar events are visible. Clicking too early can cause an "not # authorized" error on the split screen API call. Retry to handle this. retry_block do page.find(".fc-event-title", text: current_work_package.subject).click - current_wp_split_screen.expect_open + wait_for_turbo_frame do + current_wp_split_screen.expect_open + end end # click back goes back to calendar diff --git a/modules/team_planner/app/controllers/team_planner/team_planner_controller.rb b/modules/team_planner/app/controllers/team_planner/team_planner_controller.rb index 2e02e5797252..19469857dc42 100644 --- a/modules/team_planner/app/controllers/team_planner/team_planner_controller.rb +++ b/modules/team_planner/app/controllers/team_planner/team_planner_controller.rb @@ -1,10 +1,15 @@ +# frozen_string_literal: true + module ::TeamPlanner class TeamPlannerController < BaseController include EnterpriseHelper include Layout + include WorkPackages::WithSplitView + before_action :load_and_authorize_in_optional_project before_action :build_plan_view, only: %i[new] - before_action :find_plan_view, only: %i[destroy] + before_action :find_plan_view, only: %i[destroy split_view] + authorize_with_permission :add_work_packages, only: %i[split_create] guard_enterprise_feature(:team_planner_view, except: %i[index overview]) do redirect_to action: :index @@ -21,6 +26,7 @@ def overview render layout: "global" end + def show; end def new; end def create @@ -37,8 +43,28 @@ def create end end - def show - render layout: "angular/angular" + 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 split_create + respond_to do |format| + format.html do + if turbo_frame_request? + render "work_packages/split_create", layout: false + else + render :show + end + end + end end def upsell; end @@ -63,12 +89,23 @@ def destroy private + def split_view_base_route + # Unsaved team planners use the /new collection path (no :id). + # In that case @view is nil and we return the /new path as the base route + # so that the split view close button navigates back correctly. + if @view + project_team_planner_path(@project, @view, request.query_parameters) + else + new_project_team_planners_path(@project, request.query_parameters) + end + end + def create_service_class TeamPlanner::Views::GlobalCreateService end def plan_view_params - params.require(:query).permit(:name, :public, :starred).merge(project_id: @project&.id) + params.expect(query: %i[name public starred]).merge(project_id: @project&.id) end def build_plan_view @@ -76,6 +113,11 @@ def build_plan_view end def find_plan_view + # The split_view action is also reachable via the /new collection path + # (e.g. /team_planners/new/details/:wp_id) which carries no :id. + # In that case @view remains nil and split_view_base_route handles it. + return if params[:id].blank? + @view = Query .visible(current_user) .find(params[:id]) diff --git a/modules/team_planner/app/views/team_planner/menus/_menu.html.erb b/modules/team_planner/app/views/team_planner/menus/_menu.html.erb index d27316de1e35..a5b5bd887535 100644 --- a/modules/team_planner/app/views/team_planner/menus/_menu.html.erb +++ b/modules/team_planner/app/views/team_planner/menus/_menu.html.erb @@ -1,5 +1,5 @@ <%= turbo_frame_tag "team_planner_sidemenu", src: menu_project_team_planners_path(@project, **params.permit(:id)), target: "_top", - data: { turbo: false }, + data: { turbo: false, query_param: "id" }, loading: :lazy %> diff --git a/modules/team_planner/app/views/team_planner/team_planner/show.html.erb b/modules/team_planner/app/views/team_planner/team_planner/show.html.erb index 157af1b2b0ef..4621ccf2bc3d 100644 --- a/modules/team_planner/app/views/team_planner/team_planner/show.html.erb +++ b/modules/team_planner/app/views/team_planner/team_planner/show.html.erb @@ -1 +1,10 @@ <% html_title(t("team_planner.label_team_planner")) -%> + +<% content_for :content_body do %> + <%= angular_component_tag "opce-team-planner-view" %> +<% end %> + +<% content_for :content_body_right do %> + <%= render(split_view_instance) if render_work_package_split_view? %> + <%= render(split_create_instance) if render_work_package_split_create? %> +<% end %> diff --git a/modules/team_planner/config/routes.rb b/modules/team_planner/config/routes.rb index 45b91bc3547d..cb40015d1239 100644 --- a/modules/team_planner/config/routes.rb +++ b/modules/team_planner/config/routes.rb @@ -15,10 +15,26 @@ as: :team_planners do collection do get "menu" => "team_planner/menus#show" + get "new/details/new", + action: :split_create, + work_package_split_create: true + get "new/details/:work_package_id(/:tab)", + action: :split_view, + defaults: { tab: "overview" }, + work_package_split_view: true get "/new", to: "team_planner/team_planner#show", as: :new end member do + get "details/new", + action: :split_create, + as: :split_create, + work_package_split_create: true + get "details/:work_package_id(/:tab)", + action: :split_view, + defaults: { tab: "overview" }, + as: :details, + work_package_split_view: true get "(/*state)" => "team_planner/team_planner#show", as: "" end end diff --git a/modules/team_planner/lib/open_project/team_planner/engine.rb b/modules/team_planner/lib/open_project/team_planner/engine.rb index c4c51f2303ee..1a5a5c918752 100644 --- a/modules/team_planner/lib/open_project/team_planner/engine.rb +++ b/modules/team_planner/lib/open_project/team_planner/engine.rb @@ -40,7 +40,7 @@ class Engine < ::Rails::Engine dependencies: :work_package_tracking, enterprise_feature: "team_planner_view" do permission :view_team_planner, - { "team_planner/team_planner": %i[index show upsell overview], + { "team_planner/team_planner": %i[index show split_view split_create upsell overview], "team_planner/menus": %i[show] }, permissible_on: :project, dependencies: %i[view_work_packages], diff --git a/modules/team_planner/spec/features/team_planner_add_existing_work_packages_spec.rb b/modules/team_planner/spec/features/team_planner_add_existing_work_packages_spec.rb index c6d85903af1d..628161313428 100644 --- a/modules/team_planner/spec/features/team_planner_add_existing_work_packages_spec.rb +++ b/modules/team_planner/spec/features/team_planner_add_existing_work_packages_spec.rb @@ -130,8 +130,9 @@ # Select work package in add existing add_existing_pane.card(second_wp).click split_screen = Pages::SplitWorkPackage.new second_wp - split_screen.expect_subject + # Wait for navigation to complete before checking the split panel DOM expect(page).to have_current_path /\/details\/#{second_wp.id}/ + split_screen.expect_subject end it "allows to add work packages via drag&drop from the left hand shortlist" do @@ -169,7 +170,10 @@ # New events are directly clickable split_view = team_planner.open_split_view_by_info_icon(third_wp) - split_view.expect_open + wait_for_turbo_frame do + expect(page).to have_current_path /\/details\/#{third_wp.id}/ + split_view.expect_subject + end end context "with non-working days" do diff --git a/modules/team_planner/spec/features/team_planner_dates_spec.rb b/modules/team_planner/spec/features/team_planner_dates_spec.rb index 1f653a368f82..ef8bb3f3a6f9 100644 --- a/modules/team_planner/spec/features/team_planner_dates_spec.rb +++ b/modules/team_planner/spec/features/team_planner_dates_spec.rb @@ -42,6 +42,7 @@ it 'hides sat and sun in the "Work week" view andd renders sat and sun as non working in the "1-week" view' do team_planner.visit! + team_planner.wait_for_loaded team_planner.expect_empty_state team_planner.add_assignee user.name diff --git a/modules/team_planner/spec/features/team_planner_error_handling_spec.rb b/modules/team_planner/spec/features/team_planner_error_handling_spec.rb index 9b1bc621c4b3..0c91f557db9f 100644 --- a/modules/team_planner/spec/features/team_planner_error_handling_spec.rb +++ b/modules/team_planner/spec/features/team_planner_error_handling_spec.rb @@ -107,7 +107,7 @@ .perform end - team_planner.expect_toast(type: :error, message: I18n.t("api_v3.errors.code_409")) + expect_flash(type: :error, message: I18n.t("notice_locking_conflict_danger")) work_package.reload expect(work_package.start_date).to eq(Time.zone.today.beginning_of_week.next_occurring(:tuesday)) diff --git a/modules/team_planner/spec/features/team_planner_spec.rb b/modules/team_planner/spec/features/team_planner_spec.rb index b4e11253832d..20a1171f4be0 100644 --- a/modules/team_planner/spec/features/team_planner_spec.rb +++ b/modules/team_planner/spec/features/team_planner_spec.rb @@ -46,6 +46,7 @@ expect(page).to have_content "There is currently nothing to display." page.find_test_selector("add-team-planner-button").click + expect_angular_frontend_initialized team_planner.expect_title filters.expect_filter_count("1") @@ -241,6 +242,7 @@ it "can add and remove assignees" do team_planner.visit! + team_planner.wait_for_loaded team_planner.expect_empty_state team_planner.expect_assignee(user, present: false) team_planner.expect_assignee(other_user, present: false) diff --git a/modules/team_planner/spec/features/team_planner_view_modes_spec.rb b/modules/team_planner/spec/features/team_planner_view_modes_spec.rb index c7e19f938989..51251f46016b 100644 --- a/modules/team_planner/spec/features/team_planner_view_modes_spec.rb +++ b/modules/team_planner/spec/features/team_planner_view_modes_spec.rb @@ -38,6 +38,7 @@ it "allows switching of view modes", with_settings: { working_days: [1, 2, 3, 4, 5] } do team_planner.visit! + team_planner.wait_for_loaded team_planner.expect_empty_state team_planner.add_assignee user.name diff --git a/modules/team_planner/spec/routing/team_planner_routing_spec.rb b/modules/team_planner/spec/routing/team_planner_routing_spec.rb index c8efea61ed93..be48e18b5a5b 100644 --- a/modules/team_planner/spec/routing/team_planner_routing_spec.rb +++ b/modules/team_planner/spec/routing/team_planner_routing_spec.rb @@ -69,11 +69,18 @@ .to(controller: "team_planner/team_planner", action: :create) end - it "routes to team_planner#show with state" do + it "routes to team_planner#split_view" do expect(subject) .to route(:get, "/projects/foobar/team_planners/1234/details/555") - .to(controller: "team_planner/team_planner", action: :show, project_id: "foobar", id: "1234", - state: "details/555") + .to(controller: "team_planner/team_planner", action: :split_view, project_id: "foobar", id: "1234", + work_package_id: "555", tab: "overview", work_package_split_view: true) + end + + it "routes to team_planner#split_create" do + expect(subject) + .to route(:get, "/projects/foobar/team_planners/1234/details/new") + .to(controller: "team_planner/team_planner", action: :split_create, project_id: "foobar", id: "1234", + work_package_split_create: true) end it "routes to team_planner#destroy" do diff --git a/spec/features/work_packages/table/context_menu/context_menu_shared_examples.rb b/spec/features/work_packages/table/context_menu/context_menu_shared_examples.rb index 5dcbc4d33fc1..ab2c1af9acd6 100644 --- a/spec/features/work_packages/table/context_menu/context_menu_shared_examples.rb +++ b/spec/features/work_packages/table/context_menu/context_menu_shared_examples.rb @@ -56,7 +56,7 @@ open_context_menu.call menu.choose("Create new child") expect(page).to have_css(".inline-edit--container.subject input") - expect(current_url).to match(/.*\/create_new\?.*(&)*parent_id=#{work_package.id}/) + expect(current_url).to match(/.*\/(create_new|details\/new)\?.*(&)*parent_id=#{work_package.id}/) find_by_id("work-packages--edit-actions-cancel").click expect(page).to have_no_css(".inline-edit--container.subject input") @@ -82,7 +82,7 @@ open_context_menu.call menu.choose("Create new child") expect(page).to have_css(".inline-edit--container.subject input") - expect(current_url).to match(/.*\/create_new\?.*(&)*parent_id=#{work_package.id}/) + expect(current_url).to match(/.*\/(create_new|details\/new)\?.*(&)*parent_id=#{work_package.id}/) split_view = Pages::SplitWorkPackageCreate.new project: work_package.project subject = split_view.edit_field(:subject) diff --git a/spec/support/pages/work_packages/work_package_cards.rb b/spec/support/pages/work_packages/work_package_cards.rb index 388e39e8c7e8..325b1e697cbc 100644 --- a/spec/support/pages/work_packages/work_package_cards.rb +++ b/spec/support/pages/work_packages/work_package_cards.rb @@ -83,7 +83,7 @@ def open_split_view_by_info_icon(work_package) # The offset is needed to ensure that the resizer does not catch the click, instead of the info icon element.hover.find('[data-test-selector="op-wp-single-card--details-button"]').click(x: -5, y: 0) - ::Pages::SplitWorkPackage.new(work_package, project) + ::Pages::PrimerizedSplitWorkPackage.new(work_package, project) end def drag_and_drop_work_package(from:, to:)