]> git.apps.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard: Make form modal more flexible
authorStephan Müller <smueller@suse.com>
Tue, 19 Nov 2019 08:28:26 +0000 (09:28 +0100)
committerStephan Müller <smueller@suse.com>
Fri, 13 Dec 2019 14:43:45 +0000 (15:43 +0100)
Now any input type is supported and the special binary type which will
use the cd-binary input and automatically format the binary size into
bytes when submitting.

Now any field can have custom validators and error messages.
The form will fallback on predefined error messages if not configured.
It will use the error messages provided by the binary min and max
validators. It still provides the error message for a required field.

Fixes: https://tracker.ceph.com/issues/38287
Signed-off-by: Stephan Müller <smueller@suse.com>
src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-form-modal-field-config.ts [new file with mode: 0644]

index 35fedeb5f6d0a5448bb4e620905b95bbd1a3478a..a7c76941afd30bb6e9e5a1f22884148a838cad31 100755 (executable)
@@ -5,54 +5,49 @@
   </ng-container>
   <ng-container class="modal-content">
     <form [formGroup]="formGroup"
+          #formDir="ngForm"
           novalidate>
       <div class="modal-body">
         <p *ngIf="message">{{ message }}</p>
         <ng-container *ngFor="let field of fields">
-          <div class="form-group row">
-            <ng-container [ngSwitch]="field.type">
-              <ng-template [ngSwitchCase]="'inputText'">
-                <label *ngIf="field.label"
-                       class="col-form-label col-sm-3"
-                       [for]="field.name">
-                  {{ field.label }}
-                </label>
-                <div [ngClass]="{'col-sm-9': field.label, 'col-sm-12': !field.label}">
-                  <input type="text"
-                         class="form-control"
-                         [id]="field.name"
-                         [name]="field.name"
-                         [formControlName]="field.name">
-                  <span *ngIf="formGroup.hasError('required', field.name)"
-                        class="invalid-feedback"
-                        i18n>This field is required.</span>
-                </div>
-              </ng-template>
-              <ng-template [ngSwitchCase]="'select'">
-                <label *ngIf="field.label"
-                       class="col-form-label col-sm-3"
-                       [for]="field.name">
-                  {{ field.label }}
-                </label>
-                <div [ngClass]="{'col-sm-9': field.label, 'col-sm-12': !field.label}">
-                  <select class="form-control custom-select"
-                          [id]="field.name"
-                          [formControlName]="field.name">
-                    <option *ngIf="field.placeholder"
-                            [ngValue]="null">
-                      {{ field.placeholder }}
-                    </option>
-                    <option *ngFor="let option of field.options"
-                            [value]="option.value">
-                      {{ option.text }}
-                    </option>
-                  </select>
-                  <span *ngIf="formGroup.hasError('required', field.name)"
-                        class="invalid-feedback"
-                        i18n>This field is required.</span>
-                </div>
-              </ng-template>
-            </ng-container>
+          <div class="form-group row cd-{{field.name}}-form-group">
+            <label *ngIf="field.label"
+                   class="col-form-label col-sm-3"
+                   [for]="field.name">
+              {{ field.label }}
+            </label>
+            <div [ngClass]="{'col-sm-9': field.label, 'col-sm-12': !field.label}">
+              <input *ngIf="['text', 'number'].includes(field.type)"
+                     [type]="field.type"
+                     class="form-control"
+                     [id]="field.name"
+                     [name]="field.name"
+                     [formControlName]="field.name">
+              <input *ngIf="field.type === 'binary'"
+                     type="text"
+                     class="form-control"
+                     [id]="field.name"
+                     [name]="field.name"
+                     [formControlName]="field.name"
+                     cdDimlessBinary>
+              <select *ngIf="field.name === 'select'"
+                      class="form-control custom-select"
+                      [id]="field.name"
+                      [formControlName]="field.name">
+                <option *ngIf="field.placeholder"
+                        [ngValue]="null">
+                  {{ field.placeholder }}
+                </option>
+                <option *ngFor="let option of field.options"
+                        [value]="option.value">
+                  {{ option.text }}
+                </option>
+              </select>
+              <span *ngIf="formGroup.showError(field.name,formDir)"
+                    class="invalid-feedback">
+                {{ getError(field) }}
+              </span>
+            </div>
           </div>
         </ng-container>
       </div>
index 70c1872023ccb2dbf30196a193111e2d4c7df1b1..5dde3e7621f9881ed83491655be8ef19cc27616f 100755 (executable)
@@ -1,5 +1,5 @@
 import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { ReactiveFormsModule } from '@angular/forms';
+import { ReactiveFormsModule, Validators } from '@angular/forms';
 import { RouterTestingModule } from '@angular/router/testing';
 
 import { NgBootstrapFormValidationModule } from 'ng-bootstrap-form-validation';
@@ -8,8 +8,10 @@ import { BsModalRef, ModalModule } from 'ngx-bootstrap/modal';
 import {
   configureTestBed,
   FixtureHelper,
+  FormHelper,
   i18nProviders
 } from '../../../../testing/unit-test-helper';
+import { CdValidators } from '../../forms/cd-validators';
 import { SharedModule } from '../../shared.module';
 import { FormModalComponent } from './form-modal.component';
 
@@ -17,6 +19,7 @@ describe('InputModalComponent', () => {
   let component: FormModalComponent;
   let fixture: ComponentFixture<FormModalComponent>;
   let fh: FixtureHelper;
+  let formHelper: FormHelper;
   let submitted;
 
   const initialState = {
@@ -24,15 +27,24 @@ describe('InputModalComponent', () => {
     message: 'Some description',
     fields: [
       {
-        type: 'inputText',
+        type: 'text',
         name: 'requiredField',
         value: 'some-value',
         required: true
       },
       {
-        type: 'inputText',
+        type: 'number',
         name: 'optionalField',
-        label: 'Optional'
+        label: 'Optional',
+        errors: { min: 'Value has to be above zero!' },
+        validators: [Validators.min(0), Validators.max(10)]
+      },
+      {
+        type: 'binary',
+        name: 'dimlessBinary',
+        label: 'Size',
+        value: 2048,
+        validators: [CdValidators.binaryMin(1024), CdValidators.binaryMax(3072)]
       }
     ],
     submitButtonText: 'Submit button name',
@@ -56,6 +68,7 @@ describe('InputModalComponent', () => {
     Object.assign(component, initialState);
     fixture.detectChanges();
     fh = new FixtureHelper(fixture);
+    formHelper = new FormHelper(component.formGroup);
   });
 
   it('should create', () => {
@@ -86,6 +99,61 @@ describe('InputModalComponent', () => {
   it('gives back all form values on submit', () => {
     component.onSubmitForm(component.formGroup.value);
     expect(submitted).toEqual({
+      dimlessBinary: 2048,
+      requiredField: 'some-value',
+      optionalField: null
+    });
+  });
+
+  it('tests required field validation', () => {
+    formHelper.expectErrorChange('requiredField', '', 'required');
+  });
+
+  it('tests required field message', () => {
+    formHelper.setValue('requiredField', '', true);
+    fh.expectTextToBe('.cd-requiredField-form-group .invalid-feedback', 'This field is required.');
+  });
+
+  it('tests custom validator on number field', () => {
+    formHelper.expectErrorChange('optionalField', -1, 'min');
+    formHelper.expectErrorChange('optionalField', 11, 'max');
+  });
+
+  it('tests custom validator error message', () => {
+    formHelper.setValue('optionalField', -1, true);
+    fh.expectTextToBe(
+      '.cd-optionalField-form-group .invalid-feedback',
+      'Value has to be above zero!'
+    );
+  });
+
+  it('tests default error message', () => {
+    formHelper.setValue('optionalField', 11, true);
+    fh.expectTextToBe('.cd-optionalField-form-group .invalid-feedback', 'An error occurred.');
+  });
+
+  it('tests binary error messages', () => {
+    formHelper.setValue('dimlessBinary', '4 K', true);
+    fh.expectTextToBe(
+      '.cd-dimlessBinary-form-group .invalid-feedback',
+      'Size has to be at most 3 KiB or less'
+    );
+    formHelper.setValue('dimlessBinary', '0.5 K', true);
+    fh.expectTextToBe(
+      '.cd-dimlessBinary-form-group .invalid-feedback',
+      'Size has to be at least 1 KiB or more'
+    );
+  });
+
+  it('shows result of dimlessBinary pipe', () => {
+    fh.expectFormFieldToBe('#dimlessBinary', '2 KiB');
+  });
+
+  it('changes dimlessBinary value and the result will still be a number', () => {
+    formHelper.setValue('dimlessBinary', '3 K', true);
+    component.onSubmitForm(component.formGroup.value);
+    expect(submitted).toEqual({
+      dimlessBinary: 3072,
       requiredField: 'some-value',
       optionalField: null
     });
index 0572cd02ed5835751b9aa3c351e3739db34cbd36..6488819edbbbccb95945a8c28b1473d1daecc782 100755 (executable)
@@ -1,24 +1,15 @@
 import { Component, OnInit } from '@angular/core';
-import { FormControl, FormGroup, Validators } from '@angular/forms';
+import { FormControl, ValidatorFn, Validators } from '@angular/forms';
 
+import { I18n } from '@ngx-translate/i18n-polyfill';
 import * as _ from 'lodash';
 import { BsModalRef } from 'ngx-bootstrap/modal';
 
+import { DimlessBinaryPipe } from 'app/shared/pipes/dimless-binary.pipe';
 import { CdFormBuilder } from '../../forms/cd-form-builder';
-
-interface CdFormFieldConfig {
-  type: 'inputText' | 'select';
-  name: string;
-  label?: string;
-  value?: any;
-  required?: boolean;
-  // --- select ---
-  placeholder?: string;
-  options?: Array<{
-    text: string;
-    value: any;
-  }>;
-}
+import { CdFormGroup } from '../../forms/cd-form-group';
+import { CdFormModalFieldConfig } from '../../models/cd-form-modal-field-config';
+import { FormatterService } from '../../services/formatter.service';
 
 @Component({
   selector: 'cd-form-modal',
@@ -29,32 +20,90 @@ export class FormModalComponent implements OnInit {
   // Input
   titleText: string;
   message: string;
-  fields: CdFormFieldConfig[];
+  fields: CdFormModalFieldConfig[];
   submitButtonText: string;
   onSubmit: Function;
 
   // Internal
-  formGroup: FormGroup;
+  formGroup: CdFormGroup;
+
+  constructor(
+    public bsModalRef: BsModalRef,
+    private formBuilder: CdFormBuilder,
+    private formatter: FormatterService,
+    private dimlessBinaryPipe: DimlessBinaryPipe,
+    private i18n: I18n
+  ) {}
 
-  constructor(public bsModalRef: BsModalRef, private formBuilder: CdFormBuilder) {}
+  ngOnInit() {
+    this.createForm();
+  }
 
   createForm() {
     const controlsConfig = {};
     this.fields.forEach((field) => {
-      const validators = [];
-      if (_.isBoolean(field.required) && field.required) {
-        validators.push(Validators.required);
-      }
-      controlsConfig[field.name] = new FormControl(_.defaultTo(field.value, null), { validators });
+      controlsConfig[field.name] = this.createFormControl(field);
     });
     this.formGroup = this.formBuilder.group(controlsConfig);
   }
 
-  ngOnInit() {
-    this.createForm();
+  private createFormControl(field: CdFormModalFieldConfig): FormControl {
+    let validators: ValidatorFn[] = [];
+    if (_.isBoolean(field.required) && field.required) {
+      validators.push(Validators.required);
+    }
+    if (field.validators) {
+      validators = validators.concat(field.validators);
+    }
+    return new FormControl(
+      _.defaultTo(
+        field.type === 'binary' ? this.dimlessBinaryPipe.transform(field.value) : field.value,
+        null
+      ),
+      { validators }
+    );
+  }
+
+  getError(field: CdFormModalFieldConfig): string {
+    const formErrors = this.formGroup.get(field.name).errors;
+    const errors = Object.keys(formErrors).map((key) => {
+      return this.getErrorMessage(key, formErrors[key], field.errors);
+    });
+    return errors.join('<br>');
+  }
+
+  private getErrorMessage(
+    error: string,
+    errorContext: any,
+    fieldErrors: { [error: string]: string }
+  ): string {
+    if (fieldErrors) {
+      const customError = fieldErrors[error];
+      if (customError) {
+        return customError;
+      }
+    }
+    if (['binaryMin', 'binaryMax'].includes(error)) {
+      // binaryMin and binaryMax return a function that take I18n to
+      // provide a translated error message.
+      return errorContext(this.i18n);
+    }
+    if (error === 'required') {
+      return this.i18n('This field is required.');
+    }
+    return this.i18n('An error occurred.');
   }
 
   onSubmitForm(values) {
+    const binaries = this.fields
+      .filter((field) => field.type === 'binary')
+      .map((field) => field.name);
+    binaries.forEach((key) => {
+      const value = values[key];
+      if (value) {
+        values[key] = this.formatter.toBytes(value);
+      }
+    });
     this.bsModalRef.hide();
     if (_.isFunction(this.onSubmit)) {
       this.onSubmit(values);
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-form-modal-field-config.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-form-modal-field-config.ts
new file mode 100644 (file)
index 0000000..7b2fe3f
--- /dev/null
@@ -0,0 +1,19 @@
+import { ValidatorFn } from '@angular/forms';
+
+export class CdFormModalFieldConfig {
+  name: string;
+  // 'binary' will use cdDimlessBinary directive on input element
+  // 'select' will use select element
+  type: 'number' | 'text' | 'binary' | 'select';
+  label?: string;
+  required?: boolean;
+  value?: any;
+  errors?: { [errorName: string]: string };
+  validators: ValidatorFn[];
+  // only for type select
+  placeholder?: string;
+  options?: Array<{
+    text: string;
+    value: any;
+  }>;
+}