From cbf0fac8d3cb12446477a212cfd2bbc24e172635 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Thu, 2 Apr 2026 15:39:07 +0200 Subject: [PATCH] Remove uiRouter from Calendars --- frontend/src/app/app.module.ts | 2 + .../src/app/core/main-menu/submenu.service.ts | 42 ++++----- .../app/core/routing/openproject.routes.ts | 2 - ...-routes.ts => calendar-entry.component.ts} | 28 ++++-- .../app/features/calendar/calendar.routes.ts | 84 ----------------- .../op-work-packages-calendar.service.ts | 55 ++++++------ .../calendar/openproject-calendar.module.ts | 7 +- .../wp-calendar-page.component.html | 49 ++++++++++ .../wp-calendar-page.component.ts | 23 ++++- .../wp-calendar/wp-calendar.component.ts | 32 ++++--- .../planner/team-planner.component.html | 2 +- .../planner/team-planner.component.ts | 30 +++++++ .../wp-list/wp-list-checksum.service.ts | 26 ++++++ .../components/wp-list/wp-list.service.ts | 29 +++++- .../wp-list/wp-query-view.service.ts | 28 +++--- .../calendar/add_button_component.rb | 2 +- .../calendar/calendars_controller.rb | 43 +++++++-- .../controllers/calendar/menus_controller.rb | 2 +- .../views/calendar/calendars/_form.html.erb | 89 ------------------- .../app/views/calendar/calendars/new.html.erb | 49 ++++++++-- .../views/calendar/calendars/show.html.erb | 11 ++- .../app/views/calendar/menus/_menu.html.erb | 2 +- modules/calendar/config/routes.rb | 11 ++- .../lib/open_project/calendar/engine.rb | 2 +- .../spec/features/team_planner_spec.rb | 1 + .../features/team_planner_view_modes_spec.rb | 1 + 26 files changed, 359 insertions(+), 293 deletions(-) rename frontend/src/app/features/calendar/{calendar.lazy-routes.ts => calendar-entry.component.ts} (62%) delete mode 100644 frontend/src/app/features/calendar/calendar.routes.ts create mode 100644 frontend/src/app/features/calendar/wp-calendar-page/wp-calendar-page.component.html delete mode 100644 modules/calendar/app/views/calendar/calendars/_form.html.erb diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index a002c9df85e..c7c37370d5c 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -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 }); diff --git a/frontend/src/app/core/main-menu/submenu.service.ts b/frontend/src/app/core/main-menu/submenu.service.ts index dff74195f52..5ad6678b260 100644 --- a/frontend/src/app/core/main-menu/submenu.service.ts +++ b/frontend/src/app/core/main-menu/submenu.service.ts @@ -9,31 +9,31 @@ export class SubmenuService { reloadSubmenu(selectedQueryId:string|null, sidemenuId?:string):void { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment const menuIdentifier:string|undefined = sidemenuId ?? this.$state.current.data?.sideMenuOptions?.sidemenuId; + if (!menuIdentifier) { return; } - if (menuIdentifier) { - const menu = document.getElementById(menuIdentifier) as FrameElement; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const sideMenuOptions = this.$state.$current.data?.sideMenuOptions as { hardReloadOnBaseRoute?:boolean, defaultQuery?:string }; - const currentSrc = menu.getAttribute('src'); + const menu = document.getElementById(menuIdentifier) as FrameElement|null; + const currentSrc = menu?.getAttribute('src'); + if (!currentSrc || !menu) { return; } - if (currentSrc && menu) { - const frameUrl = new URL(currentSrc, window.location.origin); - const defaultQuery = sideMenuOptions?.defaultQuery; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const sideMenuOptions = this.$state.$current.data?.sideMenuOptions as { hardReloadOnBaseRoute?:boolean, defaultQuery?:string }; + const frameUrl = new URL(currentSrc, window.location.origin); - if (selectedQueryId) { - // If there is a default query passed in the route definition, it means that id passed as argument and not as parameter, - // e.g. calendars/:id, team_planner/:id, ... - // Otherwise, we will just replace the params - if (defaultQuery) { - frameUrl.search = `?id=${selectedQueryId}`; - } else { - frameUrl.search = `?query_id=${selectedQueryId}`; - } - } + if (selectedQueryId) { + // Prefer the data attribute on the frame, then fall back to route sideMenuOptions, + // then default to 'query_id'. Modules with path-based IDs (e.g. calendars/:id) + // set data-query-param="id" on the frame. + const queryParam = menu.getAttribute('data-query-param') + ?? (sideMenuOptions?.defaultQuery ? 'id' : 'query_id'); - // Override the frame src to enforce a reload - menu.setAttribute('src', frameUrl.href); - } + frameUrl.search = `?${queryParam}=${selectedQueryId}`; + } + + const newSrc = frameUrl.href; + if (menu.getAttribute('src') !== newSrc) { + menu.setAttribute('src', newSrc); + } else { + void menu.reload(); } } } diff --git a/frontend/src/app/core/routing/openproject.routes.ts b/frontend/src/app/core/routing/openproject.routes.ts index fb01324276b..59250162984 100644 --- a/frontend/src/app/core/routing/openproject.routes.ts +++ b/frontend/src/app/core/routing/openproject.routes.ts @@ -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, ]; /** diff --git a/frontend/src/app/features/calendar/calendar.lazy-routes.ts b/frontend/src/app/features/calendar/calendar-entry.component.ts similarity index 62% rename from frontend/src/app/features/calendar/calendar.lazy-routes.ts rename to frontend/src/app/features/calendar/calendar-entry.component.ts index 70e8ab18acc..1200e7da5b9 100644 --- a/frontend/src/app/features/calendar/calendar.lazy-routes.ts +++ b/frontend/src/app/features/calendar/calendar-entry.component.ts @@ -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: '', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, +}) +export class CalendarEntryComponent { + @Input() queryId:string; + + constructor(readonly elementRef:ElementRef) { + populateInputsFromDataset(this); + document.body.classList.add('router--calendar'); + } +} diff --git a/frontend/src/app/features/calendar/calendar.routes.ts b/frontend/src/app/features/calendar/calendar.routes.ts deleted file mode 100644 index 4a2e1c34d1c..00000000000 --- a/frontend/src/app/features/calendar/calendar.routes.ts +++ /dev/null @@ -1,84 +0,0 @@ -//-- copyright -// OpenProject is an open source project management software. -// Copyright (C) the OpenProject GmbH -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License version 3. -// -// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -// Copyright (C) 2006-2013 Jean-Philippe Lang -// Copyright (C) 2010-2013 the ChiliProject Team -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License -// as published by the Free Software Foundation; either version 2 -// of the License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -// -// See COPYRIGHT and LICENSE files for more details. -//++ - -import { Ng2StateDeclaration } from '@uirouter/angular'; -import { makeSplitViewRoutes } from 'core-app/features/work-packages/routing/split-view-routes.template'; -import { WorkPackageSplitViewComponent } from 'core-app/features/work-packages/routing/wp-split-view/wp-split-view.component'; -import { WorkPackagesBaseComponent } from 'core-app/features/work-packages/routing/wp-base/wp--base.component'; -import { WorkPackagesCalendarComponent } from 'core-app/features/calendar/wp-calendar/wp-calendar.component'; -import { WorkPackagesCalendarPageComponent } from 'core-app/features/calendar/wp-calendar-page/wp-calendar-page.component'; - -export const sidemenuId = 'calendar_sidemenu'; -export const sideMenuOptions = { - sidemenuId, - hardReloadOnBaseRoute: true, - defaultQuery: 'new', -}; - -export const CALENDAR_ROUTES:Ng2StateDeclaration[] = [ - { - name: 'calendar', - parent: 'optional_project', - url: '/calendars/:query_id?&query_props&cdate&cview', - redirectTo: 'calendar.page', - views: { - '!$default': { component: WorkPackagesBaseComponent }, - }, - params: { - query_id: { type: 'opQueryId', dynamic: true }, - cdate: { type: 'string', dynamic: true }, - cview: { type: 'string', dynamic: true }, - // Use custom encoder/decoder that ensures validity of URL string - query_props: { type: 'opQueryString' }, - }, - }, - { - name: 'calendar.page', - component: WorkPackagesCalendarPageComponent, - redirectTo: 'calendar.page.show', - data: { - bodyClasses: 'router--calendar', - sideMenuOptions, - }, - }, - { - name: 'calendar.page.show', - data: { - baseRoute: 'calendar.page.show', - sideMenuOptions, - }, - views: { - 'content-left': { component: WorkPackagesCalendarComponent }, - }, - }, - ...makeSplitViewRoutes( - 'calendar.page.show', - undefined, - WorkPackageSplitViewComponent, - ), -]; diff --git a/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts b/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts index 86dbd0c0d09..3a51ebeb6ad 100644 --- a/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts +++ b/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts @@ -15,9 +15,8 @@ import { ConfigurationService } from 'core-app/core/config/configuration.service import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { DomSanitizer } from '@angular/platform-browser'; import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; -import { splitViewRoute } from 'core-app/features/work-packages/routing/split-view-routes.helper'; -import { StateService } from '@uirouter/angular'; import { WorkPackageCollectionResource } from 'core-app/features/hal/resources/wp-collection-resource'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; import { ToastService } from 'core-app/shared/components/toaster/toast.service'; import { firstValueFrom, Observable } from 'rxjs'; import { @@ -34,7 +33,6 @@ import { UrlParamsHelperService, } from 'core-app/features/work-packages/components/wp-query/url-params-helper'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; -import { UIRouterGlobals } from '@uirouter/core'; import { TimezoneService } from 'core-app/core/datetime/timezone.service'; import { WorkPackagesListChecksumService, @@ -93,7 +91,6 @@ export class OpWorkPackagesCalendarService extends UntilDestroyedMixin { private I18n:I18nService, private configuration:ConfigurationService, private sanitizer:DomSanitizer, - private $state:StateService, readonly injector:Injector, readonly schemaCache:SchemaCacheService, readonly toastService:ToastService, @@ -104,8 +101,8 @@ export class OpWorkPackagesCalendarService extends UntilDestroyedMixin { readonly querySpace:IsolatedQuerySpace, readonly apiV3Service:ApiV3Service, readonly halResourceService:HalResourceService, - readonly uiRouterGlobals:UIRouterGlobals, readonly timezoneService:TimezoneService, + readonly pathHelper:PathHelperService, readonly halEditing:HalResourceEditingService, readonly wpTableSelection:WorkPackageViewSelectionService, readonly contextMenuService:OPContextMenuService, @@ -283,23 +280,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/, /team_planners/). + const match = /\/(?:calendars|team_planners)\/([^/]+)/.exec(window.location.pathname); + const rawId = match?.[1]; + return { + query_id: rawId === 'new' ? undefined : rawId, + query_props: search.get('query_props') ?? undefined, + cdate: search.get('cdate') ?? undefined, + cview: search.get('cview') ?? undefined, + }; } private get areFiltersEmpty():boolean { @@ -417,17 +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 { diff --git a/frontend/src/app/features/calendar/openproject-calendar.module.ts b/frontend/src/app/features/calendar/openproject-calendar.module.ts index ead83646fc5..fde87c840c5 100644 --- a/frontend/src/app/features/calendar/openproject-calendar.module.ts +++ b/frontend/src/app/features/calendar/openproject-calendar.module.ts @@ -32,12 +32,11 @@ import { ReactiveFormsModule } from '@angular/forms'; import { FullCalendarModule } from '@fullcalendar/angular'; import { WorkPackagesCalendarComponent } from 'core-app/features/calendar/wp-calendar/wp-calendar.component'; import { OpenprojectWorkPackagesModule } from 'core-app/features/work-packages/openproject-work-packages.module'; -import { UIRouterModule } from '@uirouter/angular'; import { TimeEntryCalendarComponent } from 'core-app/features/calendar/te-calendar/te-calendar.component'; import { OpenprojectFieldsModule } from 'core-app/shared/components/fields/openproject-fields.module'; import { OpenprojectTimeEntriesModule } from 'core-app/shared/components/time_entries/openproject-time-entries.module'; import { WorkPackagesCalendarPageComponent } from 'core-app/features/calendar/wp-calendar-page/wp-calendar-page.component'; -import { CALENDAR_ROUTES } from 'core-app/features/calendar/calendar.routes'; +import { CalendarEntryComponent } from 'core-app/features/calendar/calendar-entry.component'; import { QueryGetIcalUrlModalComponent } from 'core-app/shared/components/modals/get-ical-url-modal/query-get-ical-url.modal'; @NgModule({ @@ -45,9 +44,6 @@ import { QueryGetIcalUrlModalComponent } from 'core-app/shared/components/modals // Commons OpSharedModule, - // Routes for /calendar - UIRouterModule.forChild({ states: CALENDAR_ROUTES }), - // Work Package module OpenprojectWorkPackagesModule, @@ -65,6 +61,7 @@ import { QueryGetIcalUrlModalComponent } from 'core-app/shared/components/modals ], declarations: [ // Work package calendars + CalendarEntryComponent, WorkPackagesCalendarPageComponent, WorkPackagesCalendarComponent, TimeEntryCalendarComponent, diff --git a/frontend/src/app/features/calendar/wp-calendar-page/wp-calendar-page.component.html b/frontend/src/app/features/calendar/wp-calendar-page/wp-calendar-page.component.html new file mode 100644 index 00000000000..b81b824c8e6 --- /dev/null +++ b/frontend/src/app/features/calendar/wp-calendar-page/wp-calendar-page.component.html @@ -0,0 +1,49 @@ +
+ +
+
+

+ +

+ @if (showToolbar) { +
    + @for (definition of toolbarButtonComponents; track definition) { + @if (!definition.show || definition.show()) { +
  • + +
  • + } + } +
+ } +
+
+ +
+ @if (filterContainerDefinition) { + + } +
+ +
+
+ +
+
+
diff --git a/frontend/src/app/features/calendar/wp-calendar-page/wp-calendar-page.component.ts b/frontend/src/app/features/calendar/wp-calendar-page/wp-calendar-page.component.ts index 06bc7e78440..1c0a3052e78 100644 --- a/frontend/src/app/features/calendar/wp-calendar-page/wp-calendar-page.component.ts +++ b/frontend/src/app/features/calendar/wp-calendar-page/wp-calendar-page.component.ts @@ -29,6 +29,8 @@ import { ChangeDetectionStrategy, Component, + Input, + OnInit, ViewChild, } from '@angular/core'; import { WorkPackagesCalendarComponent } from 'core-app/features/calendar/wp-calendar/wp-calendar.component'; @@ -50,7 +52,8 @@ import { ActionsService } from 'core-app/core/state/actions/actions.service'; import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; @Component({ - templateUrl: '../../work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.html', + selector: 'op-wp-calendar-page', + templateUrl: './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. diff --git a/frontend/src/app/features/calendar/wp-calendar/wp-calendar.component.ts b/frontend/src/app/features/calendar/wp-calendar/wp-calendar.component.ts index e0f6f2f5f2c..63e93f1ad67 100644 --- a/frontend/src/app/features/calendar/wp-calendar/wp-calendar.component.ts +++ b/frontend/src/app/features/calendar/wp-calendar/wp-calendar.component.ts @@ -57,7 +57,6 @@ import { WorkPackageViewFiltersService, } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-filters.service'; import { WorkPackagesListService } from 'core-app/features/work-packages/components/wp-list/wp-list.service'; -import { StateService } from '@uirouter/core'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { ToastService } from 'core-app/shared/components/toaster/toast.service'; import { DomSanitizer } from '@angular/platform-browser'; @@ -70,7 +69,7 @@ import { HalResourceEditingService, } from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service'; import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service'; -import { splitViewRoute } from 'core-app/features/work-packages/routing/split-view-routes.helper'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; import { CalendarViewEvent, OpWorkPackagesCalendarService, @@ -88,7 +87,6 @@ import { import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder'; import allLocales from '@fullcalendar/core/locales-all'; -import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; import { MeetingResource } from 'core-app/features/hal/resources/meeting-resource'; import { TimezoneService } from 'core-app/core/datetime/timezone.service'; @@ -128,7 +126,6 @@ export class WorkPackagesCalendarComponent extends UntilDestroyedMixin implement constructor( readonly actions$:ActionsService, readonly states:States, - readonly $state:StateService, readonly wpTableFilters:WorkPackageViewFiltersService, readonly wpListService:WorkPackagesListService, readonly querySpace:IsolatedQuerySpace, @@ -345,13 +342,10 @@ export class WorkPackagesCalendarComponent extends UntilDestroyedMixin implement const workPackageId = (evt.event.extendedProps.workPackage as WorkPackageResource).id!; // Currently the calendar widget is shown on multiple pages, // but only the calendar module itself is a partitioned query space which can deal with a split screen request - if (this.$state.includes('calendar')) { + if (window.location.pathname.includes('/calendars/')) { this.workPackagesCalendar.openSplitView(workPackageId); } else { - void this.$state.go( - 'work-packages.show', - { workPackageId }, - ); + window.location.href = this.pathHelper.workPackagePath(workPackageId); } } }, @@ -414,7 +408,7 @@ export class WorkPackagesCalendarComponent extends UntilDestroyedMixin implement durationEditable: this.workPackagesCalendar.eventDurationEditable(workPackage), end: exclusiveEnd, allDay: true, - className: `fc-event-clickable __hl_background_type_${workPackage.type.id || ''}`, + className: `fc-event-clickable __hl_background_type_${workPackage.type.id ?? ''}`, workPackage, }; }); @@ -444,13 +438,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) diff --git a/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.html b/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.html index 3ebc827c247..e43d51c6725 100644 --- a/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.html +++ b/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.html @@ -98,7 +98,7 @@ [showStartDate]="!isWpStartDateInCurrentView(wp)" [showEndDate]="!isWpEndDateInCurrentView(wp)" (stateLinkClicked)="openStateLink($event)" - (cardClicked)="workPackagesCalendar.onCardClicked($event)" + (cardClicked)="onCardClicked($event)" (cardDblClicked)="workPackagesCalendar.onCardDblClicked($event)" (cardContextMenu)="workPackagesCalendar.showEventContextMenu($event)" /> diff --git a/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts b/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts index 14a92373753..87846cdfd3e 100644 --- a/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts +++ b/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts @@ -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; diff --git a/frontend/src/app/features/work-packages/components/wp-list/wp-list-checksum.service.ts b/frontend/src/app/features/work-packages/components/wp-list/wp-list-checksum.service.ts index 45bcd15c26a..8b214522f5f 100644 --- a/frontend/src/app/features/work-packages/components/wp-list/wp-list-checksum.service.ts +++ b/frontend/src/app/features/work-packages/components/wp-list/wp-list-checksum.service.ts @@ -31,6 +31,7 @@ import { UrlParamsHelperService } from 'core-app/features/work-packages/componen import { Injectable } from '@angular/core'; import { WorkPackageViewPagination } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-table-pagination'; import { QueryResource } from 'core-app/features/hal/resources/query-resource'; +import { Subject } from 'rxjs'; @Injectable() export class WorkPackagesListChecksumService { @@ -44,6 +45,9 @@ export class WorkPackagesListChecksumService { public visibleChecksum:string|null; + /** Emits whenever visibleChecksum changes (useful for non-uiRouter pages to react to URL param changes) */ + public readonly visibleChecksum$ = new Subject(); + public updateIfDifferent(query:QueryResource, pagination:WorkPackageViewPagination):Promise { const newQueryChecksum = this.getNewChecksum(query, pagination); @@ -153,6 +157,28 @@ export class WorkPackagesListChecksumService { private maintainUrlQueryState(id:string|null, checksum:string|null):TransitionPromise { this.visibleChecksum = checksum; + this.visibleChecksum$.next(checksum); + + // When uiRouter is not managing the current page (e.g. calendar after Turbo migration), + // $state.current.name is empty and state.go('.') does nothing. Fall back to pushState. + if (!this.$state.current.name) { + const url = new URL(window.location.href); + + if (checksum) { + url.searchParams.set('query_props', checksum); + } else { + url.searchParams.delete('query_props'); + } + + if (id) { + url.searchParams.set('query_id', id); + } else { + url.searchParams.delete('query_id'); + } + + window.history.pushState({}, '', url.toString()); + return Promise.resolve() as unknown as TransitionPromise; + } return this.$state.go( '.', diff --git a/frontend/src/app/features/work-packages/components/wp-list/wp-list.service.ts b/frontend/src/app/features/work-packages/components/wp-list/wp-list.service.ts index 063442197c8..6b47e92c2b6 100644 --- a/frontend/src/app/features/work-packages/components/wp-list/wp-list.service.ts +++ b/frontend/src/app/features/work-packages/components/wp-list/wp-list.service.ts @@ -261,6 +261,7 @@ export class WorkPackagesListService { // Reload the query, and then reload the menu this.reloadQuery(createdQuery).subscribe(() => { + this.navigateToQueryOnNonRouterPage(createdQuery.id); this.states.changes.queries.next(createdQuery.id); this.reloadSidemenu(createdQuery.id); }); @@ -311,7 +312,11 @@ export class WorkPackagesListService { this.toastService.addSuccess(this.I18n.t('js.notice_successful_update')); const queryAccessibleByUser = query.public || query.user.id === this.currentUser.userId; if (queryAccessibleByUser) { - void this.$state.go('.', { query_id: query.id, query_props: null }, { reload: true }); + if (!this.$state.current.name) { + this.navigateToQueryOnNonRouterPage(query.id); + } else { + void this.$state.go('.', { query_id: query.id, query_props: null }, { reload: true }); + } this.states.changes.queries.next(query.id); this.reloadSidemenu(query.id); } else { @@ -463,7 +468,27 @@ export class WorkPackagesListService { } } + private navigateToQueryOnNonRouterPage(queryId:string|null):void { + if (this.$state.current.name) { return; } + + // update the URL path to reflect the saved query ID so subsequent refetches use the correct query_id. + const url = new URL(window.location.href); + url.pathname = url.pathname.replace(/\/[^/]+$/, `/${queryId}`); + url.searchParams.delete('query_id'); + url.searchParams.delete('query_props'); + window.history.pushState({}, '', url.toString()); + } + private reloadSidemenu(selectedQueryId:string|null):void { - this.submenuService.reloadSubmenu(selectedQueryId); + const sidemenuId = !this.$state.current.name ? this.getNonRouterSidemenuId() : undefined; + this.submenuService.reloadSubmenu(selectedQueryId, sidemenuId); + } + + private getNonRouterSidemenuId():string|undefined { + const { pathname } = window.location; + if (pathname.includes('/calendars')) return 'calendar_sidemenu'; + if (pathname.includes('/team_planners')) return 'team_planner_sidemenu'; + if (pathname.includes('/ifc_models')) return 'bim_sidemenu'; + return undefined; } } diff --git a/frontend/src/app/features/work-packages/components/wp-list/wp-query-view.service.ts b/frontend/src/app/features/work-packages/components/wp-list/wp-query-view.service.ts index 27ff2c11f0e..e9b7d05c6e0 100644 --- a/frontend/src/app/features/work-packages/components/wp-list/wp-query-view.service.ts +++ b/frontend/src/app/features/work-packages/components/wp-list/wp-query-view.service.ts @@ -34,20 +34,20 @@ export class WorkPackagesQueryViewService { } private get viewType() { - if (this.$state.includes('work-packages')) { - return 'work_packages_table'; - } - if (this.$state.includes('team_planner')) { - return 'team_planner'; - } - if (this.$state.includes('bim')) { - return 'bim'; - } - if (this.$state.includes('calendar')) { - return 'work_packages_calendar'; - } - if (this.$state.includes('gantt')) { - return 'gantt'; + if (this.$state.current.name) { + if (this.$state.includes('work-packages')) { return 'work_packages_table'; } + if (this.$state.includes('team_planner')) { return 'team_planner'; } + if (this.$state.includes('bim')) { return 'bim'; } + if (this.$state.includes('calendar')) { return 'work_packages_calendar'; } + if (this.$state.includes('gantt')) { return 'gantt'; } + } else { + // Non-uiRouter page — derive view type from URL path + const { pathname } = window.location; + if (pathname.includes('/calendars')) { return 'work_packages_calendar'; } + if (pathname.includes('/team_planners')) { return 'team_planner'; } + if (pathname.includes('/ifc_models')) { return 'bim'; } + if (pathname.includes('/gantt')) { return 'gantt'; } + if (pathname.includes('/work_packages')) { return 'work_packages_table'; } } throw new Error('Not on a path defined for query views'); diff --git a/modules/calendar/app/components/calendar/add_button_component.rb b/modules/calendar/app/components/calendar/add_button_component.rb index c825653bcce..1cad85460df 100644 --- a/modules/calendar/app/components/calendar/add_button_component.rb +++ b/modules/calendar/app/components/calendar/add_button_component.rb @@ -41,7 +41,7 @@ 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 diff --git a/modules/calendar/app/controllers/calendar/calendars_controller.rb b/modules/calendar/app/controllers/calendar/calendars_controller.rb index 4d34662cece..c515bdb82b3 100644 --- a/modules/calendar/app/controllers/calendar/calendars_controller.rb +++ b/modules/calendar/app/controllers/calendar/calendars_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -31,14 +33,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 diff --git a/modules/calendar/app/controllers/calendar/menus_controller.rb b/modules/calendar/app/controllers/calendar/menus_controller.rb index d0a735356cc..bf2ff10413b 100644 --- a/modules/calendar/app/controllers/calendar/menus_controller.rb +++ b/modules/calendar/app/controllers/calendar/menus_controller.rb @@ -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 diff --git a/modules/calendar/app/views/calendar/calendars/_form.html.erb b/modules/calendar/app/views/calendar/calendars/_form.html.erb deleted file mode 100644 index 1e082372b43..00000000000 --- a/modules/calendar/app/views/calendar/calendars/_form.html.erb +++ /dev/null @@ -1,89 +0,0 @@ -<%#-- copyright -OpenProject is an open source project management software. -Copyright (C) the OpenProject GmbH - -This program is free software; you can redistribute it and/or -modify it under the terms of the GNU General Public License version 3. - -OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -Copyright (C) 2006-2013 Jean-Philippe Lang -Copyright (C) 2010-2013 the ChiliProject Team - -This program is free software; you can redistribute it and/or -modify it under the terms of the GNU General Public License -as published by the Free Software Foundation; either version 2 -of the License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - -See COPYRIGHT and LICENSE files for more details. - -++#%> - -<%= error_messages_for @view %> - -
- -
- <%= f.text_field :name, - label: t(:label_title), - required: true, - size: 60, - container_class: "-wide" %> -
- -
- -
- <%= angular_component_tag "opce-project-autocompleter", - inputs: { - filters: [{ name: :user_action, operator: "=", values: ["calendars/create"] }], - inputName: "project_id", - inputValue: @project&.id - }, - id: "project_id", - class: "form--select-container -wide remote-field--input", - data: { - "test-selector": "project_id" - } %> -
-
-

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

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

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

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

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

-
-
-
diff --git a/modules/calendar/app/views/calendar/calendars/new.html.erb b/modules/calendar/app/views/calendar/calendars/new.html.erb index 0de4281096f..78979bb28bc 100644 --- a/modules/calendar/app/views/calendar/calendars/new.html.erb +++ b/modules/calendar/app/views/calendar/calendars/new.html.erb @@ -39,9 +39,48 @@ See COPYRIGHT and LICENSE files for more details. end %> -<%= labelled_tabular_form_for @view, url: { controller: "/calendar/calendars", action: "create" }, html: { id: "calendar-form" } do |f| -%> - <%= render partial: "form", locals: { f: f } %> - <%= styled_button_tag t(:button_create), class: "-primary" %> - <%= link_to t(:button_cancel), { controller: "calendar/calendars", action: "index" }, - class: "button" %> +<%= primer_form_with(model: @view, scope: :query, url: calendars_path) do |f| %> + <%= render_inline_form(f) do |form| + project_id_value = @project&.id + form.text_field( + name: :name, + label: helpers.t(:label_title), + required: true, + input_width: :large + ) + + form.project_autocompleter( + name: :project_id, + label: Query.human_attribute_name(:project), + required: true, + caption: helpers.t("help_texts.views.project", + singular: helpers.t(:label_calendar).downcase, + plural: helpers.t(:label_calendar_plural)), + input_width: :large, + autocomplete_options: { + focusDirectly: false, + dropdownPosition: "bottom", + inputValue: project_id_value, + filters: [{ name: "user_action", operator: "=", values: ["calendars/create"] }], + data: { "test-selector": "project_id" } + } + ) + + form.check_box( + name: :public, + label: Query.human_attribute_name(:public), + caption: helpers.t("help_texts.views.public") + ) + + form.check_box( + name: :starred, + label: helpers.t(:label_favorite), + caption: helpers.t("help_texts.views.favoured") + ) + + form.group(layout: :horizontal) do |button_group| + button_group.submit(label: helpers.t(:button_create), name: :submit, scheme: :primary) + button_group.button(tag: :a, href: helpers.calendars_path, label: helpers.t(:button_cancel), name: :cancel) + end + end %> <% end %> diff --git a/modules/calendar/app/views/calendar/calendars/show.html.erb b/modules/calendar/app/views/calendar/calendars/show.html.erb index 71d24703371..9866eca179e 100644 --- a/modules/calendar/app/views/calendar/calendars/show.html.erb +++ b/modules/calendar/app/views/calendar/calendars/show.html.erb @@ -27,4 +27,13 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<% html_title(t(:label_calendar_plural)) -%> +<% html_title(@view.name.presence || t(:label_calendar_plural)) -%> + +<% content_for :content_body do %> + <%= angular_component_tag "opce-calendar-view", inputs: { queryId: @view.id.to_s } %> +<% end %> + +<% content_for :content_body_right do %> + <%= turbo_stream.set_title(title: page_title(*html_title_parts)) if turbo_frame_request? %> + <%= render(split_view_instance) if render_work_package_split_view? %> +<% end %> diff --git a/modules/calendar/app/views/calendar/menus/_menu.html.erb b/modules/calendar/app/views/calendar/menus/_menu.html.erb index 4ecb73e0680..dfce2ac8225 100644 --- a/modules/calendar/app/views/calendar/menus/_menu.html.erb +++ b/modules/calendar/app/views/calendar/menus/_menu.html.erb @@ -1,5 +1,5 @@ <%= turbo_frame_tag "calendar_sidemenu", src: menu_project_calendars_path(@project, **params.permit(:id)), target: "_top", - data: { turbo: false }, + data: { turbo: false, query_param: "id" }, loading: :lazy %> diff --git a/modules/calendar/config/routes.rb b/modules/calendar/config/routes.rb index ec673b259fc..d23071a44ed 100644 --- a/modules/calendar/config/routes.rb +++ b/modules/calendar/config/routes.rb @@ -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 diff --git a/modules/calendar/lib/open_project/calendar/engine.rb b/modules/calendar/lib/open_project/calendar/engine.rb index fc0f08a0e75..6fb9244572e 100644 --- a/modules/calendar/lib/open_project/calendar/engine.rb +++ b/modules/calendar/lib/open_project/calendar/engine.rb @@ -28,7 +28,7 @@ module OpenProject::Calendar settings: {} do project_module :calendar_view, dependencies: :work_package_tracking do permission :view_calendar, - { "calendar/calendars": %i[index show], + { "calendar/calendars": %i[index show split_view new], "calendar/menus": %i[show] }, permissible_on: :project, dependencies: %i[view_work_packages], diff --git a/modules/team_planner/spec/features/team_planner_spec.rb b/modules/team_planner/spec/features/team_planner_spec.rb index b4e11253832..45a54eca9e6 100644 --- a/modules/team_planner/spec/features/team_planner_spec.rb +++ b/modules/team_planner/spec/features/team_planner_spec.rb @@ -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) diff --git a/modules/team_planner/spec/features/team_planner_view_modes_spec.rb b/modules/team_planner/spec/features/team_planner_view_modes_spec.rb index c7e19f93898..51251f46016 100644 --- a/modules/team_planner/spec/features/team_planner_view_modes_spec.rb +++ b/modules/team_planner/spec/features/team_planner_view_modes_spec.rb @@ -38,6 +38,7 @@ 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