From 631a9c9844225ec47bb175a893da39795ec50e7b Mon Sep 17 00:00:00 2001 From: Volker Theile Date: Wed, 29 Aug 2018 12:18:37 +0200 Subject: [PATCH] mgr/dashboard: Add unique validator Relocate an already existing async validator into a separate validator that can be reused by every other form. This validator is useful to check immediately after typing if an entered value, e.g. username, already exists. The API request will be triggered after a delay of 500ms (can be modified). During this delay, every keystroke will reset the timer, so the REST API is not flooded with request. Signed-off-by: Volker Theile --- .../rgw-user-form.component.html | 2 +- .../rgw-user-form.component.spec.ts | 76 ++++++++----------- .../rgw-user-form/rgw-user-form.component.ts | 44 ++--------- .../src/app/shared/api/rgw-user.service.ts | 4 +- .../app/shared/forms/cd-validators.spec.ts | 50 +++++++++++- .../src/app/shared/forms/cd-validators.ts | 55 +++++++++++++- 6 files changed, 142 insertions(+), 89 deletions(-) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.html index 8b46cea8ca92e..943a4d96ff3bd 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.html @@ -45,7 +45,7 @@ This field is required. The chosen user ID is already in use. diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.spec.ts index e47a33571925c..c353455fbdfbc 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.spec.ts @@ -4,7 +4,7 @@ import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { RouterTestingModule } from '@angular/router/testing'; import { BsModalService } from 'ngx-bootstrap/modal'; -import { Observable, of as observableOf } from 'rxjs'; +import { of as observableOf } from 'rxjs'; import { configureTestBed } from '../../../../testing/unit-test-helper'; import { RgwUserService } from '../../../shared/api/rgw-user.service'; @@ -15,18 +15,11 @@ import { RgwUserFormComponent } from './rgw-user-form.component'; describe('RgwUserFormComponent', () => { let component: RgwUserFormComponent; let fixture: ComponentFixture; - let queryResult: Array = []; - - class MockRgwUserService extends RgwUserService { - enumerate() { - return observableOf(queryResult); - } - } configureTestBed({ declarations: [RgwUserFormComponent], imports: [HttpClientTestingModule, ReactiveFormsModule, RouterTestingModule, SharedModule], - providers: [BsModalService, { provide: RgwUserService, useClass: MockRgwUserService }] + providers: [BsModalService, RgwUserService] }); beforeEach(() => { @@ -119,52 +112,43 @@ describe('RgwUserFormComponent', () => { }); }); - describe('userIdValidator', () => { - it('should validate user id (1/3)', () => { - const validatorFn = component.userIdValidator(); - const ctrl = new FormControl(''); - const validator$ = validatorFn(ctrl); - expect(validator$ instanceof Observable).toBeTruthy(); - if (validator$ instanceof Observable) { - validator$.subscribe((resp) => { - expect(resp).toBe(null); - }); - } + describe('username validation', () => { + let rgwUserService: RgwUserService; + + beforeEach(() => { + rgwUserService = TestBed.get(RgwUserService); + spyOn(rgwUserService, 'enumerate').and.returnValue(observableOf(['abc', 'xyz'])); + }); + + it('should validate that username is required', () => { + const user_id = component.userForm.get('user_id'); + user_id.markAsDirty(); + user_id.setValue(''); + expect(user_id.hasError('required')).toBeTruthy(); + expect(user_id.valid).toBeFalsy(); }); it( - 'should validate user id (2/3)', + 'should validate that username is valid', fakeAsync(() => { - const validatorFn = component.userIdValidator(0); - const ctrl = new FormControl('ab'); - ctrl.markAsDirty(); - const validator$ = validatorFn(ctrl); - expect(validator$ instanceof Observable).toBeTruthy(); - if (validator$ instanceof Observable) { - validator$.subscribe((resp) => { - expect(resp).toBe(null); - }); - tick(); - } + const user_id = component.userForm.get('user_id'); + user_id.markAsDirty(); + user_id.setValue('ab'); + tick(500); + expect(user_id.hasError('notUnique')).toBeFalsy(); + expect(user_id.valid).toBeTruthy(); }) ); it( - 'should validate user id (3/3)', + 'should validate that username is invalid', fakeAsync(() => { - queryResult = ['abc']; - const validatorFn = component.userIdValidator(0); - const ctrl = new FormControl('abc'); - ctrl.markAsDirty(); - const validator$ = validatorFn(ctrl); - expect(validator$ instanceof Observable).toBeTruthy(); - if (validator$ instanceof Observable) { - validator$.subscribe((resp) => { - expect(resp instanceof Object).toBeTruthy(); - expect(resp.userIdExists).toBeTruthy(); - }); - tick(); - } + const user_id = component.userForm.get('user_id'); + user_id.markAsDirty(); + user_id.setValue('abc'); + tick(500); + expect(user_id.hasError('notUnique')).toBeTruthy(); + expect(user_id.valid).toBeFalsy(); }) ); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.ts index f681542fee6ae..8623351791de6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.ts @@ -4,13 +4,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import * as _ from 'lodash'; import { BsModalService } from 'ngx-bootstrap'; -import { - forkJoin as observableForkJoin, - Observable, - of as observableOf, - timer as observableTimer -} from 'rxjs'; -import { map, switchMapTo, take } from 'rxjs/operators'; +import { forkJoin as observableForkJoin, Observable } from 'rxjs'; import { RgwUserService } from '../../../shared/api/rgw-user.service'; import { CdFormBuilder } from '../../../shared/forms/cd-form-builder'; @@ -57,7 +51,11 @@ export class RgwUserFormComponent implements OnInit { createForm() { this.userForm = this.formBuilder.group({ // General - user_id: [null, [Validators.required], [this.userIdValidator()]], + user_id: [ + null, + [Validators.required], + [CdValidators.unique(this.rgwUserService.exists, this.rgwUserService)] + ], display_name: [null, [Validators.required]], email: [null, [CdValidators.email]], max_buckets: [1000, [Validators.required, Validators.min(0)]], @@ -278,36 +276,6 @@ export class RgwUserFormComponent implements OnInit { return bytes < 1024 ? { quotaMaxSize: true } : null; } - /** - * Validate the username. - * @param {number|Date} dueTime The delay time to wait before the - * API call is executed. This is useful to prevent API calls on - * every keystroke. Defaults to 500. - */ - userIdValidator(dueTime = 500): AsyncValidatorFn { - const rgwUserService = this.rgwUserService; - return (control: AbstractControl): Observable => { - // Exit immediately if user has not interacted with the control yet - // or the control value is empty. - if (control.pristine || control.value === '') { - return observableOf(null); - } - // Forgot previous requests if a new one arrives within the specified - // delay time. - return observableTimer(dueTime).pipe( - switchMapTo(rgwUserService.exists(control.value)), - map((resp: boolean) => { - if (!resp) { - return null; - } else { - return { userIdExists: true }; - } - }), - take(1) - ); - }; - } - /** * Add/Update a subuser. */ diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.ts index cc7b749a584b8..07b05b78b01c3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.ts @@ -2,7 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; import * as _ from 'lodash'; -import { forkJoin as observableForkJoin, of as observableOf } from 'rxjs'; +import { forkJoin as observableForkJoin, Observable, of as observableOf } from 'rxjs'; import { mergeMap } from 'rxjs/operators'; import { cdEncode } from '../decorators/cd-encode'; @@ -127,7 +127,7 @@ export class RgwUserService { * @param {string} uid The user ID to check. * @return {Observable} */ - exists(uid: string) { + exists(uid: string): Observable { return this.enumerate().pipe( mergeMap((resp: string[]) => { const index = _.indexOf(resp, uid); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.spec.ts index 3f111b555e7c4..857800d9559ff 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.spec.ts @@ -1,4 +1,7 @@ -import { FormControl, FormGroup } from '@angular/forms'; +import { fakeAsync, tick } from '@angular/core/testing'; +import { AbstractControl, FormControl, FormGroup } from '@angular/forms'; + +import { of as observableOf } from 'rxjs'; import { CdValidators } from './cd-validators'; @@ -199,4 +202,49 @@ describe('CdValidators', () => { expect(y.hasError('match')).toBeFalsy(); }); }); + + describe('unique', () => { + let form: FormGroup; + let x: AbstractControl; + + beforeEach(() => { + form = new FormGroup({ + x: new FormControl( + '', + null, + CdValidators.unique((value) => { + return observableOf('xyz' === value); + }) + ) + }); + x = form.get('x'); + x.markAsDirty(); + }); + + it('should not error because of empty input', () => { + x.setValue(''); + expect(x.hasError('notUnique')).toBeFalsy(); + expect(x.valid).toBeTruthy(); + }); + + it( + 'should not error because of not existing input', + fakeAsync(() => { + x.setValue('abc'); + tick(500); + expect(x.hasError('notUnique')).toBeFalsy(); + expect(x.valid).toBeTruthy(); + }) + ); + + it( + 'should error because of already existing input', + fakeAsync(() => { + x.setValue('xyz'); + tick(500); + expect(x.hasError('notUnique')).toBeTruthy(); + expect(x.valid).toBeFalsy(); + }) + ); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts index 7898260c3f986..f1480409cfb10 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts @@ -1,11 +1,21 @@ -import { AbstractControl, ValidationErrors, ValidatorFn, Validators } from '@angular/forms'; +import { + AbstractControl, + AsyncValidatorFn, + ValidationErrors, + ValidatorFn, + Validators +} from '@angular/forms'; import * as _ from 'lodash'; +import { Observable, of as observableOf, timer as observableTimer } from 'rxjs'; +import { map, switchMapTo, take } from 'rxjs/operators'; export function isEmptyInputValue(value: any): boolean { return value == null || value.length === 0; } +export type existsServiceFn = (value: any) => Observable; + export class CdValidators { /** * Validator that performs email validation. In contrast to the Angular @@ -112,4 +122,47 @@ export class CdValidators { return null; }; } + + /** + * Asynchronous validator that requires the control's value to be unique. + * The validation is only executed after the specified delay. Every + * keystroke during this delay will restart the timer. + * @param serviceFn {existsServiceFn} The service function that is + * called to check whether the given value exists. It must return + * boolean 'true' if the given value exists, otherwise 'false'. + * @param serviceFnThis {any} The object to be used as the 'this' object + * when calling the serviceFn function. Defaults to null. + * @param {number|Date} dueTime The delay time to wait before the + * serviceFn call is executed. This is useful to prevent calls on + * every keystroke. Defaults to 500. + * @return {AsyncValidatorFn} Returns an asynchronous validator function + * that returns an error map with the `notUnique` property if the + * validation check succeeds, otherwise `null`. + */ + static unique( + serviceFn: existsServiceFn, + serviceFnThis: any = null, + dueTime = 500 + ): AsyncValidatorFn { + return (control: AbstractControl): Observable => { + // Exit immediately if user has not interacted with the control yet + // or the control value is empty. + if (control.pristine || isEmptyInputValue(control.value)) { + return observableOf(null); + } + // Forgot previous requests if a new one arrives within the specified + // delay time. + return observableTimer(dueTime).pipe( + switchMapTo(serviceFn.call(serviceFnThis, control.value)), + map((resp: boolean) => { + if (!resp) { + return null; + } else { + return { notUnique: true }; + } + }), + take(1) + ); + }; + } } -- 2.39.5