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 00000000000..8e389eff581 --- /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 00000000000..b33309649ed --- /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 9fc30f347ab..5b12485489a 100644 --- a/app/helpers/work_packages/split_view_helper.rb +++ b/app/helpers/work_packages/split_view_helper.rb @@ -33,6 +33,14 @@ module WorkPackages::SplitViewHelper params[:work_package_split_view].present? end + def render_work_package_split_create? + params[:work_package_split_create].present? + end + + def split_create_instance + WorkPackages::SplitCreateComponent.new(project_identifier: params[:project_id]) + end + def split_view_instance WorkPackages::SplitViewComponent.new(id: params[:work_package_id], tab: params[:tab], 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 00000000000..f6140ad4c93 --- /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 c7c37370d5c..1c97c54808f 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -144,6 +144,9 @@ 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'; @@ -393,6 +396,7 @@ 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-wp-full-view', WorkPackageFullViewEntryComponent, { injector }); 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 3a51ebeb6ad..4bb84a74627 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 @@ -289,6 +289,12 @@ export class OpWorkPackagesCalendarService extends UntilDestroyedMixin { Turbo.visit(link, { frame: 'content-bodyRight', action: 'advance' }); } + public openSplitCreate():void { + const basePath = window.location.pathname.replace(/\/details\/.*$/, ''); + const link = `${basePath}/details/new${window.location.search}`; + Turbo.visit(link, { frame: 'content-bodyRight', action: 'advance' }); + } + public openFullView(id:string):void { this.wpTableSelection.setSelection(id, -1); Turbo.visit(this.pathHelper.workPackagePath(id)); 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 fb4b9b42630..48a3ef0eafb 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,82 @@ 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 date defaults passed via URL params (e.g. when dragging to create on the calendar). + const startDate = params.get('startDate'); + const dueDate = params.get('dueDate'); + const ignoreNonWorkingDays = params.get('ignoreNonWorkingDays'); + if (startDate || dueDate || ignoreNonWorkingDays) { + this.stateParams = { + ...this.stateParams, + defaults: { + _links: {}, + ...this.stateParams?.defaults, + ...(startDate ? { startDate } : {}), + ...(dueDate ? { dueDate } : {}), + ...(ignoreNonWorkingDays ? { ignoreNonWorkingDays: true } : {}), + }, + }; + } + } + + 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 490ca2e61c0..19616116d94 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 @@ -406,6 +406,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: [ @@ -590,6 +593,7 @@ import { WorkPackageFullViewEntryComponent } from 'core-app/features/work-packag WorkPackageDetailsViewButtonComponent, WorkPackageSplitViewComponent, WorkPackageSplitViewEntryComponent, + WorkPackageSplitCreateEntryComponent, WorkPackageBreadcrumbComponent, WorkPackageSplitViewToolbarComponent, WorkPackageWatcherButtonComponent, 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 00000000000..45b2a759e25 --- /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/modules/calendar/app/controllers/calendar/calendars_controller.rb b/modules/calendar/app/controllers/calendar/calendars_controller.rb index c515bdb82b3..352c97749bd 100644 --- a/modules/calendar/app/controllers/calendar/calendars_controller.rb +++ b/modules/calendar/app/controllers/calendar/calendars_controller.rb @@ -36,6 +36,7 @@ module ::Calendar 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[show split_view destroy] menu_item :calendar_view @@ -66,6 +67,18 @@ module ::Calendar 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 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. diff --git a/modules/calendar/app/views/calendar/calendars/show.html.erb b/modules/calendar/app/views/calendar/calendars/show.html.erb index 9866eca179e..c0101485696 100644 --- a/modules/calendar/app/views/calendar/calendars/show.html.erb +++ b/modules/calendar/app/views/calendar/calendars/show.html.erb @@ -36,4 +36,5 @@ See COPYRIGHT and LICENSE files for more details. <% 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? %> + <%= render(split_create_instance) if render_work_package_split_create? %> <% end %> diff --git a/modules/calendar/config/routes.rb b/modules/calendar/config/routes.rb index d23071a44ed..997d536f18b 100644 --- a/modules/calendar/config/routes.rb +++ b/modules/calendar/config/routes.rb @@ -9,6 +9,10 @@ Rails.application.routes.draw do end get "/ical" => "calendar/ical#show", on: :member, as: "ical" 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 }, diff --git a/modules/calendar/lib/open_project/calendar/engine.rb b/modules/calendar/lib/open_project/calendar/engine.rb index 6fb9244572e..031234fdd6d 100644 --- a/modules/calendar/lib/open_project/calendar/engine.rb +++ b/modules/calendar/lib/open_project/calendar/engine.rb @@ -28,7 +28,7 @@ module OpenProject::Calendar settings: {} do project_module :calendar_view, dependencies: :work_package_tracking do permission :view_calendar, - { "calendar/calendars": %i[index show split_view new], + { "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/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 c6d85903af1..d8e96b987df 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 @@ RSpec.describe "Team planner add existing work packages", # 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