Add basic assignee autocompleter (#9940)

* Add basic assignee autocompleter

* Add basic principal store

* Improve Principal typing, working principal adding in resource table

* Small improvements to comments

* Simpler handling

* Better add button

* Add assignee remove button

* Basic spec

* Working team planner assignee spec

* Spec is a little better

* Fixed work package loading and specs

* Fix linting issues

* Fix linting issues

* Fix spec

* Remove @ts-ignore usage

Co-authored-by: Oliver Günther <mail@oliverguenther.de>

Co-authored-by: Oliver Günther <mail@oliverguenther.de>
pull/9971/head
Benjamin Bädorf 3 years ago committed by GitHub
parent a3f1046727
commit 61efe76ad1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      app/helpers/application_helper.rb
  2. 1
      docker-compose.yml
  3. 3
      frontend/src/app/core/state/collection-store.ts
  4. 2
      frontend/src/app/core/state/openproject-state.module.ts
  5. 13
      frontend/src/app/core/state/principals/group.model.ts
  6. 17
      frontend/src/app/core/state/principals/placeholder-user.model.ts
  7. 5
      frontend/src/app/core/state/principals/principal.model.ts
  8. 10
      frontend/src/app/core/state/principals/principals.query.ts
  9. 110
      frontend/src/app/core/state/principals/principals.service.ts
  10. 13
      frontend/src/app/core/state/principals/principals.store.ts
  11. 31
      frontend/src/app/core/state/principals/user.model.ts
  12. 2
      frontend/src/app/features/in-app-notifications/center/state/ian-center.service.ts
  13. 8
      frontend/src/app/features/team-planner/team-planner/planner/add-assignee.component.html
  14. 96
      frontend/src/app/features/team-planner/team-planner/planner/add-assignee.component.ts
  15. 43
      frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.html
  16. 3
      frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.sass
  17. 257
      frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts
  18. 30
      frontend/src/app/features/team-planner/team-planner/planner/tp-assignee.sass
  19. 5
      frontend/src/app/features/team-planner/team-planner/team-planner.module.ts
  20. 2
      frontend/src/app/features/work-packages/routing/wp-view-base/state/wp-single-view.query.ts
  21. 6
      frontend/src/app/shared/components/autocompleter/members-autocompleter/members-autocompleter.component.ts
  22. 9
      frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts
  23. 20
      frontend/src/app/shared/components/principal/principal-helper.ts
  24. 23
      frontend/src/app/shared/components/principal/principal-renderer.service.ts
  25. 1
      frontend/src/assets/sass/_helpers.sass
  26. 13
      frontend/src/global_styles/content/_principal.sass
  27. 2
      frontend/src/global_styles/content/modules/_team_planner.sass
  28. 1
      frontend/src/global_styles/openproject/_mixins.sass
  29. 2
      frontend/src/global_styles/openproject/_variables.sass
  30. 2
      modules/team_planner/config/locales/js-en.yml
  31. 100
      modules/team_planner/spec/features/team_planner_spec.rb
  32. 22
      modules/team_planner/spec/support/pages/team_planner.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

@ -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:

@ -93,7 +93,8 @@ export function selectEntitiesFromIDCollection<T extends CollectionItem>(service
* @param state
* @param params
*/
export function selectCollectionAsEntities$<T extends CollectionItem>(service:CollectionService<T>, state:CollectionState<T>, params:Apiv3ListParameters):T[] {
export function selectCollectionAsEntities$<T extends CollectionItem>(service:CollectionService<T>, params:Apiv3ListParameters):T[] {
const state = service.query.getValue();
const key = collectionKey(params);
const collection = state.collections[key];

@ -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 {

@ -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;
}

@ -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;
}

@ -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;

@ -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<PrincipalsState> {
public byIds(ids:string[]):Observable<Principal[]> {
return this.selectMany(ids);
}
}

@ -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<IHALCollection<Principal>> {
const collectionURL = collectionKey(params);
return this
.http
.get<IHALCollection<Principal>>(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<Principal>):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)),
},
},
}
));
}
}

@ -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<Principal> {
}
@StoreConfig({ name: 'principals' })
export class PrincipalsStore extends EntityStore<PrincipalsState> {
constructor() {
super(createInitialCollectionState());
}
}

@ -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;
}

@ -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);

@ -0,0 +1,8 @@
<op-autocompleter
data-qa-selector="tp-add-assignee"
(change)="selectUser($event)"
[fetchDataDirectly]="true"
[getOptionsFn]="getOptionsFn"
resource="user"
appendTo="body"
></op-autocompleter>

@ -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<HalResource>();
@Input() alreadySelected:string[] = [];
public getOptionsFn = (query:string):Observable<unknown[]> => 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<HalResource[]> {
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);
}
}

@ -11,8 +11,45 @@
[textContent]="calendar.tooManyResultsText"
class="op-wp-calendar--notification"></div>
</ng-container>
<ng-template #resourceContent let-resource="resource">
<op-principal
[principal]="resource.extendedProps.user"
></op-principal>
<div
*ngIf="resource.extendedProps.principal"
class="tp-assignee"
>
<op-principal
[principal]="resource.extendedProps.principal"
class="tp-assignee--principal"
></op-principal>
<button
type="button"
class="tp-assignee--remove"
(click)="removeAssignee(resource.id)"
[aria-label]="text.remove_assignee"
[attr.data-qa-remove-assignee]="resource.extendedProps.principal.id"
>
<op-icon icon-classes="icon-remove"></op-icon>
</button>
</div>
<op-tp-add-assignee
*ngIf="!resource.extendedProps.principal"
(selectAssignee)="addAssignee($event)"
[alreadySelected]="principalIds$ | async"
></op-tp-add-assignee>
</ng-template>
<div
class="wp-inline-create-button"
*ngIf="!(showAddAssignee$ | async)"
>
<button
type="button"
class="wp-inline-create--add-link tp-assignee-add-button"
(click)="showAssigneeAddRow()"
data-qa-selector="tp-assignee-add-button"
>
<op-icon icon-classes="icon-context icon-add"></op-icon>
<span [textContent]="text.add_assignee"></span>
</button>
</div>

@ -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

@ -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<unknown>;
@ViewChild('assigneeAutocompleter') assigneeAutocompleter:TemplateRef<unknown>;
private resizeSubject = new Subject<unknown>();
calendarOptions$ = new Subject<CalendarOptions>();
projectIdentifier:string|undefined = undefined;
showAddAssignee$ = new Subject<boolean>();
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<EventInput[]> {
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<EventInput[]> {
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<EventInput[]> {
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<unknown>, 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;
}
}

@ -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

@ -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 {}

@ -19,7 +19,7 @@ export class WpSingleViewQuery extends Query<WpSingleViewState> {
this.resourceService.query.select(),
]).pipe(
filter((filters) => filters.length > 0),
map(([filters, state]) => selectCollectionAsEntities$<InAppNotification>(this.resourceService, state, { filters })),
map(([filters]) => selectCollectionAsEntities$<InAppNotification>(this.resourceService, { filters })),
);
selectNotificationsCount$ = this

@ -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<UserAutocompleteItem[]> {
protected getAvailableUsers(url:string, searchTerm:any):Observable<IUserAutocompleteItem[]> {
return this.http
.get<UserAutocompleteItem[]>(url,
.get<IUserAutocompleteItem[]>(url,
{
params: new HttpParams({ encoder: new URLParamsEncoder(), fromObject: { q: searchTerm } }),
responseType: 'json',

@ -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<void>();
@Output() public onChange = new EventEmitter<IUserAutocompleteItem>();
@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<string, UserAutocompleteItem>(
public requests = new DebouncedRequestSwitchmap<string, IUserAutocompleteItem>(
(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<UserAutocompleteItem[]> {
protected getAvailableUsers(url:string, searchTerm:any):Observable<IUserAutocompleteItem[]> {
// Need to clone the filters to not add additional filters on every
// search term being processed.
const searchFilters = this.inputFilters.clone();

@ -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);

@ -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':

@ -4,4 +4,5 @@
* importing these helpers!
*/
@import "~global_styles/openproject/_mixins"
@import "~global_styles/openproject/_variables"
@import "~global_styles/content/drag_and_drop"

@ -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

@ -1,3 +1,5 @@
@import "~app/features/team-planner/team-planner/planner/tp-assignee"
.router--team-planner
#content
height: 100%

@ -273,3 +273,4 @@ $scrollbar-size: 10px
&:focus
right: 0

@ -0,0 +1,2 @@
// A selector that checks whether we are running in a test environment
$spec-active-selector: '.env-test'

@ -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'

@ -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

@ -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

Loading…
Cancel
Save