Feature/35521 ium capabilities (#9158)

* Started experimenting with capabilities

* Started adding akita store

* Use current user store to filter projects

* Work on integrating capabilities

* Added capability checks

* Fix project select

* Working on ium specs

* Change ng select option list format

* Fix proejct search label

* Fix some specs

* Fix issue with principal select

* Add spec for missing placeholder user capabilities

* Add new capability specs, fix existing ones

* Fix import

* Update shrinkwrap

* Add JSDoc deprecations

Co-authored-by: Oliver Günther <mail@oliverguenther.de>
This commit is contained in:
Benjamin Bädorf
2021-04-21 05:54:34 +00:00
committed by GitHub
parent a723802985
commit 68949daa03
31 changed files with 1478 additions and 201 deletions
+1
View File
@@ -1050,6 +1050,7 @@ en:
required: 'Please select a project'
next_button: 'Next'
no_results: 'No projects were found'
no_invite_rights: 'You are not allowed to invite members to this project'
type:
required: 'Please select the type to be invited'
user:
+1
View File
@@ -147,6 +147,7 @@ services:
CAPYBARA_APP_HOSTNAME: backend-test
OPENPROJECT_CLI_PROXY: http://frontend-test:4200
OPENPROJECT_TESTING_NO_HEADLESS: "true"
OPENPROJECT_TESTING_AUTO_DEVTOOLS: "true"
volumes:
- ".:/home/dev/openproject"
- "fedata-test:/home/dev/openproject/public/assets/frontend"
+799 -41
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -42,6 +42,7 @@
"@angular/platform-browser": "~11.2.3",
"@angular/platform-browser-dynamic": "~11.2.3",
"@angular/router": "~11.2.3",
"@datorama/akita": "^6.1.3",
"@fullcalendar/angular": "5.5.0",
"@fullcalendar/core": "5.5.0",
"@fullcalendar/daygrid": "5.5.0",
@@ -30,7 +30,7 @@ import { Component, ElementRef } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { I18nService } from "app/modules/common/i18n/i18n.service";
import { EnterpriseTrialData, EnterpriseTrialService } from "core-components/enterprise/enterprise-trial.service";
import { CurrentUserService } from "core-components/user/current-user.service";
import { CurrentUserService } from "core-app/modules/current-user/current-user.service";
import { I18nHelpers } from "core-app/helpers/i18n/localized-link";
const newsletterURL = 'https://www.openproject.com/newsletter/';
@@ -49,7 +49,7 @@ import { HalResourceSortingService } from "core-app/modules/hal/services/hal-res
import { PathHelperService } from "core-app/modules/common/path-helper/path-helper.service";
import { NgSelectComponent } from "@ng-select/ng-select";
import { APIV3Service } from "core-app/modules/apiv3/api-v3.service";
import { CurrentUserService } from "core-components/user/current-user.service";
import { CurrentUserService } from "core-app/modules/current-user/current-user.service";
@Component({
selector: 'filter-toggled-multiselect-value',
@@ -17,6 +17,7 @@ import { QueryGroupByResource } from "core-app/modules/hal/resources/query-group
import { VersionResource } from "core-app/modules/hal/resources/version-resource";
import { WorkPackageDisplayRepresentationValue } from "core-app/modules/work_packages/routing/wp-view-base/view-services/wp-view-display-representation.service";
import { TimeEntryResource } from "core-app/modules/hal/resources/time-entry-resource";
import { CapabilityResource } from "core-app/modules/hal/resources/capability-resource";
export class States extends StatesGroup {
name = 'MainStore';
@@ -40,7 +41,10 @@ export class States extends StatesGroup {
statuses = multiInput<StatusResource>();
/* /api/v3/time_entries */
timeEntries:MultiInputState<TimeEntryResource> = multiInput<TimeEntryResource>();
timeEntries = multiInput<TimeEntryResource>();
/* /api/v3/capabilities */
capabilities = multiInput<CapabilityResource>();
/* /api/v3/versions */
versions = multiInput<VersionResource>();
@@ -27,7 +27,7 @@
//++
import { TestBed } from "@angular/core/testing";
import { CurrentUserService } from "core-components/user/current-user.service";
import { CurrentUserService } from "core-app/modules/current-user/current-user.service";
import { HalResourceService } from "core-app/modules/hal/services/hal-resource.service";
import { Injector } from "@angular/core";
import { SchemaCacheService } from "core-components/schemas/schema-cache.service";
@@ -1,6 +1,6 @@
import { HalResource } from 'core-app/modules/hal/resources/hal-resource';
import { QueryFilterInstanceResource } from 'core-app/modules/hal/resources/query-filter-instance-resource';
import { CurrentUserService } from "core-components/user/current-user.service";
import { CurrentUserService } from "core-app/modules/current-user/current-user.service";
import { HalResourceService } from 'core-app/modules/hal/services/hal-resource.service';
import { Injector } from '@angular/core';
import { AngularTrackingHelpers } from "core-components/angular/tracking-functions";
@@ -32,7 +32,7 @@ import { Injectable } from '@angular/core';
import { PathHelperService } from "core-app/modules/common/path-helper/path-helper.service";
import { CurrentProjectService } from "core-components/projects/current-project.service";
import { StateService } from "@uirouter/core";
import { CurrentUserService } from "core-components/user/current-user.service";
import { CurrentUserService } from "core-app/modules/current-user/current-user.service";
@Injectable()
export class WorkPackageStaticQueriesService {
@@ -1,8 +1,8 @@
import { Injectable } from '@angular/core';
import { APIV3Service } from 'core-app/modules/apiv3/api-v3.service';
import { Observable } from 'rxjs';
import { Observable, of } from 'rxjs';
import { CurrentProjectService } from 'core-components/projects/current-project.service';
import { map } from 'rxjs/operators';
import { map, catchError } from 'rxjs/operators';
import { FilterOperator } from 'core-components/api/api-v3/api-v3-filter-builder';
@Injectable({
@@ -22,6 +22,12 @@ export class PermissionsService {
.memberships
.available_projects
.list({ filters })
.pipe(map(collection => !!collection.elements.length));
.pipe(
map(collection => !!collection.elements.length),
catchError((error) => {
console.error(error);
return of(false);
}),
);
}
}
@@ -36,6 +36,7 @@ import { Constructor } from "@angular/cdk/table";
import { PathHelperService } from "core-app/modules/common/path-helper/path-helper.service";
import { Apiv3GridsPaths } from "core-app/modules/apiv3/endpoints/grids/apiv3-grids-paths";
import { Apiv3TimeEntriesPaths } from "core-app/modules/apiv3/endpoints/time-entries/apiv3-time-entries-paths";
import { Apiv3CapabilitiesPaths } from "core-app/modules/apiv3/endpoints/capabilities/apiv3-capabilities-paths";
import { Apiv3MembershipsPaths } from "core-app/modules/apiv3/endpoints/memberships/apiv3-memberships-paths";
import { Apiv3UsersPaths } from "core-app/modules/apiv3/endpoints/users/apiv3-users-paths";
import { Apiv3PlaceholderUsersPaths } from 'core-app/modules/apiv3/endpoints/placeholder-users/apiv3-placeholder-users-paths.ts';
@@ -90,6 +91,12 @@ export class APIV3Service {
// /api/v3/time_entries
public readonly time_entries = this.apiV3CustomEndpoint(Apiv3TimeEntriesPaths);
// /api/v3/actions
public readonly actions = this.apiV3CollectionEndpoint('actions');
// /api/v3/capabilities
public readonly capabilities = this.apiV3CustomEndpoint(Apiv3CapabilitiesPaths);
// /api/v3/memberships
public readonly memberships = this.apiV3CustomEndpoint(Apiv3MembershipsPaths);
@@ -0,0 +1,69 @@
//-- 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 docs/COPYRIGHT.rdoc for more details.
//++
import { Apiv3CapabilityPaths } from "core-app/modules/apiv3/endpoints/capabilities/apiv3-capability-paths";
import { CapabilityResource } from "core-app/modules/hal/resources/capability-resource";
import { APIV3Service } from "core-app/modules/apiv3/api-v3.service";
import { Observable } from "rxjs";
import { CollectionResource } from "core-app/modules/hal/resources/collection-resource";
import { CachableAPIV3Collection } from "core-app/modules/apiv3/cache/cachable-apiv3-collection";
import { MultiInputState } from "reactivestates";
import {
Apiv3ListParameters,
Apiv3ListResourceInterface,
listParamsString
} from "core-app/modules/apiv3/paths/apiv3-list-resource.interface";
import { CapabilityCacheService } from "core-app/modules/apiv3/endpoints/capabilities/capability-cache.service";
import { StateCacheService } from "core-app/modules/apiv3/cache/state-cache.service";
export class Apiv3CapabilitiesPaths
extends CachableAPIV3Collection<CapabilityResource, Apiv3CapabilityPaths>
implements Apiv3ListResourceInterface<CapabilityResource> {
constructor(protected apiRoot:APIV3Service,
protected basePath:string) {
super(apiRoot, basePath, 'capabilities', Apiv3CapabilityPaths);
}
/**
* Load a list of capabilities with a given list parameter filter
* @param params
*/
public list(params?:Apiv3ListParameters):Observable<CollectionResource<CapabilityResource>> {
return this
.halResourceService
.get<CollectionResource<CapabilityResource>>(this.path + listParamsString(params))
.pipe(
this.cacheResponse()
);
}
protected createCache():StateCacheService<CapabilityResource> {
return new CapabilityCacheService(this.injector, this.states.capabilities);
}
}
@@ -26,35 +26,13 @@
// See docs/COPYRIGHT.rdoc for more details.
//++
import { Injectable } from "@angular/core";
import { CapabilityResource } from "core-app/modules/hal/resources/capability-resource";
import { CachableAPIV3Resource } from "core-app/modules/apiv3/cache/cachable-apiv3-resource";
import { StateCacheService } from "core-app/modules/apiv3/cache/state-cache.service";
import { Apiv3CapabilitiesPaths } from "core-app/modules/apiv3/endpoints/capabilities/apiv3-capabilities-paths";
@Injectable({ providedIn: 'root' })
export class CurrentUserService {
public get isLoggedIn() {
return this.userMeta.length > 0;
}
public get userId() {
return this.userMeta.data('id');
}
public get href() {
return `/api/v3/users/${this.userId}`;
}
public get name() {
return this.userMeta.data('name');
}
public get mail() {
return this.userMeta.data('mail');
}
public get language() {
return I18n.locale || 'en';
}
private get userMeta():JQuery {
return jQuery('meta[name=current_user]');
export class Apiv3CapabilityPaths extends CachableAPIV3Resource<CapabilityResource> {
protected createCache():StateCacheService<CapabilityResource> {
return (this.parent as Apiv3CapabilitiesPaths).cache;
}
}
@@ -0,0 +1,48 @@
//-- 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 docs/COPYRIGHT.rdoc for more details.
//++
import { CapabilityResource } from "core-app/modules/hal/resources/capability-resource";
import { InjectField } from "core-app/helpers/angular/inject-field.decorator";
import { SchemaCacheService } from "core-components/schemas/schema-cache.service";
import { States } from "core-components/states.service";
import { Injector } from "@angular/core";
import { StateCacheService } from "core-app/modules/apiv3/cache/state-cache.service";
import { MultiInputState } from "reactivestates";
export class CapabilityCacheService extends StateCacheService<CapabilityResource> {
@InjectField() readonly states:States;
constructor(readonly injector:Injector, state:MultiInputState<CapabilityResource>) {
super(state);
}
updateValue(id:string, val:CapabilityResource):Promise<CapabilityResource> {
this.putValue(id, val);
return Promise.resolve(val);
}
}
@@ -40,10 +40,10 @@ import {StateService, UIRouterModule} from '@uirouter/angular';
import {HookService} from '../plugins/hook-service';
import {OpenprojectAccessibilityModule} from 'core-app/modules/a11y/openproject-a11y.module';
import {CurrentUserModule} from 'core-app/modules/current-user/current-user.module';
import {IconTriggeredContextMenuComponent} from 'core-components/op-context-menu/icon-triggered-context-menu/icon-triggered-context-menu.component';
import {CurrentProjectService} from 'core-components/projects/current-project.service';
import {CurrentUserService} from 'core-components/user/current-user.service';
import {TablePaginationComponent} from 'core-components/table-pagination/table-pagination.component';
import {SortHeaderDirective} from 'core-components/wp-table/sort-header/sort-header.directive';
import {ZenModeButtonComponent} from 'core-components/wp-buttons/zen-mode-toggle-button/zen-mode-toggle-button.component';
@@ -87,14 +87,9 @@ import {OpenprojectPrincipalRenderingModule} from "core-app/modules/principal/pr
export function bootstrapModule(injector:Injector) {
// Ensure error reporter is run
const currentProject = injector.get(CurrentProjectService);
const currentUser = injector.get(CurrentUserService);
const routerState = injector.get(StateService);
window.ErrorReporter.addContext((scope) => {
if (currentUser.isLoggedIn) {
scope.setUser({ name: currentUser.name, id: currentUser.userId, email: currentUser.mail });
}
if (currentProject.inProjectContext) {
scope.setTag('project', currentProject.identifier!);
}
@@ -124,6 +119,7 @@ export function bootstrapModule(injector:Injector) {
DragulaModule,
// Our own A11y module
OpenprojectAccessibilityModule,
CurrentUserModule,
NgSelectModule,
NgOptionHighlightModule,
@@ -0,0 +1,41 @@
import { Injector, NgModule } from "@angular/core";
import { CollectionResource } from "core-app/modules/hal/resources/collection-resource";
import { CapabilityResource } from "core-app/modules/hal/resources/capability-resource";
import { CurrentUserService } from "./current-user.service";
import { CurrentUserStore } from "./current-user.store";
import { CurrentUserQuery } from "./current-user.query";
export function bootstrapModule(injector:Injector) {
const currentUserService = injector.get(CurrentUserService);
window.ErrorReporter.addContext((scope) => {
currentUserService.user$.subscribe(({ id, name, mail }) => {
scope.setUser({
name,
mail,
id: id || undefined, // scope expects undefined instead of null
});
});
});
const userMeta = document.querySelectorAll('meta[name=current_user]')[0] as HTMLElement|undefined;
currentUserService.setUser({
id: userMeta?.dataset.id || null,
name: userMeta?.dataset.name || null,
mail: userMeta?.dataset.mail || null,
});
}
@NgModule({
providers: [
CurrentUserService,
CurrentUserStore,
CurrentUserQuery,
],
})
export class CurrentUserModule {
constructor(injector:Injector) {
bootstrapModule(injector);
}
}
@@ -0,0 +1,21 @@
import { Injectable } from '@angular/core';
import { Query } from '@datorama/akita';
import { Observable, combineLatest } from 'rxjs';
import { map } from 'rxjs/operators';
import {
CurrentUserStore,
CurrentUserState,
CurrentUser,
} from './current-user.store';
import { CapabilityResource } from "core-app/modules/hal/resources/capability-resource";
@Injectable()
export class CurrentUserQuery extends Query<CurrentUserState> {
constructor(protected store: CurrentUserStore) {
super(store);
}
isLoggedIn$ = this.select(state => !!state.id);
user$ = this.select(({ id, name, mail }) => ({ id, name, mail }));
capabilities$ = this.select('capabilities');
}
@@ -0,0 +1,129 @@
//-- 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 docs/COPYRIGHT.rdoc for more details.
//++
import { Injectable } from "@angular/core";
import { APIV3Service } from "core-app/modules/apiv3/api-v3.service";
import { take } from 'rxjs/operators';
import { CurrentUserStore, CurrentUser } from "./current-user.store";
import { CapabilityResource } from "core-app/modules/hal/resources/capability-resource";
import { CurrentUserQuery } from "./current-user.query";
@Injectable({ providedIn: 'root' })
export class CurrentUserService {
constructor(
private apiV3Service: APIV3Service,
private currentUserStore: CurrentUserStore,
private currentUserQuery: CurrentUserQuery,
) {
this.setupLegacyDataListeners();
}
public capabilities$ = this.currentUserQuery.capabilities$;
public isLoggedIn$ = this.currentUserQuery.isLoggedIn$;
public user$ = this.currentUserQuery.user$;
public setUser(user: CurrentUser) {
this.currentUserStore.update(state => ({
...state,
...user,
}));
this.getCapabilities();
}
public getCapabilities() {
this.user$.pipe(take(1)).subscribe((user) => {
if (!user.id) {
this.currentUserStore.update(state => ({
...state,
capabilities: [],
}));
return;
}
this.apiV3Service.capabilities.list({
filters: [ ['principal', '=', [user.id]], ],
pageSize: 1000,
}).subscribe((data) => {
this.currentUserStore.update(state => ({
...state,
capabilities: data.elements,
}));
});
});
return this.currentUserQuery.capabilities$;
}
// Everything below this is deprecated legacy interfacing and should not be used
private setupLegacyDataListeners() {
this.currentUserQuery.user$.subscribe(user => this._user = user);
this.currentUserQuery.isLoggedIn$.subscribe(isLoggedIn => this._isLoggedIn = isLoggedIn);
}
private _isLoggedIn = false;
/** @deprecated Use the store mechanism `currentUserQuery.isLoggedIn$` */
public get isLoggedIn() {
return this._isLoggedIn;
}
private _user: CurrentUser = {
id: null,
name: null,
mail: null,
};
/** @deprecated Use the store mechanism `currentUserQuery.user$` */
public get userId() {
return this._user.id || '';
}
/** @deprecated Use the store mechanism `currentUserQuery.user$` */
public get name() {
return this._user.name || '';
}
/** @deprecated Use the store mechanism `currentUserQuery.user$` */
public get mail() {
return this._user.mail || '';
}
/** @deprecated Use the store mechanism `currentUserQuery.user$` */
public get href() {
return `/api/v3/users/${this.userId}`;
}
/** @deprecated Use `I18nService.locale` instead */
public get language() {
return I18n.locale || 'en';
}
}
@@ -0,0 +1,58 @@
//-- 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 docs/COPYRIGHT.rdoc for more details.
//++
import { Injectable } from "@angular/core";
import { Store, StoreConfig } from '@datorama/akita';
import { CapabilityResource } from "core-app/modules/hal/resources/capability-resource";
export interface CurrentUser {
id: string|null;
name: string|null;
mail: string|null;
}
export interface CurrentUserState extends CurrentUser {
capabilities: CapabilityResource[];
}
export function createInitialState(): CurrentUserState {
return {
id: null,
name: null,
mail: null,
capabilities: [],
};
}
@Injectable()
@StoreConfig({ name: 'current-user' })
export class CurrentUserStore extends Store<CurrentUserState> {
constructor() {
super(createInitialState());
}
}
@@ -0,0 +1,34 @@
//-- 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 docs/COPYRIGHT.rdoc for more details.
//++
import { HalResource } from 'core-app/modules/hal/resources/hal-resource';
import { SchemaResource } from "core-app/modules/hal/resources/schema-resource";
import { SchemaCacheService } from "core-components/schemas/schema-cache.service";
import { InjectField } from "core-app/helpers/angular/inject-field.decorator";
export class CapabilityResource extends HalResource {}
@@ -7,13 +7,14 @@ import {
OnInit,
ViewEncapsulation,
} from '@angular/core';
import {OpModalLocalsMap} from 'core-app/modules/modal/modal.types';
import {OpModalComponent} from 'core-app/modules/modal/modal.component';
import {OpModalLocalsToken} from "core-app/modules/modal/modal.service";
import {APIV3Service} from "core-app/modules/apiv3/api-v3.service";
import {RoleResource} from "core-app/modules/hal/resources/role-resource";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {ProjectResource} from "core-app/modules/hal/resources/project-resource";
import { OpModalLocalsMap } from 'core-app/modules/modal/modal.types';
import { OpModalComponent } from 'core-app/modules/modal/modal.component';
import { OpModalLocalsToken } from "core-app/modules/modal/modal.service";
import { APIV3Service } from "core-app/modules/apiv3/api-v3.service";
import { ApiV3FilterBuilder } from "core-components/api/api-v3/api-v3-filter-builder";
import { RoleResource } from "core-app/modules/hal/resources/role-resource";
import { HalResource } from "core-app/modules/hal/resources/hal-resource";
import { ProjectResource } from "core-app/modules/hal/resources/project-resource";
enum Steps {
ProjectSelection,
@@ -7,13 +7,13 @@
[clearable]="true"
[clearOnBackspace]="false"
[clearSearchOnAdd]="false"
bindLabel="name"
bindValue="value"
[compareWith]="compareWith"
bindValue="principal"
autofocus
#ngselect
>
<ng-template ng-label-tmp let-item="item">
{{item.value.name }}
{{ item.principal.name || item.name }}
</ng-template>
<ng-template ng-option-tmp let-item="item" let-search="searchTerm">
@@ -22,7 +22,7 @@
class="ng-option-label"
>
<!--Selectable option-->
<div [ngOptionHighlight]="search">{{ item.value.name }}</div>
<div [ngOptionHighlight]="search">{{ item.principal.name }}</div>
<!-- Already a member of the project -->
<div
@@ -14,9 +14,16 @@ import {ApiV3FilterBuilder} from "core-components/api/api-v3/api-v3-filter-build
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {UntilDestroyedMixin} from "core-app/helpers/angular/until-destroyed.mixin";
import {ProjectResource} from "core-app/modules/hal/resources/project-resource";
import {CapabilityResource} from "core-app/modules/hal/resources/capability-resource";
import {PrincipalLike} from "core-app/modules/principal/principal-types";
import {CurrentUserService} from "core-app/modules/current-user/current-user.service";
import {PrincipalType} from '../invite-user.component';
interface NgSelectPrincipalOption {
principal: PrincipalLike,
disabled: boolean;
};
@Component({
selector: 'op-ium-principal-search',
templateUrl: './principal-search.component.html',
@@ -30,9 +37,51 @@ export class PrincipalSearchComponent extends UntilDestroyedMixin implements OnI
public input$ = new BehaviorSubject<string>('');
public input = '';
public items$:Observable<any[]>;
public canInviteByEmail$:Observable<boolean>;
public canCreateNewPlaceholder$:Observable<boolean>;
public items$: Observable<NgSelectPrincipalOption[]> = this.input$
.pipe(
this.untilDestroyed(),
debounceTime(200),
distinctUntilChanged(),
switchMap(this.loadPrincipalData.bind(this)),
);
public canInviteByEmail$ = combineLatest(
this.items$,
this.input$,
this.currentUserService.capabilities$.pipe(
map(capabilities => !!capabilities.find(c => c.action.href.endsWith('/users/create'))),
distinctUntilChanged(),
),
).pipe(
map(([elements, input, canCreateUsers]) =>
canCreateUsers
&& this.type === PrincipalType.User
&& input?.includes('@')
&& !elements.find((el:any) => el.email === input)
),
);
public canCreateNewPlaceholder$ = combineLatest(
this.items$,
this.input$,
this.currentUserService.capabilities$.pipe(
map(capabilities => !!capabilities.find(c => c.action.href.endsWith('/placeholderUsers/create'))),
distinctUntilChanged(),
),
).pipe(
map(([elements, input, hasCapability]) => {
if (!hasCapability) {
return false;
}
if (this.type !== PrincipalType.Placeholder) {
return false;
}
return !!input && !elements.find((el:any) => el.name === input);
}),
);
public showAddTag = false;
public text = {
@@ -52,6 +101,7 @@ export class PrincipalSearchComponent extends UntilDestroyedMixin implements OnI
public I18n:I18nService,
readonly elementRef:ElementRef,
readonly apiV3Service:APIV3Service,
readonly currentUserService:CurrentUserService,
) {
super();
@@ -59,34 +109,6 @@ export class PrincipalSearchComponent extends UntilDestroyedMixin implements OnI
this.input = input;
});
this.items$ = this.input$
.pipe(
this.untilDestroyed(),
debounceTime(200),
distinctUntilChanged(),
switchMap(this.loadPrincipalData.bind(this)),
);
this.canInviteByEmail$ = combineLatest(
this.items$,
this.input$,
).pipe(
map(([elements, input]) => this.type === PrincipalType.User && input?.includes('@') && !elements.find((el:any) => el.email === input)),
);
this.canCreateNewPlaceholder$ = combineLatest(
this.items$,
this.input$,
).pipe(
map(([elements, input]) => {
if (this.type !== PrincipalType.Placeholder) {
return false;
}
return !!input && !elements.find((el:any) => el.name === input);
}),
);
combineLatest(
this.canInviteByEmail$,
this.canCreateNewPlaceholder$,
@@ -129,17 +151,20 @@ export class PrincipalSearchComponent extends UntilDestroyedMixin implements OnI
})
.pipe(
map(({ members, nonMembers }) => [
...nonMembers.elements.map((nonMember:any) => ({
value: nonMember,
...nonMembers.elements.map((nonMember:PrincipalLike) => ({
principal: nonMember,
disabled: false,
})),
...members.elements.map((member:any) => ({
value: member,
...members.elements.map((member:PrincipalLike) => ({
principal: member,
disabled: true,
})),
]),
shareReplay(1),
);
}
compareWith = (a: NgSelectPrincipalOption, b: PrincipalLike) => {
return a.principal.id === b.id;
}
}
@@ -6,13 +6,29 @@
[clearable]="true"
[clearOnBackspace]="false"
[clearSearchOnAdd]="false"
bindLabel="name"
[compareWith]="compareWith"
bindValue="project"
autofocus
#ngselect
>
<!--Selectable option-->
<ng-template ng-label-tmp let-item="item">
{{ item.project?.name || item.name }}
</ng-template>
<ng-template ng-option-tmp let-item="item" let-search="searchTerm">
<div [ngOptionHighlight]="search" class="ng-option-label ellipsis">{{ item.name }}</div>
<div
*ngIf="item"
class="ng-option-label"
>
<!--Selectable option-->
<div [ngOptionHighlight]="search">{{ item.project.name }}</div>
<!-- No invite rights -->
<div
*ngIf="item.disabled"
class="ellipsis"
>{{ text.noInviteRights }}</div>
</div>
</ng-template>
<!--Nothing found -->
@@ -5,12 +5,19 @@ import {
ElementRef,
} from '@angular/core';
import { FormControl, NgControl } from "@angular/forms";
import { Observable, Subject } from "rxjs";
import { Observable, BehaviorSubject, combineLatest } from "rxjs";
import { debounceTime, distinctUntilChanged, filter, map, switchMap, tap } from "rxjs/operators";
import { APIV3Service } from "core-app/modules/apiv3/api-v3.service";
import { ApiV3FilterBuilder } from "core-components/api/api-v3/api-v3-filter-builder";
import { I18nService } from "core-app/modules/common/i18n/i18n.service";
import { UntilDestroyedMixin } from "core-app/helpers/angular/until-destroyed.mixin";
import { ProjectResource } from "core-app/modules/hal/resources/project-resource";
import { CurrentUserService } from 'core-app/modules/current-user/current-user.service';
interface NgSelectProjectOption {
project: ProjectResource,
disabled: boolean;
};
@Component({
selector: 'op-ium-project-search',
@@ -21,34 +28,57 @@ export class ProjectSearchComponent extends UntilDestroyedMixin implements OnIni
public text = {
noResultsFound: this.I18n.t('js.invite_user_modal.project.no_results'),
noInviteRights: this.I18n.t('js.invite_user_modal.project.no_invite_rights'),
};
public input$ = new Subject<string|null>();
public items$:Observable<any>;
public input$ = new BehaviorSubject<string|null>('');
public items$ = combineLatest([
this.input$.pipe(
debounceTime(100),
switchMap((searchTerm:string) => {
const filters = new ApiV3FilterBuilder();
if (searchTerm) {
filters.add('name_and_identifier', '~', [searchTerm]);
}
return this.apiV3Service.projects
.filtered(filters)
.get()
.pipe(map(collection => collection.elements));
})
),
this.currentUserService.capabilities$.pipe(
map(capabilities => capabilities.filter(c => c.action.href.endsWith('/memberships/create')))
),
])
.pipe(
this.untilDestroyed(),
map(([ projects, projectInviteCapabilities ]) => {
const mapped = projects.map((project: ProjectResource) => ({
project,
disabled: !projectInviteCapabilities.find(cap => cap.context.id === project.id),
}));
mapped.sort(
(a: NgSelectProjectOption, b: NgSelectProjectOption) => (a.disabled ? 1 : 0) - (b.disabled ? 1 : 0),
);
return mapped;
})
);
constructor(
readonly I18n:I18nService,
readonly elementRef:ElementRef,
readonly apiV3Service:APIV3Service,
readonly currentUserService:CurrentUserService,
) {
super();
this.items$ = this.input$
.pipe(
this.untilDestroyed(),
debounceTime(100),
switchMap((searchTerm:string) => {
const filters = new ApiV3FilterBuilder();
if (searchTerm) {
filters.add('name_and_identifier', '~', [searchTerm]);
}
return this.apiV3Service.projects.filtered(filters).get().pipe(map(collection => collection.elements));
}),
);
}
ngOnInit() {
// Make sure we have initial data
setTimeout(() => this.input$.next(''));
}
compareWith = (a: NgSelectProjectOption, b: ProjectResource) => {
return a.project.id === b.id;
}
}
@@ -11,10 +11,13 @@ import {
FormGroup,
Validators,
} from '@angular/forms';
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {BannersService} from "core-app/modules/common/enterprise/banners.service";
import {IOpOptionListOption} from "core-app/modules/common/option-list/option-list.component";
import {PrincipalType} from '../invite-user.component';
import { take } from "rxjs/operators";
import { I18nService } from "core-app/modules/common/i18n/i18n.service";
import { BannersService } from "core-app/modules/common/enterprise/banners.service";
import { CurrentUserService } from 'core-app/modules/current-user/current-user.service';
import { IOpOptionListOption } from "core-app/modules/common/option-list/option-list.component";
import { ProjectResource } from "core-app/modules/hal/resources/project-resource";
import { PrincipalType } from '../invite-user.component';
@Component({
selector: 'op-ium-project-selection',
@@ -23,7 +26,7 @@ import {PrincipalType} from '../invite-user.component';
})
export class ProjectSelectionComponent implements OnInit {
@Input() type:PrincipalType;
@Input() project:any = null;
@Input() project:ProjectResource|null;
@Output() close = new EventEmitter<void>();
@Output() save = new EventEmitter<{project:any, type:string}>();
@@ -64,27 +67,43 @@ export class ProjectSelectionComponent implements OnInit {
readonly I18n:I18nService,
readonly elementRef:ElementRef,
readonly bannersService:BannersService,
readonly currentUserService:CurrentUserService,
) {}
ngOnInit() {
this.typeControl?.setValue(this.type);
this.projectControl?.setValue(this.project);
this.typeOptions.push({
value: PrincipalType.Placeholder,
title: this.bannersService.eeShowBanners
? this.I18n.t('js.invite_user_modal.type.placeholder.title_no_ee')
: this.I18n.t('js.invite_user_modal.type.placeholder.title'),
description: this.bannersService.eeShowBanners
? this.I18n.t('js.invite_user_modal.type.placeholder.description_no_ee', {
eeHref: this.bannersService.getEnterPriseEditionUrl({
referrer: 'placeholder-users',
hash: 'placeholder-users',
}),
})
: this.I18n.t('js.invite_user_modal.type.placeholder.description'),
disabled: this.bannersService.eeShowBanners,
});
this.setPlaceholderOption();
}
private setPlaceholderOption() {
if (this.bannersService.eeShowBanners) {
this.typeOptions.push({
value: PrincipalType.Placeholder,
title: this.I18n.t('js.invite_user_modal.type.placeholder.title_no_ee'),
description: this.I18n.t('js.invite_user_modal.type.placeholder.description_no_ee', {
eeHref: this.bannersService.getEnterPriseEditionUrl({
referrer: 'placeholder-users',
hash: 'placeholder-users',
}),
}),
disabled: true,
});
} else {
this.currentUserService.capabilities$.pipe(take(1)).subscribe((capabilities) => {
if (!capabilities.find(c => c.action.href.endsWith('/placeholderUsers/read'))) {
return;
}
// We only add the option if the user has placeholder read rights
this.typeOptions.push({
value: PrincipalType.Placeholder,
title: this.I18n.t('js.invite_user_modal.type.placeholder.title'),
description: this.I18n.t('js.invite_user_modal.type.placeholder.description'),
disabled: false,
});
});
}
}
onSubmit($e:Event) {
@@ -80,6 +80,7 @@ export class SummaryComponent {
}
private createPrincipal(principal:PrincipalLike):Observable<HalResource> {
console.log(principal);
if (principal instanceof HalResource) {
return of(principal);
}
+49 -21
View File
@@ -56,10 +56,8 @@ feature 'Invite user modal', type: :feature, js: true do
it 'will invite that principal to the project' do
modal.run_all_steps
assignee_field.expect_active!
# TODO assignee field should contain the user name now
#assignee_field.expect_value principal.name
assignee_field.expect_value nil
assignee_field.expect_inactive!
assignee_field.expect_display_value added_principal.name
new_member = project.reload.member_principals.find_by(user_id: added_principal.id)
expect(new_member).to be_present
@@ -67,7 +65,7 @@ feature 'Invite user modal', type: :feature, js: true do
end
end
describe 'inviting a non-project existing user' do
describe 'inviting a principal to a project' do
describe 'through the assignee field' do
let(:wp_page) { Pages::FullWorkPackage.new(work_package, project) }
let(:assignee_field) { wp_page.edit_field :assignee }
@@ -80,19 +78,16 @@ feature 'Invite user modal', type: :feature, js: true do
find('.ng-dropdown-footer button', text: 'Invite', wait: 10).click
end
context 'with a non project user' do
let!(:principal) do
FactoryBot.create :user,
firstname: 'Nonproject',
lastname: 'User'
end
context 'with an existing user' do
let!(:principal) { FactoryBot.create :user,
firstname: 'Nonproject firstname',
lastname: 'nonproject lastname'
}
it 'can add an existing user to the project' do
modal.run_all_steps
# TODO assignee field should close and contain the user name now
#assignee_field.expect_value principal.name
assignee_field.expect_active!
assignee_field.expect_value nil
assignee_field.expect_inactive!
assignee_field.expect_display_value principal.name
# But the user got created
new_member = project.reload.members.find_by(user_id: principal.id)
@@ -111,12 +106,42 @@ feature 'Invite user modal', type: :feature, js: true do
let(:added_principal) { User.find_by!(mail: principal.mail) }
end
end
context 'when the current user does not have permissions to invite a user to the instance by email' do
let(:permissions) { %i[view_work_packages edit_work_packages manage_members] }
it 'does not show the invite user option' do
modal.project_step
ngselect = modal.open_select_in_step principal.mail
expect(ngselect).to have_text "No users were found"
expect(ngselect).not_to have_text "Invite: #{principal.mail}"
end
end
context 'when the current user does not have permissions to invite a user in this project' do
let(:permissions) { %i[view_work_packages edit_work_packages manage_members manage_user] }
let(:project_no_permissions) { FactoryBot.create :project }
let(:role_no_permissions) { FactoryBot.create :role,
permissions: %i[view_work_packages edit_work_packages]
}
let!(:membership_no_permission) {
FactoryBot.create :member,
user: current_user,
project: project_no_permissions,
roles: [role_no_permissions]
}
it 'disables projects for which you do not have rights' do
ngselect = modal.open_select_in_step
expect(ngselect).to have_text "#{project_no_permissions.name}\nYou are not allowed to invite members to this project"
end
end
end
describe 'inviting placeholders' do
let(:principal) { FactoryBot.build :placeholder_user, name: 'MY NEW PLACEHOLDER' }
context 'system has enterprise', with_ee: %i[placeholder_users] do
context 'an enterprise system', with_ee: %i[placeholder_users] do
describe 'create a new placeholder' do
context 'with permissions to manage placeholders' do
let(:permissions) { %i[view_work_packages edit_work_packages manage_members manage_placeholder_user] }
@@ -127,15 +152,18 @@ feature 'Invite user modal', type: :feature, js: true do
end
context 'without permissions to manage placeholders' do
let(:permissions) { %i[view_work_packages edit_work_packages manage_members manage_placeholder_user] }
let(:permissions) { %i[view_work_packages edit_work_packages manage_members] }
it 'does not allow to invite a new placeholder' do
skip "TODO wait for permissions API"
modal.within_modal do
expect(page).to have_selector '.op-option-list--item', count: 2
end
end
end
end
context 'with an existing placeholder' do
let(:principal) { FactoryBot.create :placeholder_user }
let(:principal) { FactoryBot.create :placeholder_user, name: 'EXISTING PLACEHOLDER' }
let(:permissions) { %i[view_work_packages edit_work_packages manage_members manage_placeholder_user] }
it_behaves_like 'invites the principal to the project' do
let(:added_principal) { principal }
@@ -143,7 +171,7 @@ feature 'Invite user modal', type: :feature, js: true do
end
end
context 'system has no enterprise' do
context 'non-enterprise system' do
it 'shows the modal with placeholder option disabled' do
modal.within_modal do
expect(page).to have_field 'Placeholder user', disabled: true
@@ -171,7 +199,7 @@ feature 'Invite user modal', type: :feature, js: true do
wp_page.visit!
end
it 'can add an existing user to the project' do
it 'cannot add an existing user to the project' do
assignee_field.activate!
expect(page).to have_no_selector('.ng-dropdown-footer', text: 'Invite')
+9 -10
View File
@@ -9,24 +9,23 @@ def register_chrome(language, name: :"chrome_#{language}")
if ActiveRecord::Type::Boolean.new.cast(ENV['OPENPROJECT_TESTING_NO_HEADLESS'])
# Maximize the window however large the available space is
options.add_argument('start-maximized')
# options.add_argument('window-size=1920,1080')
options.add_argument('--start-maximized')
# Open dev tools for quick access
if ActiveRecord::Type::Boolean.new.cast(ENV['OPENPROJECT_TESTING_AUTO_DEVTOOLS'])
options.add_argument('auto-open-devtools-for-tabs')
options.add_argument('--auto-open-devtools-for-tabs')
end
else
options.add_argument('window-size=1920,1080')
options.add_argument('headless')
options.add_argument('--window-size=1920,1080')
options.add_argument('--headless')
end
options.add_argument('no-sandbox')
options.add_argument('disable-gpu')
options.add_argument('disable-popup-blocking')
options.add_argument("lang=#{language}")
options.add_argument('--no-sandbox')
options.add_argument('--disable-gpu')
options.add_argument('--disable-popup-blocking')
options.add_argument("--lang=#{language}")
# This is REQUIRED for running in a docker container
# https://github.com/grosser/parallel_tests/issues/658
options.add_argument('disable-dev-shm-usage')
options.add_argument('--disable-dev-shm-usage')
options.add_preference(:download,
directory_upgrade: true,
@@ -98,6 +98,12 @@ module Components
click_next if next_step
end
def open_select_in_step(query = '')
search_autocomplete modal_element.find('.ng-select-container'),
query: query,
results_selector: 'body'
end
def principal_step(next_step: true)
if invite_user?
autocomplete principal_name, select_text: "Invite: #{principal_name}"