From: Volker Theile Date: Wed, 29 Aug 2018 10:18:37 +0000 (+0200) Subject: mgr/dashboard: Add unique validator X-Git-Tag: v14.0.1~441^2 X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=refs%2Fpull%2F23802%2Fhead;p=ceph.git 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 --- 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 8b46cea8ca92..943a4d96ff3b 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 e47a33571925..c353455fbdfb 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 f681542fee6a..8623351791de 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 cc7b749a584b..07b05b78b01c 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 3f111b555e7c..857800d9559f 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 7898260c3f98..f1480409cfb1 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) + ); + }; + } }