Finish user invite modal (#9061)

* Fix some ium stylings

* Extend create service to also test with empty names

* Add PrincipalLike type to pass around non-created placeholder refs

* Add icon-context

* Move principal rendering to its own module

* Fix emit create new placeholder to principals

* Revert op-principal for now

* Add memberships form API to properly post

* Fix types for returned principals

* Move untilDestroyed in role

* Filter input if not string in role-search

* Pass correct inputs to success component

* Return principal after saving membership

* Fix small stuff around  the ium

* Fix the way HalResources are selected and passed

* Move principal module to be exported by common

* Disable quotemark in tslint until eslint is enabled

* Fix image path in success

* Adapt modal to run all steps in one within the modal helper component

* Several fixes to modals

* Fix ium success component styles,
* Registration modal y-overflow
* Add SMTP parameters to .env.example

* Add disabled option to op-option-list, disabled placeholder users for non-ee instances

* Add correct ee link to placeholder user option

* Fix build

* Removed unused sass files

* Fix principal search not found indicator, added placeholder add image

* Fix enterprise edition url, use dirty instead of touched check

* Use backend class names for frontend principal types

* Fix duplicate import and principal type usage

* Also disable banners if with_ee is present in test

* Extend specs for placeholders

* Fix disabled attribute

* Extend spec WIP

* Improved inline-validation styles, fixed more PrincipalType usages

* Add group happy path test, fix more PrincipalType usage

* Fix a translation

* Revert line deletion

* Rewrite same spec examples into shared examples

* Fix name of shared example

* Dont run assets:clean to remove angular assets

* Output whether assets are there at all

* Fix placeholder path

* Revert "Output whether assets are there at all"

This reverts commit 42219c2755.

Co-authored-by: Benjamin Bädorf <b.baedorf@openproject.com>
This commit is contained in:
Oliver Günther
2021-03-04 21:47:25 +01:00
committed by GitHub
parent 355e9db913
commit d329db23ff
58 changed files with 723 additions and 386 deletions
+3
View File
@@ -51,6 +51,9 @@ plugins:
# Disable whitespace due to 3.7 incompatibiltiy with ? navigation
whitespace:
enabled: false
# Disable difference in import styles until we migrate to eslint
quotemark:
enabled: false
fixme:
enabled: true
ratings:
+8
View File
@@ -47,3 +47,11 @@ DB_PORT=5432
DB_USERNAME=openproject
DB_PASSWORD=openproject
DB_DATABASE=openproject
CI_JOBS=24
SMTP_ADDRESS=
SMTP_PORT=
SMTP_DOMAIN=
SMTP_USER_NAME=
SMTP_PASSWORD=
+3 -1
View File
@@ -1,3 +1,5 @@
<% if footer = registration_footer %>
<span class="registration-footer"><%= footer %></span>
<div class="op-modal--footer">
<span class="registration-footer"><%= footer %></span>
</div>
<% end %>
+2 -3
View File
@@ -112,8 +112,7 @@ See docs/COPYRIGHT.rdoc for more details.
<%= render partial: 'account/auth_providers', locals: { omniauth_title: I18n.t('account.signup_with_auth_provider'), wide: true } %>
</div>
<div class="op-modal--footer">
<%= render partial: 'account/footer' %>
</div>
<%= render partial: 'account/footer' %>
<% end %>
</div>
+1 -1
View File
@@ -29,7 +29,7 @@ See docs/COPYRIGHT.rdoc for more details.
<section data-augmented-model-wrapper
data-modal-initialize-now="true"
data-modal-class-name="registration-modal modal-wrapper -highlight">
data-modal-class-name="registration-modal -highlight">
<% @user ||= User.new %>
<%= render partial: '/account/register' %>
</section>
+11 -9
View File
@@ -1040,9 +1040,9 @@ en:
title:
invite: 'Invite user'
invite_to_project: 'Invite %{type} to %{project}'
user: 'user'
group: 'group'
placeholder: 'placeholder user'
User: 'user'
Group: 'group'
PlaceholderUser: 'placeholder user'
invite_principal_to_project: 'Invite %{principal} to %{project}'
project:
label: 'Project'
@@ -1058,8 +1058,11 @@ en:
title: 'Group'
description: 'Permissions based on the assigned role in the selected project'
placeholder:
title: 'Placeholder'
description: 'Has no access to the project and no emails are sent out'
title: 'Placeholder user'
title_no_ee: 'Placeholder user (Enterprise Edition only feature)'
description: 'Has no access to the project and no emails are sent out.'
description_no_ee: 'Has no access to the project and no emails are sent out.
<br>Check out the <a href="%{eeHref}" target="_blank">Enterprise Edition</a>'
principal:
label:
@@ -1068,15 +1071,14 @@ en:
already_member_message: 'Already a member of %{project}'
no_results_user: 'No users were found'
invite_user: 'Invite: %{email}'
invite_user: 'Invite:'
change_user_selection: 'Change e-mail-address or select an existing user'
no_results_placeholder: 'No placeholders were found'
change_placeholder_selection: 'Change name or select an existing placeholder'
create_new_placeholder: 'Create new placeholder: %{name}'
create_new_placeholder: 'Create new placeholder:'
no_results_group: 'No groups were found'
create_new_group: 'Create new group: %{name}'
change_group_selection: 'Change name or select an existing group'
next_button: 'Next'
@@ -1108,7 +1110,7 @@ en:
success:
title: '%{principal} was invited!'
description:
user: 'The user can now login to access %{project}. Meanwhile you can already plan with that user and assign work packages for instance.'
user: 'The user can now log in to access %{project}. Meanwhile you can already plan with that user and assign work packages for instance.'
placeholder: 'The placeholder can now be used in %{project}. Meanwhile you can already plan with that user and assign work packages for instance.'
group: 'The group is now a part of %{project}. Meanwhile you can already plan with that group and assign work packages for instance.'
next_button: 'Continue'
+2
View File
@@ -103,6 +103,8 @@ services:
stop_grace_period: "3s"
volumes:
- "pgdata-test:/var/lib/postgresql/data"
ports:
- "5432:5432"
environment:
POSTGRES_DB: openproject
POSTGRES_USER: openproject
@@ -1,5 +1,5 @@
import {Component, Input} from "@angular/core";
import {enterpriseEditionUrl} from "core-app/globals/constants.const";
import {BannersService} from "core-app/modules/common/enterprise/banners.service";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
@Component({
@@ -28,14 +28,12 @@ export class EnterpriseBannerComponent {
enterpriseFeature: this.I18n.t('js.upsale.ee_only'),
};
constructor(protected I18n:I18nService) {
}
constructor(
protected I18n:I18nService,
protected bannersService:BannersService,
) {}
public eeLink() {
if (this.opReferrer) {
return enterpriseEditionUrl + '&op_referrer=' + this.opReferrer;
} else {
return enterpriseEditionUrl;
}
this.bannersService.getEnterPriseEditionUrl({ referrer: this.opReferrer });
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
export const enterpriseEditionUrl = "https://www.openproject.org/enterprise-edition/?op_edtion=community-edition";
export const enterpriseEditionUrl = "https://www.openproject.org/enterprise-edition/?op_edition=community-edition";
export const enterpriseDemoUrl = "https://www.openproject.org/enterprise-demo/";
@@ -0,0 +1,64 @@
//-- 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 {APIv3FormResource} from "core-app/modules/apiv3/forms/apiv3-form-resource";
import {SchemaResource} from "core-app/modules/hal/resources/schema-resource";
import {HalPayloadHelper} from "core-app/modules/hal/schemas/hal-payload.helper";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {MembershipResource, MembershipResourceEmbedded} from "core-app/modules/hal/resources/membership-resource";
export class Apiv3MembershipsForm extends APIv3FormResource {
/**
* We need to override the grid widget extraction
* to pass the correct payload to the API.
*
* @param resource
* @param schema
*/
public static extractPayload(resource:MembershipResourceEmbedded):Object {
return {
_links: {
project: { href: resource.project.href },
principal: { href: resource.principal.href },
roles: resource.roles.map(role => ({ href: role.href })),
}
}
}
/**
* Extract payload for the form from the request and optional schema.
*
* @param request
* @param schema
*/
public extractPayload(request:MembershipResourceEmbedded) {
return Apiv3MembershipsForm.extractPayload(request);
}
}
@@ -36,17 +36,24 @@ import {
import {Observable} from "rxjs";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {CollectionResource} from "core-app/modules/hal/resources/collection-resource";
import {MembershipResource} from "core-app/modules/hal/resources/membership-resource";
import {MembershipResource, MembershipResourceEmbedded} from "core-app/modules/hal/resources/membership-resource";
import {ProjectResource} from 'core-app/modules/hal/resources/project-resource';
import {UserResource} from "core-app/modules/hal/resources/user-resource";
import {GroupResource} from "core-app/modules/hal/resources/group-resource";
import {PlaceholderUserResource} from "core-app/modules/hal/resources/placeholder-user-resource";
import {RoleResource} from 'core-app/modules/hal/resources/role-resource';
import {Apiv3MembershipsForm} from "core-app/modules/apiv3/endpoints/memberships/apiv3-memberships-form";
import {SchemaResource} from "core-app/modules/hal/resources/schema-resource";
import {map, switchMap} from "rxjs/operators";
export class Apiv3MembershipsPaths
extends APIv3ResourceCollection<MembershipResource, APIv3GettableResource<MembershipResource>>
implements Apiv3ListResourceInterface<MembershipResource> {
// Static paths
readonly form = this.subResource('form', Apiv3MembershipsForm);
constructor(protected apiRoot:APIV3Service,
protected basePath:string) {
super(apiRoot, basePath, 'memberships');
@@ -71,16 +78,14 @@ export class Apiv3MembershipsPaths
*
* @param resource
*/
public post(resource:{
principal:UserResource|GroupResource|PlaceholderUserResource,
project:ProjectResource,
roles:RoleResource[],
}):Observable<MembershipResource> {
public post(resource:MembershipResourceEmbedded):Observable<MembershipResource> {
const payload = this.form.extractPayload(resource);
return this
.halResourceService
.post<MembershipResource>(
this.path,
resource,
payload,
);
}
}
@@ -27,7 +27,6 @@
</ng-template>
<ng-template ng-footer-tmp *ngIf="showAddNewButton">
<op-invite-user-button (invited)="onUserInvited($event)">
</op-invite-user-button>
<op-invite-user-button (invited)="onUserInvited($event)"></op-invite-user-button>
</ng-template>
</ng-select>
@@ -9,7 +9,7 @@ import {NewBoardModalComponent} from "core-app/modules/boards/new-board-modal/ne
import {BannersService} from "core-app/modules/common/enterprise/banners.service";
import {LoadingIndicatorService} from "core-app/modules/common/loading-indicator/loading-indicator.service";
import {AuthorisationService} from "core-app/modules/common/model-auth/model-auth.service";
import {enterpriseDemoUrl, enterpriseEditionUrl} from "core-app/globals/constants.const";
import {enterpriseDemoUrl} from "core-app/globals/constants.const";
import {DomSanitizer} from "@angular/platform-browser";
import {boardTeaserVideoURL} from "core-app/modules/boards/board-constants.const";
import {UntilDestroyedMixin} from "core-app/helpers/angular/until-destroyed.mixin";
@@ -99,7 +99,7 @@ export class BoardsIndexPageComponent extends UntilDestroyedMixin implements OnI
}
public eeLink() {
return enterpriseEditionUrl + '&op_referrer=boards';
return this.bannerService.getEnterPriseEditionUrl({ referrer: 'boards' });
}
public demoLink() {
@@ -28,6 +28,7 @@
import {Inject, Injectable} from '@angular/core';
import {DOCUMENT} from "@angular/common";
import {enterpriseEditionUrl} from "core-app/globals/constants.const";
@Injectable({ providedIn: 'root' })
export class BannersService {
@@ -42,6 +43,19 @@ export class BannersService {
return this._banners;
}
public getEnterPriseEditionUrl({ referrer, hash }:{referrer?:string, hash?:string} = {}) {
const url = new URL(enterpriseEditionUrl);
if (referrer) {
url.searchParams.set('op_referrer', referrer);
}
if (hash) {
url.hash = hash;
}
return url.toString();
}
public conditional(bannersVisible?:() => void, bannersNotVisible?:() => void) {
this._banners ? this.callMaybe(bannersVisible) : this.callMaybe(bannersNotVisible);
}
@@ -7,6 +7,7 @@ import {
NgControl,
FormControl,
FormGroup,
FormArray,
} from '@angular/forms';
export const formControlBinding:any = {
@@ -14,11 +15,15 @@ export const formControlBinding:any = {
useExisting: forwardRef(() => OpFormBindingDirective)
};
@Directive({selector: '[opFormBinding]', providers: [formControlBinding], exportAs: 'ngForm'})
@Directive({
selector: '[opFormBinding]',
providers: [formControlBinding],
exportAs: 'ngForm',
})
export class OpFormBindingDirective extends NgControl {
@Input('opFormBinding') form!:FormControl|FormGroup;
@Input('opFormBinding') form!:FormControl|FormGroup|FormArray;
get control():FormControl|FormGroup {
get control():FormControl|FormGroup|FormArray {
return this.form;
}
@@ -1,41 +0,0 @@
@import './form-field.children'
.op-form-field
display: flex
flex-direction: column
&--label,
&--description,
&--input,
&--help-text
margin-bottom: 0.5rem
&--label-wrap
display: flex
flex-direction: column
margin: 0
&--label
font-size: 14px
font-weight: bold
line-height: 1.2
&-indicator
color: var(--primary-color)
&_invalid &--label
color: var(--content-form-error-color)
&--input
&--description,
&--help-text
font-size: 12px
&--errors
display: flex
flex-direction: column
&--error
color: var(--content-form-error-color)
margin-top: 1rem
@@ -5,7 +5,8 @@ import {
ContentChild,
} from "@angular/core";
import {
NgControl
NgControl,
AbstractControl,
} from "@angular/forms";
@Component({
@@ -23,7 +24,24 @@ export class OpFormFieldComponent {
@ContentChild(NgControl) control:NgControl;
get isDirty() {
let control:AbstractControl|null = this.control?.control;
do {
if (!control) {
return false;
}
if (control.dirty) {
return true;
}
control = control.parent;
} while (control);
return false;
}
get isInvalid() {
return this.control?.touched && this.control?.invalid;
return this.isDirty && this.control?.invalid;
}
}
@@ -36,4 +36,4 @@
&--error
color: var(--content-form-error-color)
margin-top: 1rem
margin-bottom: 1rem
@@ -1,26 +0,0 @@
.op-modal
display: flex
background: white
&--heading
display: flex
padding-top: 1rem
flex-direction: column
position: relative
&--close-button
position: absolute
right: 0px
top: 0px
height: 2rem
width: 2rem
display: flex
justify-content: center
align-items: center
cursor: pointer
color: var(--body-font-color)
background: transparent
border: 0
&:hover
text-decoration: none
color: var(--content-link-color)
@@ -2,4 +2,3 @@
@import './form-field/form-field'
@import './option-list/option-list'
@import './export-options/export-options'
@import './modal/modal.component'
@@ -79,11 +79,11 @@ import {AutofocusDirective} from './autofocus/autofocus.directive';
import {ShowSectionDropdownComponent} from './hide-section/show-section-dropdown.component';
import {SlideToggleComponent} from './slide-toggle/slide-toggle.component';
import {DynamicBootstrapModule} from './dynamic-bootstrap/dynamic-bootstrap.module';
import {OpPrincipalComponent} from './principal/op-principal.component';
import {OpFormFieldComponent} from './form-field/form-field.component';
import {OpFormBindingDirective} from './form-field/form-binding.directive';
import {OpOptionListComponent} from './option-list/option-list.component';
import {OpIconComponent} from './icon/icon.component';
import {OpenprojectPrincipalRenderingModule} from "core-app/modules/principal/principal-rendering.module";
export function bootstrapModule(injector:Injector) {
// Ensure error reporter is run
@@ -129,6 +129,7 @@ export function bootstrapModule(injector:Injector) {
NgOptionHighlightModule,
DynamicBootstrapModule,
OpenprojectPrincipalRenderingModule,
],
exports: [
// Re-export all commonly used
@@ -142,6 +143,7 @@ export function bootstrapModule(injector:Injector) {
NgSelectModule,
NgOptionHighlightModule,
DynamicBootstrapModule,
OpenprojectPrincipalRenderingModule,
OpDatePickerComponent,
OpDateTimeComponent,
@@ -187,7 +189,6 @@ export function bootstrapModule(injector:Injector) {
// filter
SlideToggleComponent,
OpPrincipalComponent,
OpFormFieldComponent,
OpFormBindingDirective,
@@ -237,7 +238,6 @@ export function bootstrapModule(injector:Injector) {
// User Avatar
UserAvatarComponent,
OpPrincipalComponent,
PersistentToggleComponent,
HideSectionLinkComponent,
@@ -1,6 +1,6 @@
<label
*ngFor="let option of options"
[class]="{ 'op-option-list--item': true, 'op-option-list--item_selected': selected === option.value }"
[class]="getClassListForItem(option)"
>
<input
type="radio"
@@ -8,12 +8,14 @@
[value]="option.value"
[(ngModel)]="selected"
class="op-option-list--input"
[disabled]="option.disabled"
/>
<div>
<p class="op-option-list--item-title">{{ option.title }}</p>
<p
*ngIf="option.description"
class="op-option-list--item-description"
>{{ option.description }}</p>
[innerHTML]="option.description"
></p>
</div>
</label>
@@ -1,36 +0,0 @@
.op-option-list
display: flex
flex-direction: column
font-size: 1rem
// TODO: remove the [type] selector once globally overwriting styles are removed
&--input[type]
margin: 0
margin-right: 0.5rem
&--item
padding: 1rem 1rem 0.5rem 0.75rem
display: flex
border: 1px solid #cbd5e0
background: #f7fafc
border-radius: 4px
&:not(:last-child)
margin-bottom: 0.5rem
&_selected
border: 1px solid #90cdf4
background: #ebf8ff
&-title,
&-description
margin: 0
margin-bottom: 0.5rem
line-height: 1.2
&-title
font-weight: bold
font-size: 14px
&-description
font-size: 12px
@@ -14,6 +14,7 @@ import {
export interface IOpOptionListOption<T> {
value:T;
title:string;
disabled?:boolean;
description?:string;
}
@@ -45,6 +46,14 @@ export class OpOptionListComponent<T> implements ControlValueAccessor {
this.onChange(value);
}
getClassListForItem(option:IOpOptionListOption<T>) {
return {
'op-option-list--item': true,
'op-option-list--item_selected': this.selected === option.value,
'op-option-list--item_disabled': !!option.disabled,
};
}
onChange = (_:IOpOptionListValue<T>) => {};
onTouched = (_:IOpOptionListValue<T>) => {};
@@ -22,6 +22,9 @@
border: 1px solid #90cdf4
background: #ebf8ff
&_disabled
color: #959595
&-title,
&-description
margin: 0
@@ -8,6 +8,7 @@ import {MembershipResource} from "core-app/modules/hal/resources/membership-reso
import {RoleResource} from "core-app/modules/hal/resources/role-resource";
import {APIV3Service} from "core-app/modules/apiv3/api-v3.service";
import {Apiv3ListParameters} from "core-app/modules/apiv3/paths/apiv3-list-resource.interface";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
const DISPLAYED_MEMBERS_LIMIT = 100;
@@ -24,7 +25,7 @@ export class WidgetMembersComponent extends AbstractWidgetComponent implements O
};
public totalMembers:number;
public entriesByRoles:{[roleId:string]:{role:RoleResource, users:UserResource[]}} = {};
public entriesByRoles:{[roleId:string]:{role:RoleResource, users:HalResource[]}} = {};
private entriesLoaded = false;
public membersAddable:boolean = false;
@@ -38,7 +38,7 @@ export interface MembershipResourceLinks {
}
export interface MembershipResourceEmbedded {
principal:UserResource;
principal:HalResource;
roles:RoleResource[];
project:ProjectResource;
}
@@ -0,0 +1,3 @@
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
export type PrincipalLike = HalResource|{ name: string};
@@ -55,7 +55,10 @@
></op-ium-summary>
<op-ium-success
[principal]="principal"
[project]="project"
[type]="type"
*ngIf="step === Steps.Success"
class="op-modal op-modal"
(close)="closeMe()"
(close)="closeWithPrincipal()"
></op-ium-success>
@@ -12,6 +12,8 @@ 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";
enum Steps {
ProjectSelection,
@@ -23,9 +25,9 @@ enum Steps {
}
export enum PrincipalType {
User = 'user',
Placeholder = 'placeholder',
Group = 'group',
User = 'User',
Placeholder = 'PlaceholderUser',
Group = 'Group',
}
@Component({
@@ -48,8 +50,8 @@ export class InviteUserModalComponent extends OpModalComponent implements OnInit
public data:any = null;
public type:PrincipalType|null = null;
public project:any = null;
public principal = null;
public project:ProjectResource|null = null;
public principal:HalResource|null = null;
public role:RoleResource|null = null;
public message = '';
@@ -90,7 +92,7 @@ export class InviteUserModalComponent extends OpModalComponent implements OnInit
onRoleSave(role:RoleResource) {
this.role = role;
if (this.type === 'placeholder') {
if (this.type === PrincipalType.Placeholder) {
this.goTo(Steps.Summary);
} else {
this.goTo(Steps.Message);
@@ -102,6 +104,11 @@ export class InviteUserModalComponent extends OpModalComponent implements OnInit
this.goTo(Steps.Summary);
}
onSuccessfulSubmission($event:{ principal:HalResource }) {
this.principal = $event.principal;
this.goTo(Steps.Success);
}
goTo(step:Steps) {
this.step = step;
}
@@ -11,6 +11,8 @@ import {
FormGroup,
} from '@angular/forms';
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {HalResource} from 'core-app/modules/hal/resources/hal-resource';
import {ProjectResource} from 'core-app/modules/hal/resources/project-resource';
import {PrincipalType} from '../invite-user.component';
@Component({
@@ -20,8 +22,8 @@ import {PrincipalType} from '../invite-user.component';
})
export class MessageComponent implements OnInit {
@Input() type:PrincipalType;
@Input() project:any = null;
@Input() principal:any = null;
@Input() project:ProjectResource;
@Input() principal:HalResource;
@Input() message:string = '';
@Output() close = new EventEmitter<void>();
@@ -59,7 +61,7 @@ export class MessageComponent implements OnInit {
onSubmit($e:Event) {
$e.preventDefault();
if (this.messageForm.invalid) {
this.messageForm.markAllAsTouched();
this.messageForm.markAsDirty();
return;
}
@@ -1,5 +1,6 @@
<ng-select
[formControl]="principalControl"
[addTag]="showAddTag ? createNewFromInput.bind(this) : false"
[typeahead]="input$"
[items]="items$ | async"
[virtualScroll]="true"
@@ -7,51 +8,51 @@
[clearOnBackspace]="false"
[clearSearchOnAdd]="false"
bindLabel="name"
bindValue="value"
autofocus
#ngselect
>
<ng-template ng-label-tmp let-item="item">
{{item.value.name }}
</ng-template>
<ng-template ng-option-tmp let-item="item" let-search="searchTerm">
<!--Selectable option-->
<div
[ngOptionHighlight]="search"
*ngIf="item"
class="ng-option-label"
>{{ item.name }}</div>
<!-- Already a member of the project -->
<div
*ngIf="item.disabled"
class="ng-option-label ellipsis"
>{{ text.alreadyAMember() }}</div>
>
<!--Selectable option-->
<div [ngOptionHighlight]="search">{{ item.value.name }}</div>
<!-- Already a member of the project -->
<div
*ngIf="item.disabled"
class="ellipsis"
>{{ text.alreadyAMember() }}</div>
</div>
</ng-template>
<!--Nothing found -->
<ng-template ng-notfound-tmp let-searchTerm="searchTerm">
<ng-template ng-notfound-tmp>
<div class="ng-option disabled">
{{ text.noResults[type] }}
</div>
</ng-template>
<ng-template ng-footer-tmp>
<ng-template ng-tag-tmp>
<!--Invite a new user by email-->
<div *ngIf="canInviteByEmail$ | async">
<button
class="button"
(click)="createNewFromInput()"
>
<op-icon icon-classes="icon-mail1"></op-icon>
{{ text.inviteNewUser() }}
</button>
<op-icon icon-classes="icon-mail1 icon-context"></op-icon>
<b>{{ text.inviteNewUser }}</b>
{{ input }}
</div>
<!--Create a new placeholder by name-->
<div *ngIf="canCreateNewPlaceholder$ | async">
<button
class="button"
(click)="createNewFromInput()"
>
<op-icon icon-classes="icon-mail1"></op-icon>
{{ text.createNewPlaceholder() }}
</button>
<op-icon icon-classes="icon-mail1 icon-context"></op-icon>
<b>{{ text.createNewPlaceholder }}</b>
{{ input }}
</div>
</ng-template>
</ng-select>
@@ -8,11 +8,13 @@ import {
} from '@angular/core';
import {FormControl} from "@angular/forms";
import {Observable, BehaviorSubject, combineLatest, forkJoin} from "rxjs";
import {debounceTime, distinctUntilChanged, first, shareReplay, map, switchMap} from "rxjs/operators";
import {debounceTime, distinctUntilChanged, tap, shareReplay, map, switchMap} 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 {PrincipalLike} from "core-app/modules/invite-user-modal/invite-user-modal.types";
import {PrincipalType} from '../invite-user.component';
@Component({
@@ -22,30 +24,27 @@ import {PrincipalType} from '../invite-user.component';
export class PrincipalSearchComponent extends UntilDestroyedMixin implements OnInit {
@Input('opFormBinding') principalControl:FormControl;
@Input() type:PrincipalType;
@Input() project:any = null;
@Input() project:ProjectResource;
@Output() createNew = new EventEmitter<string>();
@Output() createNew = new EventEmitter<PrincipalLike>();
public input$ = new BehaviorSubject<string>('');
public input = '';
public items$:Observable<any[]>;
public canInviteByEmail$:Observable<boolean>;
public canCreateNewPlaceholder$:Observable<boolean>;
public showAddTag = false;
public text = {
alreadyAMember: () => this.I18n.t('js.invite_user_modal.principal.already_member_message', {
project: this.project?.name,
}),
inviteNewUser: () => this.I18n.t('js.invite_user_modal.principal.invite_user', {
email: this.input,
}),
createNewPlaceholder: () => this.I18n.t('js.invite_user_modal.principal.create_new_placeholder', {
name: this.input,
}),
inviteNewUser: this.I18n.t('js.invite_user_modal.principal.invite_user'),
createNewPlaceholder: this.I18n.t('js.invite_user_modal.principal.create_new_placeholder'),
noResults: {
user: this.I18n.t('js.invite_user_modal.principal.no_results_user'),
placeholder: this.I18n.t('js.invite_user_modal.principal.no_results_placeholder'),
group: this.I18n.t('js.invite_user_modal.principal.no_results_group'),
User: this.I18n.t('js.invite_user_modal.principal.no_results_user'),
PlaceholderUser: this.I18n.t('js.invite_user_modal.principal.no_results_placeholder'),
Group: this.I18n.t('js.invite_user_modal.principal.no_results_group'),
},
};
@@ -72,7 +71,7 @@ export class PrincipalSearchComponent extends UntilDestroyedMixin implements OnI
this.items$,
this.input$,
).pipe(
map(([elements, input]) => this.type === 'user' && input?.includes('@') && !elements.find((el:any) => el.email === input)),
map(([elements, input]) => this.type === PrincipalType.User && input?.includes('@') && !elements.find((el:any) => el.email === input)),
);
this.canCreateNewPlaceholder$ = combineLatest(
@@ -87,6 +86,13 @@ export class PrincipalSearchComponent extends UntilDestroyedMixin implements OnI
return !!input && !elements.find((el:any) => el.name === input);
}),
);
combineLatest(
this.canInviteByEmail$,
this.canCreateNewPlaceholder$,
).pipe(
map(([canInviteByEmail, canCreateNewPlaceholder]:boolean[]) => canInviteByEmail || canCreateNewPlaceholder)
).subscribe(showAddTag => { this.showAddTag = showAddTag; });
}
ngOnInit() {
@@ -95,37 +101,27 @@ export class PrincipalSearchComponent extends UntilDestroyedMixin implements OnI
}
createNewFromInput() {
this.input$
.pipe(first())
.subscribe((input:string) => {
this.createNew.emit(input);
});
this.createNew.emit({ name: this.input });
}
private loadPrincipalData(searchTerm:string) {
const type = {
placeholder: 'PlaceholderUser',
user: 'User',
group: 'Group',
}[this.type];
const nonMemberFilter = new ApiV3FilterBuilder();
if (searchTerm) {
nonMemberFilter.add('name', '~', [searchTerm]);
}
nonMemberFilter.add('status', '!', [3]);
nonMemberFilter.add('type', '=', [type]);
nonMemberFilter.add('type', '=', [this.type]);
nonMemberFilter.add('member', '!', [this.project?.id]);
const nonMembers = this.apiV3Service.principals.filtered(nonMemberFilter).get();
const memberFilter = new ApiV3FilterBuilder();
if (searchTerm) {
memberFilter.add('name', '~', [searchTerm]);
}
memberFilter.add('status', '!', [3]);
memberFilter.add('type', '=', [type]);
nonMemberFilter.add('member', '=', [this.project?.id]);
memberFilter.add('type', '=', [this.type]);
memberFilter.add('member', '=', [this.project?.id]);
const members = this.apiV3Service.principals.filtered(memberFilter).get();
const nonMembers = this.apiV3Service.principals.filtered(nonMemberFilter).get();
return forkJoin({
members,
@@ -133,12 +129,12 @@ export class PrincipalSearchComponent extends UntilDestroyedMixin implements OnI
})
.pipe(
map(({ members, nonMembers }) => [
...members.elements.map((member:any) => ({
...member,
...nonMembers.elements.map((nonMember:any) => ({
value: nonMember,
disabled: false,
})),
...nonMembers.elements.map((nonMember:any) => ({
...nonMember,
...members.elements.map((member:any) => ({
value: member,
disabled: true,
})),
]),
@@ -10,7 +10,7 @@
required
>
<op-ium-principal-search
*ngIf="!isNewPrincipal"
*ngIf="!(hasPrincipalSelected && isNewPrincipal)"
[opFormBinding]="principalControl"
[type]="type"
[project]="project"
@@ -19,11 +19,10 @@
></op-ium-principal-search>
<div
class="pill"
*ngIf="isNewPrincipal && type === 'user'"
*ngIf="isNewPrincipal && type === PrincipalType.User"
slot="input"
>
{{ text.inviteUser() }}
<b>{{ text.inviteUser }}</b> {{ principal.name }}
<button
class="op-link"
(click)="principalControl?.setValue(null)"
@@ -31,29 +30,16 @@
</div>
<div
class="pill"
*ngIf="isNewPrincipal && type === 'placeholder'"
*ngIf="isNewPrincipal && type === PrincipalType.Placeholder"
slot="input"
>
{{ text.createNew.placeholder() }}
<b>{{ text.createNewPlaceholder }}</b> {{ principal.name }}
<button
class="op-link"
(click)="principalControl?.setValue(null)"
>{{ text.changePlaceholderSelection }}</button>
</div>
<div
class="pill"
*ngIf="isNewPrincipal && type === 'group'"
slot="input"
>
{{ text.createNew.group() }}
<button
class="op-link"
(click)="principalControl?.setValue(null)"
>{{ text.changeGroupSelection }}</button>
</div>
<div
slot="errors"
class="op-form-field--error"
@@ -3,7 +3,7 @@ import {
OnInit,
Input,
Output,
EventEmitter,
EventEmitter, ChangeDetectorRef,
} from '@angular/core';
import {
FormGroup,
@@ -12,6 +12,9 @@ import {
} from '@angular/forms';
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {PrincipalType} from '../invite-user.component';
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {PrincipalLike} from "core-app/modules/invite-user-modal/invite-user-modal.types";
import {ProjectResource} from "core-app/modules/hal/resources/project-resource";
@Component({
selector: 'op-ium-principal',
@@ -19,42 +22,35 @@ import {PrincipalType} from '../invite-user.component';
styleUrls: ['./principal.component.sass'],
})
export class PrincipalComponent implements OnInit {
@Input() principal:any = null;
@Input() project:any = null;
@Input('principal') storedPrincipal:PrincipalLike|null = null;
@Input() project:ProjectResource;
@Input() type:PrincipalType;
@Output() close = new EventEmitter<void>();
@Output() save = new EventEmitter<{ principal:any, isAlreadyMember:boolean }>();
@Output() save = new EventEmitter<{ principal:PrincipalLike, isAlreadyMember:boolean }>();
@Output() back = new EventEmitter();
public PrincipalType = PrincipalType;
public text = {
title: () => this.I18n.t('js.invite_user_modal.title.invite_to_project', {
type: this.I18n.t(`js.invite_user_modal.title.${this.type}`),
project: this.project.name,
}),
label: {
user: this.I18n.t('js.invite_user_modal.principal.label.name_or_email'),
placeholder: this.I18n.t('js.invite_user_modal.principal.label.name'),
group: this.I18n.t('js.invite_user_modal.principal.label.name'),
User: this.I18n.t('js.invite_user_modal.principal.label.name_or_email'),
PlaceholderUser: this.I18n.t('js.invite_user_modal.principal.label.name'),
Group: this.I18n.t('js.invite_user_modal.principal.label.name'),
},
inviteUser: () => this.I18n.t('js.invite_user_modal.principal.invite_user', {
email: this.principalControl?.value?.name,
}),
changeUserSelection: this.I18n.t('js.invite_user_modal.principal.change_user_selection'),
changePlaceholderSelection: this.I18n.t('js.invite_user_modal.principal.change_placeholder_selection'),
changeGroupSelection: this.I18n.t('js.invite_user_modal.principal.change_group_selection'),
createNew: {
placeholder: () => this.I18n.t('js.invite_user_modal.principal.create_new_placeholder', {
name: this.principalControl?.value?.name
}),
group: () => this.I18n.t('js.invite_user_modal.principal.create_new_group', {
name: this.principalControl?.value?.name
}),
},
inviteUser: this.I18n.t('js.invite_user_modal.principal.invite_user'),
createNewPlaceholder: this.I18n.t('js.invite_user_modal.principal.create_new_placeholder'),
required: {
user: this.I18n.t('js.invite_user_modal.principal.required.user'),
placeholder: this.I18n.t('js.invite_user_modal.principal.required.placeholder'),
group: this.I18n.t('js.invite_user_modal.principal.required.group'),
User: this.I18n.t('js.invite_user_modal.principal.required.user'),
PlaceholderUser: this.I18n.t('js.invite_user_modal.principal.required.placeholder'),
Group: this.I18n.t('js.invite_user_modal.principal.required.group'),
},
backButton: this.I18n.t('js.invite_user_modal.back'),
nextButton: this.I18n.t('js.invite_user_modal.principal.next_button'),
@@ -68,8 +64,16 @@ export class PrincipalComponent implements OnInit {
return this.principalForm.get('principal');
}
get principal():PrincipalLike|undefined {
return this.principalControl?.value;
}
get hasPrincipalSelected() {
return !!this.principal;
}
get isNewPrincipal() {
return typeof this.principalControl?.value === 'string';
return this.hasPrincipalSelected && !(this.principal instanceof HalResource);
}
get isMemberOfCurrentProject() {
@@ -79,10 +83,10 @@ export class PrincipalComponent implements OnInit {
constructor(readonly I18n:I18nService) {}
ngOnInit() {
this.principalControl?.setValue(this.principal);
this.principalControl?.setValue(this.storedPrincipal);
}
createNewFromInput(input:string) {
createNewFromInput(input:PrincipalLike) {
this.principalControl?.setValue(input);
}
@@ -90,12 +94,12 @@ export class PrincipalComponent implements OnInit {
$e.preventDefault();
if (this.principalForm.invalid) {
this.principalForm.markAllAsTouched();
this.principalForm.markAsDirty();
return;
}
this.save.emit({
principal: this.principalControl?.value,
principal: this.principal!,
isAlreadyMember: this.isMemberOfCurrentProject,
});
}
@@ -30,7 +30,7 @@
></op-option-list>
<div
*ngIf="typeControl?.touched && typeControl?.invalid"
*ngIf="projectAndTypeForm?.dirty && typeControl?.invalid"
class="op-form-field--errors"
>
<div class="op-form-field--error">
@@ -12,6 +12,8 @@ import {
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';
@Component({
@@ -37,21 +39,16 @@ export class ProjectSelectionComponent implements OnInit {
nextButton: this.I18n.t('js.invite_user_modal.project.next_button'),
};
public typeOptions = [
public typeOptions:IOpOptionListOption<string>[] = [
{
value: 'user',
title: 'User',
description: 'Permissions based on the assigned role in the selected project'
value: PrincipalType.User,
title: this.I18n.t('js.invite_user_modal.type.user.title'),
description: this.I18n.t('js.invite_user_modal.type.user.description'),
},
{
value: 'group',
title: 'Group',
description: 'Permissions based on the assigned role in the selected project'
},
{
value: 'placeholder',
title: 'Placeholder',
description: 'Has no access to the proejct and no emails are sent out'
value: PrincipalType.Group,
title: this.I18n.t('js.invite_user_modal.type.group.title'),
description: this.I18n.t('js.invite_user_modal.type.group.description'),
},
];
@@ -66,17 +63,34 @@ export class ProjectSelectionComponent implements OnInit {
constructor(
readonly I18n:I18nService,
readonly elementRef:ElementRef,
readonly bannersService:BannersService,
) {}
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,
});
}
onSubmit($e:Event) {
$e.preventDefault();
if (this.projectAndTypeForm.invalid) {
this.projectAndTypeForm.markAllAsTouched();
this.projectAndTypeForm.markAsDirty();
return;
}
@@ -24,7 +24,7 @@ export class RoleSearchComponent extends UntilDestroyedMixin implements OnInit {
public items$:Observable<any[]>;
public text = {
noRolesFound: this.I18n.t('js.invite_user_modal.role_search.no_roles_found'),
noRolesFound: this.I18n.t('js.invite_user_modal.role.no_roles_found'),
};
constructor(
@@ -39,12 +39,12 @@ export class RoleSearchComponent extends UntilDestroyedMixin implements OnInit {
.pipe(
this.untilDestroyed(),
debounceTime(200),
filter(input => typeof input === 'string'),
map((input:string) => input.toLowerCase()),
distinctUntilChanged(),
),
this.roles$,
).pipe(
tap(console.log),
map(([input, roles]:[string, any[]]) => roles.filter((role) => !input || role.name.toLowerCase().indexOf(input) !== -1))
);
}
@@ -13,6 +13,8 @@ import {
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {PrincipalType} from '../invite-user.component';
import {RoleResource} from "core-app/modules/hal/resources/role-resource";
import {ProjectResource} from "core-app/modules/hal/resources/project-resource";
import {HalResource} from 'core-app/modules/hal/resources/hal-resource';
@Component({
selector: 'op-ium-role',
@@ -21,9 +23,9 @@ import {RoleResource} from "core-app/modules/hal/resources/role-resource";
})
export class RoleComponent implements OnInit {
@Input() type:PrincipalType;
@Input() project:any = null;
@Input() principal:any = null;
@Input() role:any = null;
@Input() project:ProjectResource;
@Input() principal:HalResource;
@Input() role:RoleResource;
@Output() close = new EventEmitter<void>();
@Output() back = new EventEmitter<void>();
@@ -60,7 +62,7 @@ export class RoleComponent implements OnInit {
onSubmit($e:Event) {
$e.preventDefault();
if (this.roleForm.invalid) {
this.roleForm.markAllAsTouched();
this.roleForm.markAsDirty();
return;
}
@@ -1,18 +1,16 @@
<div class="op-ium-success">
<op-modal-header (close)="close.emit()"></op-modal-header>
<div class="op-modal--body">
<img src="" />
<img [src]="type === PrincipalType.Placeholder ? placeholder_image : user_image" />
<h1 [textContent]="text.title()"></h1>
<p [textContent]="text.description[type]()"></p>
</div>
<div class="op-modal--footer">
<button
(click)="close.emit()"
class="button"
type="button"
>{{ text.nextButton }}</button>
<p>
<button
(click)="close.emit()"
class="button -highlight"
type="button"
>{{ text.nextButton }}</button>
</p>
</div>
</div>
@@ -0,0 +1,9 @@
.op-ium-success
flex-grow: 1
display: flex
flex-direction: column
.op-modal--body
justify-content: center
align-items: center
text-align: center
@@ -7,6 +7,9 @@ import {
} from '@angular/core';
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {PrincipalType} from '../invite-user.component';
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {ProjectResource} from "core-app/modules/hal/resources/project-resource";
import {ImageHelpers} from "core-app/helpers/images/path-helper";
@Component({
selector: 'op-ium-success',
@@ -14,20 +17,25 @@ import {PrincipalType} from '../invite-user.component';
styleUrls: ['./success.component.sass'],
})
export class SuccessComponent {
@Input() principal:any = null;
@Input() project:any = null;
@Input() principal:HalResource;
@Input() project:ProjectResource;
@Input() type:PrincipalType;
@Output() close = new EventEmitter<void>();
public PrincipalType = PrincipalType;
user_image = ImageHelpers.imagePath('invite-user-modal/successful-invite.svg');
placeholder_image = ImageHelpers.imagePath('invite-user-modal/placeholder-added.svg');
public text = {
title: () => this.I18n.t('js.invite_user_modal.success.title', {
principal: this.principal.name,
}),
description: {
user: () => this.I18n.t('js.invite_user_modal.success.description.user', { project: this.project?.name }),
placeholder: () => this.I18n.t('js.invite_user_modal.success.description.placeholder', { project: this.project?.name }),
group: () => this.I18n.t('js.invite_user_modal.success.description.group', { project: this.project?.name }),
User: () => this.I18n.t('js.invite_user_modal.success.description.user', { project: this.project?.name }),
PlaceholderUser: () => this.I18n.t('js.invite_user_modal.success.description.placeholder', { project: this.project?.name }),
Group: () => this.I18n.t('js.invite_user_modal.success.description.group', { project: this.project?.name }),
},
nextButton: this.I18n.t('js.invite_user_modal.success.next_button'),
};
@@ -19,7 +19,7 @@
</div>
<op-form-field
[label]="text.messageLabel"
*ngIf="type !== 'placeholder'"
*ngIf="type !== PrincipalType.Placeholder"
>
<p slot="input">{{ message }}</p>
</op-form-field>
@@ -28,8 +28,11 @@
<div class="op-modal--footer">
<button
type="button"
class="button"
(click)="back.emit()"
>{{ text.backButton }}</button>
<button>{{ text.nextButton() }}</button>
<button
class="button -highlight"
>{{ text.nextButton() }}</button>
</div>
</form>
@@ -5,10 +5,15 @@ import {
Output,
ElementRef,
} from '@angular/core';
import {Observable, of} from "rxjs";
import {mapTo, switchMap} from "rxjs/operators";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {APIV3Service} from "core-app/modules/apiv3/api-v3.service";
import {PrincipalType} from '../invite-user.component';
import {RoleResource} from "core-app/modules/hal/resources/role-resource";
import {PrincipalLike} from "core-app/modules/invite-user-modal/invite-user-modal.types";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {ProjectResource} from 'core-app/modules/hal/resources/project-resource';
import {PrincipalType} from '../invite-user.component';
@Component({
selector: 'op-ium-summary',
@@ -17,15 +22,17 @@ import {RoleResource} from "core-app/modules/hal/resources/role-resource";
})
export class SummaryComponent {
@Input() type:PrincipalType;
@Input() project:any = null;
@Input() project:ProjectResource;
@Input() role:RoleResource;
@Input() principal:any = null;
@Input() principal:PrincipalLike;
@Input() message:string = '';
@Output() close = new EventEmitter<void>();
@Output() back = new EventEmitter<void>();
@Output() save = new EventEmitter();
public PrincipalType = PrincipalType;
public text = {
title: () => this.I18n.t('js.invite_user_modal.title.invite_principal_to_project', {
principal: this.principal?.name,
@@ -33,9 +40,9 @@ export class SummaryComponent {
}),
projectLabel: this.I18n.t('js.invite_user_modal.project.label'),
principalLabel: {
user: this.I18n.t('js.invite_user_modal.principal.label.name_or_email'),
placeholder: this.I18n.t('js.invite_user_modal.principal.label.name'),
group: this.I18n.t('js.invite_user_modal.principal.label.name'),
User: this.I18n.t('js.invite_user_modal.principal.label.name_or_email'),
PlaceholderUser: this.I18n.t('js.invite_user_modal.principal.label.name'),
Group: this.I18n.t('js.invite_user_modal.principal.label.name'),
},
roleLabel: () => this.I18n.t('js.invite_user_modal.role.label', {
project: this.project?.name,
@@ -52,36 +59,52 @@ export class SummaryComponent {
readonly I18n:I18nService,
readonly elementRef:ElementRef,
readonly api:APIV3Service,
) {}
async invite() {
const principal = await (async () => {
if (this.principal.id) {
return this.principal;
}
switch (this.type) {
case PrincipalType.User:
return this.api.users.post({
email: this.principal.name,
firstName: this.principal.email,
status: 'invited',
});
case PrincipalType.Placeholder:
return this.api.placeholder_users.post({ name: this.principal.name });
}
})();
return this.api.memberships.post({
principal,
project: this.project,
roles: [this.role],
});
) {
}
async onSubmit($e:Event) {
invite() {
return of(this.principal)
.pipe(
switchMap((principal:PrincipalLike) => this.createPrincipal(principal)),
switchMap((principal:HalResource) =>
this.api.memberships
.post({
principal,
project: this.project,
roles: [this.role],
})
.pipe(
mapTo(principal)
)
)
);
}
private createPrincipal(principal:PrincipalLike):Observable<HalResource> {
if (principal instanceof HalResource) {
return of(principal);
}
switch (this.type) {
case PrincipalType.User:
return this.api.users.post({
email: principal.name,
status: 'invited',
});
case PrincipalType.Placeholder:
return this.api.placeholder_users.post({ name: principal.name });
default:
throw new Error("Unsupported PrincipalType given");
}
}
onSubmit($e:Event) {
$e.preventDefault();
this.save.emit({ principal: this.principal });
this
.invite()
.subscribe((principal) =>
this.save.emit({ principal })
);
}
}
@@ -0,0 +1,7 @@
// Some backend modals come wrapped once extra. Because of the way
// augmented modals are loaded, this is not so easy to change.
// TODO: When refactoring the modal service, this should be removed
.op-modal > .form:only-child
display: flex
flex-direction: column
max-height: 100vh
@@ -1,3 +1,4 @@
@import './modal-overrides'
@import './modal-overlay'
@import './modal-delivery-element'
@import './modal'
+9 -5
View File
@@ -1,11 +1,13 @@
// TODO: Check out -danger-zone and modal-close-button patterns
.op-modal
$modal-padding: 2rem
display: flex
flex-direction: column
align-items: stretch
background: white
width: 30rem
width: 40rem
min-height: 20vh
max-width: 100vw
@@ -16,7 +18,9 @@
min-height: 40vh
&--body
padding: 1rem
display: flex
flex-direction: column
padding: $modal-padding
flex-grow: 1
flex-shrink: 1
overflow-y: auto
@@ -24,7 +28,7 @@
&--header
display: flex
padding: 2rem 1rem 1rem 1rem
padding: 2rem $modal-padding 1rem $modal-padding
position: relative
border-bottom: 1px solid #eee
@@ -51,11 +55,11 @@
margin-right: auto
&--footer
padding: 1rem 1rem 2rem 1rem
padding: 1rem $modal-padding 2rem $modal-padding
&--close-button
height: 100%
width: 3rem
width: 5rem
padding: 0.45rem 0
display: flex
justify-content: center
@@ -2,7 +2,8 @@ import {Injectable} from "@angular/core";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
import {ColorsService} from "core-app/modules/common/colors/colors.service";
import {APIV3Service} from "core-app/modules/apiv3/api-v3.service";
import {PrincipalHelper} from "core-app/modules/common/principal/principal-helper";
import {PrincipalHelper} from "./principal-helper";
import PrincipalType = PrincipalHelper.PrincipalType;
export interface PrincipalLike {
@@ -0,0 +1,17 @@
import {NgModule} from "@angular/core";
import {OpPrincipalComponent} from './principal.component';
import {PrincipalRendererService} from "./principal-renderer.service";
@NgModule({
imports: [],
exports: [
OpPrincipalComponent,
],
providers: [
PrincipalRendererService,
],
declarations: [
OpPrincipalComponent,
],
})
export class OpenprojectPrincipalRenderingModule { }
@@ -31,9 +31,10 @@ import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
import {PathHelperService} from 'core-app/modules/common/path-helper/path-helper.service';
import {TimezoneService} from 'core-components/datetime/timezone.service';
import {APIV3Service} from "core-app/modules/apiv3/api-v3.service";
import {PrincipalHelper} from "core-app/modules/common/principal/principal-helper";
import {PrincipalHelper} from "./principal-helper";
import PrincipalPluralType = PrincipalHelper.PrincipalPluralType;
import {PrincipalLike, PrincipalRendererService} from "core-app/modules/common/principal/principal-renderer.service";
import {PrincipalLike, PrincipalRendererService} from "./principal-renderer.service";
@Component({
template: '',
@@ -0,0 +1,4 @@
<svg width="149" height="140" viewBox="0 0 149 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M45 0C44.5312 0 44.0625 0.234375 43.5938 0.234375C18.9844 0.9375 0 22.2656 0 47.1094V108.75C0 111.094 1.64062 112.5 3.75 112.5C4.6875 112.5 5.625 112.266 6.32812 111.562L12.1875 107.109C12.8906 106.641 13.5938 106.406 14.2969 106.406C15.4688 106.406 16.4062 106.875 17.1094 107.578L27.1875 119.062C27.8906 119.766 28.8281 120.234 30 120.234C30.9375 120.234 31.875 119.766 32.5781 119.062L42.1875 108.281C42.8906 107.344 43.8281 107.109 45 107.109C45.9375 107.109 46.875 107.344 47.5781 108.281L57.1875 119.062C57.8906 119.766 58.8281 120.234 59.7656 120.234C60.9375 120.234 61.875 119.766 62.5781 119.062L72.6562 107.578C73.125 106.875 74.5312 106.406 75.4688 106.406C76.1719 106.406 77.1094 106.875 77.5781 107.109L83.4375 111.562C84.1406 112.266 85.0781 112.5 86.0156 112.5C88.125 112.5 90 111.094 90 108.75V45C90 20.1562 69.8438 0 45 0ZM78.75 95.625C77.5781 95.3906 76.4062 95.1562 75.4688 95.1562C71.0156 95.1562 67.0312 97.0312 64.2188 100.312L60 105L56.0156 100.781C53.2031 97.5 49.2188 95.8594 45 95.8594C40.5469 95.8594 36.5625 97.5 33.75 100.781L30 105L25.5469 100.312C23.2031 97.5 18.0469 95.1562 14.2969 95.1562C13.3594 95.1562 12.1875 95.3906 11.0156 95.625V47.1094C11.0156 27.8906 25.7812 11.9531 43.8281 11.4844L45 11.25C63.5156 11.25 78.75 26.4844 78.75 45V95.625ZM30 37.5C25.7812 37.5 22.5 41.0156 22.5 45C22.5 49.2188 25.7812 52.5 30 52.5C33.9844 52.5 37.5 49.2188 37.5 45C37.5 41.0156 33.9844 37.5 30 37.5ZM60 37.5C55.7812 37.5 52.5 41.0156 52.5 45C52.5 49.2188 55.7812 52.5 60 52.5C63.9844 52.5 67.5 49.2188 67.5 45C67.5 41.0156 63.9844 37.5 60 37.5Z" fill="#A0AEC0"/>
<path d="M136.973 53.9006L140.643 50.0673L136.943 46.2625L126.153 35.1657L122.179 31.0792L118.237 35.1963L62.238 93.6809L45.7632 76.4748L41.8211 72.3578L37.8474 76.4443L27.0569 87.5411L23.3571 91.3459L27.0274 95.1791L58.2654 127.804L62.238 131.953L66.2105 127.804L136.973 53.9006Z" fill="#1A67A3" stroke="white" stroke-width="11"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,4 @@
<svg width="151" height="117" viewBox="0 0 151 117" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M111.469 13H11.5312C5.04492 13 0 18.2708 0 24.5V93.5C0 99.9688 5.04492 105 11.5312 105H111.469C117.715 105 123 99.9688 123 93.5V24.5C123 18.2708 117.715 13 111.469 13ZM111.469 24.5V34.3229C105.943 38.875 97.2949 45.5833 79.0371 59.9583C74.9531 63.0729 67.0254 70.7396 61.5 70.5C55.7344 70.7396 47.8066 63.0729 43.7227 59.9583C25.4648 45.5833 16.8164 38.875 11.5312 34.3229V24.5H111.469ZM11.5312 93.5V49.1771C16.8164 53.4896 24.7441 59.7188 36.5156 69.0625C41.8008 73.1354 51.1699 82.2396 61.5 82C71.5898 82.2396 80.7188 73.1354 86.2441 69.0625C98.0156 59.7188 105.943 53.4896 111.469 49.1771V93.5H11.5312Z" fill="#A0AEC0"/>
<path d="M138.973 30.9006L142.643 27.0673L138.943 23.2625L128.153 12.1657L124.179 8.07924L120.237 12.1963L64.238 70.6809L47.7632 53.4748L43.8211 49.3578L39.8474 53.4443L29.0569 64.5411L25.3571 68.3459L29.0274 72.1791L60.2654 104.804L64.238 108.953L68.2105 104.804L138.973 30.9006Z" fill="#1A67A3" stroke="white" stroke-width="11"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+120 -41
View File
@@ -28,6 +28,7 @@
require 'spec_helper'
feature 'Invite user modal', type: :feature, js: true do
shared_let(:project) { FactoryBot.create :project }
shared_let(:work_package) { FactoryBot.create :work_package, project: project }
@@ -39,22 +40,132 @@ feature 'Invite user modal', type: :feature, js: true do
member_through_role: role
end
let!(:non_project_user) do
FactoryBot.create :user,
firstname: 'Nonproject',
lastname: 'User'
end
let!(:role) do
FactoryBot.create :role,
name: 'Member',
permissions: permissions
end
describe 'through the assignee field' do
let(:modal) do
::Components::Users::InviteUserModal.new project: project,
principal: principal,
role: role
end
shared_examples 'invites the principal to the project' 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
new_member = project.reload.member_principals.find_by(user_id: added_principal.id)
expect(new_member).to be_present
expect(new_member.roles).to eq [role]
end
end
describe 'inviting a non-project existing user' do
describe 'through the assignee field' do
let(:wp_page) { Pages::FullWorkPackage.new(work_package, project) }
let(:assignee_field) { wp_page.edit_field :assignee }
before do
wp_page.visit!
assignee_field.activate!
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
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
# But the user got created
new_member = project.reload.members.find_by(user_id: principal.id)
expect(new_member).to be_present
expect(new_member.roles).to eq [role]
end
end
context 'with a user to be invited' do
let(:principal) { FactoryBot.build :invited_user }
context 'when the current user has permissions to create a user' do
let(:permissions) { %i[view_work_packages edit_work_packages manage_members manage_user] }
it_behaves_like 'invites the principal to the project' do
let(:added_principal) { User.find_by!(mail: principal.mail) }
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
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] }
it_behaves_like 'invites the principal to the project' do
let(:added_principal) { PlaceholderUser.find_by!(name: 'MY NEW PLACEHOLDER') }
end
end
context 'without permissions to manage placeholders' do
let(:permissions) { %i[view_work_packages edit_work_packages manage_members manage_placeholder_user] }
it 'does not allow to invite a new placeholder' do
skip "TODO wait for permissions API"
end
end
end
context 'with an existing placeholder' do
let(:principal) { FactoryBot.create :placeholder_user }
it_behaves_like 'invites the principal to the project' do
let(:added_principal) { principal }
end
end
end
context 'system has no enterprise' do
it 'shows the modal with placeholder option disabled' do
modal.within_modal do
expect(page).to have_field 'Placeholder user', disabled: true
end
end
end
end
describe 'inviting groups' do
let(:principal) { FactoryBot.create :group, name: 'MY NEW GROUP' }
it_behaves_like 'invites the principal to the project' do
let(:added_principal) { principal }
end
end
end
end
context 'when the user has no permission to manage members' do
let(:permissions) { %i[view_work_packages edit_work_packages] }
let(:wp_page) { Pages::FullWorkPackage.new(work_package, project) }
let(:assignee_field) { wp_page.edit_field :assignee }
let(:modal) { ::Components::Users::InviteUserModal.new }
before do
wp_page.visit!
@@ -63,39 +174,7 @@ feature 'Invite user modal', type: :feature, js: true do
it 'can add an existing user to the project' do
assignee_field.activate!
find('.ng-dropdown-footer button', text: 'Invite', wait: 10).click
modal.expect_open
# STEP 1: Project and type
modal.expect_title 'Invite user'
modal.autocomplete project.name
modal.select_type 'User'
modal.next
# STEP 2: User name
modal.autocomplete non_project_user.name
modal.next
# STEP 3: Role name
modal.autocomplete role.name
modal.next
# STEP 4: Invite message
modal.invitation_message 'Welcome user!'
modal.click_modal_button 'Review Invitation'
modal.within_modal do
expect(page).to have_text project.name
expect(page).to have_text non_project_user.name
expect(page).to have_text role.name
expect(page).to have_text 'Welcome user!'
end
end
context 'when the user has no permission to manage members' do
let(:permissions) { %i[view_work_packages edit_work_packages] }
expect(page).to have_no_selector('.ng-dropdown-footer', text: 'Invite')
end
end
end
@@ -43,6 +43,14 @@ describe Users::CreateService do
end
end
context 'and the user has no names set' do
let(:model_instance) { FactoryBot.build :invited_user, firstname: nil, lastname: nil, mail: 'foo@example.com' }
it 'will call UserInvitation' do
expect(::UserInvitation).to receive(:invite_user!).with(model_instance).and_return(model_instance)
expect(subject).to be_success
end
end
context 'and the mail is empty' do
let(:model_instance) { FactoryBot.build :invited_user, mail: nil }
it 'will call not call UserInvitation' do
+6
View File
@@ -48,6 +48,12 @@ module Components
expect(page).to have_no_selector(selector)
end
def expect_text(text)
within_modal do
expect(page).to have_text(text)
end
end
def click_modal_button(text)
within_modal do
click_button text
@@ -33,9 +33,105 @@ module Components
class InviteUserModal < ::Components::Common::Modal
include ::Components::NgSelectAutocompleteHelpers
def autocomplete(query)
attr_accessor :project, :principal, :role, :invite_message
def initialize(project:, principal:, role:, invite_message: 'Welcome!')
self.project = project
self.principal = principal
self.role = role
self.invite_message = invite_message
super()
end
def run_all_steps
expect_open
# STEP 1: Project and type
project_step
# STEP 2: User name
principal_step
# STEP 3: Role name
role_step
# STEP 4: Invite message
invitation_step unless placeholder?
# STEP 5: Confirmation screen
confirmation_step
# Step 6: Perform invite
click_modal_button 'Send Invitation'
if invite_user?
expect_text "Invite #{principal.mail} to #{project.name}"
else
expect_text "#{principal_name} was invited!"
end
text =
case principal
when User
"The user can now log in to access #{project.name}"
when PlaceholderUser
"The placeholder can now be used in #{project.name}"
when Group
"The group is now a part of #{project.name}"
else
raise ArgumentError, "Wrong type"
end
expect_text text
# Close
click_modal_button 'Continue'
expect_closed
end
def project_step(next_step: true)
expect_title 'Invite user'
autocomplete project.name
select_type type
click_next if next_step
end
def principal_step(next_step: true)
if invite_user?
autocomplete principal_name, select_text: "Invite: #{principal_name}"
else
autocomplete principal_name
end
click_next if next_step
end
def role_step(next_step: true)
autocomplete role.name
click_next if next_step
end
def invitation_step(next_step: true)
invitation_message invite_message
click_modal_button 'Review Invitation' if next_step
end
def confirmation_step
within_modal do
expect(page).to have_text project.name
expect(page).to have_text principal_name
expect(page).to have_text role.name
expect(page).to have_text invite_message unless placeholder?
end
end
def autocomplete(query, select_text: query)
select_autocomplete modal_element.find('.ng-select-container'),
query: query,
select_text: select_text,
results_selector: 'body'
end
@@ -45,10 +141,8 @@ module Components
end
end
def next
within_modal do
click_button 'Next'
end
def click_next
click_modal_button 'Next'
end
def invitation_message(text)
@@ -56,6 +150,25 @@ module Components
find('textarea').set text
end
end
def invite_user?
principal.invited?
end
def placeholder?
principal.is_a?(PlaceholderUser)
end
def principal_name
if invite_user?
principal.mail
else
principal.name
end
end
def type
principal.model_name.human
end
end
end
end
+3
View File
@@ -53,6 +53,9 @@ RSpec.configure do |config|
.with(k)
.and_return true
end
# Also disable banners to signal the frontend we're on EE
allow(EnterpriseToken).to receive(:show_banners?).and_return(allowed.empty?)
end
end
end