mirror of
https://github.com/opf/openproject.git
synced 2026-06-13 19:20:00 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+799
-41
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
+1
-1
@@ -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/';
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
+7
-29
@@ -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,
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+60
-35
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+19
-3
@@ -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 -->
|
||||
|
||||
+46
-16
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+39
-20
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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}"
|
||||
|
||||
Reference in New Issue
Block a user