Remove uiRouter from Calendars

This commit is contained in:
Henriette Darge
2026-04-02 15:39:07 +02:00
parent 15ae3effb4
commit cbf0fac8d3
26 changed files with 359 additions and 293 deletions
+2
View File
@@ -147,6 +147,7 @@ import {
import {
BoardEntryComponent,
} from 'core-app/features/boards/board/board-partitioned-page/board-entry.component';
import { CalendarEntryComponent } from 'core-app/features/calendar/calendar-entry.component';
import {
StorageLoginButtonComponent,
} from 'core-app/shared/components/storages/storage-login-button/storage-login-button.component';
@@ -393,6 +394,7 @@ export class OpenProjectModule implements DoBootstrap {
registerCustomElement('opce-notification-center', InAppNotificationCenterComponent, { injector });
registerCustomElement('opce-wp-split-view', WorkPackageSplitViewEntryComponent, { injector });
registerCustomElement('opce-board-view', BoardEntryComponent, { injector });
registerCustomElement('opce-calendar-view', CalendarEntryComponent, { injector });
registerCustomElement('opce-wp-full-view', WorkPackageFullViewEntryComponent, { injector });
registerCustomElement('opce-wp-full-create', WorkPackageFullCreateEntryComponent, { injector });
registerCustomElement('opce-wp-full-copy', WorkPackageFullCopyEntryComponent, { injector });
@@ -9,31 +9,31 @@ export class SubmenuService {
reloadSubmenu(selectedQueryId:string|null, sidemenuId?:string):void {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment
const menuIdentifier:string|undefined = sidemenuId ?? this.$state.current.data?.sideMenuOptions?.sidemenuId;
if (!menuIdentifier) { return; }
if (menuIdentifier) {
const menu = document.getElementById(menuIdentifier) as FrameElement;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const sideMenuOptions = this.$state.$current.data?.sideMenuOptions as { hardReloadOnBaseRoute?:boolean, defaultQuery?:string };
const currentSrc = menu.getAttribute('src');
const menu = document.getElementById(menuIdentifier) as FrameElement|null;
const currentSrc = menu?.getAttribute('src');
if (!currentSrc || !menu) { return; }
if (currentSrc && menu) {
const frameUrl = new URL(currentSrc, window.location.origin);
const defaultQuery = sideMenuOptions?.defaultQuery;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const sideMenuOptions = this.$state.$current.data?.sideMenuOptions as { hardReloadOnBaseRoute?:boolean, defaultQuery?:string };
const frameUrl = new URL(currentSrc, window.location.origin);
if (selectedQueryId) {
// If there is a default query passed in the route definition, it means that id passed as argument and not as parameter,
// e.g. calendars/:id, team_planner/:id, ...
// Otherwise, we will just replace the params
if (defaultQuery) {
frameUrl.search = `?id=${selectedQueryId}`;
} else {
frameUrl.search = `?query_id=${selectedQueryId}`;
}
}
if (selectedQueryId) {
// Prefer the data attribute on the frame, then fall back to route sideMenuOptions,
// then default to 'query_id'. Modules with path-based IDs (e.g. calendars/:id)
// set data-query-param="id" on the frame.
const queryParam = menu.getAttribute('data-query-param')
?? (sideMenuOptions?.defaultQuery ? 'id' : 'query_id');
// Override the frame src to enforce a reload
menu.setAttribute('src', frameUrl.href);
}
frameUrl.search = `?${queryParam}=${selectedQueryId}`;
}
const newSrc = frameUrl.href;
if (menu.getAttribute('src') !== newSrc) {
menu.setAttribute('src', newSrc);
} else {
void menu.reload();
}
}
}
@@ -40,7 +40,6 @@ import {
redirectToMobileAlternative,
} from 'core-app/shared/helpers/routing/mobile-guard.helper';
import { TEAM_PLANNER_LAZY_ROUTES } from 'core-app/features/team-planner/team-planner/team-planner.lazy-routes';
import { CALENDAR_LAZY_ROUTES } from 'core-app/features/calendar/calendar.lazy-routes';
export const OPENPROJECT_ROUTES:Ng2StateDeclaration[] = [
{
@@ -75,7 +74,6 @@ export const OPENPROJECT_ROUTES:Ng2StateDeclaration[] = [
loadChildren: () => import('../../features/bim/ifc_models/openproject-ifc-models.module').then((m) => m.OpenprojectIFCModelsModule),
},
...TEAM_PLANNER_LAZY_ROUTES,
...CALENDAR_LAZY_ROUTES,
];
/**
@@ -26,13 +26,23 @@
// See COPYRIGHT and LICENSE files for more details.
//++
import { Ng2StateDeclaration } from '@uirouter/angular';
import { ChangeDetectionStrategy, Component, ElementRef, Input } from '@angular/core';
import { populateInputsFromDataset } from 'core-app/shared/components/dataset-inputs';
import {
WorkPackageIsolatedQuerySpaceDirective,
} from 'core-app/features/work-packages/directives/query-space/wp-isolated-query-space.directive';
export const CALENDAR_LAZY_ROUTES:Ng2StateDeclaration[] = [
{
name: 'calendar.**',
parent: 'optional_project',
url: '/calendars',
loadChildren: () => import('./openproject-calendar.module').then((m) => m.OpenprojectCalendarModule),
},
];
@Component({
hostDirectives: [WorkPackageIsolatedQuerySpaceDirective],
template: '<op-wp-calendar-page [queryId]="queryId" />',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class CalendarEntryComponent {
@Input() queryId:string;
constructor(readonly elementRef:ElementRef) {
populateInputsFromDataset(this);
document.body.classList.add('router--calendar');
}
}
@@ -1,84 +0,0 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See COPYRIGHT and LICENSE files for more details.
//++
import { Ng2StateDeclaration } from '@uirouter/angular';
import { makeSplitViewRoutes } from 'core-app/features/work-packages/routing/split-view-routes.template';
import { WorkPackageSplitViewComponent } from 'core-app/features/work-packages/routing/wp-split-view/wp-split-view.component';
import { WorkPackagesBaseComponent } from 'core-app/features/work-packages/routing/wp-base/wp--base.component';
import { WorkPackagesCalendarComponent } from 'core-app/features/calendar/wp-calendar/wp-calendar.component';
import { WorkPackagesCalendarPageComponent } from 'core-app/features/calendar/wp-calendar-page/wp-calendar-page.component';
export const sidemenuId = 'calendar_sidemenu';
export const sideMenuOptions = {
sidemenuId,
hardReloadOnBaseRoute: true,
defaultQuery: 'new',
};
export const CALENDAR_ROUTES:Ng2StateDeclaration[] = [
{
name: 'calendar',
parent: 'optional_project',
url: '/calendars/:query_id?&query_props&cdate&cview',
redirectTo: 'calendar.page',
views: {
'!$default': { component: WorkPackagesBaseComponent },
},
params: {
query_id: { type: 'opQueryId', dynamic: true },
cdate: { type: 'string', dynamic: true },
cview: { type: 'string', dynamic: true },
// Use custom encoder/decoder that ensures validity of URL string
query_props: { type: 'opQueryString' },
},
},
{
name: 'calendar.page',
component: WorkPackagesCalendarPageComponent,
redirectTo: 'calendar.page.show',
data: {
bodyClasses: 'router--calendar',
sideMenuOptions,
},
},
{
name: 'calendar.page.show',
data: {
baseRoute: 'calendar.page.show',
sideMenuOptions,
},
views: {
'content-left': { component: WorkPackagesCalendarComponent },
},
},
...makeSplitViewRoutes(
'calendar.page.show',
undefined,
WorkPackageSplitViewComponent,
),
];
@@ -15,9 +15,8 @@ import { ConfigurationService } from 'core-app/core/config/configuration.service
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import { DomSanitizer } from '@angular/platform-browser';
import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service';
import { splitViewRoute } from 'core-app/features/work-packages/routing/split-view-routes.helper';
import { StateService } from '@uirouter/angular';
import { WorkPackageCollectionResource } from 'core-app/features/hal/resources/wp-collection-resource';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
import { ToastService } from 'core-app/shared/components/toaster/toast.service';
import { firstValueFrom, Observable } from 'rxjs';
import {
@@ -34,7 +33,6 @@ import {
UrlParamsHelperService,
} from 'core-app/features/work-packages/components/wp-query/url-params-helper';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { UIRouterGlobals } from '@uirouter/core';
import { TimezoneService } from 'core-app/core/datetime/timezone.service';
import {
WorkPackagesListChecksumService,
@@ -93,7 +91,6 @@ export class OpWorkPackagesCalendarService extends UntilDestroyedMixin {
private I18n:I18nService,
private configuration:ConfigurationService,
private sanitizer:DomSanitizer,
private $state:StateService,
readonly injector:Injector,
readonly schemaCache:SchemaCacheService,
readonly toastService:ToastService,
@@ -104,8 +101,8 @@ export class OpWorkPackagesCalendarService extends UntilDestroyedMixin {
readonly querySpace:IsolatedQuerySpace,
readonly apiV3Service:ApiV3Service,
readonly halResourceService:HalResourceService,
readonly uiRouterGlobals:UIRouterGlobals,
readonly timezoneService:TimezoneService,
readonly pathHelper:PathHelperService,
readonly halEditing:HalResourceEditingService,
readonly wpTableSelection:WorkPackageViewSelectionService,
readonly contextMenuService:OPContextMenuService,
@@ -283,23 +280,18 @@ export class OpWorkPackagesCalendarService extends UntilDestroyedMixin {
this.wpTableSelection.setSelection(id, -1);
// Only open the split view if already open, otherwise only clicking the details opens
if (onlyWhenOpen && !this.$state.includes('**.details.*')) {
if (onlyWhenOpen && !window.location.pathname.includes('/details/')) {
return;
}
void this.$state.go(
`${splitViewRoute(this.$state)}.tabs`,
{ workPackageId: id, tabIdentifier: 'overview' },
);
const basePath = window.location.pathname.replace(/\/details\/.*$/, '');
const link = `${basePath}/details/${id}${window.location.search}`;
Turbo.visit(link, { frame: 'content-bodyRight', action: 'advance' });
}
public openFullView(id:string):void {
this.wpTableSelection.setSelection(id, -1);
void this.$state.go(
'work-packages.show',
{ workPackageId: id },
);
Turbo.visit(this.pathHelper.workPackagePath(id));
}
public onCardClicked({ workPackageId, event }:{ workPackageId:string, event:MouseEvent }):void {
@@ -399,8 +391,22 @@ export class OpWorkPackagesCalendarService extends UntilDestroyedMixin {
&& !this.urlParams.query_props;
}
public get urlParams() {
return this.uiRouterGlobals.params;
public get urlParams():{
query_id?:string;
query_props?:string;
cdate?:string;
cview?:string;
} {
const search = new URLSearchParams(window.location.search);
// Extract query_id from path-based routing (e.g. /calendars/<id>, /team_planners/<id>).
const match = /\/(?:calendars|team_planners)\/([^/]+)/.exec(window.location.pathname);
const rawId = match?.[1];
return {
query_id: rawId === 'new' ? undefined : rawId,
query_props: search.get('query_props') ?? undefined,
cdate: search.get('cdate') ?? undefined,
cview: search.get('cview') ?? undefined,
};
}
private get areFiltersEmpty():boolean {
@@ -417,17 +423,10 @@ export class OpWorkPackagesCalendarService extends UntilDestroyedMixin {
}
private updateDateParam(dates:DatesSetArg) {
void this.$state.go(
'.',
{
cdate: this.timezoneService.formattedISODate(dates.view.calendar.getDate()),
// v6.beta3 fails to have type on the ViewAPI
cview: (dates.view as unknown as { type:string }).type,
},
{
custom: { notify: false },
},
);
const url = new URL(window.location.href);
url.searchParams.set('cdate', this.timezoneService.formattedISODate(dates.view.calendar.getDate()));
url.searchParams.set('cview', (dates.view as unknown as { type:string }).type);
window.history.pushState({}, '', url);
}
updateDates(resizeInfo:EventResizeDoneArg|EventDropArg|EventReceiveArg, dragged?:boolean):ResourceChangeset<WorkPackageResource> {
@@ -32,12 +32,11 @@ import { ReactiveFormsModule } from '@angular/forms';
import { FullCalendarModule } from '@fullcalendar/angular';
import { WorkPackagesCalendarComponent } from 'core-app/features/calendar/wp-calendar/wp-calendar.component';
import { OpenprojectWorkPackagesModule } from 'core-app/features/work-packages/openproject-work-packages.module';
import { UIRouterModule } from '@uirouter/angular';
import { TimeEntryCalendarComponent } from 'core-app/features/calendar/te-calendar/te-calendar.component';
import { OpenprojectFieldsModule } from 'core-app/shared/components/fields/openproject-fields.module';
import { OpenprojectTimeEntriesModule } from 'core-app/shared/components/time_entries/openproject-time-entries.module';
import { WorkPackagesCalendarPageComponent } from 'core-app/features/calendar/wp-calendar-page/wp-calendar-page.component';
import { CALENDAR_ROUTES } from 'core-app/features/calendar/calendar.routes';
import { CalendarEntryComponent } from 'core-app/features/calendar/calendar-entry.component';
import { QueryGetIcalUrlModalComponent } from 'core-app/shared/components/modals/get-ical-url-modal/query-get-ical-url.modal';
@NgModule({
@@ -45,9 +44,6 @@ import { QueryGetIcalUrlModalComponent } from 'core-app/shared/components/modals
// Commons
OpSharedModule,
// Routes for /calendar
UIRouterModule.forChild({ states: CALENDAR_ROUTES }),
// Work Package module
OpenprojectWorkPackagesModule,
@@ -65,6 +61,7 @@ import { QueryGetIcalUrlModalComponent } from 'core-app/shared/components/modals
],
declarations: [
// Work package calendars
CalendarEntryComponent,
WorkPackagesCalendarPageComponent,
WorkPackagesCalendarComponent,
TimeEntryCalendarComponent,
@@ -0,0 +1,49 @@
<div class="work-packages-partitioned-query-space--container"
[ngClass]="currentPartition">
<op-breadcrumbs [items]="breadcrumbItems()"
[lastItemSection]="currentMenuSectionHeader()"
/>
<div class="toolbar-container -editable">
<div class="toolbar">
<h2 class="toolbar-title--container">
<editable-toolbar-title [title]="selectedTitle"
[inFlight]="toolbarDisabled"
[showSaveCondition]="showToolbarSaveButton"
(onSave)="changeChangesFromTitle($event)"
(onEmptySubmit)="updateTitleName('')"
[editable]="titleEditingEnabled" />
</h2>
@if (showToolbar) {
<ul class="toolbar-items hide-when-print">
@for (definition of toolbarButtonComponents; track definition) {
@if (!definition.show || definition.show()) {
<li class="toolbar-item"
[ngClass]="definition.containerClasses">
<ndc-dynamic [ndcDynamicComponent]="definition.component"
[ndcDynamicInputs]="definition.inputs"
[ndcDynamicInjector]="injector"
[ndcDynamicOutputs]="definition.outputs" />
</li>
}
}
</ul>
}
</div>
</div>
<div class="work-packages-partitioned-query-space--filter-area">
@if (filterContainerDefinition) {
<ndc-dynamic [ndcDynamicComponent]="filterContainerDefinition.component"
[ndcDynamicInputs]="filterContainerDefinition.inputs"
[ndcDynamicOutputs]="filterContainerDefinition.outputs"
[ndcDynamicInjector]="injector" />
}
</div>
<div class="work-packages-partitioned-page--content-container">
<div class="work-packages-partitioned-page--content-left loading-indicator--location"
data-indicator-name="table">
<op-wp-calendar />
</div>
</div>
</div>
@@ -29,6 +29,8 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnInit,
ViewChild,
} from '@angular/core';
import { WorkPackagesCalendarComponent } from 'core-app/features/calendar/wp-calendar/wp-calendar.component';
@@ -50,7 +52,8 @@ import { ActionsService } from 'core-app/core/state/actions/actions.service';
import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator';
@Component({
templateUrl: '../../work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.html',
selector: 'op-wp-calendar-page',
templateUrl: './wp-calendar-page.component.html',
styleUrls: [
'../../work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.sass',
],
@@ -60,7 +63,9 @@ import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decora
],
standalone: false,
})
export class WorkPackagesCalendarPageComponent extends PartitionedQuerySpacePageComponent {
export class WorkPackagesCalendarPageComponent extends PartitionedQuerySpacePageComponent implements OnInit {
@Input() queryId:string;
@InjectField(ActionsService) actions$:ActionsService;
@ViewChild(WorkPackagesCalendarComponent, { static: true }) calendarElement:WorkPackagesCalendarComponent;
@@ -121,6 +126,20 @@ export class WorkPackagesCalendarPageComponent extends PartitionedQuerySpacePage
},
];
override ngOnInit():void {
super.ngOnInit();
// Fix showToolbarSaveButton from actual URL params (not uiRouter state)
this.showToolbarSaveButton = !!new URLSearchParams(window.location.search).get('query_props');
// Update save button reactively when query_props changes via pushState (non-uiRouter pages)
this.wpListChecksumService.visibleChecksum$
.pipe(this.untilDestroyed())
.subscribe((checksum) => {
this.showToolbarSaveButton = !!checksum;
this.cdRef.detectChanges();
});
}
/**
* We need to set the current partition to the grid to ensure
* either side gets expanded to full width if we're not in '-split' mode.
@@ -57,7 +57,6 @@ import {
WorkPackageViewFiltersService,
} from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-filters.service';
import { WorkPackagesListService } from 'core-app/features/work-packages/components/wp-list/wp-list.service';
import { StateService } from '@uirouter/core';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { ToastService } from 'core-app/shared/components/toaster/toast.service';
import { DomSanitizer } from '@angular/platform-browser';
@@ -70,7 +69,7 @@ import {
HalResourceEditingService,
} from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service';
import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service';
import { splitViewRoute } from 'core-app/features/work-packages/routing/split-view-routes.helper';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
import {
CalendarViewEvent,
OpWorkPackagesCalendarService,
@@ -88,7 +87,6 @@ import {
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder';
import allLocales from '@fullcalendar/core/locales-all';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
import { MeetingResource } from 'core-app/features/hal/resources/meeting-resource';
import { TimezoneService } from 'core-app/core/datetime/timezone.service';
@@ -128,7 +126,6 @@ export class WorkPackagesCalendarComponent extends UntilDestroyedMixin implement
constructor(
readonly actions$:ActionsService,
readonly states:States,
readonly $state:StateService,
readonly wpTableFilters:WorkPackageViewFiltersService,
readonly wpListService:WorkPackagesListService,
readonly querySpace:IsolatedQuerySpace,
@@ -345,13 +342,10 @@ export class WorkPackagesCalendarComponent extends UntilDestroyedMixin implement
const workPackageId = (evt.event.extendedProps.workPackage as WorkPackageResource).id!;
// Currently the calendar widget is shown on multiple pages,
// but only the calendar module itself is a partitioned query space which can deal with a split screen request
if (this.$state.includes('calendar')) {
if (window.location.pathname.includes('/calendars/')) {
this.workPackagesCalendar.openSplitView(workPackageId);
} else {
void this.$state.go(
'work-packages.show',
{ workPackageId },
);
window.location.href = this.pathHelper.workPackagePath(workPackageId);
}
}
},
@@ -414,7 +408,7 @@ export class WorkPackagesCalendarComponent extends UntilDestroyedMixin implement
durationEditable: this.workPackagesCalendar.eventDurationEditable(workPackage),
end: exclusiveEnd,
allDay: true,
className: `fc-event-clickable __hl_background_type_${workPackage.type.id || ''}`,
className: `fc-event-clickable __hl_background_type_${workPackage.type.id ?? ''}`,
workPackage,
};
});
@@ -444,13 +438,17 @@ export class WorkPackagesCalendarComponent extends UntilDestroyedMixin implement
ignoreNonWorkingDays: nonWorkingDays,
};
void this.$state.go(
splitViewRoute(this.$state, 'new'),
{
defaults,
tabIdentifier: 'overview',
},
);
if (window.location.pathname.includes('/calendars/')) {
const basePath = window.location.pathname.replace(/\/details\/.*$/, '');
const params = new URLSearchParams(window.location.search);
params.set('startDate', defaults.startDate);
params.set('dueDate', defaults.dueDate);
if (defaults.ignoreNonWorkingDays) {
params.set('ignoreNonWorkingDays', 'true');
}
const link = `${basePath}/details/new?${params.toString()}`;
Turbo.visit(link, { frame: 'content-bodyRight', action: 'advance' });
}
}
@EffectCallback(calendarRefreshRequest)
@@ -98,7 +98,7 @@
[showStartDate]="!isWpStartDateInCurrentView(wp)"
[showEndDate]="!isWpEndDateInCurrentView(wp)"
(stateLinkClicked)="openStateLink($event)"
(cardClicked)="workPackagesCalendar.onCardClicked($event)"
(cardClicked)="onCardClicked($event)"
(cardDblClicked)="workPackagesCalendar.onCardDblClicked($event)"
(cardContextMenu)="workPackagesCalendar.showEventContextMenu($event)"
/>
@@ -40,6 +40,7 @@ import {
import {
CalendarOptions,
DateSelectArg,
DatesSetArg,
EventApi,
EventDropArg,
EventInput,
@@ -78,6 +79,7 @@ import { WorkPackageViewFiltersService } from 'core-app/features/work-packages/r
import { IsolatedQuerySpace } from 'core-app/features/work-packages/directives/query-space/isolated-query-space';
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
import { splitViewRoute } from 'core-app/features/work-packages/routing/split-view-routes.helper';
import { isClickedWithModifier } from 'core-app/shared/helpers/link-handling/link-handling';
import { QueryFilterInstanceResource } from 'core-app/features/hal/resources/query-filter-instance-resource';
import { PrincipalsResourceService } from 'core-app/core/state/principals/principals.service';
import {
@@ -460,6 +462,7 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit,
.pipe(
this.untilDestroyed(),
debounceTime(0),
filter(() => !!this.ucCalendar),
)
.subscribe(([principals, showAddAssignee]) => {
const api = this.ucCalendar.getApi();
@@ -511,6 +514,19 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit,
.then(() => {
this.calendarOptions$.next(
this.workPackagesCalendar.calendarOptions({
// Override datesSet to persist cdate/cview via uiRouter instead of pushState,
// because uiRouter manages the TeamPlanner URL and would otherwise strip these params.
// Remove once uiRouter is removed.
datesSet: (dates:DatesSetArg) => {
void this.$state.go(
'.',
{
cdate: this.workPackagesCalendar.timezoneService.formattedISODate(dates.view.calendar.getDate()),
cview: (dates.view as unknown as { type:string }).type,
},
{ custom: { notify: false } },
);
},
locales: allLocales,
locale: this.I18n.locale,
schedulerLicenseKey: 'GPL-My-Project-Is-Open-Source',
@@ -857,6 +873,20 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit,
}
}
onCardClicked({ workPackageId, event }:{ workPackageId:string, event:MouseEvent }):void {
if (isClickedWithModifier(event)) {
return;
}
// Only switch the split view if it is already open
if (!window.location.pathname.includes('/details/')) {
return;
}
this.workPackagesCalendar.wpTableSelection.setSelection(workPackageId, -1);
this.keepTab.goCurrentDetailsState({ workPackageId });
}
shouldShowAsGhost(id:string, globalDraggingId:string|undefined):boolean {
if (globalDraggingId === undefined) {
return false;
@@ -31,6 +31,7 @@ import { UrlParamsHelperService } from 'core-app/features/work-packages/componen
import { Injectable } from '@angular/core';
import { WorkPackageViewPagination } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-table-pagination';
import { QueryResource } from 'core-app/features/hal/resources/query-resource';
import { Subject } from 'rxjs';
@Injectable()
export class WorkPackagesListChecksumService {
@@ -44,6 +45,9 @@ export class WorkPackagesListChecksumService {
public visibleChecksum:string|null;
/** Emits whenever visibleChecksum changes (useful for non-uiRouter pages to react to URL param changes) */
public readonly visibleChecksum$ = new Subject<string|null>();
public updateIfDifferent(query:QueryResource,
pagination:WorkPackageViewPagination):Promise<unknown> {
const newQueryChecksum = this.getNewChecksum(query, pagination);
@@ -153,6 +157,28 @@ export class WorkPackagesListChecksumService {
private maintainUrlQueryState(id:string|null, checksum:string|null):TransitionPromise {
this.visibleChecksum = checksum;
this.visibleChecksum$.next(checksum);
// When uiRouter is not managing the current page (e.g. calendar after Turbo migration),
// $state.current.name is empty and state.go('.') does nothing. Fall back to pushState.
if (!this.$state.current.name) {
const url = new URL(window.location.href);
if (checksum) {
url.searchParams.set('query_props', checksum);
} else {
url.searchParams.delete('query_props');
}
if (id) {
url.searchParams.set('query_id', id);
} else {
url.searchParams.delete('query_id');
}
window.history.pushState({}, '', url.toString());
return Promise.resolve() as unknown as TransitionPromise;
}
return this.$state.go(
'.',
@@ -261,6 +261,7 @@ export class WorkPackagesListService {
// Reload the query, and then reload the menu
this.reloadQuery(createdQuery).subscribe(() => {
this.navigateToQueryOnNonRouterPage(createdQuery.id);
this.states.changes.queries.next(createdQuery.id);
this.reloadSidemenu(createdQuery.id);
});
@@ -311,7 +312,11 @@ export class WorkPackagesListService {
this.toastService.addSuccess(this.I18n.t('js.notice_successful_update'));
const queryAccessibleByUser = query.public || query.user.id === this.currentUser.userId;
if (queryAccessibleByUser) {
void this.$state.go('.', { query_id: query.id, query_props: null }, { reload: true });
if (!this.$state.current.name) {
this.navigateToQueryOnNonRouterPage(query.id);
} else {
void this.$state.go('.', { query_id: query.id, query_props: null }, { reload: true });
}
this.states.changes.queries.next(query.id);
this.reloadSidemenu(query.id);
} else {
@@ -463,7 +468,27 @@ export class WorkPackagesListService {
}
}
private navigateToQueryOnNonRouterPage(queryId:string|null):void {
if (this.$state.current.name) { return; }
// update the URL path to reflect the saved query ID so subsequent refetches use the correct query_id.
const url = new URL(window.location.href);
url.pathname = url.pathname.replace(/\/[^/]+$/, `/${queryId}`);
url.searchParams.delete('query_id');
url.searchParams.delete('query_props');
window.history.pushState({}, '', url.toString());
}
private reloadSidemenu(selectedQueryId:string|null):void {
this.submenuService.reloadSubmenu(selectedQueryId);
const sidemenuId = !this.$state.current.name ? this.getNonRouterSidemenuId() : undefined;
this.submenuService.reloadSubmenu(selectedQueryId, sidemenuId);
}
private getNonRouterSidemenuId():string|undefined {
const { pathname } = window.location;
if (pathname.includes('/calendars')) return 'calendar_sidemenu';
if (pathname.includes('/team_planners')) return 'team_planner_sidemenu';
if (pathname.includes('/ifc_models')) return 'bim_sidemenu';
return undefined;
}
}
@@ -34,20 +34,20 @@ export class WorkPackagesQueryViewService {
}
private get viewType() {
if (this.$state.includes('work-packages')) {
return 'work_packages_table';
}
if (this.$state.includes('team_planner')) {
return 'team_planner';
}
if (this.$state.includes('bim')) {
return 'bim';
}
if (this.$state.includes('calendar')) {
return 'work_packages_calendar';
}
if (this.$state.includes('gantt')) {
return 'gantt';
if (this.$state.current.name) {
if (this.$state.includes('work-packages')) { return 'work_packages_table'; }
if (this.$state.includes('team_planner')) { return 'team_planner'; }
if (this.$state.includes('bim')) { return 'bim'; }
if (this.$state.includes('calendar')) { return 'work_packages_calendar'; }
if (this.$state.includes('gantt')) { return 'gantt'; }
} else {
// Non-uiRouter page — derive view type from URL path
const { pathname } = window.location;
if (pathname.includes('/calendars')) { return 'work_packages_calendar'; }
if (pathname.includes('/team_planners')) { return 'team_planner'; }
if (pathname.includes('/ifc_models')) { return 'bim'; }
if (pathname.includes('/gantt')) { return 'gantt'; }
if (pathname.includes('/work_packages')) { return 'work_packages_table'; }
}
throw new Error('Not on a path defined for query views');
@@ -41,7 +41,7 @@ module Calendar
def dynamic_path
if current_project
new_project_calendars_path(current_project)
new_project_calendar_path(current_project)
else
new_calendar_path
end
@@ -1,3 +1,5 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
@@ -31,14 +33,17 @@ module ::Calendar
before_action :load_and_authorize_in_optional_project
before_action :build_calendar_view, only: %i[new]
before_action :authorize, except: %i[index new create]
before_action :authorize_global, only: %i[index new create]
before_action :authorize_global, only: %i[index create]
before_action :authorize_new, only: %i[new]
authorization_checked! :new
before_action :find_calendar, only: %i[destroy]
before_action :find_calendar, only: %i[show split_view destroy]
menu_item :calendar_view
include Layout
include PaginationHelper
include SortHelper
include WorkPackages::WithSplitView
def index
@views = visible_views
@@ -46,10 +51,26 @@ module ::Calendar
end
def show
render layout: "angular/angular"
render
end
def new; end
def split_view
respond_to do |format|
format.html do
if turbo_frame_request?
render "work_packages/split_view", layout: false
else
render :show
end
end
end
end
def new
# In a project context, show the calendar view with an unsaved query.
# In the global context (no project), show the form so the user can select a project.
render :show if @project
end
def create
service_result = create_service_class.new(user: User.current)
@@ -59,7 +80,7 @@ module ::Calendar
if service_result.success?
flash[:notice] = I18n.t(:notice_successful_create)
redirect_to project_calendar_path(@project, @view.query)
redirect_to project_calendar_path(@project || @view.query.project, @view.query)
else
render action: :new, status: :unprocessable_entity
end
@@ -77,6 +98,16 @@ module ::Calendar
private
# In project context, `new` renders the calendar view and needs the same project-level
# permission as `show`. In global context (no project), it shows the creation form.
def authorize_new
@project ? authorize : authorize_global
end
def split_view_base_route
project_calendar_path(@project, @view, request.query_parameters)
end
def build_calendar_view
@view = Query.new
end
@@ -86,7 +117,7 @@ module ::Calendar
end
def calendar_view_params
params.require(:query).permit(:name, :public, :starred).merge(project_id: @project&.id)
params.expect(query: %i[name public starred project_id])
end
def visible_views
@@ -33,7 +33,7 @@ module ::Calendar
def show
@submenu_menu_items = ::Calendar::Menu.new(project: @project, params:).menu_items
@create_btn_options = if User.current.allowed_in_project?(:manage_calendars, @project)
{ href: new_project_calendars_path(@project), module_key: "calendar" }
{ href: new_project_calendar_path(@project), module_key: "calendar" }
end
render layout: nil
@@ -1,89 +0,0 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2013 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See COPYRIGHT and LICENSE files for more details.
++#%>
<%= error_messages_for @view %>
<section class="form--section">
<div class="form--field -required">
<%= f.text_field :name,
label: t(:label_title),
required: true,
size: 60,
container_class: "-wide" %>
</div>
<div class="form--field -required">
<label class="form--label" for="project_id"><%= Query.human_attribute_name(:project) %>:</label>
<div class="form--field-container">
<%= angular_component_tag "opce-project-autocompleter",
inputs: {
filters: [{ name: :user_action, operator: "=", values: ["calendars/create"] }],
inputName: "project_id",
inputValue: @project&.id
},
id: "project_id",
class: "form--select-container -wide remote-field--input",
data: {
"test-selector": "project_id"
} %>
</div>
<div class="form--field-instructions">
<p><%= t "help_texts.views.project",
singular: t(:label_calendar).downcase,
plural: t(:label_calendar_plural) %></p>
</div>
</div>
<div class="form--field">
<%= styled_label_tag "query[public]",
Query.human_attribute_name(:public) %>
<div class="form--field-container">
<%= styled_check_box_tag "query[public]",
"1",
@view.public %>
</div>
<div class="form--field-instructions">
<p><%= t "help_texts.views.public" %></p>
</div>
</div>
<div class="form--field">
<%= styled_label_tag "query[starred]",
t(:label_favorite) %>
<div class="form--field-container">
<%= styled_check_box_tag "query[starred]",
"1",
@view.starred %>
</div>
<div class="form--field-instructions">
<p><%= t "help_texts.views.favoured" %></p>
</div>
</div>
</section>
@@ -39,9 +39,48 @@ See COPYRIGHT and LICENSE files for more details.
end
%>
<%= labelled_tabular_form_for @view, url: { controller: "/calendar/calendars", action: "create" }, html: { id: "calendar-form" } do |f| -%>
<%= render partial: "form", locals: { f: f } %>
<%= styled_button_tag t(:button_create), class: "-primary" %>
<%= link_to t(:button_cancel), { controller: "calendar/calendars", action: "index" },
class: "button" %>
<%= primer_form_with(model: @view, scope: :query, url: calendars_path) do |f| %>
<%= render_inline_form(f) do |form|
project_id_value = @project&.id
form.text_field(
name: :name,
label: helpers.t(:label_title),
required: true,
input_width: :large
)
form.project_autocompleter(
name: :project_id,
label: Query.human_attribute_name(:project),
required: true,
caption: helpers.t("help_texts.views.project",
singular: helpers.t(:label_calendar).downcase,
plural: helpers.t(:label_calendar_plural)),
input_width: :large,
autocomplete_options: {
focusDirectly: false,
dropdownPosition: "bottom",
inputValue: project_id_value,
filters: [{ name: "user_action", operator: "=", values: ["calendars/create"] }],
data: { "test-selector": "project_id" }
}
)
form.check_box(
name: :public,
label: Query.human_attribute_name(:public),
caption: helpers.t("help_texts.views.public")
)
form.check_box(
name: :starred,
label: helpers.t(:label_favorite),
caption: helpers.t("help_texts.views.favoured")
)
form.group(layout: :horizontal) do |button_group|
button_group.submit(label: helpers.t(:button_create), name: :submit, scheme: :primary)
button_group.button(tag: :a, href: helpers.calendars_path, label: helpers.t(:button_cancel), name: :cancel)
end
end %>
<% end %>
@@ -27,4 +27,13 @@ See COPYRIGHT and LICENSE files for more details.
++#%>
<% html_title(t(:label_calendar_plural)) -%>
<% html_title(@view.name.presence || t(:label_calendar_plural)) -%>
<% content_for :content_body do %>
<%= angular_component_tag "opce-calendar-view", inputs: { queryId: @view.id.to_s } %>
<% end %>
<% content_for :content_body_right do %>
<%= turbo_stream.set_title(title: page_title(*html_title_parts)) if turbo_frame_request? %>
<%= render(split_view_instance) if render_work_package_split_view? %>
<% end %>
@@ -1,5 +1,5 @@
<%= turbo_frame_tag "calendar_sidemenu",
src: menu_project_calendars_path(@project, **params.permit(:id)),
target: "_top",
data: { turbo: false },
data: { turbo: false, query_param: "id" },
loading: :lazy %>
+8 -3
View File
@@ -2,14 +2,19 @@ Rails.application.routes.draw do
scope "projects/:project_id", as: "project" do
resources :calendars,
controller: "calendar/calendars",
only: %i[index destroy],
only: %i[index show new create destroy],
as: :calendars do
collection do
get "menu" => "calendar/menus#show"
end
get "/new" => "calendar/calendars#show", on: :collection, as: "new"
get "/ical" => "calendar/ical#show", on: :member, as: "ical"
get "(/*state)" => "calendar/calendars#show", on: :member, as: ""
member do
get "details/:work_package_id(/:tab)",
action: :split_view,
defaults: { tab: :overview },
as: :details,
work_package_split_view: true
end
end
end
@@ -28,7 +28,7 @@ module OpenProject::Calendar
settings: {} do
project_module :calendar_view, dependencies: :work_package_tracking do
permission :view_calendar,
{ "calendar/calendars": %i[index show],
{ "calendar/calendars": %i[index show split_view new],
"calendar/menus": %i[show] },
permissible_on: :project,
dependencies: %i[view_work_packages],
@@ -241,6 +241,7 @@ RSpec.describe "Team planner",
it "can add and remove assignees" do
team_planner.visit!
team_planner.wait_for_loaded
team_planner.expect_empty_state
team_planner.expect_assignee(user, present: false)
team_planner.expect_assignee(other_user, present: false)
@@ -38,6 +38,7 @@ RSpec.describe "Team planner",
it "allows switching of view modes", with_settings: { working_days: [1, 2, 3, 4, 5] } do
team_planner.visit!
team_planner.wait_for_loaded
team_planner.expect_empty_state
team_planner.add_assignee user.name