From 44294ede049023e69b64963cb66d32bdb51f2f26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20B=C3=A4dorf?= Date: Wed, 5 May 2021 06:43:29 +0000 Subject: [PATCH] User custom fields in the invite user modal (#9220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 ab56f3c56f6c8c3c3b791c081c762aa713e83a41. * 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 Co-authored-by: Oliver Günther --- .../projects/apiv3-project-copy-paths.ts | 6 +- .../endpoints/projects/apiv3-project-paths.ts | 3 +- .../create-autocompleter.component.html | 2 +- .../dynamic-form/dynamic-form.component.html | 4 +- .../dynamic-form/dynamic-form.component.ts | 134 ++++------ .../dynamic-form/dynamic-form.service.spec.ts | 0 .../dynamic-form/dynamic-form.service.ts | 1 - .../modules/common/dynamic-forms/typings.d.ts | 1 - .../common/openproject-common.module.sass | 1 + .../src/app/modules/common/select/select.sass | 22 ++ .../button/invite-user-button.component.html | 2 +- .../button/invite-user-button.component.sass | 22 -- .../invite-user-modal.module.ts | 4 + .../invite-user.component.html | 10 +- .../invite-user.component.ts | 17 +- .../message/message.component.html | 2 +- .../principal/principal-search.component.ts | 26 +- .../principal/principal.component.html | 10 +- .../principal/principal.component.ts | 104 +++++++- .../project-selection.component.html | 2 +- .../role/role.component.html | 2 +- .../summary/summary.component.html | 4 +- .../summary/summary.component.ts | 21 +- frontend/src/app/modules/modal/modal.sass | 4 +- .../app/modules/principal/principal-types.ts | 4 + .../projects/openproject-projects.module.ts | 1 - .../api/v3/budgets/budget_representer_spec.rb | 2 +- ..._package_costs_by_type_representer_spec.rb | 2 +- spec/controllers/projects_controller_spec.rb | 2 +- spec/decorators/single_spec.rb | 2 +- spec/factories/principal_factory.rb | 10 +- spec/factories/traits/skip_validations.rb | 5 + .../invite_user_modal/custom_fields_spec.rb | 141 ++++++++++ spec/features/users/invite_user_modal_spec.rb | 251 ------------------ spec/features/work_packages/copy_spec.rb | 6 +- spec/features/wysiwyg/user_mention_spec.rb | 2 +- .../v3/work_packages/form_representer_spec.rb | 2 +- .../filter/subject_or_id_filter_spec.rb | 2 +- .../work_package_action_mailer_spec.rb | 4 +- .../work_packages/derived_dates_spec.rb | 2 +- spec/models/work_packages/spent_time_spec.rb | 2 +- spec/requests/api/v3/locale_spec.rb | 2 +- .../work_packages_by_project_resource_spec.rb | 2 +- .../work_packages_schemas_resource_spec.rb | 2 +- .../journal_wp_mail_service_spec.rb | 2 +- .../ng_select_autocomplete_helpers.rb | 2 +- spec/support/form_fields/editor_form_field.rb | 29 ++ 47 files changed, 440 insertions(+), 443 deletions(-) create mode 100644 frontend/src/app/modules/common/dynamic-forms/services/dynamic-form/dynamic-form.service.spec.ts create mode 100644 frontend/src/app/modules/common/select/select.sass create mode 100644 spec/factories/traits/skip_validations.rb create mode 100644 spec/features/users/invite_user_modal/custom_fields_spec.rb delete mode 100644 spec/features/users/invite_user_modal_spec.rb create mode 100644 spec/support/form_fields/editor_form_field.rb diff --git a/frontend/src/app/modules/apiv3/endpoints/projects/apiv3-project-copy-paths.ts b/frontend/src/app/modules/apiv3/endpoints/projects/apiv3-project-copy-paths.ts index 23aa2b1f4dc..d7f0497622c 100644 --- a/frontend/src/app/modules/apiv3/endpoints/projects/apiv3-project-copy-paths.ts +++ b/frontend/src/app/modules/apiv3/endpoints/projects/apiv3-project-copy-paths.ts @@ -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, diff --git a/frontend/src/app/modules/apiv3/endpoints/projects/apiv3-project-paths.ts b/frontend/src/app/modules/apiv3/endpoints/projects/apiv3-project-paths.ts index c714c8db913..103cadbed62 100644 --- a/frontend/src/app/modules/apiv3/endpoints/projects/apiv3-project-paths.ts +++ b/frontend/src/app/modules/apiv3/endpoints/projects/apiv3-project-paths.ts @@ -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 { // /api/v3/projects/:project_id/available_assignees diff --git a/frontend/src/app/modules/autocompleter/create-autocompleter/create-autocompleter.component.html b/frontend/src/app/modules/autocompleter/create-autocompleter/create-autocompleter.component.html index d93481bd9cb..77f5f2d4b1a 100644 --- a/frontend/src/app/modules/autocompleter/create-autocompleter/create-autocompleter.component.html +++ b/frontend/src/app/modules/autocompleter/create-autocompleter/create-autocompleter.component.html @@ -37,6 +37,6 @@ - + \ No newline at end of file diff --git a/frontend/src/app/modules/common/dynamic-forms/components/dynamic-form/dynamic-form.component.html b/frontend/src/app/modules/common/dynamic-forms/components/dynamic-form/dynamic-form.component.html index 93884f42d4d..2cf0de65c91 100644 --- a/frontend/src/app/modules/common/dynamic-forms/components/dynamic-form/dynamic-form.component.html +++ b/frontend/src/app/modules/common/dynamic-forms/components/dynamic-form/dynamic-form.component.html @@ -1,5 +1,5 @@
+ *ngIf="form && handleSubmit">
@@ -25,7 +25,7 @@
+ *ngIf="form && !handleSubmit"> 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(); 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); - } } } diff --git a/frontend/src/app/modules/common/dynamic-forms/services/dynamic-form/dynamic-form.service.spec.ts b/frontend/src/app/modules/common/dynamic-forms/services/dynamic-form/dynamic-form.service.spec.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/frontend/src/app/modules/common/dynamic-forms/services/dynamic-form/dynamic-form.service.ts b/frontend/src/app/modules/common/dynamic-forms/services/dynamic-form/dynamic-form.service.ts index e12250edf1f..6b192baae36 100644 --- a/frontend/src/app/modules/common/dynamic-forms/services/dynamic-form/dynamic-form.service.ts +++ b/frontend/src/app/modules/common/dynamic-forms/services/dynamic-form/dynamic-form.service.ts @@ -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; diff --git a/frontend/src/app/modules/common/dynamic-forms/typings.d.ts b/frontend/src/app/modules/common/dynamic-forms/typings.d.ts index 90ac15aac69..89b26cb0fc5 100644 --- a/frontend/src/app/modules/common/dynamic-forms/typings.d.ts +++ b/frontend/src/app/modules/common/dynamic-forms/typings.d.ts @@ -4,7 +4,6 @@ import { FormGroup } from "@angular/forms"; export interface IOPDynamicFormSettings { fields:IOPFormlyFieldSettings[]; model:IOPFormModel; - form:FormGroup; } export interface IOPFormlyFieldSettings extends FormlyFieldConfig { diff --git a/frontend/src/app/modules/common/openproject-common.module.sass b/frontend/src/app/modules/common/openproject-common.module.sass index 782133c7aba..39a53d7371b 100644 --- a/frontend/src/app/modules/common/openproject-common.module.sass +++ b/frontend/src/app/modules/common/openproject-common.module.sass @@ -5,3 +5,4 @@ @import './forms' @import './option-list/option-list' @import './export-options/export-options' +@import './select/select' diff --git a/frontend/src/app/modules/common/select/select.sass b/frontend/src/app/modules/common/select/select.sass new file mode 100644 index 00000000000..40ed8fac58d --- /dev/null +++ b/frontend/src/app/modules/common/select/select.sass @@ -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 diff --git a/frontend/src/app/modules/invite-user-modal/button/invite-user-button.component.html b/frontend/src/app/modules/invite-user-modal/button/invite-user-button.component.html index afc36f0c502..c450c22f3ea 100644 --- a/frontend/src/app/modules/invite-user-modal/button/invite-user-button.component.html +++ b/frontend/src/app/modules/invite-user-modal/button/invite-user-button.component.html @@ -1,5 +1,5 @@