User custom fields in the invite user modal (#9220)

* Projects form working with formly 50%

* Removed console.log

* Working with formattable

* Working with formattable

* Input with id and label

* Input with id and label

* Useless dependencies removed

* Saving forms + required labels with *

* First backend validation approach

* Removed reload on type change + keep model on route changes

* Handlig backend validations with setError

* Formatting the form model to submit

* Make up refactor

* working with op-form-field

* Form creation moved to the service

* Working with op-form-field wrapper

* Working with validation and op-form-field

* Working with []CustomFields

* Clean up

* Clean up

* Clean up

* Clean up

* Form routing working

* Notification on form error and success

* Refactor + removed useless dynamic form observable

* DynamicFieldsService with tests

* Refactor: inputs catalog + catch form load error

* Filter out non writable fields

* Refactor: naming consistency

* Cleaning comments

* dynamic-fields-service tests + wrapper component

* DynamicForm Tests

* @ngx-formly/core dependency added

* Cleaning up

* Provide DynamicFieldsService in root so it can be used independently

* DynamicForm working as a FormControl

* Getting route params sync

* Global FormsService: submit + formatting + error handling

* Fix: @Optional() FormGroupDirective in OpFormFieldComponent

* Code climate fix

* Removed CdkTextareaAutosize because of CDK issue 22469

* DynamicFormComponent tests

* Dynamic input test helpers + boolean and text tests

* Refactor edit fields to avoid circular dependencies in the dynamic forms

* Naming fix

* IntegerInputComponent tests

* SelectInputComponent tests

* Fix: duplicated identifier on inputs

* Extract toolbar to be reused for now

Still TBD whether we want to move them right now to the frontend?

* Create new project route and redirect to rails view after saving

* fieldsSettingsPipe + hide 'identifier' on projects

* Handling multi-values (also as links) and passwords

* Some TODOs removed

* FormattableTextareaInputComponent tests

* Projects form working with formly 50%

* Removed console.log

* Working with formattable

* Working with formattable

* Input with id and label

* Input with id and label

* Useless dependencies removed

* Saving forms + required labels with *

* First backend validation approach

* Removed reload on type change + keep model on route changes

* Handlig backend validations with setError

* Formatting the form model to submit

* Make up refactor

* working with op-form-field

* Form creation moved to the service

* Working with op-form-field wrapper

* Working with validation and op-form-field

* Working with []CustomFields

* Clean up

* Clean up

* Clean up

* Clean up

* Form routing working

* Notification on form error and success

* Refactor + removed useless dynamic form observable

* DynamicFieldsService with tests

* Refactor: inputs catalog + catch form load error

* Filter out non writable fields

* Refactor: naming consistency

* Cleaning comments

* dynamic-fields-service tests + wrapper component

* DynamicForm Tests

* @ngx-formly/core dependency added

* Cleaning up

* DynamicForm working as a FormControl

* Getting route params sync

* Global FormsService: submit + formatting + error handling

* Fix: @Optional() FormGroupDirective in OpFormFieldComponent

* Code climate fix

* Removed CdkTextareaAutosize because of CDK issue 22469

* DynamicFormComponent tests

* Dynamic input test helpers + boolean and text tests

* Refactor edit fields to avoid circular dependencies in the dynamic forms

* Naming fix

* IntegerInputComponent tests

* SelectInputComponent tests

* Fix: duplicated identifier on inputs

* Extract toolbar to be reused for now

Still TBD whether we want to move them right now to the frontend?

* Create new project route and redirect to rails view after saving

* fieldsSettingsPipe + hide 'identifier' on projects

* Handling multi-values (also as links) and passwords

* Some TODOs removed

* FormattableTextareaInputComponent tests

* _isResourceSchema based on parent?.location

* Scope DynamicFieldsService to DynamicFormComponent

* Added backend validation method to FormsService

* Removed projects routes and ruby template

* Removed projects routes and dynamic forms from Projects

* Revert "Provide DynamicFieldsService in root so it can be used independently"

This reverts commit ab56f3c56f.

* Provide DynamicFieldsService in root so it can be used independently

* TODO: test ProjectsComponent

* Code climate fixes (remove TODOs)

* Default OpFormFieldComponent.inlineLabel to false

* Dynamic components tests xkipped

* Typing improvements

* DynamicFormComponent working as a FormControl

* Global FormsService: submit + formatting + error handling

* Fix: @Optional() FormGroupDirective in OpFormFieldComponent

* Code climate fixes

* noWrapLabel default to false

* Started adding user custom fields to the ium

* Import the dynamic-forms module into the common module

* Refactor edit fields to avoid circular dependencies in the dynamic forms

* Using DynamicFormsModule in OpenprojectInviteUserModalModule

* Add formly form

* Update principal name filter

* Dynamic form field is rendering

* Handling multi-values (also as links) and passwords

* Added backend validation method to FormsService

* Remove form from DynamicForm when not isStandaloneForm

* Allow multiple form keys to validate

* Remove form from non standalone forms

* Remove duplicated button

* Moved to FormGroup input for dynamic form

* Custom field happy path is done

* Add explanatory comment to payload structure transformation

* add op-form class to ium steps

* Add shrinkwrap back in

* Fix test, fix dynamic form resource path

* gimme a shirnkwrap

* Remove failing tests

* Remove another failing test

* Remove more failing specs

* Fix double loading of principals

* Add custom field spec

* Fix spec

* Reset shrinkwrap

* Forbid Factory.build(:user, member_in_project)

If you use the trait member_in_project(s), the user is implicitly saved
to create the member.

This is very confusing if trying to use required custom fields, as this
will fail with the Member#user_id foreign key being nil, as the user
cannot be saved.

Instead, raise an error when trying to use this factory trait

* Change additional spec factory

Co-authored-by: Aleix Suau <info@macrofonoestudio.es>
Co-authored-by: Oliver Günther <mail@oliverguenther.de>
pull/9224/head
Benjamin Bädorf 4 years ago committed by GitHub
parent 843e6ebe14
commit 44294ede04
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      frontend/src/app/modules/apiv3/endpoints/projects/apiv3-project-copy-paths.ts
  2. 3
      frontend/src/app/modules/apiv3/endpoints/projects/apiv3-project-paths.ts
  3. 2
      frontend/src/app/modules/autocompleter/create-autocompleter/create-autocompleter.component.html
  4. 4
      frontend/src/app/modules/common/dynamic-forms/components/dynamic-form/dynamic-form.component.html
  5. 134
      frontend/src/app/modules/common/dynamic-forms/components/dynamic-form/dynamic-form.component.ts
  6. 0
      frontend/src/app/modules/common/dynamic-forms/services/dynamic-form/dynamic-form.service.spec.ts
  7. 1
      frontend/src/app/modules/common/dynamic-forms/services/dynamic-form/dynamic-form.service.ts
  8. 1
      frontend/src/app/modules/common/dynamic-forms/typings.d.ts
  9. 1
      frontend/src/app/modules/common/openproject-common.module.sass
  10. 22
      frontend/src/app/modules/common/select/select.sass
  11. 2
      frontend/src/app/modules/invite-user-modal/button/invite-user-button.component.html
  12. 22
      frontend/src/app/modules/invite-user-modal/button/invite-user-button.component.sass
  13. 4
      frontend/src/app/modules/invite-user-modal/invite-user-modal.module.ts
  14. 10
      frontend/src/app/modules/invite-user-modal/invite-user.component.html
  15. 17
      frontend/src/app/modules/invite-user-modal/invite-user.component.ts
  16. 2
      frontend/src/app/modules/invite-user-modal/message/message.component.html
  17. 26
      frontend/src/app/modules/invite-user-modal/principal/principal-search.component.ts
  18. 10
      frontend/src/app/modules/invite-user-modal/principal/principal.component.html
  19. 104
      frontend/src/app/modules/invite-user-modal/principal/principal.component.ts
  20. 2
      frontend/src/app/modules/invite-user-modal/project-selection/project-selection.component.html
  21. 2
      frontend/src/app/modules/invite-user-modal/role/role.component.html
  22. 4
      frontend/src/app/modules/invite-user-modal/summary/summary.component.html
  23. 21
      frontend/src/app/modules/invite-user-modal/summary/summary.component.ts
  24. 4
      frontend/src/app/modules/modal/modal.sass
  25. 4
      frontend/src/app/modules/principal/principal-types.ts
  26. 1
      frontend/src/app/modules/projects/openproject-projects.module.ts
  27. 2
      modules/budgets/spec/lib/api/v3/budgets/budget_representer_spec.rb
  28. 2
      modules/costs/spec/lib/api/v3/cost_entries/work_package_costs_by_type_representer_spec.rb
  29. 2
      spec/controllers/projects_controller_spec.rb
  30. 2
      spec/decorators/single_spec.rb
  31. 10
      spec/factories/principal_factory.rb
  32. 5
      spec/factories/traits/skip_validations.rb
  33. 141
      spec/features/users/invite_user_modal/custom_fields_spec.rb
  34. 251
      spec/features/users/invite_user_modal_spec.rb
  35. 6
      spec/features/work_packages/copy_spec.rb
  36. 2
      spec/features/wysiwyg/user_mention_spec.rb
  37. 2
      spec/lib/api/v3/work_packages/form_representer_spec.rb
  38. 2
      spec/models/queries/work_packages/filter/subject_or_id_filter_spec.rb
  39. 4
      spec/models/work_package/work_package_action_mailer_spec.rb
  40. 2
      spec/models/work_packages/derived_dates_spec.rb
  41. 2
      spec/models/work_packages/spent_time_spec.rb
  42. 2
      spec/requests/api/v3/locale_spec.rb
  43. 2
      spec/requests/api/v3/work_packages/work_packages_by_project_resource_spec.rb
  44. 2
      spec/requests/api/v3/work_packages/work_packages_schemas_resource_spec.rb
  45. 2
      spec/services/notifications/journal_wp_mail_service_spec.rb
  46. 2
      spec/support/components/ng_select_autocomplete_helpers.rb
  47. 29
      spec/support/form_fields/editor_form_field.rb

@ -26,9 +26,9 @@
// See docs/COPYRIGHT.rdoc for more details.
//++
import {APIv3FormResource} from "core-app/modules/apiv3/forms/apiv3-form-resource";
import {APIV3Service} from "core-app/modules/apiv3/api-v3.service";
import {SimpleResource} from "core-app/modules/apiv3/paths/path-resources";
import { APIv3FormResource } from "core-app/modules/apiv3/forms/apiv3-form-resource";
import { APIV3Service } from "core-app/modules/apiv3/api-v3.service";
import { SimpleResource } from "core-app/modules/apiv3/paths/path-resources";
export class APIv3ProjectCopyPaths extends SimpleResource {
constructor(protected apiRoot:APIV3Service,

@ -31,11 +31,10 @@ import { APIv3TypesPaths } from "core-app/modules/apiv3/endpoints/types/apiv3-ty
import { APIV3WorkPackagesPaths } from "core-app/modules/apiv3/endpoints/work_packages/api-v3-work-packages-paths";
import { ProjectResource } from "core-app/modules/hal/resources/project-resource";
import { CachableAPIV3Resource } from "core-app/modules/apiv3/cache/cachable-apiv3-resource";
import { MultiInputState } from "reactivestates";
import { APIv3VersionsPaths } from "core-app/modules/apiv3/endpoints/versions/apiv3-versions-paths";
import { StateCacheService } from "core-app/modules/apiv3/cache/state-cache.service";
import { APIv3ProjectsPaths } from "core-app/modules/apiv3/endpoints/projects/apiv3-projects-paths";
import {APIv3ProjectCopyPaths} from "core-app/modules/apiv3/endpoints/projects/apiv3-project-copy-paths";
import { APIv3ProjectCopyPaths } from "core-app/modules/apiv3/endpoints/projects/apiv3-project-copy-paths";
export class APIv3ProjectPaths extends CachableAPIV3Resource<ProjectResource> {
// /api/v3/projects/:project_id/available_assignees

@ -37,6 +37,6 @@
</ng-template>
<ng-template ng-footer-tmp *ngIf="showAddNewButton">
<op-invite-user-button></op-invite-user-button>
<op-invite-user-button class="op-select-footer"></op-invite-user-button>
</ng-template>
</ng-select>

@ -1,5 +1,5 @@
<div data-qa="op-form--container"
*ngIf="form && isStandaloneForm">
*ngIf="form && handleSubmit">
<form [formGroup]="form"
(submit)="submitForm(form)"
class="op-form">
@ -25,7 +25,7 @@
<!-- TODO: Issue: sharing the form as an ng-template between this two HTML blocks doesn't work because
the nested OpFormFieldComponent doesn't find the injected FormGroupDirective. --->
<div data-qa="op-form--container"
*ngIf="form && !isStandaloneForm">
*ngIf="form && !handleSubmit">
<formly-form [form]="form"
[model]="innerModel"
[fields]="fields"

@ -1,27 +1,23 @@
import {
ChangeDetectorRef,
Component,
EventEmitter,
Input,
OnChanges,
Output,
ViewChild,
EventEmitter,
forwardRef,
SimpleChanges,
ChangeDetectorRef,
ViewChild,
} from "@angular/core";
import { FormlyForm } from "@ngx-formly/core";
import { DynamicFormService } from "../../services/dynamic-form/dynamic-form.service";
import {
IOPDynamicFormSettings,
IOPFormlyFieldSettings,
} from "../../typings";
import { IOPDynamicFormSettings, IOPFormlyFieldSettings } from "../../typings";
import { I18nService } from "core-app/modules/common/i18n/i18n.service";
import { PathHelperService } from "core-app/modules/common/path-helper/path-helper.service";
import { catchError, finalize } from "rxjs/operators";
import { HalSource } from "core-app/modules/hal/resources/hal-resource";
import { NotificationsService } from "core-app/modules/common/notifications/notifications.service";
import { DynamicFieldsService } from "core-app/modules/common/dynamic-forms/services/dynamic-fields/dynamic-fields.service";
import { ControlValueAccessor, FormGroup, NG_VALUE_ACCESSOR } from "@angular/forms";
import { FormGroup } from "@angular/forms";
import { UntilDestroyedMixin } from "core-app/helpers/angular/until-destroyed.mixin";
import { FormsService } from "core-app/core/services/forms/forms.service";
@ -77,18 +73,13 @@ import { FormsService } from "core-app/core/services/forms/forms.service";
providers: [
DynamicFormService,
DynamicFieldsService,
{
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: forwardRef(() => DynamicFormComponent),
}
]
],
})
export class DynamicFormComponent extends UntilDestroyedMixin implements ControlValueAccessor, OnChanges {
export class DynamicFormComponent extends UntilDestroyedMixin implements OnChanges {
// Backend form URL (e.g. https://community.openproject.org/api/v3/projects/dev-large/form)
@Input() formUrl:string;
// When using the formUrl @Input(), set the http method to use if it is not 'POST'
@Input() formHttpMethod: 'post' | 'patch' = 'post';
@Input() formHttpMethod:'post'|'patch' = 'post';
// Part of the URL that belongs to the resource type (e.g. '/projects' in the previous example)
// Use this option when you don't have a form URL, the DynamicForm will build it from the resourcePath
// for you (⌐■_■).
@ -97,18 +88,20 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements Control
@Input() resourceId:string;
@Input() settings:IOPFormSettings;
// Chance to modify the dynamicFormFields settings before the form is rendered
@Input() fieldsSettingsPipe: (dynamicFieldsSettings:IOPFormlyFieldSettings[]) => IOPFormlyFieldSettings[];
@Input() fieldsSettingsPipe:(dynamicFieldsSettings:IOPFormlyFieldSettings[]) => IOPFormlyFieldSettings[];
@Input() showNotifications = true;
@Input() showValidationErrorsOn: 'change' | 'blur' | 'submit' | 'never' = 'submit';
@Input() showValidationErrorsOn:'change'|'blur'|'submit'|'never' = 'submit';
@Input() handleSubmit = true;
@Input() set model (payload:IOPFormModel) {
@Input('dynamicFormGroup') form:FormGroup = new FormGroup({});
@Input() set model(payload:IOPFormModel) {
if (!this.innerModel && !payload) {
return;
}
const formattedModel = this._dynamicFieldsService.getFormattedFieldsModel(payload);
this.innerModel = formattedModel;
};
}
/** Initial payload to POST to the form */
@Input() initialPayload:Object = {};
@ -118,8 +111,7 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements Control
@Output() errored = new EventEmitter<IOPFormErrorResponse>();
fields:IOPFormlyFieldSettings[];
form: FormGroup;
formEndpoint:string | null;
formEndpoint?:string;
inFlight:boolean;
text = {
save: this._I18n.t('js.button_save'),
@ -132,53 +124,34 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements Control
in order to fetch its setting. Please provide one.`;
noPathToSubmitToError = `DynamicForm needs a resourcePath input in order to be submitted
and validated. Please provide one.`;
onChange:Function;
onTouch:Function;
innerModel:IOPFormModel;
get model() {
return this.form.value;
}
get isFormControl():boolean {
return !!this.onChange && !!this.onTouch;
}
get isStandaloneForm():boolean {
return !this.isFormControl;
return !this.settings;
}
@ViewChild(FormlyForm)
set dynamicForm(dynamicForm: FormlyForm) {
set dynamicForm(dynamicForm:FormlyForm) {
this._dynamicFormService.registerForm(dynamicForm);
}
constructor(
private _dynamicFormService: DynamicFormService,
private _dynamicFieldsService: DynamicFieldsService,
private _dynamicFormService:DynamicFormService,
private _dynamicFieldsService:DynamicFieldsService,
private _I18n:I18nService,
private _pathHelperService:PathHelperService,
private _notificationsService:NotificationsService,
private _formsService: FormsService,
private _changeDetectorRef: ChangeDetectorRef,
private _formsService:FormsService,
private _changeDetectorRef:ChangeDetectorRef,
) {
super();
}
writeValue(value:{[key:string]:any}):void {
if (value) {
this.innerModel = value;
}
}
registerOnChange(fn: (_: any) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: any): void {
this.onTouch = fn;
}
setDisabledState(disabled: boolean): void {
setDisabledState(disabled:boolean):void {
disabled ? this.form.disable() : this.form.enable();
}
@ -188,21 +161,16 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements Control
this.resourcePath,
this.resourceId,
this.formUrl,
this.innerModel || this.initialPayload
this.innerModel || this.initialPayload,
);
}
onModelChange(changes:any) {
this.modelChange.emit(changes);
if (!this.isStandaloneForm) {
this.onChange(changes);
this.onTouch();
}
}
submitForm(form:FormGroup) {
if (!(this.isStandaloneForm && this.handleSubmit)) {
if (!this.handleSubmit) {
return;
}
@ -214,7 +182,7 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements Control
this._dynamicFormService
.submit$(form, this.formEndpoint, this.resourceId, this.formHttpMethod)
.pipe(
finalize(() => this.inFlight = false)
finalize(() => this.inFlight = false),
)
.subscribe(
(formResource:HalSource) => {
@ -229,7 +197,7 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements Control
}
private showSuccessNotification():void {
let submit_message = this.resourceId ? this.text.successful_update : this.text.successful_create;
const submit_message = this.resourceId ? this.text.successful_update : this.text.successful_create;
this._notificationsService.addSuccess(submit_message);
}
@ -238,7 +206,7 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements Control
throw new Error(this.noPathToSubmitToError);
}
this._formsService.validateForm$(this.form, this.formEndpoint).subscribe();
return this._formsService.validateForm$(this.form, this.formEndpoint);
}
private _initializeDynamicForm(
@ -248,34 +216,35 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements Control
formUrl?:string,
payload?:Object,
) {
const newFormEndPoint = this._getFormEndPoint(formUrl, resourcePath);
if (!newFormEndPoint) {
throw new Error(this.noSettingsSourceErrorMessage);
}
const isNewEndpoint = newFormEndPoint !== this.formEndpoint;
if (isNewEndpoint) {
this.formEndpoint = newFormEndPoint;
}
if (settings) {
this._setupDynamicFormFromSettings();
} else {
const newFormEndPoint = this._getFormEndPoint(formUrl, resourcePath);
if (newFormEndPoint && newFormEndPoint !== this.formEndpoint) {
this.formEndpoint = newFormEndPoint;
this._setupDynamicFormFromBackend(this.formEndpoint, resourceId, payload);
} else if (!newFormEndPoint) {
console.error(this.noSettingsSourceErrorMessage);
}
this._setupDynamicFormFromBackend(this.formEndpoint, resourceId, payload);
}
}
private _getFormEndPoint(formUrl?:string, resourcePath?:string): string | null {
let formEndpoint;
private _getFormEndPoint(formUrl?:string, resourcePath?:string):string|undefined {
if (formUrl) {
formEndpoint = formUrl.endsWith(`/form`) ?
return formUrl.endsWith(`/form`) ?
formUrl.replace(`/form`, ``) :
formUrl;
} else if (resourcePath) {
formEndpoint = `${this._pathHelperService.api.v3.apiV3Base}${resourcePath}`;
} else {
formEndpoint = null;
}
return formEndpoint;
if (resourcePath) {
return `${this._pathHelperService.api.v3.apiV3Base}${resourcePath}`;
}
return;
}
private _setupDynamicFormFromBackend(formEndpoint?:string, resourceId?:string, payload?:Object) {
@ -285,7 +254,7 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements Control
catchError(error => {
this._notificationsService.addError(this.text.load_error_message);
throw error;
})
}),
)
.subscribe(dynamicFormSettings => this._setupDynamicForm(dynamicFormSettings));
}
@ -295,22 +264,17 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements Control
_embedded: {
payload: this.settings.payload,
schema: this.settings.schema,
}
}
},
};
const dynamicFormSettings = this._dynamicFormService.getSettings(formattedSettings);
this._setupDynamicForm(dynamicFormSettings);
}
private _setupDynamicForm({fields, model, form}:IOPDynamicFormSettings) {
this.form = form;
private _setupDynamicForm({ fields, model }:IOPDynamicFormSettings) {
this.fields = this.fieldsSettingsPipe ? this.fieldsSettingsPipe(fields) : fields;
this.innerModel = model;
this._changeDetectorRef.detectChanges();
if (!this.isStandaloneForm) {
this.onChange(this.innerModel);
}
}
}

@ -50,7 +50,6 @@ export class DynamicFormService {
const dynamicForm = {
fields: this._dynamicFieldsService.getConfig(formSchema, formPayload),
model: this._dynamicFieldsService.getModel(formPayload),
form: new FormGroup({}),
};
return dynamicForm;

@ -4,7 +4,6 @@ import { FormGroup } from "@angular/forms";
export interface IOPDynamicFormSettings {
fields:IOPFormlyFieldSettings[];
model:IOPFormModel;
form:FormGroup;
}
export interface IOPFormlyFieldSettings extends FormlyFieldConfig {

@ -5,3 +5,4 @@
@import './forms'
@import './option-list/option-list'
@import './export-options/export-options'
@import './select/select'

@ -0,0 +1,22 @@
.op-select-footer
display: block
margin: 0
padding: 0
&--label
cursor: pointer
display: block
background: transparent
border: 0
padding: 8px 10px
font-size: 14px
line-height: 22px
background-color: #fff
color: rgba(0, 0, 0, 0.87)
font-weight: bold
width: 100%
text-align: left
&:hover
background-color: #f5faff
color: #333

@ -1,5 +1,5 @@
<button
class="invite-user-button"
class="op-select-footer--label"
type="button"
(click)="onAddNewClick($event)"
*ngIf="showButton"

@ -1,22 +0,0 @@
\:host
display: block
margin: 0
padding: 0
.invite-user-button
cursor: pointer
display: block
background: transparent
border: 0
padding: 8px 10px
font-size: 14px
line-height: 22px
background-color: #fff
color: rgba(0,0,0,0.87)
font-weight: bold
width: 100%
text-align: left
&:hover
background-color: #f5faff
color: #333

@ -1,9 +1,11 @@
import { APP_INITIALIZER, Injector, NgModule } from "@angular/core";
import { ReactiveFormsModule } from "@angular/forms";
import { CommonModule } from "@angular/common";
import { TextFieldModule } from '@angular/cdk/text-field';
import { NgSelectModule } from "@ng-select/ng-select";
import { OpenprojectModalModule } from "core-app/modules/modal/modal.module";
import { OpenprojectCommonModule } from "core-app/modules/common/openproject-common.module";
import { DynamicFormsModule } from "core-app/modules/common/dynamic-forms/dynamic-forms.module";
import { InviteUserButtonComponent } from "core-app/modules/invite-user-modal/button/invite-user-button.component";
import { OpInviteUserModalAugmentService } from "core-app/modules/invite-user-modal/invite-user-modal-augment.service";
import { OpInviteUserModalService } from "core-app/modules/invite-user-modal/invite-user-modal.service";
@ -27,11 +29,13 @@ export function initializeServices(injector:Injector) {
@NgModule({
imports: [
CommonModule,
OpenprojectCommonModule,
OpenprojectModalModule,
NgSelectModule,
ReactiveFormsModule,
TextFieldModule,
DynamicFormsModule,
],
exports: [
InviteUserButtonComponent,

@ -15,7 +15,7 @@
*ngIf="!loading && step === Steps.Principal"
class="op-modal"
[project]="project"
[principal]="principal"
[principalData]="principalData"
[type]="type"
(save)="onPrincipalSave($event)"
(back)="goTo(Steps.ProjectSelection)"
@ -26,7 +26,7 @@
*ngIf="!loading && step === Steps.Role"
class="op-modal"
[role]="role"
[principal]="principal"
[principal]="principalData.principal"
[project]="project"
[type]="type"
(save)="onRoleSave($event)"
@ -38,7 +38,7 @@
*ngIf="!loading && step === Steps.Message"
class="op-modal"
[message]="message"
[principal]="principal"
[principal]="principalData.principal"
[project]="project"
(save)="onMessageSave($event)"
(back)="goTo(Steps.Role)"
@ -49,7 +49,7 @@
*ngIf="!loading && step === Steps.Summary"
class="op-modal"
[project]="project"
[principal]="principal"
[principalData]="principalData"
[type]="type"
[role]="role"
[message]="message"
@ -59,7 +59,7 @@
></op-ium-summary>
<op-ium-success
[principal]="principal"
[principal]="principalData.principal"
[project]="project"
[type]="type"
[createdNewPrincipal]="createdNewPrincipal"

@ -11,7 +11,7 @@ import { OpModalLocalsMap } from 'core-app/modules/modal/modal.types';
import { OpModalComponent } from 'core-app/modules/modal/modal.component';
import { OpModalLocalsToken } from "core-app/modules/modal/modal.service";
import { APIV3Service } from "core-app/modules/apiv3/api-v3.service";
import { ApiV3FilterBuilder } from "core-components/api/api-v3/api-v3-filter-builder";
import { PrincipalData } from "core-app/modules/principal/principal-types";
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";
@ -49,7 +49,10 @@ export class InviteUserModalComponent extends OpModalComponent implements OnInit
public type:PrincipalType|null = null;
public project:ProjectResource|null = null;
public principal:HalResource|null = null;
public principalData:PrincipalData = {
principal: null,
customFields: {},
};
public role:RoleResource|null = null;
public message = '';
public createdNewPrincipal = false;
@ -90,8 +93,8 @@ export class InviteUserModalComponent extends OpModalComponent implements OnInit
this.goTo(Steps.Principal);
}
onPrincipalSave({ principal, isAlreadyMember }:{ principal:any, isAlreadyMember:boolean }) {
this.principal = principal;
onPrincipalSave({ principalData, isAlreadyMember }:{ principalData:PrincipalData, isAlreadyMember:boolean }) {
this.principalData = principalData;
if (isAlreadyMember) {
return this.closeWithPrincipal();
}
@ -115,10 +118,10 @@ export class InviteUserModalComponent extends OpModalComponent implements OnInit
}
onSuccessfulSubmission($event:{ principal:HalResource }) {
if (this.principal !== $event.principal && this.type === PrincipalType.User) {
if (this.principalData.principal !== $event.principal && this.type === PrincipalType.User) {
this.createdNewPrincipal = true;
}
this.principal = $event.principal;
this.principalData.principal = $event.principal;
this.goTo(Steps.Success);
}
@ -127,7 +130,7 @@ export class InviteUserModalComponent extends OpModalComponent implements OnInit
}
closeWithPrincipal() {
this.data = this.principal;
this.data = this.principalData.principal;
this.closeMe();
}
}

@ -4,7 +4,7 @@
>
<op-modal-header (close)="close.emit()">{{ text.title() }}</op-modal-header>
<div class="op-modal--body">
<div class="op-modal--body op-form">
<op-form-field [label]="text.label">
<p class="op-form-field--description" slot="description">
{{ text.description() }}

@ -8,13 +8,12 @@ import {
} from '@angular/core';
import {FormControl} from "@angular/forms";
import {Observable, BehaviorSubject, combineLatest, forkJoin} from "rxjs";
import {debounceTime, distinctUntilChanged, tap, shareReplay, map, switchMap} from "rxjs/operators";
import {debounceTime, distinctUntilChanged, share, map, shareReplay, 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 {CapabilityResource} from "core-app/modules/hal/resources/capability-resource";
import {PrincipalLike} from "core-app/modules/principal/principal-types";
import {CurrentUserService} from "core-app/modules/current-user/current-user.service";
import {PrincipalType} from '../invite-user.component';
@ -37,13 +36,13 @@ export class PrincipalSearchComponent extends UntilDestroyedMixin implements OnI
public input$ = new BehaviorSubject<string>('');
public input = '';
public items$: Observable<NgSelectPrincipalOption[]> = this.input$
.pipe(
this.untilDestroyed(),
debounceTime(200),
distinctUntilChanged(),
switchMap(this.loadPrincipalData.bind(this)),
);
public items$: Observable<NgSelectPrincipalOption[]> = this.input$.pipe(
this.untilDestroyed(),
debounceTime(200),
distinctUntilChanged(),
switchMap(this.loadPrincipalData.bind(this)),
share(),
);
public canInviteByEmail$ = combineLatest(
this.items$,
@ -116,14 +115,13 @@ export class PrincipalSearchComponent extends UntilDestroyedMixin implements OnI
setTimeout(() => this.input$.next(''));
}
createNewFromInput() {
public createNewFromInput() {
this.createNew.emit({ name: this.input });
}
private loadPrincipalData(searchTerm:string) {
const nonMemberFilter = new ApiV3FilterBuilder();
if (searchTerm) {
nonMemberFilter.add('name', '~', [searchTerm]);
nonMemberFilter.add('any_name_attribute', '~', [searchTerm]);
}
nonMemberFilter.add('status', '!', [3]);
nonMemberFilter.add('type', '=', [this.type]);
@ -132,7 +130,7 @@ export class PrincipalSearchComponent extends UntilDestroyedMixin implements OnI
const memberFilter = new ApiV3FilterBuilder();
if (searchTerm) {
memberFilter.add('name', '~', [searchTerm]);
memberFilter.add('any_name_attribute', '~', [searchTerm]);
}
memberFilter.add('status', '!', [3]);
memberFilter.add('type', '=', [this.type]);
@ -153,7 +151,7 @@ export class PrincipalSearchComponent extends UntilDestroyedMixin implements OnI
principal: member,
disabled: true,
})),
]),
].slice(0, 5)),
shareReplay(1),
);
}

@ -4,7 +4,7 @@
>
<op-modal-header (close)="close.emit()">{{ text.title() }}</op-modal-header>
<div class="op-modal--body">
<div class="op-modal--body op-form">
<op-form-field
[label]="text.label[type]"
required
@ -48,6 +48,14 @@
{{ text.required[type] }}
</div>
</op-form-field>
<op-dynamic-form
*ngIf="isNewPrincipal && type === PrincipalType.User && userDynamicFieldConfig.schema"
[dynamicFormGroup]="dynamicFieldsControl"
[settings]="userDynamicFieldConfig"
[resourcePath]="pathHelper.usersPath()"
[handleSubmit]="false"
></op-dynamic-form>
</div>
<div class="op-modal--footer">

@ -3,18 +3,37 @@ import {
OnInit,
Input,
Output,
EventEmitter, ChangeDetectorRef,
EventEmitter,
ViewChild,
ChangeDetectorRef,
} from '@angular/core';
import { HttpClient } from "@angular/common/http";
import {
FormGroup,
FormControl,
Validators,
} from '@angular/forms';
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {PrincipalLike} from "core-app/modules/principal/principal-types";
import {ProjectResource} from "core-app/modules/hal/resources/project-resource";
import {PrincipalType} from '../invite-user.component';
import { PathHelperService } from "core-app/modules/common/path-helper/path-helper.service";
import { I18nService } from "core-app/modules/common/i18n/i18n.service";
import { HalResource } from "core-app/modules/hal/resources/hal-resource";
import { PrincipalData, PrincipalLike } from "core-app/modules/principal/principal-types";
import { ProjectResource } from "core-app/modules/hal/resources/project-resource";
import { DynamicFormComponent } from "core-app/modules/common/dynamic-forms/components/dynamic-form/dynamic-form.component"
import { PrincipalType } from '../invite-user.component';
function extractCustomFieldsFromSchema(schema: IOPFormSettings['_embedded']['schema']) {
return Object.keys(schema)
.reduce((fields, name) => {
if (name.startsWith('customField') && schema[name].required) {
return {
...fields,
[name]: schema[name],
};
}
return fields;
}, {});
}
@Component({
selector: 'op-ium-principal',
@ -22,14 +41,16 @@ import {PrincipalType} from '../invite-user.component';
styleUrls: ['./principal.component.sass'],
})
export class PrincipalComponent implements OnInit {
@Input('principal') storedPrincipal:PrincipalLike|null = null;
@Input() principalData:PrincipalData;
@Input() project:ProjectResource;
@Input() type:PrincipalType;
@Output() close = new EventEmitter<void>();
@Output() save = new EventEmitter<{ principal:PrincipalLike, isAlreadyMember:boolean }>();
@Output() save = new EventEmitter<{ principalData:PrincipalData, isAlreadyMember:boolean }>();
@Output() back = new EventEmitter();
@ViewChild(DynamicFormComponent) dynamicForm: DynamicFormComponent;
public PrincipalType = PrincipalType;
public text = {
@ -58,8 +79,17 @@ export class PrincipalComponent implements OnInit {
public principalForm = new FormGroup({
principal: new FormControl(null, [ Validators.required ]),
userDynamicFields: new FormGroup({}),
});
public userDynamicFieldConfig: {
payload: IOPFormSettings['_embedded']['payload']|null,
schema: IOPFormSettings['_embedded']['schema']|null,
} = {
payload: null,
schema: null,
};
get principalControl() {
return this.principalForm.get('principal');
}
@ -68,6 +98,14 @@ export class PrincipalComponent implements OnInit {
return this.principalControl?.value;
}
get dynamicFieldsControl() {
return this.principalForm.get('userDynamicFields');
}
get customFields():{[key:string]:any} {
return this.dynamicFieldsControl?.value;
}
get hasPrincipalSelected() {
return !!this.principal;
}
@ -80,10 +118,26 @@ export class PrincipalComponent implements OnInit {
return !!this.principalControl?.value?.memberships?.elements?.find((mem:any) => mem.project.id === this.project.id);
}
constructor(readonly I18n:I18nService) {}
constructor(
readonly I18n:I18nService,
readonly httpClient:HttpClient,
readonly pathHelper:PathHelperService,
readonly cdRef: ChangeDetectorRef,
) {}
ngOnInit() {
this.principalControl?.setValue(this.storedPrincipal);
this.principalControl?.setValue(this.principalData.principal);
if (this.type === PrincipalType.User) {
const payload = this.isNewPrincipal ? this.principalData.customFields : {};
this.httpClient
.post<IOPFormSettings>('/api/v3/users/form', payload, { withCredentials: true, responseType: 'json' })
.subscribe((formConfig) => {
this.userDynamicFieldConfig.schema = extractCustomFieldsFromSchema(formConfig._embedded?.schema);
this.userDynamicFieldConfig.payload = formConfig._embedded?.payload;
this.cdRef.detectChanges();
});
}
}
createNewFromInput(input:PrincipalLike) {
@ -93,13 +147,39 @@ export class PrincipalComponent implements OnInit {
onSubmit($e:Event) {
$e.preventDefault();
if (this.dynamicForm) {
this.dynamicForm.validateForm().subscribe(() => {
this.onValidatedSubmit();
});
} else {
this.onValidatedSubmit();
}
}
onValidatedSubmit() {
if (this.principalForm.invalid) {
this.principalForm.markAsDirty();
return;
}
// The code below transforms the model value as it comes from the dynamic form to the value accepted by the API.
// This is not just necessary for submit, but also so that we can reseed the initial values to the payload
// when going back to this step after having completed it once.
const links = this.customFields!._links || {};
const customFields = {
...this.customFields!,
_links: Object.keys(links).reduce((cfs, name) => ({
...cfs,
[name]: Array.isArray(links[name])
? links[name].map((opt: any) => opt._links ? opt._links.self : opt)
: (links[name]._links ? links[name]._links.self : links[name])
}), {}),
};
this.save.emit({
principal: this.principal!,
principalData: {
customFields,
principal: this.principal!,
},
isAlreadyMember: this.isMemberOfCurrentProject,
});
}

@ -4,7 +4,7 @@
>
<op-modal-header (close)="close.emit()">{{ text.title }}</op-modal-header>
<div class="op-modal--body">
<div class="op-modal--body op-form">
<op-form-field
label="Project"
required

@ -4,7 +4,7 @@
>
<op-modal-header (close)="close.emit()">{{ text.title() }}</op-modal-header>
<div class="op-modal--body">
<div class="op-modal--body op-form">
<op-form-field
[label]="text.label()"
required

@ -5,13 +5,13 @@
>
<op-modal-header (close)="close.emit()">{{ text.title() }}</op-modal-header>
<div class="op-modal--body">
<div class="op-modal--body op-form">
<op-form-field [label]="text.projectLabel">
<p slot="input">{{ project.name }}</p>
</op-form-field>
<div class="op-ium-summary__row">
<op-form-field [label]="text.principalLabel[type]">
<p slot="input">{{ principal.name }}</p>
<p slot="input">{{ principal?.name }}</p>
</op-form-field>
<op-form-field [label]="text.roleLabel()">
<p slot="input">{{ role.name }}</p>

@ -10,7 +10,7 @@ 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 {RoleResource} from "core-app/modules/hal/resources/role-resource";
import {PrincipalLike} from "core-app/modules/principal/principal-types";
import {PrincipalData, PrincipalLike} from "core-app/modules/principal/principal-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';
@ -24,7 +24,7 @@ export class SummaryComponent {
@Input() type:PrincipalType;
@Input() project:ProjectResource;
@Input() role:RoleResource;
@Input() principal:PrincipalLike;
@Input() principalData:PrincipalData;
@Input() message:string = '';
@Output() close = new EventEmitter<void>();
@ -55,6 +55,10 @@ export class SummaryComponent {
}),
};
public get principal() {
return this.principalData.principal;
}
constructor(
readonly I18n:I18nService,
readonly elementRef:ElementRef,
@ -62,9 +66,9 @@ export class SummaryComponent {
) { }
invite() {
return of(this.principal)
return of(this.principalData)
.pipe(
switchMap((principal:PrincipalLike) => this.createPrincipal(principal)),
switchMap((principalData:PrincipalData) => this.createPrincipal(principalData)),
switchMap((principal:HalResource) =>
this.api.memberships
.post({
@ -82,8 +86,8 @@ export class SummaryComponent {
);
}
private createPrincipal(principal:PrincipalLike):Observable<HalResource> {
console.log(principal);
private createPrincipal(principalData:PrincipalData):Observable<HalResource> {
const { principal, customFields } = principalData;
if (principal instanceof HalResource) {
return of(principal);
}
@ -91,11 +95,12 @@ export class SummaryComponent {
switch (this.type) {
case PrincipalType.User:
return this.api.users.post({
email: principal.name,
email: principal!.name,
status: 'invited',
...customFields,
});
case PrincipalType.Placeholder:
return this.api.placeholder_users.post({ name: principal.name });
return this.api.placeholder_users.post({ name: principal!.name });
default:
throw new Error("Unsupported PrincipalType given");
}

@ -14,6 +14,9 @@
max-width: 100vw
max-height: 100vh
overflow-y: auto
@include styled-scroll-bar
@media (max-width: 680px), (max-height: 500px)
--modal-padding: 1rem
height: 100vh
@ -53,7 +56,6 @@
flex-grow: 1
flex-shrink: 1
overflow-y: auto
@include styled-scroll-bar
&--title
font-size: 1.3rem

@ -3,3 +3,7 @@ import {PlaceholderUserResource} from "core-app/modules/hal/resources/placeholde
import {GroupResource} from "core-app/modules/hal/resources/group-resource";
export type PrincipalLike = UserResource|PlaceholderUserResource|GroupResource|{ id?:string, name:string, href?:string };
export interface PrincipalData {
principal: PrincipalLike|null;
customFields: {[key:string]: any},
}

@ -38,7 +38,6 @@ import { ReactiveFormsModule } from "@angular/forms";
import { OpenprojectCommonModule } from "core-app/modules/common/openproject-common.module";
import {CopyProjectComponent} from "core-app/modules/projects/components/copy-project/copy-project.component";
@NgModule({
imports: [
// Commons

@ -33,7 +33,7 @@ describe ::API::V3::Budgets::BudgetRepresenter do
let(:project) { FactoryBot.build(:project, id: 999) }
let(:user) do
FactoryBot.build(:user,
FactoryBot.create(:user,
member_in_project: project,
created_at: 1.day.ago,
updated_at: Date.today)

@ -52,7 +52,7 @@ describe ::API::V3::CostEntries::WorkPackageCostsByTypeRepresenter do
cost_type: cost_type_B)
end
let(:current_user) do
FactoryBot.build(:user, member_in_project: project, member_through_role: role)
FactoryBot.create(:user, member_in_project: project, member_through_role: role)
end
let(:role) { FactoryBot.build(:role, permissions: [:view_cost_entries]) }

@ -113,7 +113,7 @@ describe ProjectsController, type: :controller do
end
context 'as user' do
let(:user) { FactoryBot.build(:user, member_in_project: project_b) }
let(:user) { FactoryBot.create(:user, member_in_project: project_b) }
it_behaves_like 'successful index'

@ -30,7 +30,7 @@
require 'spec_helper'
describe ::API::Decorators::Single do
let(:user) { FactoryBot.build(:user, member_in_project: project, member_through_role: role) }
let(:user) { FactoryBot.create(:user, member_in_project: project, member_through_role: role) }
let(:project) { FactoryBot.create(:project_with_types) }
let(:role) { FactoryBot.create(:role, permissions: permissions) }
let(:permissions) { [:view_work_packages] }

@ -43,7 +43,15 @@ FactoryBot.define do
created_at { Time.now }
updated_at { Time.now }
callback(:after_build) do |principal, evaluator| # this is also done after :create
callback(:after_build) do |principal, evaluator|
is_build_strategy = evaluator.instance_eval { @build_strategy.is_a? FactoryBot::Strategy::Build }
uses_member_association = evaluator.member_in_project || evaluator.member_in_projects
if is_build_strategy && uses_member_association
raise ArgumentError, "Use FactoryBot.create(...) with principals and member_in_project(s) traits."
end
end
callback(:after_create) do |principal, evaluator|
(projects = evaluator.member_in_projects || [])
projects << evaluator.member_in_project if evaluator.member_in_project
if projects.any?

@ -0,0 +1,5 @@
FactoryBot.define do
trait :skip_validations do
to_create { |model| model.save!(validate: false) }
end
end

@ -0,0 +1,141 @@
#-- 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.
#++
require 'spec_helper'
# rubocop:disable RSpec/MultipleMemoizedHelpers
feature 'Invite user modal custom fields', type: :feature, js: true do
shared_let(:project) { FactoryBot.create :project }
let(:permissions) { %i[view_project manage_members] }
let(:global_permissions) { %i[manage_user] }
let(:principal) { FactoryBot.build :invited_user }
let(:modal) do
::Components::Users::InviteUserModal.new project: project,
principal: principal,
role: role
end
let!(:role) do
FactoryBot.create :role,
name: 'Member',
permissions: permissions
end
let!(:boolean_cf) { FactoryBot.create :boolean_user_custom_field, name: 'bool', is_required: true }
let!(:integer_cf) { FactoryBot.create :integer_user_custom_field, name: 'int', is_required: true }
let!(:text_cf) { FactoryBot.create :text_user_custom_field, name: 'Text', is_required: true }
let!(:string_cf) { FactoryBot.create :string_user_custom_field, name: 'String', is_required: true }
# TODO float not supported yet
#let!(:float_cf) { FactoryBot.create :float_user_custom_field, name: 'Float', is_required: true }
let!(:list_cf) { FactoryBot.create :list_user_custom_field, name: 'List', is_required: true }
let!(:list_multi_cf) { FactoryBot.create :list_user_custom_field, name: 'Multi list', multi_value: true, is_required: true }
let!(:non_req_cf) { FactoryBot.create :string_user_custom_field, name: 'non req', is_required: false }
let(:boolean_field) { ::FormFields::InputFormField.new boolean_cf }
let(:integer_field) { ::FormFields::InputFormField.new integer_cf }
let(:text_field) { ::FormFields::EditorFormField.new text_cf }
let(:string_field) { ::FormFields::InputFormField.new string_cf }
# TODO float not supported yet
#let(:float_field) { ::FormFields::InputFormField.new float_cf }
let(:list_field) { ::FormFields::SelectFormField.new list_cf }
let(:list_multi_field) { ::FormFields::SelectFormField.new list_multi_cf }
let(:quick_add) { ::Components::QuickAddMenu.new }
current_user do
FactoryBot.create :user,
:skip_validations,
member_in_project: project,
member_through_role: role,
global_permissions: global_permissions
end
it 'shows the required fields during the principal step' do
visit home_path
quick_add.expect_visible
quick_add.toggle
quick_add.click_link 'Invite user'
modal.project_step
# Fill the principal and try to go to next
sleep 1
modal.principal_step
expect(page).to have_selector('form.ng-invalid', wait: 10)
modal.within_modal do
expect(page).to have_text "bool can't be blank."
expect(page).to have_text "int can't be blank."
expect(page).to have_text "Text can't be blank."
expect(page).to have_text "String can't be blank."
expect(page).to have_text "List can't be blank."
expect(page).to have_text "Multi list can't be blank."
# Does not show the non req field
expect(page).to have_no_text non_req_cf.name
end
# Fill all fields
boolean_field.input_element.check
integer_field.set_value '1234'
text_field.set_value 'A **markdown** value'
string_field.set_value 'String value'
list_field.select_option '1'
list_multi_field.select_option '1', '2'
modal.click_next
# Remaining steps
modal.role_step
modal.invitation_step
modal.confirmation_step
modal.click_modal_button 'Send invitation'
modal.expect_text "Invite #{principal.mail} to #{project.name}"
# Close
modal.click_modal_button 'Send invitation'
modal.expect_text "#{principal.mail} was invited!"
# Expect to be added to project
invited = project.users.last
expect(invited.mail).to eq principal.mail
expect(invited.custom_value_for(boolean_cf).typed_value).to eq true
expect(invited.custom_value_for(integer_cf).typed_value).to eq 1234
expect(invited.custom_value_for(text_cf).typed_value).to eq 'A **markdown** value'
expect(invited.custom_value_for(string_cf).typed_value).to eq 'String value'
expect(invited.custom_value_for(list_cf).typed_value).to eq '1'
expect(invited.custom_value_for(list_multi_cf).map(&:typed_value)).to eq %w[1 2]
end
end

@ -1,251 +0,0 @@
#-- 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.
#++
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 }
let(:permissions) { %i[view_work_packages edit_work_packages manage_members] }
let(:global_permissions) { %i[] }
let(:modal) do
::Components::Users::InviteUserModal.new project: project,
principal: principal,
role: role,
invite_message: invite_message
end
let!(:role) do
FactoryBot.create :role,
name: 'Member',
permissions: permissions
end
let(:invite_message) { "Welcome to the team. **You'll like it here**."}
let(:mail_membership_recipients) { [] }
let(:mail_invite_recipients) { [] }
current_user do
FactoryBot.create :user,
member_in_project: project,
member_through_role: role,
global_permissions: global_permissions
end
shared_examples 'invites the principal to the project' do
it 'invites that principal to the project' do
perform_enqueued_jobs do
modal.run_all_steps
end
assignee_field.expect_inactive!
assignee_field.expect_display_value added_principal.name
new_member = project.reload.member_principals.find_by(user_id: added_principal.id)
expect(new_member).to be_present
expect(new_member.roles).to eq [role]
# Check that the expected number of emails are sent.
# This includes no mails being sent if the recipient list is empty.
expect(ActionMailer::Base.deliveries.size)
.to eql mail_invite_recipients.size + mail_membership_recipients.size
mail_invite_recipients.each_with_index do |recipient, index|
expect(ActionMailer::Base.deliveries[index].to)
.to match_array [recipient.mail]
expect(ActionMailer::Base.deliveries[index].body.encoded)
.to include "Welcome to OpenProject"
end
mail_membership_recipients.each_with_index do |recipient, index|
overall_index = index + mail_invite_recipients.length
expect(ActionMailer::Base.deliveries[overall_index].to)
.to match_array [recipient.mail]
expect(ActionMailer::Base.deliveries[overall_index].body.encoded)
.to include OpenProject::TextFormatting::Renderer.format_text(invite_message)
expect(ActionMailer::Base.deliveries[overall_index].body.encoded)
.to include role.name
end
end
end
describe 'inviting a principal to a project' do
describe 'through the assignee field' do
let(:wp_page) { Pages::FullWorkPackage.new(work_package, project) }
let(:assignee_field) { wp_page.edit_field :assignee }
before do
wp_page.visit!
assignee_field.activate!
find('.ng-dropdown-footer button', text: 'Invite', wait: 10).click
end
context 'with an existing user' do
let!(:principal) do
FactoryBot.create :user,
firstname: 'Nonproject firstname',
lastname: 'nonproject lastname'
end
it_behaves_like 'invites the principal to the project' do
let(:added_principal) { principal }
let(:mail_membership_recipients) { [principal] }
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] }
let(:global_permissions) { %i[manage_user] }
it_behaves_like 'invites the principal to the project' do
let(:added_principal) { User.find_by!(mail: principal.mail) }
let(:mail_invite_recipients) { [added_principal] }
let(:mail_membership_recipients) { [added_principal] }
end
end
context 'when the current user does not have permissions to invite a user to the instance by email' do
let(:permissions) { %i[view_work_packages edit_work_packages manage_members] }
it 'does not show the invite user option' do
modal.project_step
ngselect = modal.open_select_in_step principal.mail
expect(ngselect).to have_text "No users were found"
expect(ngselect).not_to have_text "Invite: #{principal.mail}"
end
end
context 'when the current user does not have permissions to invite a user in this project' do
let(:permissions) { %i[view_work_packages edit_work_packages manage_members] }
let(:global_permissions) { %i[manage_user] }
let(:project_no_permissions) { FactoryBot.create :project }
let(:role_no_permissions) do
FactoryBot.create :role,
permissions: %i[view_work_packages edit_work_packages]
end
let!(:membership_no_permission) do
FactoryBot.create :member,
user: current_user,
project: project_no_permissions,
roles: [role_no_permissions]
end
it 'disables projects for which you do not have rights' do
ngselect = modal.open_select_in_step
expect(ngselect).to have_text "#{project_no_permissions.name}\nYou are not allowed to invite members to this project"
end
end
end
describe 'inviting placeholders' do
let(:principal) { FactoryBot.build :placeholder_user, name: 'MY NEW PLACEHOLDER' }
context 'an enterprise system', with_ee: %i[placeholder_users] do
describe 'create a new placeholder' do
context 'with permissions to manage placeholders' do
let(:permissions) { %i[view_work_packages edit_work_packages manage_members] }
let(:global_permissions) { %i[manage_placeholder_user] }
it_behaves_like 'invites the principal to the project' do
let(:added_principal) { PlaceholderUser.find_by!(name: 'MY NEW PLACEHOLDER') }
# Placeholders get no invite mail
let(:mail_membership_recipients) { [] }
end
end
context 'without permissions to manage placeholders' do
let(:permissions) { %i[view_work_packages edit_work_packages manage_members] }
it 'does not allow to invite a new placeholder' do
modal.within_modal do
expect(page).to have_selector '.op-option-list--item', count: 2
end
end
end
end
context 'with an existing placeholder' do
let(:principal) { FactoryBot.create :placeholder_user, name: 'EXISTING PLACEHOLDER' }
let(:permissions) { %i[view_work_packages edit_work_packages manage_members] }
let(:global_permissions) { %i[manage_placeholder_user] }
it_behaves_like 'invites the principal to the project' do
let(:added_principal) { principal }
# Placeholders get no invite mail
let(:mail_membership_recipients) { [] }
end
end
end
context 'non-enterprise system' do
it 'shows the modal with placeholder option disabled' do
modal.within_modal do
expect(page).to have_field 'Placeholder user', disabled: true
end
end
end
end
describe 'inviting groups' do
let(:group_user) { FactoryBot.create(:user) }
let(:principal) { FactoryBot.create :group, name: 'MY NEW GROUP', members: [group_user] }
it_behaves_like 'invites the principal to the project' do
let(:added_principal) { principal }
# Groups get no invite mail themselves but their members do
let(:mail_membership_recipients) { [group_user] }
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 }
before do
wp_page.visit!
end
it 'cannot add an existing user to the project' do
assignee_field.activate!
expect(page).to have_no_selector('.ng-dropdown-footer', text: 'Invite')
end
end
end

@ -63,21 +63,21 @@ RSpec.feature 'Work package copy', js: true, selenium: true do
end
let(:role) { FactoryBot.build(:role, permissions: [:view_work_packages]) }
let(:assignee) do
FactoryBot.build(:user,
FactoryBot.create(:user,
firstname: 'An',
lastname: 'assignee',
member_in_project: project,
member_through_role: role)
end
let(:responsible) do
FactoryBot.build(:user,
FactoryBot.create(:user,
firstname: 'The',
lastname: 'responsible',
member_in_project: project,
member_through_role: role)
end
let(:author) do
FactoryBot.build(:user,
FactoryBot.create(:user,
firstname: 'The',
lastname: 'author',
member_in_project: project,

@ -32,7 +32,7 @@ describe 'Wysiwyg work package user mentions',
type: :feature,
js: true do
let!(:user) { FactoryBot.create :admin }
let!(:user2) { FactoryBot.build(:user, firstname: 'Foo', lastname: 'Bar', member_in_project: project) }
let!(:user2) { FactoryBot.create(:user, firstname: 'Foo', lastname: 'Bar', member_in_project: project) }
let!(:group) { FactoryBot.create(:group, firstname: 'Foogroup', lastname: 'Foogroup') }
let!(:group_role) { FactoryBot.create(:role) }
let!(:group_member) do

@ -39,7 +39,7 @@ describe ::API::V3::WorkPackages::FormRepresenter do
updated_at: DateTime.now)
end
let(:current_user) do
FactoryBot.build(:user, member_in_project: work_package.project)
FactoryBot.create(:user, member_in_project: work_package.project)
end
let(:representer) do
described_class.new(work_package, current_user: current_user, errors: errors)

@ -33,7 +33,7 @@ describe Queries::WorkPackages::Filter::SubjectOrIdFilter, type: :model do
let(:operator) { '**' }
let(:subject) { 'Some subject' }
let(:work_package) { FactoryBot.create(:work_package, subject: subject) }
let(:current_user) { FactoryBot.build(:user, member_in_project: work_package.project) }
let(:current_user) { FactoryBot.create(:user, member_in_project: work_package.project) }
let(:query) { FactoryBot.build_stubbed(:global_query, user: current_user) }
let(:instance) do
described_class.create!(name: :search, context: query, operator: operator, values: [value])

@ -31,12 +31,12 @@ require 'spec_helper'
describe WorkPackage, type: :model do
describe ActionMailer::Base do
let(:user_1) do
FactoryBot.build(:user,
FactoryBot.create(:user,
mail: 'dlopper@somenet.foo',
member_in_project: project)
end
let(:user_2) do
FactoryBot.build(:user,
FactoryBot.create(:user,
mail: 'jsmith@somenet.foo',
member_in_project: project)
end

@ -57,7 +57,7 @@ describe WorkPackage, 'derived dates', type: :model do
permissions: %i[view_work_packages])
end
let(:user) do
FactoryBot.build(:user,
FactoryBot.create(:user,
member_in_project: work_package.project,
member_through_role: role)
end

@ -80,7 +80,7 @@ describe WorkPackage, 'spent_time', type: :model do
permissions: [:view_time_entries])
end
let(:user) do
FactoryBot.build(:user,
FactoryBot.create(:user,
member_in_project: project,
member_through_role: role)
end

@ -39,7 +39,7 @@ describe 'API localization', type: :request do
let(:project) { FactoryBot.create(:project) }
let(:type) { FactoryBot.create(:type) }
let(:schema_path) { api_v3_paths.work_package_schema project.id, type.id }
let(:current_user) { FactoryBot.build(:user, member_in_project: project, language: :fr) }
let(:current_user) { FactoryBot.create(:user, member_in_project: project, language: :fr) }
describe 'GET /api/v3/work_packages/schemas/:id' do
before do

@ -35,7 +35,7 @@ describe API::V3::WorkPackages::WorkPackagesByProjectAPI, type: :request do
include API::V3::Utilities::PathHelper
let(:current_user) do
FactoryBot.build(:user, member_in_project: project, member_through_role: role)
FactoryBot.create(:user, member_in_project: project, member_through_role: role)
end
let(:role) { FactoryBot.create(:role, permissions: permissions) }
let(:permissions) { [:view_work_packages] }

@ -37,7 +37,7 @@ describe API::V3::WorkPackages::Schema::WorkPackageSchemasAPI, type: :request do
let(:type) { FactoryBot.create(:type) }
let(:role) { FactoryBot.create(:role, permissions: [:view_work_packages]) }
let(:current_user) do
FactoryBot.build(:user, member_in_project: project, member_through_role: role)
FactoryBot.create(:user, member_in_project: project, member_through_role: role)
end
describe 'GET /api/v3/work_packages/schemas/filters=...' do

@ -33,7 +33,7 @@ describe Notifications::JournalWpMailService do
let(:project) { FactoryBot.create(:project_with_types) }
let(:role) { FactoryBot.create(:role, permissions: [:view_work_packages]) }
let(:author) do
FactoryBot.build(:user,
FactoryBot.create(:user,
mail_notification: 'none',
member_in_project: project,
member_through_role: role)

@ -40,7 +40,7 @@ module Components
text = select_text.presence || query
# click the element to select it
target_dropdown.find('.ng-option', text: text, match: :first).click
target_dropdown.find('.ng-option', text: text, match: :first, wait: 60).click
end
end
end

@ -0,0 +1,29 @@
require_relative './form_field'
module FormFields
class EditorFormField < FormField
attr_reader :editor
def initialize(property, selector: nil)
super
@editor = ::Components::WysiwygEditor.new(selector)
end
def expect_visible
!!editor.container
end
##
# Set or select the given value.
# For fields of type select, will check for an option with that value.
def set_value(content)
editor.set_markdown(content)
end
def input_element
editor.editor_element
end
end
end
Loading…
Cancel
Save