[67007] Render the WP full view from rails (#20109)

* Create a FullView::CopyComponent for WorkPackages which is routed from rails

* Remove angular splitCopy route and component as it was overwritten by the angular fullCopy route for quite some time already and nobody complained. So we decided to remove the splitCopy completely

* Create FullView::CreateComponent for WorkPackages which is now routed from rails instead of Angular

* First draft of implementing the FullView route for WorkPackages from rails

* Pass correct tab from the URL to the FullView::ShowComponent

* Do a hard reload to "create" route when we are not routed from Angular

* Adapt routing spec to new WorkPackage routes and to some fine-tuning with the WP routes

* Show correct tab in WP Full view and change URL when clicking a tab entry

* Adapt to new rails based routing

* Fix some routes and redirects

* Make sure, the split screen stil renders correctly

* Remove back button from WP full view

* Fix routing issues

* Start fixing specs

* Attempt to override the browser history to be able to use browser back

* Use helper function to build new WP url string

* Adapt spec that now partially renders backend toasts

* Remove ability to move to fullscreen

Theoretically, we can re-add it by posting to some form endpoint, but
not worth it for the first iteration

* Disable cache-control on angular routed pages, so back links work

* Fix double click to fullscreen

* Adapt navigation and title setting

* Let WP breadcrumb to a hard reload instead of Angular transition

* Redirect when the WP route is incomplete (this is the attempt to re-implement an angular functionality)

* Navigate with Turbo when double clicking a card

* Adapt onboarding tour to new hard reload when switching to WP full view

* Fix some specs

* Fix more tests

* Hide Overview tab on FullView

* Correct check for incomplete routes

* Do a hard refresh when coming from slpit screen to full view

* Fix notification navigation

* Adapt attachment spec as the tab switch cannot be done anymore while dragging

* Fix more tests

* Please rubocop and fix more tests

* Attempt to fix navigation_spec

* Add debian_base for pullpreview

---------

Co-authored-by: Oliver Günther <mail@oliverguenther.de>
This commit is contained in:
Henriette Darge
2025-11-03 09:02:16 +01:00
committed by GitHub
parent ac07189ffb
commit 655756631f
119 changed files with 1106 additions and 662 deletions
@@ -0,0 +1,11 @@
<%=
helpers.angular_component_tag "opce-wp-full-copy",
inputs: {
type: @type,
copiedFromWorkPackageId: @copied_from_work_package_id,
projectIdentifier: @project.present? ? @project.identifier : nil,
resizerClass: "op-work-package-split-view",
routedFromAngular: false,
}
%>
@@ -0,0 +1,18 @@
# frozen_string_literal: true
module WorkPackages
module FullView
class CopyComponent < ApplicationComponent
include OpPrimer::ComponentHelpers
include OpTurbo::Streamable
def initialize(type:, copied_from_work_package_id:, project:)
super
@type = type
@copied_from_work_package_id = copied_from_work_package_id
@project = project
end
end
end
end
@@ -0,0 +1,7 @@
<%= helpers.angular_component_tag "opce-wp-full-create",
inputs: {
type: @type,
projectIdentifier: @project.present? ? @project.identifier : nil,
routedFromAngular: false,
}
%>
@@ -0,0 +1,17 @@
# frozen_string_literal: true
module WorkPackages
module FullView
class CreateComponent < ApplicationComponent
include OpPrimer::ComponentHelpers
include OpTurbo::Streamable
def initialize(type:, project:)
super
@type = type
@project = project
end
end
end
end
@@ -0,0 +1,17 @@
<%=
if @work_package.nil?
render(Primer::Beta::Blankslate.new(spacious: true)) do |component|
component.with_visual_icon(icon: :inbox)
component.with_heading(tag: :h2).with_content(
I18n.t(:error_work_package_id_not_found)
)
end
else
helpers.angular_component_tag "opce-wp-full-view",
inputs: {
workPackageId: @id,
activeTab: @tab,
routedFromAngular: false
}
end
%>
@@ -0,0 +1,24 @@
# frozen_string_literal: true
module WorkPackages
module FullView
class ShowComponent < ApplicationComponent
include OpPrimer::ComponentHelpers
include OpTurbo::Streamable
def self.wrapper_key = :"work-package-full-view"
def initialize(id:, tab: "activity")
super
@id = id
@tab = tab
@work_package = WorkPackage.visible.find_by(id:)
end
def wrapper_uniq_by
@id
end
end
end
end
@@ -258,6 +258,8 @@ class ApplicationController < ActionController::Base
# Find project by project_id if given
def find_optional_project
@project = Project.find(params[:project_id]) if params[:project_id].present?
rescue ActiveRecord::RecordNotFound
render_404
end
# Finds and sets @project based on @object.project
@@ -64,7 +64,7 @@ class WorkPackages::BulkController < ApplicationController
respond_to do |format|
format.html do
redirect_back_or_default(project_work_packages_path(@work_packages.first.project))
redirect_to (project_work_packages_path(@work_packages.first.project))
end
format.json do
head :ok
+56 -14
View File
@@ -39,14 +39,15 @@ class WorkPackagesController < ApplicationController
before_action :authorize_on_work_package,
:project, only: %i[show generate_pdf_dialog generate_pdf]
before_action :load_and_authorize_in_optional_project,
:check_allowed_export,
before_action :check_allowed_export,
:protect_from_unauthorized_export, only: %i[index export_dialog]
before_action :load_and_authorize_in_optional_project, only: %i[index new show copy export_dialog]
before_action :authorize, only: %i[show_conflict_flash_message share_upsell]
authorization_checked! :index, :show, :export_dialog, :generate_pdf_dialog, :generate_pdf
authorization_checked! :index, :show, :new, :copy, :export_dialog, :generate_pdf_dialog, :generate_pdf
before_action :load_and_validate_query, only: %i[index copy], unless: -> { request.format.html? }
before_action :load_and_validate_query, only: :index, unless: -> { request.format.html? }
before_action :load_work_packages, only: :index, if: -> { request.format.atom? }
before_action :load_and_validate_query_for_export, only: :export_dialog
@@ -71,21 +72,34 @@ class WorkPackagesController < ApplicationController
def show
respond_to do |format|
format.html do
if show_route_incomplete?
redirect_to_complete_route
return
end
render :show,
locals: { work_package:, menu_name: project_or_global_menu },
layout: "angular/angular"
locals: { work_package:, menu_name: project_or_global_menu }
end
format.any(*supported_single_formats) do
export_single(request.format.symbol)
end
handle_standard_show_formats(format)
end
end
format.atom do
atom_journals
def copy
respond_to do |format|
format.html do
render :copy,
locals: { query: @query, project: @project, menu_name: project_or_global_menu }
end
end
end
format.all do
head :not_acceptable
def new
respond_to do |format|
format.html do
render :new,
locals: { query: @query, project: @project, menu_name: project_or_global_menu }
end
end
end
@@ -177,6 +191,20 @@ class WorkPackagesController < ApplicationController
private
def handle_standard_show_formats(format)
format.any(*supported_single_formats) do
export_single(request.format.symbol)
end
format.atom do
atom_journals
end
format.all do
head :not_acceptable
end
end
def save_export_settings
# Saving export settings is only allowed for saved queries
return false if @query.new_record?
@@ -213,7 +241,9 @@ class WorkPackagesController < ApplicationController
end
def work_package
@work_package ||= WorkPackage.visible(current_user).find_by(id: params[:id])
return @work_package if defined?(@work_package)
@work_package = WorkPackage.visible(current_user).find_by(id: params[:id])
end
def journals
@@ -258,4 +288,16 @@ class WorkPackagesController < ApplicationController
def login_back_url_params
params.permit(:query_id, :state, :query_props)
end
def redirect_to_complete_route
# redirect /work_packages/:id to a full route with project and tab
redirect_to action: "show",
id: params[:id],
project_id: params[:project_id] || work_package.project.identifier,
tab: params[:tab] || "activity"
end
def show_route_incomplete?
params[:project_id].blank? || params[:tab].blank?
end
end
+5
View File
@@ -277,6 +277,11 @@ class WorkPackage < ApplicationRecord
"#{type.name unless type.is_standard} ##{id}: #{subject}"
end
def infoline(show_standard_type: true)
type_name = show_standard_type || !type.is_standard ? type.name : ""
"#{type_name}: #{subject} (##{id})"
end
# Return true if the work_package is closed, otherwise false
def closed?
status.nil? || status.is_closed?
+1 -1
View File
@@ -133,7 +133,7 @@ module DemoData
url_helpers.project_work_package_path(
id: work_package.id,
project_id: work_package.project.identifier,
state: "activity"
tab: "activity"
)
end
end
@@ -31,6 +31,8 @@ See COPYRIGHT and LICENSE files for more details.
<% html_title(*local_assigns[:page_title]) if local_assigns[:page_title].present? %>
<!-- plug-in specific tags -->
<%= call_hook :view_work_package_overview_attributes %>
<!-- disable Turbo cache for Angular pages -->
<meta name="turbo-cache-control" content="no-cache">
<% end -%>
<%= content_for :content_body do %>
+45
View File
@@ -0,0 +1,45 @@
<%#-- 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.
++#%>
<% html_title(t('activerecord.attributes.project.work_packages')) -%>
<% content_for :sidebar do %>
<%= render partial: "sidebar" %>
<% end %>
<% content_for :header_tags do %>
<%= auto_discovery_link_tag(:atom, {query_id: query, format: "atom", page: nil, key: User.current.rss_key}, title: t(:label_work_package_plural)) %>
<%= auto_discovery_link_tag(:atom, {controller: "/journals", action: "index", query_id: query, format: "atom", page: nil, key: User.current.rss_key}, title: t(:label_changes_details)) %>
<% end %>
<% content_for :content_body do %>
<%= render WorkPackages::FullView::CopyComponent.new(type: params[:type],
copied_from_work_package_id: params[:id],
project: @project) %>
<% end %>
+44
View File
@@ -0,0 +1,44 @@
<%#-- 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.
++#%>
<% html_title(t('activerecord.attributes.project.work_packages')) -%>
<% content_for :sidebar do %>
<%= render partial: "sidebar" %>
<% end %>
<% content_for :header_tags do %>
<%= auto_discovery_link_tag(:atom, {query_id: query, format: "atom", page: nil, key: User.current.rss_key}, title: t(:label_work_package_plural)) %>
<%= auto_discovery_link_tag(:atom, {controller: "/journals", action: "index", query_id: query, format: "atom", page: nil, key: User.current.rss_key}, title: t(:label_changes_details)) %>
<% end %>
<% content_for :content_body do %>
<%= render WorkPackages::FullView::CreateComponent.new(type: params[:type],
project: @project) %>
<% end %>
+5 -1
View File
@@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details.
++#%>
<% html_title h(work_package.to_s) %>
<% html_title h(work_package.infoline) %>
<% content_for :sidebar do %>
<%= render partial: 'sidebar' %>
@@ -38,3 +38,7 @@ See COPYRIGHT and LICENSE files for more details.
{ format: 'atom', key: User.current.rss_key },
title: "#{work_package.project} - #{work_package.to_s}") %>
<% end %>
<% content_for :content_body do %>
<%= render WorkPackages::FullView::ShowComponent.new(id: work_package.id, tab: params[:tab] || "activity") %>
<% end %>
+6 -2
View File
@@ -290,7 +290,8 @@ Rails.application.reloader.to_prepare do
wpt.permission :add_work_packages,
{
work_package_relations: %i[new create]
work_package_relations: %i[new create],
work_packages: %i[new]
},
permissible_on: :project,
dependencies: :view_work_packages,
@@ -313,7 +314,10 @@ Rails.application.reloader.to_prepare do
contract_actions: { work_packages: %i[move] }
wpt.permission :copy_work_packages,
{ "work_packages/moves": %i[new create] },
{
"work_packages/moves": %i[new create],
work_packages: %i[copy]
},
permissible_on: %i[work_package project],
require: :loggedin,
dependencies: :view_work_packages,
+13 -10
View File
@@ -386,7 +386,7 @@ Rails.application.routes.draw do
# work as a catchall for everything under /wiki
get "wiki" => "wiki#show"
resources :work_packages, only: [] do
resources :work_packages, only: %i[index show] do
collection do
get "/report/:detail" => "work_packages/reports#report_details"
get "/report" => "work_packages/reports#report"
@@ -394,14 +394,16 @@ Rails.application.routes.draw do
get "/export_dialog" => "work_packages#export_dialog"
end
get "/copy" => "work_packages#copy", on: :member, as: "copy"
get "/new" => "work_packages#new", on: :collection, as: "new"
get "(/:tab)" => "work_packages#show", on: :member, as: "",
constraints: { id: /\d+/, state: /(?!(shares|copy|dialog)).+/ }
# states managed by client-side routing on work_package#index
get "(/*state)" => "work_packages#index", on: :collection, as: "", constraints: { state: /(?!(dialog)).+/ }
get "(/*state)" => "work_packages#index", on: :collection, as: "", constraints: { state: /(?!(dialog|new)).+/ }
get "/create_new" => "work_packages#index", on: :collection, as: "new_split"
get "/new" => "work_packages#index", on: :collection, as: "new"
# state for show view in project context
get "(/*state)" => "work_packages#show", on: :member, as: "", constraints: { id: /\d+/, state: /(?!(dialog)).+/ }
end
namespace :work_packages do
@@ -715,7 +717,7 @@ Rails.application.routes.draw do
get "/bulk" => "bulk#destroy"
end
resources :work_packages, only: [:index] do
resources :work_packages, only: %i[index show new] do
concerns :shareable
get "hover_card" => "work_packages/hover_card#show", on: :member
@@ -797,12 +799,13 @@ Rails.application.routes.draw do
get "/split_view/get_relations_counter" => "work_packages/split_view#get_relations_counter",
on: :member
get "/copy" => "work_packages#copy", on: :member, as: "copy"
get "(/:tab)" => "work_packages#show", on: :member, as: "", constraints: { id: /\d+/, state: /(?!(shares|new|copy)).+/ }
# states managed by client-side (angular) routing on work_package#show
get "/" => "work_packages#index", on: :collection, as: "index"
get "/create_new" => "work_packages#index", on: :collection, as: "new_split"
get "/new" => "work_packages#index", on: :collection, as: "new", state: "new"
# We do not want to match the work package export routes
get "(/*state)" => "work_packages#show", on: :member, as: "", constraints: { id: /\d+/, state: /(?!(shares|split_view)).+/ }
get "/share_upsell" => "work_packages#share_upsell", on: :collection, as: "share_upsell"
get "/edit" => "work_packages#show", on: :member, as: "edit"
end
+2
View File
@@ -13,6 +13,8 @@ volumes:
x-defaults: &defaults
build:
context: .
args:
DEBIAN_BASE: bookworm
restart: unless-stopped
env_file:
- .env.pullpreview
+6
View File
@@ -204,6 +204,9 @@ import {
OpWpDatePickerInstanceComponent,
} from 'core-app/shared/components/datepicker/wp-date-picker-modal/wp-date-picker-instance.component';
import { TimeEntryTimerService } from 'core-app/shared/components/time_entries/services/time-entry-timer.service';
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 { MyPageComponent } from './features/my-page/my-page.component';
import { DashboardComponent } from './features/overview/dashboard.component';
@@ -392,6 +395,9 @@ export class OpenProjectModule implements DoBootstrap {
registerCustomElement('opce-reminder-settings', ReminderSettingsPageComponent, { injector });
registerCustomElement('opce-notification-center', InAppNotificationCenterComponent, { injector });
registerCustomElement('opce-wp-split-view', WorkPackageSplitViewEntryComponent, { injector });
registerCustomElement('opce-wp-full-view', WorkPackageFullViewEntryComponent, { injector });
registerCustomElement('opce-wp-full-create', WorkPackageFullCreateEntryComponent, { injector });
registerCustomElement('opce-wp-full-copy', WorkPackageFullCopyEntryComponent, { injector });
registerCustomElement('opce-timer-account-menu', TimerAccountMenuComponent, { injector });
registerCustomElement('opce-remote-field-updater', RemoteFieldUpdaterComponent, { injector });
registerCustomElement('opce-wp-date-picker-instance', OpWpDatePickerInstanceComponent, { injector });
@@ -212,6 +212,10 @@ export class PathHelperService {
return `${this.staticBase}/work_packages`;
}
public workPackageNewPath():string {
return `${this.staticBase}/work_packages/new`;
}
public projectWorkPackageNewPath(projectId:string) {
return `${this.workPackagesPath(projectId)}/new`;
}
@@ -300,11 +304,23 @@ export class PathHelperService {
return `${this.staticBase}/work_packages/${id}`;
}
public genericWorkPackagePath(projectIdentifier:string|null, workPackageId:string|number, tab = 'activity') {
if (projectIdentifier) {
return `${this.projectWorkPackagePath(projectIdentifier, workPackageId)}/${tab}`;
}
return `${this.workPackagePath(workPackageId)}/${tab}`;
}
public workPackageShortPath(id:string|number) {
return `${this.staticBase}/wp/${id}`;
}
public workPackageCopyPath(workPackageId:string|number) {
public workPackageCopyPath(projectIdentifier:string|null, workPackageId:string|number) {
if (projectIdentifier) {
return `${this.workPackagesPath(projectIdentifier)}/${workPackageId}/copy`;
}
return `${this.workPackagePath(workPackageId)}/copy`;
}
@@ -316,6 +332,7 @@ export class PathHelperService {
return `${this.workPackagesPath(projectIdentifier)}/details/${workPackageId}`;
}
// Todo: Remove?
public workPackageDetailsCopyPath(projectIdentifier:string, workPackageId:string|number) {
return this.workPackageDetailsPath(projectIdentifier, workPackageId, 'copy');
}
@@ -1,5 +1,5 @@
export const onboardingTourStorageKey = 'openProject-onboardingTour';
export type OnboardingTourNames = 'homescreen'|'workPackages'|'gantt'|'final'|'boards'|'teamPlanner';
export type OnboardingTourNames = 'homescreen'|'workPackages'|'workPackagesFullView'|'gantt'|'final'|'boards'|'teamPlanner';
function matchingFilter(list:NodeListOf<HTMLElement>, filterFunction:(match:HTMLElement) => boolean):HTMLElement|null {
for (let i = 0; i < list.length; i++) {
@@ -18,6 +18,7 @@ import { ganttOnboardingTourSteps } from 'core-app/core/setup/globals/onboarding
import { ConfigurationService } from 'core-app/core/config/configuration.service';
import 'core-vendor/enjoyhint';
import { wpFullViewOnboardingTourSteps } from 'core-app/core/setup/globals/onboarding/tours/work_package_full_view_tour';
declare global {
interface Window {
@@ -77,6 +78,16 @@ function workPackageTour() {
});
}
function workPackageFullViewTour() {
initializeTour('wpFullViewTourFinished');
waitForElement('.work-package--single-view', '#content', () => {
const steps:OnboardingStep[] = wpFullViewOnboardingTourSteps();
startTour(steps);
});
}
function ganttTour(configuration:ConfigurationService) {
initializeTour('ganttTourFinished');
@@ -147,6 +158,9 @@ export function start(name:OnboardingTourNames, configuration:ConfigurationServi
case 'workPackages':
workPackageTour();
break;
case 'workPackagesFullView':
workPackageFullViewTour();
break;
case 'gantt':
ganttTour(configuration);
break;
@@ -76,8 +76,13 @@ export function detectOnboardingTour():void {
void triggerTour('workPackages');
}
// ------------------------------- Tutorial Gantt module -------------------------------
// ------------------------------- Tutorial WP Full page -------------------------------
if (currentTourPart === 'wpTourFinished') {
void triggerTour('workPackagesFullView');
}
// ------------------------------- Tutorial Gantt module -------------------------------
if (currentTourPart === 'wpFullViewTourFinished') {
void triggerTour('gantt');
return;
}
@@ -0,0 +1,29 @@
import { OnboardingStep } from 'core-app/core/setup/globals/onboarding/onboarding_tour';
export function wpFullViewOnboardingTourSteps():OnboardingStep[] {
return [
{
'next .work-packages-full-view--split-left': I18n.t('js.onboarding.steps.wp.full_view'),
showSkip: false,
nextButton: { text: I18n.t('js.onboarding.buttons.next') },
containerClass: '-dark -hidden-arrow',
onNext() {
jQuery('.main-menu--arrow-left-to-project')[0].click();
},
},
{
'next #main-menu-gantt': I18n.t('js.onboarding.steps.wp.gantt_menu'),
showSkip: false,
nextButton: { text: I18n.t('js.onboarding.buttons.next') },
onNext() {
jQuery('#main-menu-gantt')[0].click();
},
},
{
containerClass: '-dark -hidden-arrow',
onBeforeStart() {
window.location.href = `${window.location.origin}/projects/demo-project/gantt`;
},
},
];
}
@@ -3,28 +3,6 @@ import { OnboardingStep } from 'core-app/core/setup/globals/onboarding/onboardin
export function wpOnboardingTourSteps():OnboardingStep[] {
return [
{
'next .wp-table--row': I18n.t('js.onboarding.steps.wp.list'),
showSkip: false,
nextButton: { text: I18n.t('js.onboarding.buttons.next') },
onNext() {
jQuery('.inline-edit--display-field.id a ')[0].click();
},
},
{
'next .work-packages-full-view--split-left': I18n.t('js.onboarding.steps.wp.full_view'),
showSkip: false,
nextButton: { text: I18n.t('js.onboarding.buttons.next') },
containerClass: '-dark -hidden-arrow',
},
{
'next .work-packages-back-button': I18n.t('js.onboarding.steps.wp.back_button'),
showSkip: false,
nextButton: { text: I18n.t('js.onboarding.buttons.next') },
onNext() {
jQuery('.work-packages-back-button')[0].click();
},
},
{
'next .add-work-package': I18n.t('js.onboarding.steps.wp.create_button'),
showSkip: false,
@@ -37,24 +15,16 @@ export function wpOnboardingTourSteps():OnboardingStep[] {
waitForElement('#work-packages-filter-toggle-button .badge', '#content', () => {
resolve(undefined);
});
}),
onNext() {
jQuery('.main-menu--arrow-left-to-project')[0].click();
},
})
},
{
'next #main-menu-gantt': I18n.t('js.onboarding.steps.wp.gantt_menu'),
'next .wp-table--row': I18n.t('js.onboarding.steps.wp.list'),
showSkip: false,
nextButton: { text: I18n.t('js.onboarding.buttons.next') },
onNext() {
jQuery('#main-menu-gantt')[0].click();
const firstId = document.querySelectorAll('.inline-edit--display-field.id a ')[0].innerHTML;
window.location.href = `${window.location.origin}/projects/demo-project/work_packages/${firstId}/activity`;
},
},
{
containerClass: '-dark -hidden-arrow',
onBeforeStart() {
window.location.href = `${window.location.origin}/projects/demo-project/gantt`;
},
},
}
];
}
@@ -110,7 +110,6 @@ export class IFCViewerPageComponent
component: WorkPackageCreateButtonComponent,
inputs: {
stateName$: of(this.newRoute),
allowed: ['work_packages.createWorkPackage', 'work_package.copy'],
},
},
{
@@ -15,9 +15,7 @@ import {
LoadingIndicatorService,
withLoadingIndicator,
} from 'core-app/core/loading-indicator/loading-indicator.service';
import {
WorkPackageInlineCreateService,
} from 'core-app/features/work-packages/components/wp-inline-create/wp-inline-create.service';
import { WorkPackageInlineCreateService } from 'core-app/features/work-packages/components/wp-inline-create/wp-inline-create.service';
import { BoardInlineCreateService } from 'core-app/features/boards/board/board-list/board-inline-create.service';
import { AbstractWidgetComponent } from 'core-app/shared/components/grids/widgets/abstract-widget.component';
import { I18nService } from 'core-app/core/i18n/i18n.service';
@@ -25,59 +23,49 @@ import { ToastService } from 'core-app/shared/components/toaster/toast.service';
import { IsolatedQuerySpace } from 'core-app/features/work-packages/directives/query-space/isolated-query-space';
import { Board } from 'core-app/features/boards/board/board';
import { AuthorisationService } from 'core-app/core/model-auth/model-auth.service';
import {
Highlighting,
} from 'core-app/features/work-packages/components/wp-fast-table/builders/highlighting/highlighting.functions';
import {
WorkPackageCardViewComponent,
} from 'core-app/features/work-packages/components/wp-card-view/wp-card-view.component';
import {
WorkPackageStatesInitializationService,
} from 'core-app/features/work-packages/components/wp-list/wp-states-initialization.service';
import { Highlighting } from 'core-app/features/work-packages/components/wp-fast-table/builders/highlighting/highlighting.functions';
import { WorkPackageCardViewComponent } from 'core-app/features/work-packages/components/wp-card-view/wp-card-view.component';
import { WorkPackageStatesInitializationService } from 'core-app/features/work-packages/components/wp-list/wp-states-initialization.service';
import { BoardService } from 'core-app/features/boards/board/board.service';
import {
HalResourceEditingService,
} from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service';
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 {
BoardActionsRegistryService,
} from 'core-app/features/boards/board/board-actions/board-actions-registry.service';
import { BoardActionsRegistryService } from 'core-app/features/boards/board/board-actions/board-actions-registry.service';
import { BoardActionService } from 'core-app/features/boards/board/board-actions/board-action.service';
import { ComponentType } from '@angular/cdk/portal';
import { CausedUpdatesService } from 'core-app/features/boards/board/caused-updates/caused-updates.service';
import { BoardListMenuComponent } from 'core-app/features/boards/board/board-list/board-list-menu.component';
import { debugLog } from 'core-app/shared/helpers/debug_output';
import {
WorkPackageCardDragAndDropService,
} from 'core-app/features/work-packages/components/wp-card-view/services/wp-card-drag-and-drop.service';
import { WorkPackageCardDragAndDropService } from 'core-app/features/work-packages/components/wp-card-view/services/wp-card-drag-and-drop.service';
import { BoardFiltersService } from 'core-app/features/boards/board/board-filter/board-filters.service';
import { StateService, TransitionService } from '@uirouter/core';
import {
WorkPackageViewFocusService,
} from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-focus.service';
StateService,
TransitionService,
} from '@uirouter/core';
import { WorkPackageViewFocusService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-focus.service';
import { WorkPackageViewSelectionService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-selection.service';
import { BoardListCrossSelectionService } from 'core-app/features/boards/board/board-list/board-list-cross-selection.service';
import {
WorkPackageViewSelectionService,
} from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-selection.service';
import {
BoardListCrossSelectionService,
} from 'core-app/features/boards/board/board-list/board-list-cross-selection.service';
import { debounceTime, filter, map } from 'rxjs/operators';
debounceTime,
filter,
map,
} from 'rxjs/operators';
import { ChangeItem } from 'core-app/shared/components/fields/changeset/changeset';
import { WorkPackageChangeset } from 'core-app/features/work-packages/components/wp-edit/work-package-changeset';
import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { ApiV3Filter } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder';
import {
KeepTabService,
} from 'core-app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service';
import { KeepTabService } from 'core-app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { QueryResource } from 'core-app/features/hal/resources/query-resource';
import { HalEvent, HalEventsService } from 'core-app/features/hal/services/hal-events.service';
import {
HalEvent,
HalEventsService,
} from 'core-app/features/hal/services/hal-events.service';
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import { firstValueFrom } from 'rxjs';
import {
WorkPackageIsolatedQuerySpaceDirective,
} from 'core-app/features/work-packages/directives/query-space/wp-isolated-query-space.directive';
import { WorkPackageIsolatedQuerySpaceDirective } from 'core-app/features/work-packages/directives/query-space/wp-isolated-query-space.directive';
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
export interface DisabledButtonPlaceholder {
text:string;
@@ -160,7 +148,8 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni
public buttonPlaceholder:DisabledButtonPlaceholder|undefined;
constructor(readonly apiv3Service:ApiV3Service,
constructor(
readonly apiv3Service:ApiV3Service,
readonly I18n:I18nService,
readonly state:StateService,
readonly cdRef:ChangeDetectorRef,
@@ -184,7 +173,9 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni
readonly boardActionRegistry:BoardActionsRegistryService,
readonly causedUpdates:CausedUpdatesService,
readonly keepTab:KeepTabService,
readonly $state:StateService) {
readonly currentProject:CurrentProjectService,
readonly pathHelper:PathHelperService,
) {
super(I18n, injector);
}
@@ -498,10 +489,9 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni
openFullViewOnDoubleClick(event:{ workPackageId:string, double:boolean }) {
if (event.double) {
this.state.go(
'work-packages.show',
{ workPackageId: event.workPackageId },
);
const projectIdentifier = this.currentProject.identifier;
const link = this.pathHelper.genericWorkPackagePath(projectIdentifier, event.workPackageId) + window.location.search;
Turbo.visit(link, { action: 'advance' });
}
}
@@ -511,7 +501,7 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni
if (event.requestedState === 'split') {
this.keepTab.goCurrentDetailsState(params);
} else {
this.keepTab.goCurrentShowState(params);
this.keepTab.goCurrentShowState(params.workPackageId);
}
}
@@ -84,9 +84,6 @@ export class BoardPartitionedPageComponent extends UntilDestroyedMixin {
/** Whether the board is editable */
editable:boolean;
/** Go back to boards using back-button */
backButtonCallback:() => void;
/** Current query title to render */
selectedTitle?:string;
@@ -70,9 +70,6 @@ export class WorkPackagesCalendarPageComponent extends PartitionedQuerySpacePage
unsaved_title: this.I18n.t('js.calendar.unsaved_title'),
};
/** Go back using back-button */
backButtonCallback:() => void;
breadcrumbItems() {
return [
{ href: this.pathHelperService.homePath(), text: this.titleService.appTitle },
@@ -176,10 +176,8 @@ export class WorkPackageBaseResource extends HalResource {
* Return "<type name>: <subject> (#<id>)" if type and id are known.
*/
public subjectWithType(truncateSubject = 40):string {
const type = this.type ? `${this.type.name}: ` : '';
const subject = this.subjectWithId(truncateSubject);
return `${type}${subject}`;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
return `${this.type.name}: ${this.subjectWithId(truncateSubject)}`;
}
/**
@@ -187,9 +185,12 @@ export class WorkPackageBaseResource extends HalResource {
*/
public subjectWithId(truncateSubject = 40):string {
const id = isNewResource(this) ? '' : ` (#${this.id || ''})`;
const subject = truncateSubject <= 0 ? this.subject : _.truncate(this.subject, { length: truncateSubject });
return `${subject}${id}`;
return `${this.truncatedSubject(truncateSubject)}${id}`;
}
public truncatedSubject(length = 40):string {
return length <= 0 ? this.subject : _.truncate(this.subject, { length: length });
}
public get isLeaf():boolean {
@@ -14,9 +14,9 @@
class="op-ian-item--work-package-id-link spot-link"
[class.spot-link_inactive]="isMobile()"
[attr.title]="workPackage.subject"
uiSref="work-packages.show"
[uiParams]="{workPackageId: workPackage.id, projects: null, projectPath: null}"
[textContent]="'#' + workPackage.id"
[attr.href]="fullScreenLink()"
(click)="onLinkClick($event)"
>
</a>
@if (project) {
@@ -110,10 +110,20 @@ export class InAppNotificationEntryComponent implements OnInit {
}
showFullView():void {
const href = this.notification._links.resource?.href;
const id = href && HalResource.matchFromLink(href, 'work_packages');
if (!this.workPackageId) {
return;
}
this.storeService.openFullView(id);
const link = this.pathHelper.workPackagePath(this.workPackageId) + window.location.search;
Turbo.visit(link, { action: 'advance' });
}
fullScreenLink():string {
return this.workPackageId ? this.pathHelper.workPackagePath(this.workPackageId) : this.pathHelper.workPackagesPath(null);
}
onLinkClick(e:Event):void {
e.stopPropagation();
}
projectClicked(event:MouseEvent):void { // eslint-disable-line class-methods-use-this
@@ -52,9 +52,6 @@ export class TeamPlannerPageComponent extends PartitionedQuerySpacePageComponent
unsaved_title: this.I18n.t('js.team_planner.unsaved_title'),
};
/** Go back using back-button */
backButtonCallback:() => void;
/** Current query title to render */
selectedTitle = this.text.unsaved_title;
@@ -855,7 +855,7 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit,
if (event.requestedState === 'split') {
this.keepTab.goCurrentDetailsState(params);
} else {
this.keepTab.goCurrentShowState(params);
this.keepTab.goCurrentShowState(params.workPackageId);
}
}
@@ -10,7 +10,3 @@
&--icon
font-size: 1rem
line-height: 22px
&_mobile-limited-width
@media only screen and (max-width: $breakpoint-sm)
width: 64px
@@ -40,21 +40,17 @@ import { I18nService } from 'core-app/core/i18n/i18n.service';
export class BackButtonComponent {
@Input() public linkClass:string;
@Input() public customBackMethod:() => unknown;
public text = {
goBack: this.I18n.t('js.button_back'),
};
constructor(readonly backRoutingService:BackRoutingService,
readonly I18n:I18nService) {
constructor(
readonly backRoutingService:BackRoutingService,
readonly I18n:I18nService,
) {
}
public goBack():void {
if (this.customBackMethod) {
this.customBackMethod();
} else {
this.backRoutingService.goBack();
}
this.backRoutingService.goBack();
}
}
@@ -27,12 +27,16 @@
//++
import {
Component, Input, EventEmitter, Output,
Component,
EventEmitter,
Input,
Output,
} from '@angular/core';
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import { WorkPackageRelationsHierarchyService } from 'core-app/features/work-packages/components/wp-relations/wp-relations-hierarchy/wp-relations-hierarchy.service';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { WorkPackageNotificationService } from 'core-app/features/work-packages/services/notifications/work-package-notification.service';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
@Component({
templateUrl: './wp-breadcrumb-parent.html',
@@ -59,6 +63,7 @@ export class WorkPackageBreadcrumbParentComponent {
protected readonly I18n:I18nService,
protected readonly wpRelationsHierarchy:WorkPackageRelationsHierarchyService,
protected readonly notificationService:WorkPackageNotificationService,
protected readonly pathHelper:PathHelperService,
) {
}
@@ -103,4 +108,9 @@ export class WorkPackageBreadcrumbParentComponent {
this.onSwitch.emit(this.editing);
}
}
public switchToFullscreenForWp(wp:WorkPackageResource):void {
const link = this.pathHelper.genericWorkPackagePath(wp.project?.identifier, wp.id!) + window.location.search;
Turbo.visit(link, { action: 'advance' });
}
}
@@ -2,8 +2,7 @@
@if (parent) {
<a
[attr.title]="parent.name"
uiSref="work-packages.show"
[uiParams]="{ workPackageId: parent.id }"
(click)="switchToFullscreenForWp(parent)"
class="op-wp-breadcrumb-parent nocut"
data-test-selector="op-wp-breadcrumb-parent">
<span [textContent]="parent.name"></span>
@@ -43,10 +43,10 @@ import { CurrentUserService } from 'core-app/core/current-user/current-user.serv
standalone: false,
})
export class WorkPackageCreateButtonComponent extends UntilDestroyedMixin implements OnInit, OnDestroy {
@Input('allowed') allowedWhen:string[];
@Input('stateName$') stateName$:Observable<string>;
@Input() routedFromAngular:boolean = true;
allowed:boolean;
disabled:boolean;
@@ -8,6 +8,7 @@
[stateName]="stateName$ | async"
[dropdownActive]="allowed"
[title]="text.title"
[routedFromAngular]="routedFromAngular"
>
<svg plus-icon size="small"/>
<span class="button--text"
@@ -63,6 +63,17 @@ export class WorkPackageCopyController extends WorkPackageCreateComponent {
});
}
public cancelAndBack() {
this.wpCreate.cancelCreation();
if (this.routedFromAngular) {
this.$state.go(this.cancelState, this.$state.params);
} else {
const link = this.pathHelper.genericWorkPackagePath(this.currentProjectService.id, this.copiedWorkPackageId);
Turbo.visit(link + window.location.search, { action: 'advance' });
}
}
protected createdWorkPackage() {
this.copiedWorkPackageId = this.stateParams.copiedFromWorkPackageId;
return new Promise<WorkPackageChangeset>((resolve, reject) => {
@@ -20,6 +20,7 @@
<button class="button dropdown-relative"
[attr.accesskey]="7"
[title]="text.button_more"
data-test-selector="wp-details-toolbar--show-more-button"
wpSingleContextMenu
[wpSingleContextMenu-workPackage]="workPackage">
<svg
@@ -1,12 +1,19 @@
import { StateService } from '@uirouter/core';
import { KeepTabService } from 'core-app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service';
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator';
export const uiStateLinkClass = '__ui-state-link';
export const checkedClassName = '-checked';
export class UiStateLinkBuilder {
constructor(public readonly $state:StateService,
public readonly keepTab:KeepTabService) {
constructor(
public readonly $state:StateService,
public readonly keepTab:KeepTabService,
public readonly currentProject:CurrentProjectService,
public readonly pathHelper:PathHelperService,
) {
}
public linkToDetails(workPackageId:string, title:string, content:string) {
@@ -21,21 +28,23 @@ export class UiStateLinkBuilder {
const a = document.createElement('a');
let tabState:string;
let tabIdentifier:string;
let href:string;
if (state === 'show') {
tabState = 'work-packages.show.tabs';
tabIdentifier = this.keepTab.currentShowTab;
const projectIdentifier = this.currentProject.identifier;
href = this.pathHelper.genericWorkPackagePath(projectIdentifier, workPackageId, this.keepTab.currentShowTab) + window.location.search;
} else {
tabState = 'work-packages.partitioned.list.details.tabs';
tabIdentifier = this.keepTab.currentDetailsTab;
const tab = this.keepTab.currentDetailsTab;
href = this.$state.href(
'work-packages.partitioned.list.details.tabs',
{
workPackageId,
tab,
},
)
}
a.href = this.$state.href(
tabState,
{
workPackageId,
tabIdentifier,
},
);
a.href = href;
a.classList.add(uiStateLinkClass);
a.dataset.workPackageId = workPackageId;
a.dataset.wpState = state;
@@ -30,6 +30,7 @@ import {
ChangeDetectorRef,
Directive,
Injector,
Input,
OnInit,
ViewChild,
} from '@angular/core';
@@ -64,7 +65,7 @@ import { HalSource } from 'core-app/features/hal/interfaces';
export class WorkPackageCreateComponent extends UntilDestroyedMixin implements OnInit {
public successState:string = splitViewRoute(this.$state);
public cancelState:string = this.$state.current.data.baseRoute;
public cancelState:string = this.$state?.current?.data?.baseRoute;
public newWorkPackage:WorkPackageResource;
@@ -75,12 +76,14 @@ export class WorkPackageCreateComponent extends UntilDestroyedMixin implements O
/** Are we in the copying substates ? */
public copying = false;
public stateParams = this.$transition.params('to');
@Input() public stateParams:any;
public text = {
button_settings: this.I18n.t('js.button_settings'),
};
@Input() public routedFromAngular:boolean = true;
@ViewChild(EditFormComponent, { static: false }) protected editForm:EditFormComponent;
/** Explicitly remember destroy state in this abstract base */
@@ -88,7 +91,6 @@ export class WorkPackageCreateComponent extends UntilDestroyedMixin implements O
constructor(
public readonly injector:Injector,
protected readonly $transition:Transition,
protected readonly $state:StateService,
protected readonly I18n:I18nService,
protected readonly titleService:OpTitleService,
@@ -106,6 +108,12 @@ export class WorkPackageCreateComponent extends UntilDestroyedMixin implements O
}
public ngOnInit() {
// In case the create form is still routed via Angular, the stateParams are empty. We then read the params from the Transition
if (this.routedFromAngular) {
const transition = this.injector.get<Transition>(Transition);
this.stateParams = transition.params('to');
}
this.closeEditFormWhenNewWorkPackageSaved();
this.showForm();
@@ -117,23 +125,22 @@ export class WorkPackageCreateComponent extends UntilDestroyedMixin implements O
super.ngOnDestroy();
}
public switchToFullscreen() {
const type = idFromLink(this.change.value<HalResource>('type')?.href);
void this.$state.go('work-packages.new', { ...this.$state.params, type });
}
public onSaved(params:{ savedResource:WorkPackageResource, isInitial:boolean }) {
const { savedResource, isInitial } = params;
this.editForm?.cancel(false);
if (this.successState) {
if(this.routedFromAngular && this.successState) {
this.$state.go(this.successState, { workPackageId: savedResource.id })
.then(() => {
this.wpViewFocus.updateFocus(savedResource.id!);
this.notificationService.showSave(savedResource, isInitial);
});
} else {
window.OpenProject.pageState = 'submitted';
Turbo.visit(this.pathHelper.projectWorkPackagePath(savedResource.project.identifier, savedResource.id!) + window.location.search);
}
}
protected showForm() {
@@ -188,9 +195,15 @@ export class WorkPackageCreateComponent extends UntilDestroyedMixin implements O
this.titleService.setFirstPart(this.I18n.t('js.work_packages.create.title'));
}
public cancelAndBackToList() {
public cancelAndBack() {
this.wpCreate.cancelCreation();
this.$state.go(this.cancelState, this.$state.params);
if (this.routedFromAngular) {
this.$state.go(this.cancelState, this.$state.params);
} else {
const link = this.stateParams.projectPath ? this.pathHelper.workPackagesPath(this.stateParams.projectPath) : this.pathHelper.workPackagesPath(null);
window.location.href = (link + window.location.search);
}
}
protected createdWorkPackage() {
@@ -37,7 +37,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
standalone: false,
})
export class WorkPackageNewFullViewComponent extends WorkPackageCreateComponent {
public successState = this.$state.current.data.successState as string;
public successState = (this.$state?.current?.data?.successState as string) || '';
breadcrumbItems() {
const items = [];
@@ -27,8 +27,8 @@
</ul>
</div>
<wp-single-view [workPackage]="newWorkPackage"
[showProject]="copying" />
<wp-edit-actions-bar (onCancel)="cancelAndBackToList()" />
[showProject]="copying" />
<wp-edit-actions-bar (onCancel)="cancelAndBack()" />
</edit-form>
</div>
}
@@ -9,18 +9,12 @@
<div class="work-packages--details-content -create-mode">
<div class="work-packages--new-details-header">
<wp-type-status [workPackage]="newWorkPackage" />
<div class="wp--details--switch-fullscreen-wrapper">
<a (click)="switchToFullscreen()"
class="work-packages-show-view-button wp--details--switch-fullscreen ">
<span class="icon-context icon-to-fullscreen"></span>
</a>
</div>
</div>
<wp-single-view [workPackage]="newWorkPackage"
[showProject]="copying" />
</div>
<div class="work-packages--details-toolbar-container">
<wp-edit-actions-bar (onCancel)="cancelAndBackToList()" />
<wp-edit-actions-bar (onCancel)="cancelAndBack()"/>
</div>
<div class="work-packages--details--resizer hidden-for-mobile hide-when-print">
<wp-resizer [elementClass]="'work-packages-partitioned-page--content-right'"
@@ -48,6 +48,7 @@ export class WorkPackageActivityTabComponent extends ActivityPanelBaseController
ngOnInit() {
const { workPackageId } = this.uiRouterGlobals.params as unknown as { workPackageId:string };
this.workPackageId = (this.workPackage.id as string) || workPackageId;
super.ngOnInit();
if (window.location.hash) {
this.activitiesTabContentElement.nativeElement.scrollIntoView();
@@ -27,6 +27,8 @@
//++
import { KeepTabService } from './keep-tab.service';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
describe('keepTab service', () => {
let callback:(transition:any) => void;
@@ -34,6 +36,8 @@ describe('keepTab service', () => {
let $state:any;
let $transitions:any;
let uiRouterGlobals:any;
let pathHelper:any;
let currentProject:any;
let keepTab:KeepTabService;
let defaults:any;
@@ -53,7 +57,7 @@ describe('keepTab service', () => {
params: { tabIdentifier: 'activity' },
};
keepTab = new KeepTabService($state, uiRouterGlobals, $transitions);
keepTab = new KeepTabService($state, uiRouterGlobals, $transitions, pathHelper, currentProject);
defaults = {
showTab: 'work-packages.show.tabs',
@@ -27,11 +27,16 @@
//++
import {
StateService, Transition, TransitionService, UIRouterGlobals,
StateService,
Transition,
TransitionService,
UIRouterGlobals,
} from '@uirouter/core';
import { ReplaySubject } from 'rxjs';
import { Injectable } from '@angular/core';
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 { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
@Injectable({ providedIn: 'root' })
export class KeepTabService {
@@ -39,9 +44,13 @@ export class KeepTabService {
protected subject = new ReplaySubject<{ [tab:string]:string; }>(1);
constructor(protected $state:StateService,
constructor(
protected $state:StateService,
protected uiRouterGlobals:UIRouterGlobals,
protected $transitions:TransitionService) {
protected $transitions:TransitionService,
protected pathHelper:PathHelperService,
protected currentProject:CurrentProjectService,
) {
this.updateTabs();
$transitions.onSuccess({}, (transition:Transition) => {
this.updateTabs(transition.params('to').tabIdentifier);
@@ -63,15 +72,9 @@ export class KeepTabService {
return this.currentDetailsTab;
}
public goCurrentShowState(params:Record<string, unknown> = {}):void {
this.$state.go(
'work-packages.show.tabs',
{
...this.uiRouterGlobals.params,
...params,
tabIdentifier: this.currentShowTab,
},
);
public goCurrentShowState(workPackageId:string):void {
const projectIdentifier = this.currentProject.identifier;
window.location.href = this.pathHelper.genericWorkPackagePath(projectIdentifier, workPackageId, this.currentShowTab) + window.location.search;
}
public goCurrentDetailsState(params:Record<string, unknown> = {}):void {
@@ -95,6 +95,7 @@ export class WorkPackageWatchersTabComponent extends UntilDestroyedMixin impleme
public ngOnInit() {
this.$element = jQuery(this.elementRef.nativeElement);
const { workPackageId } = this.uiRouterGlobals.params as unknown as { workPackageId:string };
this.workPackageId = (this.workPackage.id as string) || workPackageId;
@@ -180,7 +181,9 @@ export class WorkPackageWatchersTabComponent extends UntilDestroyedMixin impleme
}
public updateCounter() {
const url = this.pathHelper.workPackageUpdateCounterPath(this.workPackageId, 'watchers');
void this.turboRequests.request(url);
if (this.workPackageId !== undefined) {
const url = this.pathHelper.workPackageUpdateCounterPath(this.workPackageId, 'watchers');
void this.turboRequests.request(url);
}
}
}
@@ -26,7 +26,10 @@
// See COPYRIGHT and LICENSE files for more details.
//++
import { Component, Input, OnInit } from '@angular/core';
import {
Component,
Input,
} from '@angular/core';
import { UIRouterGlobals } from '@uirouter/core';
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import { randomString } from 'core-app/shared/helpers/random-string';
@@ -38,29 +41,15 @@ import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
templateUrl: './wp-subject.html',
standalone: false,
})
export class WorkPackageSubjectComponent extends UntilDestroyedMixin implements OnInit {
export class WorkPackageSubjectComponent extends UntilDestroyedMixin {
@Input('workPackage') workPackage:WorkPackageResource;
public readonly uniqueElementIdentifier = `work-packages--subject-type-row-${randomString(16)}`;
constructor(protected uiRouterGlobals:UIRouterGlobals,
protected apiV3Service:ApiV3Service) {
constructor(
protected uiRouterGlobals:UIRouterGlobals,
protected apiV3Service:ApiV3Service,
) {
super();
}
ngOnInit() {
if (!this.workPackage) {
this
.apiV3Service
.work_packages
.id(this.uiRouterGlobals.params.workPackageId)
.requireAndStream()
.pipe(
this.untilDestroyed(),
)
.subscribe((wp:WorkPackageResource) => {
this.workPackage = wp;
});
}
}
}
@@ -20,6 +20,7 @@ import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
import { WorkPackageStatesInitializationService } from '../../wp-list/wp-states-initialization.service';
import { firstValueFrom } from 'rxjs';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
@Directive()
export abstract class WorkPackageEmbeddedBaseComponent extends WorkPackagesViewBase implements AfterViewInit {
@@ -53,6 +54,8 @@ export abstract class WorkPackageEmbeddedBaseComponent extends WorkPackagesViewB
@InjectField() currentProject:CurrentProjectService;
@InjectField() pathHelper:PathHelperService;
@InjectField() cdRef:ChangeDetectorRef;
ngOnInit() {
@@ -181,10 +181,9 @@ export class WorkPackageEmbeddedTableComponent extends WorkPackageEmbeddedBaseCo
handleWorkPackageClicked(event:{ workPackageId:string; double:boolean }) {
if (event.double) {
this.$state.go(
'work-packages.show',
{ workPackageId: event.workPackageId },
);
const projectIdentifier = this.currentProject.identifier;
const link = this.pathHelper.genericWorkPackagePath(projectIdentifier, event.workPackageId) + window.location.search;
Turbo.visit(link, { action: 'advance' });
}
}
@@ -197,7 +196,7 @@ export class WorkPackageEmbeddedTableComponent extends WorkPackageEmbeddedBaseCo
if (event.requestedState === 'split') {
this.keepTab.goCurrentDetailsState(params);
} else {
this.keepTab.goCurrentShowState(params);
this.keepTab.goCurrentShowState(params.workPackageId);
}
}
}
@@ -4,13 +4,19 @@ import { opIconElement } from 'core-app/shared/helpers/op-icon-builder';
import { KeepTabService } from 'core-app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service';
import { UiStateLinkBuilder } from 'core-app/features/work-packages/components/wp-fast-table/builders/ui-state-link-builder';
import { StateService } from '@uirouter/core';
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
export const detailsLinkClassName = 'wp-table--details-link';
export class OpDetailsTableAction extends OpTableAction {
public readonly identifier = 'open-details-action';
private uiStatebuilder = new UiStateLinkBuilder(this.injector.get(StateService), this.injector.get(KeepTabService));
private uiStatebuilder = new UiStateLinkBuilder(
this.injector.get(StateService),
this.injector.get(KeepTabService),
this.injector.get(CurrentProjectService),
this.injector.get(PathHelperService));
private text = {
button: this.I18n.t('js.button_open_details'),
@@ -10,7 +10,7 @@ export interface TabComponent extends Component {
export interface WpTabDefinition extends TabDefinition {
component:Type<TabComponent>;
displayable?:(workPackage:WorkPackageResource, $state:StateService) => boolean;
displayable?:(workPackage:WorkPackageResource, $state:StateService|null) => boolean;
count?:(workPackage:WorkPackageResource, injector:Injector) => Observable<number>;
showCountAsBubble?:boolean;
}
@@ -29,6 +29,7 @@
import { UIRouterGlobals } from '@uirouter/core';
import {
Component,
Input,
OnInit,
} from '@angular/core';
import { I18nService } from 'core-app/core/i18n/i18n.service';
@@ -45,6 +46,9 @@ import { WorkPackageResource } from 'core-app/features/hal/resources/work-packag
standalone: false,
})
export class WpTabWrapperComponent implements OnInit {
@Input() public workPackageId:string;
@Input() public tabIdentifier:string;
workPackage:WorkPackageResource;
ndcDynamicInputs$:Observable<{
@@ -52,17 +56,18 @@ export class WpTabWrapperComponent implements OnInit {
tab:WpTabDefinition | undefined;
}>;
get workPackageId():string {
const { workPackageId } = this.uiRouterGlobals.params as unknown as { workPackageId:string };
return workPackageId;
}
constructor(readonly I18n:I18nService,
constructor(
readonly I18n:I18nService,
readonly uiRouterGlobals:UIRouterGlobals,
readonly apiV3Service:ApiV3Service,
readonly wpTabsService:WorkPackageTabsService) {}
readonly wpTabsService:WorkPackageTabsService
) {}
ngOnInit() {
if (this.workPackageId === undefined) {
this.workPackageId = this.uiRouterGlobals.params.workPackageId;
}
this.ndcDynamicInputs$ = this
.apiV3Service
.work_packages
@@ -77,8 +82,10 @@ export class WpTabWrapperComponent implements OnInit {
}
findTab(workPackage:WorkPackageResource):WpTabDefinition | undefined {
const { tabIdentifier } = this.uiRouterGlobals.params as unknown as { tabIdentifier:string };
if (this.tabIdentifier === undefined) {
this.tabIdentifier = this.uiRouterGlobals.params.tabIdentifier;
}
return this.wpTabsService.getTab(tabIdentifier, workPackage);
return this.wpTabsService.getTab(this.tabIdentifier, workPackage);
}
}
@@ -1,5 +1,6 @@
<op-scrollable-tabs
[tabs]="tabs"
[currentTabId]="currentTabId"
class="op-work-package-tabs"
>
@if (view === 'split') {
@@ -8,6 +8,8 @@ import { ScrollableTabsComponent } from 'core-app/shared/components/tabs/scrolla
import { WorkPackageTabsService } from 'core-app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-tabs.service';
import { WpTabsComponent } from './wp-tabs.component';
import { TabComponent } from '../wp-tab-wrapper/tab';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
describe('WpTabsComponent', () => {
class TestComponent implements TabComponent {
@@ -39,6 +41,8 @@ describe('WpTabsComponent', () => {
{ provide: StateService, useValue: { includes: () => false } },
{ provide: UIRouterGlobals, useValue: {} },
{ provide: KeepTabService, useValue: {} },
{ provide: CurrentProjectService, useValue: {} },
{ provide: PathHelperService, useValue: {} },
WorkPackageTabsService,
],
schemas: [NO_ERRORS_SCHEMA]
@@ -9,6 +9,9 @@ import {
WorkPackageTabsService,
} from 'core-app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-tabs.service';
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
import { WpTabDefinition } from 'core-app/features/work-packages/components/wp-tabs/components/wp-tab-wrapper/tab';
@Component({
selector: 'op-wp-tabs',
@@ -22,9 +25,11 @@ export class WpTabsComponent implements OnInit {
@Input() view:'full'|'split';
public tabs:TabDefinition[];
@Input() routedFromAngular:boolean = true;
public uiSrefBase:string;
@Input() public currentTabId:string|null = null;
public tabs:TabDefinition[];
public canViewWatchers = false;
@@ -42,28 +47,38 @@ export class WpTabsComponent implements OnInit {
readonly $state:StateService,
readonly uiRouterGlobals:UIRouterGlobals,
readonly keepTab:KeepTabService,
readonly pathHelper:PathHelperService,
readonly currentProject:CurrentProjectService,
) {
}
ngOnInit():void {
this.uiSrefBase = this.view === 'split' ? '' : 'work-packages.show';
this.canViewWatchers = !!(this.workPackage && this.workPackage.watchers);
this.tabs = this.getDisplayableTabs();
}
private getDisplayableTabs() {
private getDisplayableTabs():WpTabDefinition[]{
return this
.wpTabsService
.getDisplayableTabs(this.workPackage)
.map((tab) => ({
...tab,
route: `${this.uiSrefBase}.tabs`,
routeParams: { workPackageId: this.workPackage.id, tabIdentifier: tab.id },
}));
.getDisplayableTabs(this.workPackage, this.routedFromAngular)
.map((tab) => {
if (this.routedFromAngular) {
return ({
...tab,
route: '.tabs',
routeParams: { workPackageId: this.workPackage.id, tabIdentifier: tab.id },
});
}
return ({
...tab,
path: this.pathHelper.genericWorkPackagePath(this.currentProject.identifier, this.workPackage.id!, tab.id),
});
});
}
public switchToFullscreen():void {
this.keepTab.goCurrentShowState();
this.keepTab.goCurrentShowState(this.workPackage.id!);
}
public close():void {
@@ -92,11 +92,11 @@ export class WorkPackageTabsService {
}
}
getDisplayableTabs(workPackage:WorkPackageResource):WpTabDefinition[] {
getDisplayableTabs(workPackage:WorkPackageResource, routedFromAngular = true):WpTabDefinition[] {
return this
.tabs
.filter(
(tab) => !tab.displayable || tab.displayable(workPackage, this.$state),
(tab) => !tab.displayable || tab.displayable(workPackage, routedFromAngular ? this.$state : null),
)
.map(
(tab) => ({
@@ -118,7 +118,7 @@ export class WorkPackageTabsService {
component: WorkPackageOverviewTabComponent,
name: this.I18n.t('js.work_packages.tabs.overview'),
id: 'overview',
displayable: (_, $state) => $state.includes('**.details.*'),
displayable: (_, $state) => $state ? $state.includes('**.details.*') : false,
},
{
id: 'activity',
@@ -297,9 +297,6 @@ import {
GroupDescriptor,
WorkPackageSingleViewComponent,
} from 'core-app/features/work-packages/components/wp-single-view/wp-single-view.component';
import {
WorkPackageCopySplitViewComponent,
} from 'core-app/features/work-packages/components/wp-copy/wp-copy-split-view.component';
import {
WorkPackageFormAttributeGroupComponent,
} from 'core-app/features/work-packages/components/wp-form-group/wp-attribute-group.component';
@@ -407,6 +404,9 @@ import { OpenprojectEnterpriseModule } from 'core-app/features/enterprise/openpr
import {
WorkPackageReminderContextMenuDirective,
} from 'core-app/features/work-packages/components/wp-buttons/wp-reminder-button/wp-reminder-context-menu.directive';
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';
@NgModule({
imports: [
@@ -491,7 +491,6 @@ import {
// WP Copy
WorkPackageCopyFullViewComponent,
WorkPackageCopySplitViewComponent,
// Embedded table
WorkPackageEmbeddedTableComponent,
@@ -602,6 +601,9 @@ import {
// Full view
WorkPackagesFullViewComponent,
WorkPackageFullViewEntryComponent,
WorkPackageFullCopyEntryComponent,
WorkPackageFullCreateEntryComponent,
// Modals
WpTableConfigurationModalComponent,
@@ -5,11 +5,6 @@
/>
<div class="toolbar-container -editable">
<div class="toolbar">
@if (backButtonCallback !== undefined) {
<op-back-button class="op-back-button"
linkClass="op-back-button_mobile-limited-width"
[customBackMethod]="backButtonCallback" />
}
<h2 class="toolbar-title--container">
<editable-toolbar-title [title]="selectedTitle"
[inFlight]="toolbarDisabled"
@@ -60,14 +60,11 @@
grid-template-rows: auto auto
justify-items: start
.op-back-button
grid-column: 1 / 3
grid-row: 1 / 2
.toolbar-title--container
grid-column: 1 / 3
grid-row: 2 / 3
grid-row: 1 / 2
overflow: hidden
.toolbar-items
grid-column: 1 / 3
grid-row: 3 / 4
grid-row: 2 / 3
justify-self: end
@@ -128,9 +128,6 @@ export class PartitionedQuerySpacePageComponent extends WorkPackagesViewBase imp
/** We need to pass the correct partition state to the view to manage the grid */
currentPartition:ViewPartitionState = '-split';
/** What route (if any) should we go back to using the back button left of the title? */
backButtonCallback:() => void|undefined;
/** Which filter container component to mount */
filterContainerDefinition:DynamicComponentDefinition = {
component: WorkPackageFilterContainerComponent,
@@ -35,6 +35,6 @@ import { StateService } from '@uirouter/angular';
*/
export function splitViewRoute(state:StateService, target:'details'|'new' = 'details'):string {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment
const baseRoute:string = state.current.data.baseRoute || '';
const baseRoute:string = state?.current?.data?.baseRoute || '';
return `${baseRoute}.${target}`;
}
@@ -30,7 +30,6 @@ import { WorkPackageNewSplitViewComponent } from 'core-app/features/work-package
import { Ng2StateDeclaration } from '@uirouter/angular';
import { ComponentType } from '@angular/cdk/overlay';
import { WpTabWrapperComponent } from 'core-app/features/work-packages/components/wp-tabs/components/wp-tab-wrapper/wp-tab-wrapper.component';
import { WorkPackageCopySplitViewComponent } from 'core-app/features/work-packages/components/wp-copy/wp-copy-split-view.component';
/**
* Return a set of routes for a split view mounted under the given base route,
@@ -125,23 +124,5 @@ export function makeSplitViewRoutes(baseRoute:string,
'content-right@^.^': { component: newComponent },
},
},
// Split copy route
{
name: `${routeName}.copy`,
url: '/details/{copiedFromWorkPackageId:[0-9]+}/copy',
views: {
'content-right@^.^': { component: WorkPackageCopySplitViewComponent },
},
reloadOnSearch: false,
data: {
baseRoute,
parent: baseRoute,
allowMovingInEditMode: true,
bodyClasses: 'router--work-packages-partitioned-split-view',
menuItem: menuItemClass,
partition: '-split',
mobileAlternative: showMobileAlternative ? 'work-packages.show' : undefined,
},
},
];
}
@@ -27,7 +27,6 @@
//++
import { WpTabWrapperComponent } from 'core-app/features/work-packages/components/wp-tabs/components/wp-tab-wrapper/wp-tab-wrapper.component';
import { WorkPackageNewFullViewComponent } from 'core-app/features/work-packages/components/wp-new/wp-new-full-view.component';
import { WorkPackagesFullViewComponent } from 'core-app/features/work-packages/routing/wp-full-view/wp-full-view.component';
import { WorkPackageSplitViewComponent } from 'core-app/features/work-packages/routing/wp-split-view/wp-split-view.component';
import { Ng2StateDeclaration } from '@uirouter/angular';
@@ -68,70 +67,6 @@ export const WORK_PACKAGES_ROUTES:Ng2StateDeclaration[] = [
name: { type: 'string', dynamic: true },
},
},
{
name: 'work-packages.new',
url: '/new?type&parent_id',
component: WorkPackageNewFullViewComponent,
reloadOnSearch: false,
params: {
defaults: {
value: null,
},
},
data: {
baseRoute: 'work-packages',
allowMovingInEditMode: true,
bodyClasses: 'router--work-packages-full-create',
menuItem: menuItemClass,
successState: 'work-packages.show',
sideMenuOptions,
},
},
{
name: 'work-packages.copy',
url: '/{copiedFromWorkPackageId:[0-9]+}/copy',
component: WorkPackageCopyFullViewComponent,
reloadOnSearch: false,
data: {
baseRoute: 'work-packages',
allowMovingInEditMode: true,
bodyClasses: 'router--work-packages-full-create',
menuItem: menuItemClass,
sideMenuOptions,
},
},
{
name: 'work-packages.show',
url: '/{workPackageId:[0-9]+}',
// Redirect to 'activity' by default.
redirectTo: (trans) => {
const params = trans.params('to');
const keepTab = trans.injector().get(KeepTabService) as KeepTabService;
const tabIdentifier = keepTab.currentShowTab;
return {
state: 'work-packages.show.tabs',
params: { ...params, tabIdentifier: tabIdentifier || 'activity' },
};
},
component: WorkPackagesFullViewComponent,
data: {
baseRoute: 'work-packages',
bodyClasses: ['router--work-packages-full-view', 'router--work-packages-base'],
newRoute: 'work-packages.new',
menuItem: menuItemClass,
sideMenuOptions,
},
},
{
name: 'work-packages.show.tabs',
url: '/:tabIdentifier',
component: WpTabWrapperComponent,
data: {
parent: 'work-packages.show',
menuItem: menuItemClass,
sideMenuOptions,
},
},
{
name: 'work-packages.partitioned',
component: WorkPackageViewPageComponent,
@@ -0,0 +1,62 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See COPYRIGHT and LICENSE files for more details.
//++
import { ChangeDetectionStrategy, Component, ElementRef, Input } 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 view
*/
@Component({
hostDirectives: [WorkPackageIsolatedQuerySpaceDirective],
standalone: false,
template: `
<wp-copy-full-view
[stateParams]="{ type: type, parent_id: parentId, projectPath: projectIdentifier, copiedFromWorkPackageId: copiedFromWorkPackageId }"
[routedFromAngular]="routedFromAngular"
></wp-copy-full-view>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WorkPackageFullCopyEntryComponent {
@Input() type:string;
@Input() copiedFromWorkPackageId:string;
@Input() parentId?:string;
@Input() projectIdentifier?:string;
@Input() routedFromAngular:boolean;
constructor(readonly elementRef:ElementRef) {
populateInputsFromDataset(this);
document.body.classList.add('router--work-packages-full-create');
}
}
@@ -0,0 +1,61 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See COPYRIGHT and LICENSE files for more details.
//++
import { ChangeDetectionStrategy, Component, ElementRef, Input } 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 view
*/
@Component({
hostDirectives: [WorkPackageIsolatedQuerySpaceDirective],
standalone: false,
template: `
<wp-new-full-view
[stateParams]="{ type: type, parent_id: parentId, projectPath: projectIdentifier }"
[routedFromAngular]="routedFromAngular"
></wp-new-full-view>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WorkPackageFullCreateEntryComponent {
@Input() type:string;
@Input() parentId?:string;
@Input() projectIdentifier?:string;
@Input() routedFromAngular:boolean;
constructor(readonly elementRef:ElementRef) {
populateInputsFromDataset(this);
document.body.classList.add('router--work-packages-full-create');
}
}
@@ -26,14 +26,36 @@
// See COPYRIGHT and LICENSE files for more details.
//++
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { WorkPackageCopyController } from 'core-app/features/work-packages/components/wp-copy/wp-copy.controller';
import { ChangeDetectionStrategy, Component, ElementRef, Input } 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 full view
*/
@Component({
selector: 'wp-copy-split-view',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: '../wp-new/wp-new-split-view.html',
hostDirectives: [WorkPackageIsolatedQuerySpaceDirective],
standalone: false,
template: `
<op-wp-full-view
[workPackageId]="workPackageId"
[activeTab]="activeTab"
[routedFromAngular]="routedFromAngular"
></op-wp-full-view>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WorkPackageCopySplitViewComponent extends WorkPackageCopyController {
export class WorkPackageFullViewEntryComponent {
@Input() workPackageId:string;
@Input() activeTab:string;
@Input() routedFromAngular:boolean;
constructor(readonly elementRef:ElementRef) {
populateInputsFromDataset(this);
document.body.classList.add('router--work-packages-full-view', 'router--work-packages-base');
}
}
@@ -31,10 +31,10 @@ import {
Component,
HostListener,
Injector,
Input,
OnInit,
} from '@angular/core';
import { StateService } from '@uirouter/core';
import { ConfigurationService } from 'core-app/core/config/configuration.service';
import { CurrentUserService } from 'core-app/core/current-user/current-user.service';
import { RecentItemsService } from 'core-app/core/recent-items.service';
import { ProjectResource } from 'core-app/features/hal/resources/project-resource';
@@ -48,7 +48,7 @@ import { Observable, of } from 'rxjs';
@Component({
templateUrl: './wp-full-view.html',
selector: 'wp-full-view-entry',
selector: 'op-wp-full-view',
// Required class to support inner scrolling on page
host: { class: 'work-packages-page--ui-view' },
providers: [
@@ -78,17 +78,14 @@ export class WorkPackagesFullViewComponent extends WorkPackageSingleViewBase imp
},
};
stateName$ = of('work-packages.new');
constructor(
public injector:Injector,
public wpTableSelection:WorkPackageViewSelectionService,
public recentItemsService:RecentItemsService,
readonly $state:StateService,
readonly currentUserService:CurrentUserService,
private readonly configurationService:ConfigurationService,
) {
super(injector, $state.params.workPackageId);
super(injector);
}
// enable other parts of the application to trigger an immediate update
@@ -7,16 +7,12 @@
<wp-breadcrumb class="wp-show--header-container--breadcrumb"
[workPackage]="workPackage"
/>
<op-back-button class="wp-show--header-container--back-button"
linkClass="work-packages-back-button"
/>
<div class="subject-header wp-show--header-container--subject">
<wp-subject />
<wp-subject [workPackage]="workPackage" />
</div>
<ul id="toolbar-items" class="toolbar-items hide-when-print wp-show--header-container--toolbar-items">
<li class="toolbar-item hidden-for-tablet">
<wp-create-button [allowed]="['work_package.addChild', 'work_package.copy']"
[stateName$]="stateName$" />
<wp-create-button [routedFromAngular]="routedFromAngular" />
</li>
@if (displayShareButton$ | async) {
<li class="toolbar-item"
@@ -70,10 +66,16 @@
<div class="work-packages-full-view--split-right work-packages-tab-view--overflow">
<div class="work-packages--panel-inner">
<span class="sr-only" tabindex="-1" [textContent]="focusAnchorLabel"></span>
<op-wp-tabs [workPackage]="workPackage" [view]="'full'" />
<op-wp-tabs [workPackage]="workPackage"
[currentTabId]="activeTab"
[view]="'full'"
[routedFromAngular]="routedFromAngular"/>
<div class="tabcontent"
data-notification-selector='notification-scroll-container'
ui-view>
data-notification-selector='notification-scroll-container'>
<op-wp-tab
[workPackageId]="workPackage.id!"
[tabIdentifier]="activeTab"
></op-wp-tab>
</div>
</div>
<div class="work-packages-full-view--resizer hidden-for-mobile hide-when-print">
@@ -30,10 +30,11 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Injector,
OnInit,
ElementRef,
inject,
Injector,
NgZone,
OnInit,
} from '@angular/core';
import { take } from 'rxjs/operators';
import { CausedUpdatesService } from 'core-app/features/boards/board/caused-updates/caused-updates.service';
@@ -56,6 +57,7 @@ import { StateService } from '@uirouter/core';
import { KeepTabService } from 'core-app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service';
import { WorkPackageViewBaselineService } from '../wp-view-base/view-services/wp-view-baseline.service';
import { combineLatest } from 'rxjs';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
@Component({
selector: 'wp-list-view',
@@ -71,6 +73,21 @@ import { combineLatest } from 'rxjs';
standalone: false,
})
export class WorkPackageListViewComponent extends UntilDestroyedMixin implements OnInit {
readonly I18n = inject(I18nService);
readonly injector = inject(Injector);
readonly $state = inject(StateService);
readonly keepTab = inject(KeepTabService);
readonly querySpace = inject(IsolatedQuerySpace);
readonly wpViewFilters = inject(WorkPackageViewFiltersService);
readonly deviceService = inject(DeviceService);
readonly CurrentProject = inject(CurrentProjectService);
readonly wpDisplayRepresentation = inject(WorkPackageViewDisplayRepresentationService);
readonly cdRef = inject(ChangeDetectorRef);
readonly elementRef = inject(ElementRef);
readonly ngZone = inject(NgZone);
readonly wpTableBaseline = inject(WorkPackageViewBaselineService);
readonly pathHelper = inject(PathHelperService);
text = {
jump_to_pagination: this.I18n.t('js.work_packages.jump_marks.pagination'),
text_jump_to_pagination: this.I18n.t('js.work_packages.jump_marks.label_pagination'),
@@ -96,24 +113,6 @@ export class WorkPackageListViewComponent extends UntilDestroyedMixin implements
dragAndDropEnabled: true,
};
constructor(
readonly I18n:I18nService,
readonly injector:Injector,
readonly $state:StateService,
readonly keepTab:KeepTabService,
readonly querySpace:IsolatedQuerySpace,
readonly wpViewFilters:WorkPackageViewFiltersService,
readonly deviceService:DeviceService,
readonly CurrentProject:CurrentProjectService,
readonly wpDisplayRepresentation:WorkPackageViewDisplayRepresentationService,
readonly cdRef:ChangeDetectorRef,
readonly elementRef:ElementRef,
private ngZone:NgZone,
readonly wpTableBaseline:WorkPackageViewBaselineService,
) {
super();
}
ngOnInit() {
// Mark tableInformationLoaded when initially loading done
this.setupInformationLoadedListener();
@@ -192,7 +191,7 @@ export class WorkPackageListViewComponent extends UntilDestroyedMixin implements
if (event.requestedState === 'split') {
this.keepTab.goCurrentDetailsState(params);
} else {
this.keepTab.goCurrentShowState(params);
this.openInFullView(event.workPackageId);
}
}
@@ -209,9 +208,7 @@ export class WorkPackageListViewComponent extends UntilDestroyedMixin implements
}
private openInFullView(workPackageId:string) {
this.$state.go(
'work-packages.show',
{ workPackageId },
);
const projectIdentifier = this.CurrentProject.identifier;
window.location.href = this.pathHelper.genericWorkPackagePath(projectIdentifier, workPackageId) + window.location.search;
}
}
@@ -55,5 +55,7 @@ export class WorkPackageSplitViewEntryComponent {
constructor(readonly elementRef:ElementRef) {
populateInputsFromDataset(this);
document.body.classList.add('router--work-packages-partitioned-split-view-details');
}
}
@@ -70,9 +70,7 @@ export class WorkPackageSplitViewComponent extends WorkPackageSingleViewBase imp
/** Reference to the base route e.g., work-packages.partitioned.list or bim.partitioned.split */
private baseRoute:string = this.$state.current?.data?.baseRoute as string;
@Input() workPackageId:string;
@Input() showTabs = true;
@Input() activeTab?:string;
@Input() resizerClass = 'work-packages-partitioned-page--content-right';
@@ -89,7 +87,7 @@ export class WorkPackageSplitViewComponent extends WorkPackageSingleViewBase imp
readonly backRouting:BackRoutingService,
readonly wpTabs:WorkPackageTabsService,
) {
super(injector, $state.params.workPackageId);
super(injector);
}
// enable other parts of the application to trigger an immediate update
@@ -104,16 +102,7 @@ export class WorkPackageSplitViewComponent extends WorkPackageSingleViewBase imp
this.observeWorkPackage();
const wpId = (this.$state.params.workPackageId || this.workPackageId) as string;
const focusedWP = this.wpTableFocus.focusedWorkPackage;
if (!focusedWP) {
// Focus on the work package if we're the first route
const isFirstRoute = this.firstRoute.name === `${this.baseRoute}.details.overview`;
const isSameID = this.firstRoute.params && wpId === this.firstRoute.params.workPackageI;
this.wpTableFocus.updateFocus(wpId, (isFirstRoute && isSameID));
} else {
this.wpTableFocus.updateFocus(wpId, false);
}
this.wpTableFocus.updateFocus(wpId, false);
if (this.wpTableSelection.isEmpty) {
this.wpTableSelection.setRowState(wpId, true);
@@ -48,7 +48,7 @@
data-notification-selector='notification-scroll-container'
>
<ndc-dynamic [ndcDynamicComponent]="activeTabComponent"
[ndcDynamicInputs]="{ workPackage: workPackage }" />
[ndcDynamicInputs]="{ workPackage: workPackage, tab: tabIdentifier }" />
</div>
}
</edit-form>
@@ -26,7 +26,12 @@
// See COPYRIGHT and LICENSE files for more details.
//++
import { ChangeDetectorRef, Injector } from '@angular/core';
import {
ChangeDetectorRef,
Directive,
Injector,
Input,
} from '@angular/core';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
import {
@@ -62,10 +67,20 @@ import { ProjectsResourceService } from 'core-app/core/state/projects/projects.s
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { ToastService } from 'core-app/shared/components/toaster/toast.service';
import { HttpErrorResponse } from '@angular/common/http';
import { StateService } from '@uirouter/angular';
@Directive()
export abstract class WorkPackageSingleViewBase extends UntilDestroyedMixin {
@Input() routedFromAngular:boolean = true;
@Input() workPackageId:string;
@Input() activeTab:string = 'activity';
export class WorkPackageSingleViewBase extends UntilDestroyedMixin {
@InjectField() states:States;
@InjectField() $state:StateService;
@InjectField() i18n:I18nService;
@InjectField() keepTab:KeepTabService;
@@ -115,9 +130,12 @@ export class WorkPackageSingleViewBase extends UntilDestroyedMixin {
constructor(
public injector:Injector,
protected workPackageId:string,
) {
super();
if (this.routedFromAngular && this.workPackageId === undefined) {
this.workPackageId = this.$state.params.workPackageId as string;
}
}
/**
@@ -139,6 +157,9 @@ export class WorkPackageSingleViewBase extends UntilDestroyedMixin {
this.workPackage = wp;
}
// Push the current title
this.titleService.setFirstPart(this.workPackage.subjectWithType(-1));
this.cdRef.detectChanges();
}, (error) => {
this.handleLoadingError(error);
@@ -177,9 +198,6 @@ export class WorkPackageSingleViewBase extends UntilDestroyedMixin {
// Set authorisation data
this.authorisationService.initModelAuth('work_package', this.workPackage.$links);
// Push the current title
this.titleService.setFirstPart(this.workPackage.subjectWithType(-1));
// Preselect this work package for future list operations
this.showStaticPagePath = this.PathHelper.workPackagePath(this.workPackageId);
@@ -68,7 +68,6 @@ export class WorkPackageViewPageComponent extends PartitionedQuerySpacePageCompo
component: WorkPackageCreateButtonComponent,
inputs: {
stateName$: of(this.stateName),
allowed: ['work_packages.createWorkPackage'],
},
},
{
@@ -82,7 +82,7 @@ export class WorkPackageAuthorization {
if (stateName.indexOf('work-packages.partitioned.list.details') === 0) {
return this.PathHelper.workPackageDetailsCopyPath(this.project.identifier, this.workPackage.id as string);
}
return this.PathHelper.workPackageCopyPath(this.workPackage.id as string);
return this.PathHelper.workPackageCopyPath(this.project.identifier, this.workPackage.id as string);
}
private shortLink() {
@@ -31,6 +31,8 @@ import { KeepTabService } from 'core-app/features/work-packages/components/wp-si
import { UiStateLinkBuilder } from 'core-app/features/work-packages/components/wp-fast-table/builders/ui-state-link-builder';
import { WorkPackageDisplayField } from 'core-app/shared/components/fields/display/field-types/work-package-display-field.module';
import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator';
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
export class LinkedWorkPackageDisplayField extends WorkPackageDisplayField {
public text = {
@@ -42,7 +44,11 @@ export class LinkedWorkPackageDisplayField extends WorkPackageDisplayField {
@InjectField() keepTab!:KeepTabService;
private uiStateBuilder:UiStateLinkBuilder = new UiStateLinkBuilder(this.$state, this.keepTab);
@InjectField() currentProject!:CurrentProjectService;
@InjectField() pathHelper!:PathHelperService;
private uiStateBuilder:UiStateLinkBuilder = new UiStateLinkBuilder(this.$state, this.keepTab, this.currentProject, this.pathHelper);
public render(element:HTMLElement, displayText:string):void {
if (this.isEmpty()) {
@@ -31,13 +31,19 @@ import { StateService } from '@uirouter/core';
import { UiStateLinkBuilder } from 'core-app/features/work-packages/components/wp-fast-table/builders/ui-state-link-builder';
import { IdDisplayField } from 'core-app/shared/components/fields/display/field-types/id-display-field.module';
import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator';
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
export class WorkPackageIdDisplayField extends IdDisplayField {
@InjectField() $state!:StateService;
@InjectField() keepTab!:KeepTabService;
private uiStateBuilder:UiStateLinkBuilder = new UiStateLinkBuilder(this.$state, this.keepTab);
@InjectField() currentProject!:CurrentProjectService;
@InjectField() pathHelper!:PathHelperService;
private uiStateBuilder:UiStateLinkBuilder = new UiStateLinkBuilder(this.$state, this.keepTab, this.currentProject, this.pathHelper);
public render(element:HTMLElement, displayText:string):void {
if (!this.value) {
@@ -29,22 +29,24 @@
import { WorkPackagesListService } from 'core-app/features/work-packages/components/wp-list/wp-list.service';
import { States } from 'core-app/core/states/states.service';
import {
ChangeDetectorRef, Component, ElementRef, Inject, OnInit,
ChangeDetectorRef,
Component,
ElementRef,
Inject,
OnInit,
} from '@angular/core';
import { OpModalComponent } from 'core-app/shared/components/modal/modal.component';
import { OpModalLocalsToken } from 'core-app/shared/components/modal/modal.service';
import { OpModalLocalsMap } from 'core-app/shared/components/modal/modal.types';
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import {
WorkPackageViewFocusService,
} from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-focus.service';
import { WorkPackageViewFocusService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-focus.service';
import { StateService } from '@uirouter/core';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { BackRoutingService } from 'core-app/features/work-packages/components/back-routing/back-routing.service';
import {
WorkPackageNotificationService,
} from 'core-app/features/work-packages/services/notifications/work-package-notification.service';
import { WorkPackageNotificationService } from 'core-app/features/work-packages/services/notifications/work-package-notification.service';
import { WorkPackageService } from 'core-app/features/work-packages/services/work-package.service';
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
import { BackRoutingService } from 'core-app/features/work-packages/components/back-routing/back-routing.service';
@Component({
templateUrl: './wp-destroy.modal.html',
@@ -82,7 +84,8 @@ export class WpDestroyModalComponent extends OpModalComponent implements OnInit
deletesChildren: '',
};
constructor(readonly elementRef:ElementRef,
constructor(
readonly elementRef:ElementRef,
readonly workPackageService:WorkPackageService,
@Inject(OpModalLocalsToken) public locals:OpModalLocalsMap,
readonly I18n:I18nService,
@@ -92,7 +95,10 @@ export class WpDestroyModalComponent extends OpModalComponent implements OnInit
readonly wpTableFocus:WorkPackageViewFocusService,
readonly wpListService:WorkPackagesListService,
readonly notificationService:WorkPackageNotificationService,
readonly backRoutingService:BackRoutingService) {
readonly currentProject:CurrentProjectService,
readonly pathHelper:PathHelperService,
readonly backRoutingService:BackRoutingService,
) {
super(locals, cdRef, elementRef);
}
@@ -154,10 +160,11 @@ export class WpDestroyModalComponent extends OpModalComponent implements OnInit
this.busy = false;
this.closeMe($event);
this.wpTableFocus.clear('Clearing after destroying work packages');
// Go back to a previous list state if we're in a split or full view
if (this.$state.current.data.baseRoute) {
if (this.$state.current.data?.baseRoute) {
this.backRoutingService.goBack(true);
} else {
const projectIdentifier = this.currentProject.identifier;
window.location.href = this.pathHelper.workPackagesPath(projectIdentifier) + window.location.search;
}
})
.catch(() => {
@@ -29,17 +29,19 @@
import { OpContextMenuItem } from 'core-app/shared/components/op-context-menu/op-context-menu.types';
import { StateService } from '@uirouter/core';
import { OPContextMenuService } from 'core-app/shared/components/op-context-menu/op-context-menu.service';
import { Directive, ElementRef, Input } from '@angular/core';
import {
Directive,
ElementRef,
Input,
} from '@angular/core';
import { isClickedWithModifier } from 'core-app/shared/helpers/link-handling/link-handling';
import {
OpContextMenuTrigger,
} from 'core-app/shared/components/op-context-menu/handlers/op-context-menu-trigger.directive';
import { BrowserDetector } from 'core-app/core/browser/browser-detector.service';
import { OpContextMenuTrigger } from 'core-app/shared/components/op-context-menu/handlers/op-context-menu-trigger.directive';
import { WorkPackageCreateService } from 'core-app/features/work-packages/components/wp-new/wp-create.service';
import {
Highlighting,
} from 'core-app/features/work-packages/components/wp-fast-table/builders/highlighting/highlighting.functions';
import { Highlighting } from 'core-app/features/work-packages/components/wp-fast-table/builders/highlighting/highlighting.functions';
import { TypeResource } from 'core-app/features/hal/resources/type-resource';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
import { extendSearchParams } from 'core-stimulus/helpers/url-helpers';
@Directive({
selector: '[opTypesCreateDropdown]',
@@ -52,14 +54,17 @@ export class OpTypesContextMenuDirective extends OpContextMenuTrigger {
@Input('dropdownActive') active:boolean;
@Input() routedFromAngular:boolean = true;
public isOpen = false;
constructor(
readonly elementRef:ElementRef,
readonly opContextMenu:OPContextMenuService,
readonly browserDetector:BrowserDetector,
readonly wpCreate:WorkPackageCreateService,
readonly $state:StateService,
readonly pathHelper:PathHelperService,
readonly currentProject:CurrentProjectService,
) {
super(elementRef, opContextMenu);
}
@@ -70,11 +75,6 @@ export class OpTypesContextMenuDirective extends OpContextMenuTrigger {
if (!this.active) {
return;
}
// Force full-view create if in mobile view
if (this.browserDetector.isMobile) {
this.stateName = 'work-packages.new';
}
}
protected open(evt:JQuery.TriggeredEvent) {
@@ -113,12 +113,19 @@ export class OpTypesContextMenuDirective extends OpContextMenuTrigger {
ariaLabel: type.name,
class: Highlighting.inlineClass('type', type.id!),
onClick: ($event:JQuery.TriggeredEvent) => {
this.isOpen = false;
if (isClickedWithModifier($event)) {
return false;
}
if (this.routedFromAngular) {
this.isOpen = false;
if (isClickedWithModifier($event)) {
return false;
}
this.$state.go(this.stateName, { type: type.id });
this.$state.go(this.stateName, { type: type.id });
} else {
window.location.href = extendSearchParams(
this.pathHelper.projectWorkPackageNewPath(this.currentProject.id!),
{ type: type.id! },
);
}
return true;
},
}));
@@ -91,7 +91,9 @@ export class WorkPackageSingleContextMenuDirective extends OpContextMenuTrigger
void this.timeEntryService.stop();
break;
case 'copy':
this.$state.go('work-packages.copy', { copiedFromWorkPackageId: this.workPackage.id });
if (this.workPackage.id) {
window.location.href = `${this.PathHelper.workPackageCopyPath(this.workPackage.project.identifier, this.workPackage.id)}`;
}
break;
case 'delete':
this.opModalService.show(WpDestroyModalComponent, this.injector, { workPackages: [this.workPackage] });
@@ -29,6 +29,7 @@ import { WpDestroyModalComponent } from 'core-app/shared/components/modals/wp-de
import isNewResource from 'core-app/features/hal/helpers/is-new-resource';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
import { TurboRequestsService } from 'core-app/core/turbo/turbo-requests.service';
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
export class WorkPackageViewContextMenu extends OpContextMenuHandler {
@InjectField() protected states!:States;
@@ -43,6 +44,8 @@ export class WorkPackageViewContextMenu extends OpContextMenuHandler {
@InjectField() protected WorkPackageContextMenuHelper!:WorkPackageContextMenuHelperService;
@InjectField() protected currentProject:CurrentProjectService;
@InjectField() protected pathHelper:PathHelperService;
@InjectField() protected turboRequests:TurboRequestsService;
@@ -103,7 +106,7 @@ export class WorkPackageViewContextMenu extends OpContextMenuHandler {
this.editSelectedWorkPackages(link!);
break;
case 'copy':
case 'duplicate':
this.copySelectedWorkPackages(link!);
break;
@@ -162,11 +165,9 @@ export class WorkPackageViewContextMenu extends OpContextMenuHandler {
return;
}
const params = {
copiedFromWorkPackageId: selected[0].id,
};
this.$state.go(`${this.baseRoute}.copy`, params);
if (selected[0].id) {
window.location.href = this.pathHelper.workPackageCopyPath(selected[0].project.id, selected[0].id);
}
}
private logTimeForSelectedWorkPackage() {
@@ -206,21 +207,22 @@ export class WorkPackageViewContextMenu extends OpContextMenuHandler {
}));
if (selected.length === 1 && !isNewResource(this.workPackage)) {
const projectIdentifier = this.currentProject.identifier;
const link = this.pathHelper.genericWorkPackagePath(projectIdentifier, this.workPackageId) + window.location.search;
items.unshift({
disabled: false,
icon: 'icon-view-fullscreen',
class: 'openFullScreenView',
href: this.$state.href('work-packages.show', { workPackageId: this.workPackageId }),
href: link,
linkText: I18n.t('js.button_open_fullscreen'),
onClick: ($event:JQuery.TriggeredEvent) => {
if (isClickedWithModifier($event)) {
return false;
}
this.$state.go(
'work-packages.show',
{ workPackageId: this.workPackageId },
);
Turbo.visit(link, { action: 'advance' });
return true;
},
});
@@ -28,8 +28,20 @@
[attr.aria-selected]="tab.id === currentTabId"
(click)="clickTab(tab, $event)"
[attr.title]="tabTitle(tab)"
[textContent]="tab.name"
>
<span [textContent]="tab.name"></span>
@if (counter(tab) | async; as tabCounter) {
@if (tab.showCountAsBubble) {
<op-tab-count [count]="tabCounter"
[attr.data-test-selector]="'tab-counter-' + tab.name"
/>
}
@if (tabCounter > 0 && !tab.showCountAsBubble) {
<span
data-test-selector="tab-count"
> ({{ tabCounter }})</span>
}
}
</a>
}
@if (tab.route) {
@@ -118,10 +118,11 @@ export class ScrollableTabsComponent extends UntilDestroyedMixin implements Afte
this.currentTabId = tab.id;
this.tabSelected.emit(tab);
// If the tab does not provide its own link,
// avoid propagation
if (!tab.path) {
event.preventDefault();
event.preventDefault();
// Override history to avoid that browser back leads you to a different tab instead of the page you originated from
if (tab.path) {
Turbo.visit(tab.path, { action: document.referrer != '' ? 'replace' : 'advance' })
}
}
@@ -39,7 +39,7 @@
display: grid
grid-template-columns: auto 1fr auto
grid-template-rows: minmax(30px, auto) 1fr
grid-template-areas: "breadcrumb breadcrumb breadcrumb" "backButton subject toolbar"
grid-template-areas: "breadcrumb breadcrumb breadcrumb" "subject subject toolbar"
align-items: center
grid-column-gap: 5px
@@ -47,9 +47,6 @@
grid-area: breadcrumb
align-self: baseline
&--back-button
grid-area: backButton
&--subject
grid-area: subject
@@ -114,10 +114,6 @@
grid-template-areas: "backButton toolbar" "breadcrumb breadcrumb" "subject subject"
grid-row-gap: 0.5rem
// On mobile, we will not show the back button any more. The space will be taken by the menu toggler.
.op-back-button
display: none
.work-packages-full-view--split-container
border-top: none
@@ -0,0 +1,51 @@
/*
* -- 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.
* ++
*/
/**
* Extend a given URL (string or URL object) with the provided search parameters.
*
* @param base The base URL to extend
* @param params A record of key-value pairs to add as search parameters
* @param addCurrentSearch Whether to include the current window's search parameters (default: true)
*/
export function extendSearchParams(
base:string,
params:Record<string, string>,
addCurrentSearch = true,
) {
const url = new URL(base, window.location.origin);
url.search = addCurrentSearch ? window.location.search : '';
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
return url.toString();
}
@@ -129,7 +129,7 @@ module Redmine::MenuManager::TopMenu::QuickAddMenu
classes: "__hl_inline_type_#{type_id}" }
else
{ caption: type_name,
href: new_work_packages_path(type: type_id),
href: new_work_package_path(type: type_id),
classes: "__hl_inline_type_#{type_id}" }
end
end
@@ -122,14 +122,13 @@ RSpec.describe "BIM navigation spec", :js, with_config: { edition: "bim" } do
details_view.expect_closed
end
it "after deleting an WP in full view it returns to the model and list view (see #33317)" do
it "after deleting an WP in split view it returns to the model and list view (see #33317)" do
# Go to full single view
card_view.open_split_view_by_info_icon(work_package)
details_view.switch_to_fullscreen
full_view.expect_tab "Activity"
details_view.expect_tab "Overview"
# Delete via the context menu
find("#action-show-more-dropdown-menu .button").click
page.find_test_selector("wp-details-toolbar--show-more-button").click
find(".menu-item", text: "Delete").click
destroy_modal.expect_listed(work_package)
@@ -76,9 +76,11 @@ RSpec.describe "Work Package boards spec",
expect(page).to have_current_path project_work_package_path(project, wp.id, "activity")
# Click back goes back to the board
find(".work-packages-back-button").click
page.go_back
expect(page).to have_current_path project_work_package_board_path(project, board_view)
wait_for_network_idle
# Open the details page with the info icon
card = board_page.card_for(wp)
split_view = card.open_details_view
@@ -199,6 +201,7 @@ RSpec.describe "Work Package boards spec",
# Go to full view of WP
split_view.switch_to_fullscreen
wait_for_network_idle
find_by_id("action-show-more-dropdown-menu").click
click_link(I18n.t("js.button_delete"))
@@ -208,8 +211,6 @@ RSpec.describe "Work Package boards spec",
wait_for_network_idle
board_page.expect_query "List 1", editable: true
board_page.expect_not_any_card
board_page.expect_path
expect(page).to have_current_path "/projects/#{project.identifier}/work_packages"
end
end
@@ -365,9 +365,9 @@ module Pages
def expect_query(name, editable: true)
if editable
expect(page).to have_field("editable-toolbar-title", with: name)
expect(page).to have_field("editable-toolbar-title", with: name, wait: 10)
else
expect(page).to have_css(".editable-toolbar-title--fixed", text: name)
expect(page).to have_css(".editable-toolbar-title--fixed", text: name, wait: 10)
end
end
@@ -65,6 +65,9 @@ RSpec.describe WorkPackagesController do
"with having the necessary permissions" do
before do
expect(WorkPackage).to receive_message_chain("visible.find_by").and_return(stub_work_package)
mock_permissions_for(current_user) do |mock|
mock.allow_in_project :view_work_packages, project: stub_work_package.project
end
end
instance_eval(&)
@@ -261,10 +264,10 @@ RSpec.describe WorkPackagesController do
let(:call_action) { get("show", params: { id: "1337" }) }
requires_permission_in_project do
it "renders the show builder template" do
it "redirects to the full url" do
call_action
expect(response).to render_template("work_packages/show")
expect(response).to redirect_to("/projects/test_project/work_packages/1337/activity")
end
end
end
@@ -1453,6 +1453,10 @@ RSpec.describe "Work package activity", :js, :with_cuprite do
context "when the current user does not have the activity tab open the whole time" do
it "raises a conflict warning when the work package is updated by another user while the current user is editing" do
pending "This has become obselete with the removal of uiRouter (#67007), because switching tabs now result in a
hard reload and no conflict appears like described in these examples.
Before removing this, please check the polling controller for possible cleanups.
Ticket: https://community.openproject.org/wp/68630"
using_session(:admin) do
login_as(admin)
@@ -148,7 +148,7 @@ RSpec.describe "Quick-add menu", :js do
quick_add.expect_work_package_type other_project_type.name
quick_add.click_link other_project_type.name
expect(page).to have_current_path new_work_packages_path(type: other_project_type.id)
expect(page).to have_current_path new_work_package_path(type: other_project_type.id)
end
end
@@ -56,6 +56,7 @@ RSpec.describe "Notification center navigation", :js do
# Close the split screen
split_screen.close
wait_for_network_idle
expect(page).to have_current_path "/notifications"
end
end
@@ -69,27 +69,25 @@ RSpec.describe "Turbo and Angular navigation integration", :js do
# Step 3: Click on the work package #id from the notifications list
# This should open the work package in full view
center.click_id notification
wait_for_network_idle
full_screen.expect_subject
# Step 4: Click back once
# Expected: Should go back to the notification center with split screen open
# Bug: The same work package page is displayed again
page.go_back
wait_for_network_idle
# This assertion will fail with the current bug - it should show the split screen
# but instead shows the full work package view again
expect(page).to have_current_path(/notifications/)
split_screen.expect_open
# Step 5: Click back a second time
# Expected: Should go back to the notification center, but the work package sidebar is hidden
# Bug: The work package sidebar should be visible but is hidden
page.go_back
wait_for_network_idle
# Sidebar should remain open
expect(page).to have_current_path(/notifications/)
center.expect_open
split_screen.expect_open
split_screen.expect_closed
end
end
end

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