mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
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>
This commit is contained in:
@@ -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
|
||||
|
||||
+1
-1
@@ -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>
|
||||
+2
-2
@@ -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"
|
||||
|
||||
+49
-85
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -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() }}
|
||||
|
||||
+12
-14
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
+1
-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="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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
Reference in New Issue
Block a user