diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 22d67a30f1..9f31426e61 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -308,6 +308,8 @@ module ApplicationHelper css << "ee-banners-#{EnterpriseToken.show_banners? ? 'visible' : 'hidden'}" + css << "env-#{Rails.env}" + # Add browser specific classes to aid css fixes css += browser_specific_classes diff --git a/docker-compose.yml b/docker-compose.yml index 9204582884..5200b4dd02 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -167,6 +167,7 @@ services: - "opdata:/var/openproject/assets" - "bundle:/usr/local/bundle" - "tmp-test:/home/dev/openproject/tmp" + - "./tmp/capybara:/home/dev/openproject/tmp/capybara" # https://vitobotta.com/2019/09/04/rails-parallel-system-tests-selenium-docker/ selenium-hub: diff --git a/frontend/src/app/core/state/collection-store.ts b/frontend/src/app/core/state/collection-store.ts index 5f3f351a4b..cbc08aa4bc 100644 --- a/frontend/src/app/core/state/collection-store.ts +++ b/frontend/src/app/core/state/collection-store.ts @@ -93,7 +93,8 @@ export function selectEntitiesFromIDCollection(service * @param state * @param params */ -export function selectCollectionAsEntities$(service:CollectionService, state:CollectionState, params:Apiv3ListParameters):T[] { +export function selectCollectionAsEntities$(service:CollectionService, params:Apiv3ListParameters):T[] { + const state = service.query.getValue(); const key = collectionKey(params); const collection = state.collections[key]; diff --git a/frontend/src/app/core/state/openproject-state.module.ts b/frontend/src/app/core/state/openproject-state.module.ts index b508ea908c..391137755b 100644 --- a/frontend/src/app/core/state/openproject-state.module.ts +++ b/frontend/src/app/core/state/openproject-state.module.ts @@ -31,11 +31,13 @@ import { } from '@angular/core'; import { InAppNotificationsResourceService } from './in-app-notifications/in-app-notifications.service'; import { ProjectsResourceService } from './projects/projects.service'; +import { PrincipalsResourceService } from './principals/principals.service'; @NgModule({ providers: [ InAppNotificationsResourceService, ProjectsResourceService, + PrincipalsResourceService, ], }) export class OpenProjectStateModule { diff --git a/frontend/src/app/core/state/principals/group.model.ts b/frontend/src/app/core/state/principals/group.model.ts new file mode 100644 index 0000000000..9fd2628ff2 --- /dev/null +++ b/frontend/src/app/core/state/principals/group.model.ts @@ -0,0 +1,13 @@ +import { ID } from '@datorama/akita'; +import { HalResourceLinks } from 'core-app/core/state/hal-resource'; + +export interface GroupHalResourceLinks extends HalResourceLinks { } + +export interface Group { + id:ID; + name:string; + createdAt:string; + updatedAt:string; + + _links:GroupHalResourceLinks; +} diff --git a/frontend/src/app/core/state/principals/placeholder-user.model.ts b/frontend/src/app/core/state/principals/placeholder-user.model.ts new file mode 100644 index 0000000000..3dc91212b6 --- /dev/null +++ b/frontend/src/app/core/state/principals/placeholder-user.model.ts @@ -0,0 +1,17 @@ +import { ID } from '@datorama/akita'; +import { HalResourceLink, HalResourceLinks } from 'core-app/core/state/hal-resource'; + +export interface PlaceholderUserHalResourceLinks extends HalResourceLinks { + updateImmediately:HalResourceLink; + delete:HalResourceLink; + showUser:HalResourceLink; +} + +export interface PlaceholderUser { + id:ID; + name:string; + createdAt:string; + updatedAt:string; + + _links:PlaceholderUserHalResourceLinks; +} diff --git a/frontend/src/app/core/state/principals/principal.model.ts b/frontend/src/app/core/state/principals/principal.model.ts new file mode 100644 index 0000000000..41baf6dcd6 --- /dev/null +++ b/frontend/src/app/core/state/principals/principal.model.ts @@ -0,0 +1,5 @@ +import { User } from './user.model'; +import { Group } from './group.model'; +import { PlaceholderUser } from './placeholder-user.model'; + +export type Principal = User|Group|PlaceholderUser; diff --git a/frontend/src/app/core/state/principals/principals.query.ts b/frontend/src/app/core/state/principals/principals.query.ts new file mode 100644 index 0000000000..ec5d89563e --- /dev/null +++ b/frontend/src/app/core/state/principals/principals.query.ts @@ -0,0 +1,10 @@ +import { QueryEntity } from '@datorama/akita'; +import { Observable } from 'rxjs'; +import { Principal } from './principal.model'; +import { PrincipalsState } from './principals.store'; + +export class PrincipalsQuery extends QueryEntity { + public byIds(ids:string[]):Observable { + return this.selectMany(ids); + } +} diff --git a/frontend/src/app/core/state/principals/principals.service.ts b/frontend/src/app/core/state/principals/principals.service.ts new file mode 100644 index 0000000000..59603bdb7e --- /dev/null +++ b/frontend/src/app/core/state/principals/principals.service.ts @@ -0,0 +1,110 @@ +import { Injectable } from '@angular/core'; +import { + catchError, + tap, +} from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import { + applyTransaction, + ID, +} from '@datorama/akita'; +import { APIV3Service } from 'core-app/core/apiv3/api-v3.service'; +import { ToastService } from 'core-app/shared/components/toaster/toast.service'; +import { IHALCollection } from 'core-app/core/apiv3/types/hal-collection.type'; +import { HttpClient } from '@angular/common/http'; +import { PrincipalsQuery } from 'core-app/core/state/principals/principals.query'; +import { Apiv3ListParameters } from 'core-app/core/apiv3/paths/apiv3-list-resource.interface'; +import { collectionKey } from 'core-app/core/state/collection-store'; +import { + EffectHandler, +} from 'core-app/core/state/effects/effect-handler.decorator'; +import { ActionsService } from 'core-app/core/state/actions/actions.service'; +import { PrincipalsStore } from './principals.store'; +import { Principal } from './principal.model'; + +@EffectHandler +@Injectable() +export class PrincipalsResourceService { + protected store = new PrincipalsStore(); + + readonly query = new PrincipalsQuery(this.store); + + private get principalsPath():string { + return this + .apiV3Service + .principals + .path; + } + + constructor( + readonly actions$:ActionsService, + private http:HttpClient, + private apiV3Service:APIV3Service, + private toastService:ToastService, + ) { + } + + fetchPrincipals(params:Apiv3ListParameters):Observable> { + const collectionURL = collectionKey(params); + + return this + .http + .get>(this.principalsPath + collectionURL) + .pipe( + tap((events) => { + applyTransaction(() => { + this.store.add(events._embedded.elements); + this.store.update(({ collections }) => ( + { + collections: { + ...collections, + [collectionURL]: { + ...collections[collectionURL], + ids: events._embedded.elements.map((el) => el.id), + }, + }, + } + )); + }); + }), + catchError((error) => { + this.toastService.addError(error); + throw error; + }), + ); + } + + update(id:ID, principal:Partial):void { + this.store.update(id, principal); + } + + modifyCollection(params:Apiv3ListParameters, callback:(collection:ID[]) => ID[]):void { + const key = collectionKey(params); + this.store.update(({ collections }) => ( + { + collections: { + ...collections, + [key]: { + ...collections[key], + ids: [...callback(collections[key]?.ids || [])], + }, + }, + } + )); + } + + removeFromCollection(params:Apiv3ListParameters, ids:ID[]):void { + const key = collectionKey(params); + this.store.update(({ collections }) => ( + { + collections: { + ...collections, + [key]: { + ...collections[key], + ids: (collections[key]?.ids || []).filter((id) => !ids.includes(id)), + }, + }, + } + )); + } +} diff --git a/frontend/src/app/core/state/principals/principals.store.ts b/frontend/src/app/core/state/principals/principals.store.ts new file mode 100644 index 0000000000..19b8fae8b2 --- /dev/null +++ b/frontend/src/app/core/state/principals/principals.store.ts @@ -0,0 +1,13 @@ +import { EntityStore, StoreConfig } from '@datorama/akita'; +import { Principal } from './principal.model'; +import { CollectionState, createInitialCollectionState } from 'core-app/core/state/collection-store'; + +export interface PrincipalsState extends CollectionState { +} + +@StoreConfig({ name: 'principals' }) +export class PrincipalsStore extends EntityStore { + constructor() { + super(createInitialCollectionState()); + } +} diff --git a/frontend/src/app/core/state/principals/user.model.ts b/frontend/src/app/core/state/principals/user.model.ts new file mode 100644 index 0000000000..60f6282d61 --- /dev/null +++ b/frontend/src/app/core/state/principals/user.model.ts @@ -0,0 +1,31 @@ +import { ID } from '@datorama/akita'; +import { HalResourceLink, HalResourceLinks } from 'core-app/core/state/hal-resource'; + +export interface UserHalResourceLinks extends HalResourceLinks { + lock:HalResourceLink; + unlock:HalResourceLink; + delete:HalResourceLink; + showUser:HalResourceLink; +} + +export interface User { + id:ID; + name:string; + createdAt:string; + updatedAt:string; + + // Properties + login:string; + + firstName:string; + + lastName:string; + + email:string; + + avatar:string; + + status:string; + + _links:UserHalResourceLinks; +} diff --git a/frontend/src/app/features/in-app-notifications/center/state/ian-center.service.ts b/frontend/src/app/features/in-app-notifications/center/state/ian-center.service.ts index c40417b191..37a3563ba3 100644 --- a/frontend/src/app/features/in-app-notifications/center/state/ian-center.service.ts +++ b/frontend/src/app/features/in-app-notifications/center/state/ian-center.service.ts @@ -137,7 +137,7 @@ export class IanCenterService extends UntilDestroyedMixin { } markAllAsRead():void { - const ids:ID[] = selectCollectionAsEntities$(this.resourceService, this.resourceService.query.getValue(), this.query.params) + const ids:ID[] = selectCollectionAsEntities$(this.resourceService, this.query.params) .filter((notification) => notification.readIAN === false) .map((notification) => notification.id); diff --git a/frontend/src/app/features/team-planner/team-planner/planner/add-assignee.component.html b/frontend/src/app/features/team-planner/team-planner/planner/add-assignee.component.html new file mode 100644 index 0000000000..f87631480d --- /dev/null +++ b/frontend/src/app/features/team-planner/team-planner/planner/add-assignee.component.html @@ -0,0 +1,8 @@ + diff --git a/frontend/src/app/features/team-planner/team-planner/planner/add-assignee.component.ts b/frontend/src/app/features/team-planner/team-planner/planner/add-assignee.component.ts new file mode 100644 index 0000000000..2329aff784 --- /dev/null +++ b/frontend/src/app/features/team-planner/team-planner/planner/add-assignee.component.ts @@ -0,0 +1,96 @@ +// -- copyright +// OpenProject is an open source project management software. +// Copyright (C) 2012-2021 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 { + Component, + ElementRef, + EventEmitter, + Injector, + Input, + Output, + ChangeDetectionStrategy, +} from '@angular/core'; +import { HalResourceService } from 'core-app/features/hal/services/hal-resource.service'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; +import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service'; +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder'; +import { APIV3Service } from 'core-app/core/apiv3/api-v3.service'; + +@Component({ + templateUrl: './add-assignee.component.html', + selector: 'op-tp-add-assignee', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AddAssigneeComponent { + @Output() public selectAssignee = new EventEmitter(); + + @Input() alreadySelected:string[] = []; + + public getOptionsFn = (query:string):Observable => this.autocomplete(query); + + constructor( + protected elementRef:ElementRef, + protected halResourceService:HalResourceService, + protected I18n:I18nService, + protected halNotification:HalResourceNotificationService, + readonly pathHelper:PathHelperService, + readonly apiV3Service:APIV3Service, + readonly injector:Injector, + readonly currentProjectService:CurrentProjectService, + ) { } + + public autocomplete(term:string|null):Observable { + const filters = new ApiV3FilterBuilder(); + + filters.add('member', '=', [this.currentProjectService.id || '']); + + if (term) { + filters.add('name_and_identifier', '~', [term]); + } + + return this + .apiV3Service + .principals + .filtered(filters) + .get() + .pipe( + map((collection) => collection.elements.filter( + (user) => !this.alreadySelected.find((selected) => selected === user.id), + )), + ); + } + + public selectUser(user:HalResource):void { + this.selectAssignee.emit(user); + } +} 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 0d9fd43010..d66b1708b2 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 @@ -11,8 +11,45 @@ [textContent]="calendar.tooManyResultsText" class="op-wp-calendar--notification"> + - +
+ + +
+ +
+ +
+ +
diff --git a/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.sass b/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.sass index 0bebb94376..302ab38e75 100644 --- a/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.sass +++ b/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.sass @@ -6,3 +6,6 @@ overflow: auto // Avoid empty space on the right since the styled scrollbar would only be visible on hover. @include no-visible-scroll-bar + +.tp-assignee-add-button + margin-top: 1rem 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 c348ff2f3a..b05c4e3be9 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 @@ -11,27 +11,34 @@ import { CalendarOptions, EventInput, } from '@fullcalendar/core'; +import { + combineLatest, + Subject, +} from 'rxjs'; +import { + debounceTime, + mergeMap, + map, + filter, + distinctUntilChanged, +} from 'rxjs/operators'; +import { EventClickArg } from '@fullcalendar/common'; +import { StateService } from '@uirouter/angular'; import resourceTimelinePlugin from '@fullcalendar/resource-timeline'; +import { FullCalendarComponent } from '@fullcalendar/angular'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { ConfigurationService } from 'core-app/core/config/configuration.service'; -import { FullCalendarComponent } from '@fullcalendar/angular'; import { EventViewLookupService } from 'core-app/features/team-planner/team-planner/planner/event-view-lookup.service'; -import { States } from 'core-app/core/states/states.service'; -import { StateService } from '@uirouter/angular'; -import { DomSanitizer } from '@angular/platform-browser'; 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 { IsolatedQuerySpace } from 'core-app/features/work-packages/directives/query-space/isolated-query-space'; -import { WorkPackagesListChecksumService } from 'core-app/features/work-packages/components/wp-list/wp-list-checksum.service'; -import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; -import { OpTitleService } from 'core-app/core/html/op-title.service'; -import { Subject } from 'rxjs'; +import { splitViewRoute } from 'core-app/features/work-packages/routing/split-view-routes.helper'; +import { QueryFilterInstanceResource } from 'core-app/features/hal/resources/query-filter-instance-resource'; +import { PrincipalsResourceService } from 'core-app/core/state/principals/principals.service'; +import { Apiv3ListParameters } from 'core-app/core/apiv3/paths/apiv3-list-resource.interface'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; -import { debounceTime } from 'rxjs/operators'; -import { APIV3Service } from 'core-app/core/apiv3/api-v3.service'; import { ResourceLabelContentArg } from '@fullcalendar/resource-common'; import { OpCalendarService } from 'core-app/shared/components/calendar/op-calendar.service'; import { WorkPackageCollectionResource } from 'core-app/features/hal/resources/wp-collection-resource'; @@ -56,28 +63,58 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, @ViewChild('resourceContent') resourceContent:TemplateRef; + @ViewChild('assigneeAutocompleter') assigneeAutocompleter:TemplateRef; + + private resizeSubject = new Subject(); + calendarOptions$ = new Subject(); projectIdentifier:string|undefined = undefined; + showAddAssignee$ = new Subject(); + + private principalIds$ = this.wpTableFilters + .live$() + .pipe( + this.untilDestroyed(), + map((queryFilters) => { + const assigneeFilter = queryFilters.find((queryFilter) => queryFilter._type === 'AssigneeQueryFilter'); + return ((assigneeFilter?.values || []) as HalResource[]).map((p) => p.id); + }), + ); + + private params$ = this.principalIds$ + .pipe( + this.untilDestroyed(), + filter((ids) => ids.length > 0), + map((ids) => ({ + filters: [['id', '=', ids]], + }) as Apiv3ListParameters), + ); + + assignees:HalResource[] = []; + text = { assignees: this.I18n.t('js.team_planner.label_assignee_plural'), + add_assignee: this.I18n.t('js.team_planner.add_assignee'), + remove_assignee: this.I18n.t('js.team_planner.remove_assignee'), }; + principals$ = this.principalIds$ + .pipe( + this.untilDestroyed(), + mergeMap((ids:string[]) => this.principalsResourceService.query.byIds(ids)), + debounceTime(50), + distinctUntilChanged((prev, curr) => prev.length === curr.length && prev.length === 0), + ); + constructor( - private elementRef:ElementRef, - private states:States, private $state:StateService, - private sanitizer:DomSanitizer, private configuration:ConfigurationService, - private apiV3Service:APIV3Service, + private principalsResourceService:PrincipalsResourceService, private wpTableFilters:WorkPackageViewFiltersService, - private wpListService:WorkPackagesListService, private querySpace:IsolatedQuerySpace, - private wpListChecksumService:WorkPackagesListChecksumService, - private schemaCache:SchemaCacheService, private currentProject:CurrentProjectService, - private titleService:OpTitleService, private viewLookup:EventViewLookupService, private I18n:I18nService, readonly calendar:OpCalendarService, @@ -86,18 +123,64 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, } ngOnInit():void { - this.setupWorkPackagesListener(); this.initializeCalendar(); this.projectIdentifier = this.currentProject.identifier ? this.currentProject.identifier : undefined; - this.calendar.resize$ + this + .querySpace + .results + .values$() + .pipe(this.untilDestroyed()) + .subscribe(() => { + this.ucCalendar.getApi().refetchEvents(); + }); + + this.resizeSubject + .pipe(this.untilDestroyed()) + .subscribe(() => { + this.ucCalendar.getApi().updateSize(); + }); + + this.params$ + .pipe(this.untilDestroyed()) + .subscribe((params) => { + this.principalsResourceService.fetchPrincipals(params).subscribe(); + }); + + combineLatest([ + this.principals$, + this.showAddAssignee$, + ]) .pipe( this.untilDestroyed(), - debounceTime(50), + debounceTime(0), ) - .subscribe(() => { - this.ucCalendar.getApi().updateSize(); + .subscribe(([principals, showAddAssignee]) => { + const api = this.ucCalendar.getApi(); + + api.getResources().forEach((resource) => resource.remove()); + + principals.forEach((principal) => { + const { self } = principal._links; + const id = Array.isArray(self) ? self[0].href : self.href; + api.addResource({ + principal, + id, + title: principal.name, + }); + }); + + if (showAddAssignee) { + api.addResource({ + principal: null, + id: 'NEW', + title: '', + }); + } }); + + // This needs to be done after all the subscribers are set up + this.showAddAssignee$.next(false); } ngOnDestroy():void { @@ -105,40 +188,6 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, this.calendar.resizeObs?.disconnect(); } - public calendarResourcesFunction( - fetchInfo:{ start:Date, end:Date, timeZone:string }, - successCallback:(events:EventInput[]) => void, - failureCallback:(error:unknown) => void, - ):void|PromiseLike { - this - .calendar - .currentWorkPackages$ - .toPromise() - .then((workPackages:WorkPackageCollectionResource) => { - const resources = this.mapToCalendarResources(workPackages.elements); - successCallback(resources); - }) - .catch(failureCallback); - } - - public calendarEventsFunction( - fetchInfo:{ start:Date, end:Date, timeZone:string }, - successCallback:(events:EventInput[]) => void, - failureCallback:(error:unknown) => void, - ):void|PromiseLike { - this - .calendar - .currentWorkPackages$ - .toPromise() - .then((workPackages:WorkPackageCollectionResource) => { - const events = this.mapToCalendarEvents(workPackages.elements); - successCallback(events); - }) - .catch(failureCallback); - - this.calendar.updateTimeframe(fetchInfo, this.projectIdentifier); - } - private initializeCalendar() { void this.configuration.initialized .then(() => { @@ -174,7 +223,8 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, }, }, events: this.calendarEventsFunction.bind(this) as unknown, - resources: this.calendarResourcesFunction.bind(this) as unknown, + resources: [], + eventClick: this.openSplitView.bind(this) as unknown, resourceLabelContent: (data:ResourceLabelContentArg) => this.renderTemplate(this.resourceContent, data.resource.id, data), resourceLabelWillUnmount: (data:ResourceLabelContentArg) => this.unrenderTemplate(data.resource.id), } as CalendarOptions), @@ -182,6 +232,24 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, }); } + public calendarEventsFunction( + fetchInfo:{ start:Date, end:Date, timeZone:string }, + successCallback:(events:EventInput[]) => void, + failureCallback:(error:unknown) => void, + ):void|PromiseLike { + this + .calendar + .currentWorkPackages$ + .toPromise() + .then((workPackages:WorkPackageCollectionResource) => { + const events = this.mapToCalendarEvents(workPackages.elements); + successCallback(events); + }) + .catch(failureCallback); + + this.calendar.updateTimeframe(fetchInfo, this.projectIdentifier); + } + renderTemplate(template:TemplateRef, id:string, data:ResourceLabelContentArg):{ domNodes:unknown[] } { const ref = this.viewLookup.getView(template, id, data); return { domNodes: ref.rootNodes }; @@ -191,18 +259,50 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, this.viewLookup.destroyView(id); } - private setupWorkPackagesListener():void { - this.calendar.workPackagesListener$(() => { - this.renderCurrent(); - }); + public showAssigneeAddRow():void { + this.showAddAssignee$.next(true); + this.ucCalendar.getApi().refetchEvents(); } - /** - * Renders the currently loaded set of items - */ - private renderCurrent() { - this.ucCalendar.getApi().refetchEvents(); - this.ucCalendar.getApi().refetchResources(); + public addAssignee(principal:HalResource):void { + this.showAddAssignee$.next(false); + + const modifyFilter = (assigneeFilter:QueryFilterInstanceResource) => { + // eslint-disable-next-line no-param-reassign + assigneeFilter.values = [ + ...assigneeFilter.values as HalResource[], + principal, + ]; + }; + + if (this.wpTableFilters.findIndex('assignee') === -1) { + // Replace actually also instantiates if it does not exist, which is handy here + this.wpTableFilters.replace('assignee', modifyFilter.bind(this)); + } else { + this.wpTableFilters.modify('assignee', modifyFilter.bind(this)); + } + } + + public removeAssignee(href:string):void { + const numberOfAssignees = this.wpTableFilters.find('assignee')?.values?.length; + if (numberOfAssignees && numberOfAssignees <= 1) { + this.wpTableFilters.remove('assignee'); + } else { + this.wpTableFilters.modify('assignee', (assigneeFilter:QueryFilterInstanceResource) => { + // eslint-disable-next-line no-param-reassign + assigneeFilter.values = (assigneeFilter.values as HalResource[]) + .filter((filterValue) => filterValue.href !== href); + }); + } + } + + private openSplitView(event:EventClickArg):void { + const workPackage = event.event.extendedProps.workPackage as WorkPackageResource; + + void this.$state.go( + `${splitViewRoute(this.$state)}.tabs`, + { workPackageId: workPackage.id, tabIdentifier: 'overview' }, + ); } private mapToCalendarEvents(workPackages:WorkPackageResource[]):EventInput[] { @@ -231,23 +331,4 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, }) .filter((event) => !!event) as EventInput[]; } - - private mapToCalendarResources(workPackages:WorkPackageResource[]) { - const resources:{ id:string, title:string, user:HalResource }[] = []; - - workPackages.forEach((workPackage:WorkPackageResource) => { - const assignee = workPackage.assignee as HalResource|undefined; - if (!assignee) { - return; - } - - resources.push({ - id: assignee.href as string, - title: assignee.name, - user: assignee, - }); - }); - - return resources; - } } diff --git a/frontend/src/app/features/team-planner/team-planner/planner/tp-assignee.sass b/frontend/src/app/features/team-planner/team-planner/planner/tp-assignee.sass new file mode 100644 index 0000000000..da218add5e --- /dev/null +++ b/frontend/src/app/features/team-planner/team-planner/planner/tp-assignee.sass @@ -0,0 +1,30 @@ +.tp-assignee + display: flex + max-width: 100% + + &--principal + max-width: 100% + min-width: 0 // See: https://css-tricks.com/flexbox-truncated-text/ + flex-grow: 1 + flex-shrink: 1 + + &--remove + background: white + border-radius: 50% + margin: 0 + padding: 0 + padding-left: 0.5rem + flex-grow: 0 + flex-shrink: 0 + width: 0 + border: 0 + background: transparent + cursor: normal + pointer-events: none + opacity: 0 + + @at-root &--remove:focus, &:hover &--remove, #{$spec-active-selector} &--remove + width: unset + cursor: pointer + opacity: 1 + pointer-events: all diff --git a/frontend/src/app/features/team-planner/team-planner/team-planner.module.ts b/frontend/src/app/features/team-planner/team-planner/team-planner.module.ts index 7cf7a65894..05e9b073d4 100644 --- a/frontend/src/app/features/team-planner/team-planner/team-planner.module.ts +++ b/frontend/src/app/features/team-planner/team-planner/team-planner.module.ts @@ -4,10 +4,12 @@ import { UIRouterModule } from '@uirouter/angular'; import { DynamicModule } from 'ng-dynamic-component'; import { FullCalendarModule } from '@fullcalendar/angular'; import { IconModule } from 'core-app/shared/components/icon/icon.module'; +import { OpenprojectAutocompleterModule } from 'core-app/shared/components/autocompleter/openproject-autocompleter.module'; import { OpenprojectPrincipalRenderingModule } from 'core-app/shared/components/principal/principal-rendering.module'; import { OpenprojectWorkPackagesModule } from 'core-app/features/work-packages/openproject-work-packages.module'; import { TEAM_PLANNER_ROUTES } from 'core-app/features/team-planner/team-planner/team-planner.routes'; import { TeamPlannerComponent } from 'core-app/features/team-planner/team-planner/planner/team-planner.component'; +import { AddAssigneeComponent } from 'core-app/features/team-planner/team-planner/planner/add-assignee.component'; import { TeamPlannerPageComponent } from 'core-app/features/team-planner/team-planner/page/team-planner-page.component'; import { OPSharedModule } from 'core-app/shared/shared.module'; @@ -15,6 +17,7 @@ import { OPSharedModule } from 'core-app/shared/shared.module'; declarations: [ TeamPlannerComponent, TeamPlannerPageComponent, + AddAssigneeComponent, ], imports: [ OPSharedModule, @@ -28,6 +31,8 @@ import { OPSharedModule } from 'core-app/shared/shared.module'; OpenprojectPrincipalRenderingModule, OpenprojectWorkPackagesModule, FullCalendarModule, + // Autocompleters + OpenprojectAutocompleterModule, ], }) export class TeamPlannerModule {} diff --git a/frontend/src/app/features/work-packages/routing/wp-view-base/state/wp-single-view.query.ts b/frontend/src/app/features/work-packages/routing/wp-view-base/state/wp-single-view.query.ts index 856be08025..6af224b645 100644 --- a/frontend/src/app/features/work-packages/routing/wp-view-base/state/wp-single-view.query.ts +++ b/frontend/src/app/features/work-packages/routing/wp-view-base/state/wp-single-view.query.ts @@ -19,7 +19,7 @@ export class WpSingleViewQuery extends Query { this.resourceService.query.select(), ]).pipe( filter((filters) => filters.length > 0), - map(([filters, state]) => selectCollectionAsEntities$(this.resourceService, state, { filters })), + map(([filters]) => selectCollectionAsEntities$(this.resourceService, { filters })), ); selectNotificationsCount$ = this diff --git a/frontend/src/app/shared/components/autocompleter/members-autocompleter/members-autocompleter.component.ts b/frontend/src/app/shared/components/autocompleter/members-autocompleter/members-autocompleter.component.ts index da43b7d005..eeba0894b1 100644 --- a/frontend/src/app/shared/components/autocompleter/members-autocompleter/members-autocompleter.component.ts +++ b/frontend/src/app/shared/components/autocompleter/members-autocompleter/members-autocompleter.component.ts @@ -4,7 +4,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Component } from '@angular/core'; import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; import { - UserAutocompleteItem, + IUserAutocompleteItem, UserAutocompleterComponent, } from 'core-app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component'; import { URLParamsEncoder } from 'core-app/features/hal/services/url-params-encoder'; @@ -20,9 +20,9 @@ export class MembersAutocompleterComponent extends UserAutocompleterComponent { @InjectField() pathHelper:PathHelperService; - protected getAvailableUsers(url:string, searchTerm:any):Observable { + protected getAvailableUsers(url:string, searchTerm:any):Observable { return this.http - .get(url, + .get(url, { params: new HttpParams({ encoder: new URLParamsEncoder(), fromObject: { q: searchTerm } }), responseType: 'json', diff --git a/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts b/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts index 2605eb12cc..c02bc58e70 100644 --- a/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts +++ b/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts @@ -46,10 +46,11 @@ import { ApiV3FilterBuilder, FilterOperator } from 'core-app/shared/helpers/api- export const usersAutocompleterSelector = 'user-autocompleter'; -export interface UserAutocompleteItem { +export interface IUserAutocompleteItem { name:string; id:string|null; href:string|null; + avatar:string|null; } @Component({ @@ -61,7 +62,7 @@ export class UserAutocompleterComponent implements OnInit { @ViewChild(NgSelectComponent, { static: true }) public ngSelectComponent:NgSelectComponent; - @Output() public onChange = new EventEmitter(); + @Output() public onChange = new EventEmitter(); @Input() public clearAfterSelection = false; @@ -80,7 +81,7 @@ export class UserAutocompleterComponent implements OnInit { private updateInputField:HTMLInputElement|undefined; /** Keep a switchmap for search term and loading state */ - public requests = new DebouncedRequestSwitchmap( + public requests = new DebouncedRequestSwitchmap( (searchTerm:string) => this.getAvailableUsers(this.url, searchTerm), errorNotificationHandler(this.halNotification), ); @@ -157,7 +158,7 @@ export class UserAutocompleterComponent implements OnInit { } } - protected getAvailableUsers(url:string, searchTerm:any):Observable { + protected getAvailableUsers(url:string, searchTerm:any):Observable { // Need to clone the filters to not add additional filters on every // search term being processed. const searchFilters = this.inputFilters.clone(); diff --git a/frontend/src/app/shared/components/principal/principal-helper.ts b/frontend/src/app/shared/components/principal/principal-helper.ts index 124e61822c..bdb345a717 100644 --- a/frontend/src/app/shared/components/principal/principal-helper.ts +++ b/frontend/src/app/shared/components/principal/principal-helper.ts @@ -26,10 +26,30 @@ // See COPYRIGHT and LICENSE files for more details. //++ +import { PrincipalLike } from 'core-app/shared/components/principal/principal-types'; +import { Principal } from 'core-app/core/state/principals/principal.model'; + export namespace PrincipalHelper { export type PrincipalType = 'user'|'placeholder_user'|'group'; export type PrincipalPluralType = 'users'|'placeholder_users'|'groups'; + /* + * This function is a helper that wraps around the old HalResource based principal type and the new interface based one. + * + * TODO: Remove old HalResource stuff :P + */ + export function hrefFromPrincipal(p:Principal|PrincipalLike):string { + if ((p as PrincipalLike).href) { + return (p as PrincipalLike).href || ''; + } + + if ((p as Principal)._links) { + const self = (p as Principal)._links.self as HalSourceLink; + return self.href || ''; + } + + return ''; + } export function typeFromHref(href:string):PrincipalType|null { const match = /\/(user|group|placeholder_user)s\/\d+$/.exec(href); diff --git a/frontend/src/app/shared/components/principal/principal-renderer.service.ts b/frontend/src/app/shared/components/principal/principal-renderer.service.ts index 335720907c..91c8bf96c2 100644 --- a/frontend/src/app/shared/components/principal/principal-renderer.service.ts +++ b/frontend/src/app/shared/components/principal/principal-renderer.service.ts @@ -3,6 +3,7 @@ import { PathHelperService } from 'core-app/core/path-helper/path-helper.service import { ColorsService } from 'core-app/shared/components/colors/colors.service'; import { APIV3Service } from 'core-app/core/apiv3/api-v3.service'; import idFromLink from 'core-app/features/hal/helpers/id-from-link'; +import { Principal } from 'core-app/core/state/principals/principal.model'; import { PrincipalLike } from './principal-types'; import { PrincipalHelper } from './principal-helper'; import PrincipalType = PrincipalHelper.PrincipalType; @@ -29,7 +30,7 @@ export class PrincipalRendererService { renderMultiple( container:HTMLElement, - users:PrincipalLike[], + users:PrincipalLike[]|Principal[], name:NameOptions = { hide: false, link: false }, avatar:AvatarOptions = { hide: false, size: 'default' }, multiLine = false, @@ -59,12 +60,12 @@ export class PrincipalRendererService { render( container:HTMLElement, - principal:PrincipalLike, + principal:PrincipalLike|Principal, name:NameOptions = { hide: false, link: true }, avatar:AvatarOptions = { hide: false, size: 'default' }, ):void { container.classList.add('op-principal'); - const type = PrincipalHelper.typeFromHref(principal.href || '') as PrincipalType; + const type = PrincipalHelper.typeFromHref(PrincipalHelper.hrefFromPrincipal(principal)) as PrincipalType; if (!avatar.hide) { const el = this.renderAvatar(principal, avatar, type); @@ -78,7 +79,7 @@ export class PrincipalRendererService { } private renderAvatar( - principal:PrincipalLike, + principal:PrincipalLike|Principal, options:AvatarOptions, type:PrincipalType, ) { @@ -86,6 +87,7 @@ export class PrincipalRendererService { const colorCode = this.colors.toHsl(principal.name); const fallback = document.createElement('div'); + fallback.classList.add('op-principal--avatar'); fallback.classList.add('op-avatar'); fallback.classList.add(`op-avatar_${options.size}`); fallback.classList.add(`op-avatar_${type.replace('_', '-')}`); @@ -108,7 +110,7 @@ export class PrincipalRendererService { return fallback; } - private renderUserAvatar(principal:PrincipalLike, fallback:HTMLElement, options:AvatarOptions):void { + private renderUserAvatar(principal:PrincipalLike|Principal, fallback:HTMLElement, options:AvatarOptions):void { const url = this.userAvatarUrl(principal); if (!url) { @@ -128,12 +130,12 @@ export class PrincipalRendererService { }; } - private userAvatarUrl(principal:PrincipalLike):string|null { - const id = principal.id || idFromLink(principal.href || ''); + private userAvatarUrl(principal:PrincipalLike|Principal):string|null { + const id = principal.id || idFromLink(PrincipalHelper.hrefFromPrincipal(principal)); return id ? this.apiV3Service.users.id(id).avatar.toString() : null; } - private renderName(principal:PrincipalLike, type:PrincipalType, asLink = true) { + private renderName(principal:PrincipalLike|Principal, type:PrincipalType, asLink = true) { if (asLink) { const link = document.createElement('a'); link.textContent = principal.name; @@ -150,8 +152,9 @@ export class PrincipalRendererService { return span; } - private principalURL(principal:PrincipalLike, type:PrincipalType):string { - const id = principal.id || (principal.href ? idFromLink(principal.href) : ''); + private principalURL(principal:PrincipalLike|Principal, type:PrincipalType):string { + const href = PrincipalHelper.hrefFromPrincipal(principal); + const id = principal.id || (href ? idFromLink(href) : ''); switch (type) { case 'group': diff --git a/frontend/src/assets/sass/_helpers.sass b/frontend/src/assets/sass/_helpers.sass index 0be97a8b3e..fad5fa09b5 100644 --- a/frontend/src/assets/sass/_helpers.sass +++ b/frontend/src/assets/sass/_helpers.sass @@ -4,4 +4,5 @@ * importing these helpers! */ @import "~global_styles/openproject/_mixins" +@import "~global_styles/openproject/_variables" @import "~global_styles/content/drag_and_drop" diff --git a/frontend/src/global_styles/content/_principal.sass b/frontend/src/global_styles/content/_principal.sass index 9454fb2258..f65c99f0bb 100644 --- a/frontend/src/global_styles/content/_principal.sass +++ b/frontend/src/global_styles/content/_principal.sass @@ -1,4 +1,17 @@ .op-principal + display: inline-flex + align-items: center + + &--avatar + flex-grow: 0 + flex-shrink: 0 + + &--name + @include text-shortener + flex-grow: 1 + flex-shrink: 1 + min-width: 0 // See: https://css-tricks.com/flexbox-truncated-text/ + &--multi-line display: block margin-bottom: 2px diff --git a/frontend/src/global_styles/content/modules/_team_planner.sass b/frontend/src/global_styles/content/modules/_team_planner.sass index 6eea31f8f1..096967199c 100644 --- a/frontend/src/global_styles/content/modules/_team_planner.sass +++ b/frontend/src/global_styles/content/modules/_team_planner.sass @@ -1,3 +1,5 @@ +@import "~app/features/team-planner/team-planner/planner/tp-assignee" + .router--team-planner #content height: 100% diff --git a/frontend/src/global_styles/openproject/_mixins.sass b/frontend/src/global_styles/openproject/_mixins.sass index 22c75b9980..e0ce0b5a14 100644 --- a/frontend/src/global_styles/openproject/_mixins.sass +++ b/frontend/src/global_styles/openproject/_mixins.sass @@ -273,3 +273,4 @@ $scrollbar-size: 10px &:focus right: 0 + diff --git a/frontend/src/global_styles/openproject/_variables.sass b/frontend/src/global_styles/openproject/_variables.sass new file mode 100644 index 0000000000..b870aca162 --- /dev/null +++ b/frontend/src/global_styles/openproject/_variables.sass @@ -0,0 +1,2 @@ +// A selector that checks whether we are running in a test environment +$spec-active-selector: '.env-test' diff --git a/modules/team_planner/config/locales/js-en.yml b/modules/team_planner/config/locales/js-en.yml index 0bf7129f8e..8d51d11f8a 100644 --- a/modules/team_planner/config/locales/js-en.yml +++ b/modules/team_planner/config/locales/js-en.yml @@ -5,3 +5,5 @@ en: title: 'Team planner' unsaved_title: 'Unnamed team planner' label_assignee_plural: 'Assignees' + add_assignee: 'Add assignee' + remove_assignee: 'Remove assignee' diff --git a/modules/team_planner/spec/features/team_planner_spec.rb b/modules/team_planner/spec/features/team_planner_spec.rb index e4585eeb20..ffa98ff779 100644 --- a/modules/team_planner/spec/features/team_planner_spec.rb +++ b/modules/team_planner/spec/features/team_planner_spec.rb @@ -73,7 +73,16 @@ describe 'Team planner', type: :feature, js: true do end context 'with an assigned work package' do - let!(:other_user) { FactoryBot.create :user, firstname: 'Other', lastname: 'User' } + let!(:other_user) do + FactoryBot.create :user, + firstname: 'Other', + lastname: 'User', + member_in_project: project, + member_with_permissions: %w[ + view_work_packages edit_work_packages view_team_planner manage_team_planner + ] + end + let!(:user_outside_project) { FactoryBot.create :user, firstname: 'Not', lastname: 'In Project' } let(:type_task) { FactoryBot.create :type_task } let(:type_bug) { FactoryBot.create :type_bug } @@ -115,6 +124,21 @@ describe 'Team planner', type: :feature, js: true do team_planner.title + team_planner.expect_assignee(user, present: false) + team_planner.expect_assignee(other_user, present: false) + + retry_block do + team_planner.click_add_user + page.find('[data-qa-selector="tp-add-assignee"] input') + team_planner.select_user_to_add user.name + end + + retry_block do + team_planner.click_add_user + page.find('[data-qa-selector="tp-add-assignee"] input') + team_planner.select_user_to_add other_user.name + end + team_planner.expect_assignee user team_planner.expect_assignee other_user @@ -135,7 +159,8 @@ describe 'Team planner', type: :feature, js: true do filters.expect_filter_by('Type', 'is', [type_task.name]) filters.expect_filter_count("2") - team_planner.expect_assignee(user, present: false) + team_planner.expect_assignee(user, present: true) + team_planner.expect_assignee(other_user, present: true) team_planner.within_lane(other_user) do team_planner.expect_event other_task @@ -147,8 +172,79 @@ describe 'Team planner', type: :feature, js: true do split_view.edit_field(:type).update(type_bug) split_view.expect_and_dismiss_toaster(message: "Successful update.") + team_planner.expect_assignee(user, present: true) + team_planner.expect_assignee(other_user, present: true) + end + + it 'can add and remove assignees' do + team_planner.visit! + team_planner.expect_assignee(user, present: false) team_planner.expect_assignee(other_user, present: false) + + retry_block do + team_planner.click_add_user + page.find('[data-qa-selector="tp-add-assignee"] input') + team_planner.select_user_to_add user.name + end + + team_planner.expect_assignee(user) + team_planner.expect_assignee(other_user, present: false) + + retry_block do + team_planner.click_add_user + page.find('[data-qa-selector="tp-add-assignee"] input') + team_planner.select_user_to_add other_user.name + end + + team_planner.expect_assignee(user) + team_planner.expect_assignee(other_user) + + team_planner.remove_assignee(user) + + team_planner.expect_assignee(user, present: false) + team_planner.expect_assignee(other_user) + + team_planner.remove_assignee(other_user) + + team_planner.expect_assignee(user, present: false) + team_planner.expect_assignee(other_user, present: false) + + # Try one more time to make sure deleting the full filter didn't kill the functionality + retry_block do + team_planner.click_add_user + page.find('[data-qa-selector="tp-add-assignee"] input') + team_planner.select_user_to_add user.name + end + + team_planner.expect_assignee(user) + team_planner.expect_assignee(other_user, present: false) + end + + it 'filters possible assignees correctly' do + team_planner.visit! + + retry_block do + team_planner.click_add_user + page.find('[data-qa-selector="tp-add-assignee"] input') + team_planner.search_user_to_add user_outside_project.name + end + + expect(page).to have_selector('.ng-option-disabled', text: "No items found") + + retry_block do + team_planner.select_user_to_add user.name + end + + team_planner.expect_assignee(user) + + retry_block do + team_planner.click_add_user + page.find('[data-qa-selector="tp-add-assignee"] input') + team_planner.search_user_to_add user.name + end + + expect(page).to have_selector('.ng-option-disabled', text: "No items found") end end end diff --git a/modules/team_planner/spec/support/pages/team_planner.rb b/modules/team_planner/spec/support/pages/team_planner.rb index 0f3b5546e1..7bcb34e500 100644 --- a/modules/team_planner/spec/support/pages/team_planner.rb +++ b/modules/team_planner/spec/support/pages/team_planner.rb @@ -30,6 +30,8 @@ require 'support/pages/page' module Pages class TeamPlanner < ::Pages::Page + include ::Components::NgSelectAutocompleteHelpers + attr_reader :project, :filters @@ -53,6 +55,10 @@ module Pages expect(page).to have_conditional_selector(present, '.fc-resource', text: name, wait: 10) end + def remove_assignee(user) + page.find(%([data-qa-remove-assignee="#{user.id}"])).click + end + def within_lane(user, &block) raise ArgumentError.new("Expected instance of principal") unless user.is_a?(Principal) @@ -73,5 +79,21 @@ module Pages ::Pages::SplitWorkPackage.new(work_package, project) end + + def click_add_user + page.find('[data-qa-selector="tp-assignee-add-button"]').click + end + + def select_user_to_add(name) + select_autocomplete page.find('[data-qa-selector="tp-add-assignee"]'), + query: name, + results_selector: 'body' + end + + def search_user_to_add(name) + search_autocomplete page.find('[data-qa-selector="tp-add-assignee"]'), + query: name, + results_selector: 'body' + end end end