mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,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,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+1
-2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+25
-24
@@ -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>
|
||||
|
||||
+28
-32
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
+1
-1
@@ -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">
|
||||
|
||||
+27
-13
@@ -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'
|
||||
|
||||
@@ -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
-1
@@ -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 { }
|
||||
+3
-2
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user