From ba9047eded7ce56c7d97e1911060cffbee1b99f0 Mon Sep 17 00:00:00 2001 From: Volker Theile Date: Thu, 27 Feb 2020 15:18:39 +0100 Subject: [PATCH] mgr/dashboard: Not able to restrict bucket creation for new user Hide the different meanings of max_buckets from the user by improving the UI. Fixes: https://tracker.ceph.com/issues/44322 Signed-off-by: Volker Theile --- .../dashboard/frontend/e2e/page-helper.po.ts | 3 + .../dashboard/frontend/e2e/rgw/users.po.ts | 9 +- .../rgw-user-details.component.html | 2 +- .../rgw-user-details.component.ts | 5 + .../rgw-user-form.component.html | 26 +++++- .../rgw-user-form.component.spec.ts | 91 +++++++++++++++++++ .../rgw-user-form/rgw-user-form.component.ts | 46 +++++++++- .../rgw-user-list/rgw-user-list.component.ts | 7 +- .../src/app/shared/api/rgw-user.service.ts | 4 +- .../datatable/table/table.component.html | 6 ++ .../shared/datatable/table/table.component.ts | 3 + .../src/app/shared/enum/cell-template.enum.ts | 12 ++- .../src/app/shared/pipes/map.pipe.spec.ts | 25 +++++ .../frontend/src/app/shared/pipes/map.pipe.ts | 15 +++ .../src/app/shared/pipes/pipes.module.ts | 10 +- 15 files changed, 245 insertions(+), 19 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.ts diff --git a/src/pybind/mgr/dashboard/frontend/e2e/page-helper.po.ts b/src/pybind/mgr/dashboard/frontend/e2e/page-helper.po.ts index 0a33340a431..55b84eaed49 100644 --- a/src/pybind/mgr/dashboard/frontend/e2e/page-helper.po.ts +++ b/src/pybind/mgr/dashboard/frontend/e2e/page-helper.po.ts @@ -154,6 +154,7 @@ export abstract class PageHelper { /** * Helper method to select an option inside a select element. * This method will also expect that the option was set. + * @param option The option text (not value) to be selected. */ async selectOption(selectionName: string, option: string) { await element(by.cssContainingText(`select[name=${selectionName}] option`, option)).click(); @@ -162,6 +163,8 @@ export abstract class PageHelper { /** * Helper method to expect a set option inside a select element. + * @param option The selected option text (not value) that is to + * be expected. */ async expectSelectOption(selectionName: string, option: string) { return expect( diff --git a/src/pybind/mgr/dashboard/frontend/e2e/rgw/users.po.ts b/src/pybind/mgr/dashboard/frontend/e2e/rgw/users.po.ts index 96f9c537a2f..200d60fe5f9 100644 --- a/src/pybind/mgr/dashboard/frontend/e2e/rgw/users.po.ts +++ b/src/pybind/mgr/dashboard/frontend/e2e/rgw/users.po.ts @@ -24,6 +24,7 @@ export class UsersPageHelper extends PageHelper { await element(by.id('email')).sendKeys(email); // Enter max buckets + await this.selectOption('max_buckets_mode', 'Custom'); await element(by.id('max_buckets')).click(); await element(by.id('max_buckets')).clear(); await element(by.id('max_buckets')).sendKeys(maxbuckets); @@ -51,6 +52,7 @@ export class UsersPageHelper extends PageHelper { await element(by.id('email')).sendKeys(new_email); // Change the max buckets field + await this.selectOption('max_buckets_mode', 'Custom'); await element(by.id('max_buckets')).click(); await element(by.id('max_buckets')).clear(); await element(by.id('max_buckets')).sendKeys(new_maxbuckets); @@ -87,6 +89,7 @@ export class UsersPageHelper extends PageHelper { ); // check that username field is marked invalid if username has been cleared off + await username_field.click(); for (let i = 0; i < uname.length; i++) { await username_field.sendKeys(protractor.Key.BACK_SPACE); } @@ -119,13 +122,14 @@ export class UsersPageHelper extends PageHelper { ); // put negative max buckets to make field invalid + await this.expectSelectOption('max_buckets_mode', 'Custom'); await element(by.id('max_buckets')).click(); await element(by.id('max_buckets')).clear(); await element(by.id('max_buckets')).sendKeys('-5'); await expect(element(by.id('max_buckets')).getAttribute('class')).toContain('ng-invalid'); await username_field.click(); // trigger validation check await expect(element(by.css('#max_buckets + .invalid-feedback')).getText()).toMatch( - 'The entered value must be >= 0.' + 'The entered value must be >= 1.' ); await this.navigateTo(); @@ -171,13 +175,14 @@ export class UsersPageHelper extends PageHelper { ); // put negative max buckets to make field invalid + await this.expectSelectOption('max_buckets_mode', 'Custom'); await element(by.id('max_buckets')).click(); await element(by.id('max_buckets')).clear(); await element(by.id('max_buckets')).sendKeys('-5'); await expect(element(by.id('max_buckets')).getAttribute('class')).toContain('ng-invalid'); await element(by.id('email')).click(); // trigger validation check await expect(element(by.css('#max_buckets + .invalid-feedback')).getText()).toMatch( - 'The entered value must be >= 0.' + 'The entered value must be >= 1.' ); await this.navigateTo(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.html index 54fff66f5a7..a22b1c14dea 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.html @@ -32,7 +32,7 @@ Maximum buckets - {{ user.max_buckets }} + {{ user.max_buckets | map:maxBucketsMap }}
- +
+ +
+
+
+
+ formControlName="max_buckets" + min="1"> This field is required. The entered value must be >= 0. + i18n>The entered value must be >= 1.
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 e73319c4ec0..85632651ff4 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 @@ -176,6 +176,97 @@ describe('RgwUserFormComponent', () => { })); }); + describe('max buckets', () => { + it('disable creation (create)', () => { + spyOn(rgwUserService, 'create'); + formHelper.setValue('max_buckets_mode', -1, true); + component.onSubmit(); + expect(rgwUserService.create).toHaveBeenCalledWith({ + access_key: '', + display_name: null, + email: '', + generate_key: true, + max_buckets: -1, + secret_key: '', + suspended: false, + uid: null + }); + }); + + it('disable creation (edit)', () => { + spyOn(rgwUserService, 'update'); + component.editing = true; + formHelper.setValue('max_buckets_mode', -1, true); + component.onSubmit(); + expect(rgwUserService.update).toHaveBeenCalledWith(null, { + display_name: null, + email: null, + max_buckets: -1, + suspended: false + }); + }); + + it('unlimited buckets (create)', () => { + spyOn(rgwUserService, 'create'); + formHelper.setValue('max_buckets_mode', 0, true); + component.onSubmit(); + expect(rgwUserService.create).toHaveBeenCalledWith({ + access_key: '', + display_name: null, + email: '', + generate_key: true, + max_buckets: 0, + secret_key: '', + suspended: false, + uid: null + }); + }); + + it('unlimited buckets (edit)', () => { + spyOn(rgwUserService, 'update'); + component.editing = true; + formHelper.setValue('max_buckets_mode', 0, true); + component.onSubmit(); + expect(rgwUserService.update).toHaveBeenCalledWith(null, { + display_name: null, + email: null, + max_buckets: 0, + suspended: false + }); + }); + + it('custom (create)', () => { + spyOn(rgwUserService, 'create'); + formHelper.setValue('max_buckets_mode', 1, true); + formHelper.setValue('max_buckets', 100, true); + component.onSubmit(); + expect(rgwUserService.create).toHaveBeenCalledWith({ + access_key: '', + display_name: null, + email: '', + generate_key: true, + max_buckets: 100, + secret_key: '', + suspended: false, + uid: null + }); + }); + + it('custom (edit)', () => { + spyOn(rgwUserService, 'update'); + component.editing = true; + formHelper.setValue('max_buckets_mode', 1, true); + formHelper.setValue('max_buckets', 100, true); + component.onSubmit(); + expect(rgwUserService.update).toHaveBeenCalledWith(null, { + display_name: null, + email: null, + max_buckets: 100, + suspended: false + }); + }); + }); + describe('submit form', () => { let notificationService: NotificationService; 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 12ce4da8c87..77321d6578b 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 @@ -80,7 +80,15 @@ export class RgwUserFormComponent implements OnInit { [CdValidators.email], [CdValidators.unique(this.rgwUserService.emailExists, this.rgwUserService)] ], - max_buckets: [1000, [Validators.required, Validators.min(0)]], + max_buckets_mode: [1], + max_buckets: [ + 1000, + [ + CdValidators.requiredIf({ max_buckets_mode: '1' }), + CdValidators.number(false), + Validators.min(1) + ] + ], suspended: [false], // S3 key generate_key: [true], @@ -162,6 +170,20 @@ export class RgwUserFormComponent implements OnInit { const defaults = _.clone(this.userForm.value); // Extract the values displayed in the form. let value = _.pick(resp[0], _.keys(this.userForm.value)); + // Map the max. buckets values. + switch (value['max_buckets']) { + case -1: + value['max_buckets_mode'] = -1; + value['max_buckets'] = ''; + break; + case 0: + value['max_buckets_mode'] = 0; + value['max_buckets'] = ''; + break; + default: + value['max_buckets_mode'] = 1; + break; + } // Map the quota values. ['user', 'bucket'].forEach((type) => { const quota = resp[1][type + '_quota']; @@ -520,9 +542,11 @@ export class RgwUserFormComponent implements OnInit { * @return {Boolean} Returns TRUE if the general user settings have been modified. */ private _isGeneralDirty(): boolean { - return ['display_name', 'email', 'max_buckets', 'suspended'].some((path) => { - return this.userForm.get(path).dirty; - }); + return ['display_name', 'email', 'max_buckets_mode', 'max_buckets', 'suspended'].some( + (path) => { + return this.userForm.get(path).dirty; + } + ); } /** @@ -584,6 +608,12 @@ export class RgwUserFormComponent implements OnInit { secret_key: this.userForm.getValue('secret_key') }); } + const maxBucketsMode = parseInt(this.userForm.getValue('max_buckets_mode'), 10); + if (_.includes([-1, 0], maxBucketsMode)) { + // -1 => Disable bucket creation. + // 0 => Unlimited bucket creation. + _.merge(result, { max_buckets: maxBucketsMode }); + } return result; } @@ -592,11 +622,17 @@ export class RgwUserFormComponent implements OnInit { * configuration has been modified. */ private _getUpdateArgs() { - const result: Record = {}; + const result: Record = {}; const keys = ['display_name', 'email', 'max_buckets', 'suspended']; for (const key of keys) { result[key] = this.userForm.getValue(key); } + const maxBucketsMode = parseInt(this.userForm.getValue('max_buckets_mode'), 10); + if (_.includes([-1, 0], maxBucketsMode)) { + // -1 => Disable bucket creation. + // 0 => Unlimited bucket creation. + result['max_buckets'] = maxBucketsMode; + } return result; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.ts index 5b388049dd2..46c40201963 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.ts @@ -71,7 +71,12 @@ export class RgwUserListComponent { { name: this.i18n('Max. buckets'), prop: 'max_buckets', - flexGrow: 1 + flexGrow: 1, + cellTransformation: CellTemplate.map, + customTemplateConfig: { + '-1': this.i18n('Disabled'), + 0: this.i18n('Unlimited') + } } ]; const getUserUri = () => 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 b58c15dffb4..aff3d803ce8 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 @@ -56,7 +56,7 @@ export class RgwUserService { return this.http.get(`${this.url}/${uid}/quota`); } - create(args: Record) { + create(args: Record) { let params = new HttpParams(); _.keys(args).forEach((key) => { params = params.append(key, args[key]); @@ -64,7 +64,7 @@ export class RgwUserService { return this.http.post(this.url, null, { params: params }); } - update(uid: string, args: Record) { + update(uid: string, args: Record) { let params = new HttpParams(); _.keys(args).forEach((key) => { params = params.append(key, args[key]); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html index 9330ae85fab..c985616d3a6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html @@ -284,3 +284,9 @@   + + + {{ value | map:column?.customTemplateConfig }} + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts index 371893692dd..4433326cebb 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts @@ -59,6 +59,8 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O classAddingTpl: TemplateRef; @ViewChild('badgeTpl', { static: true }) badgeTpl: TemplateRef; + @ViewChild('mapTpl', { static: true }) + mapTpl: TemplateRef; // This is the array with the items to be shown. @Input() @@ -510,6 +512,7 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O this.cellTemplates.executing = this.executingTpl; this.cellTemplates.classAdding = this.classAddingTpl; this.cellTemplates.badge = this.badgeTpl; + this.cellTemplates.map = this.mapTpl; } useCustomClass(value: any): string { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/cell-template.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/cell-template.enum.ts index 76746b3bd03..47f180bc5a9 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/cell-template.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/cell-template.enum.ts @@ -10,6 +10,7 @@ export enum CellTemplate { // supports an optional custom configuration: // { // ... + // cellTransformation: CellTemplate.badge, // customTemplateConfig: { // class?: string; // Additional class name. // prefix?: any; // Prefix of the value to be displayed. @@ -19,5 +20,14 @@ export enum CellTemplate { // } // } // } - badge = 'badge' + badge = 'badge', + // Maps the value using the given dictionary. + // { + // ... + // cellTransformation: CellTemplate.map, + // customTemplateConfig: { + // [key: any]: any + // } + // } + map = 'map' } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.spec.ts new file mode 100644 index 00000000000..337d5c37b28 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.spec.ts @@ -0,0 +1,25 @@ +import { MapPipe } from './map.pipe'; + +describe('MapPipe', () => { + const pipe = new MapPipe(); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('map value [1]', () => { + expect(pipe.transform('foo')).toBe('foo'); + }); + + it('map value [2]', () => { + expect(pipe.transform('foo', { '-1': 'disabled', 0: 'unlimited' })).toBe('foo'); + }); + + it('map value [3]', () => { + expect(pipe.transform(-1, { '-1': 'disabled', 0: 'unlimited' })).toBe('disabled'); + }); + + it('map value [4]', () => { + expect(pipe.transform(0, { '-1': 'disabled', 0: 'unlimited' })).toBe('unlimited'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.ts new file mode 100644 index 00000000000..9242bb4ebe2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.ts @@ -0,0 +1,15 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import * as _ from 'lodash'; + +@Pipe({ + name: 'map' +}) +export class MapPipe implements PipeTransform { + transform(value: string | number, map?: object): any { + if (!_.isPlainObject(map)) { + return value; + } + return _.get(map, value, value); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts index 9b42296f001..5a70d512ff6 100755 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts @@ -19,6 +19,7 @@ import { IopsPipe } from './iops.pipe'; import { IscsiBackstorePipe } from './iscsi-backstore.pipe'; import { JoinPipe } from './join.pipe'; import { LogPriorityPipe } from './log-priority.pipe'; +import { MapPipe } from './map.pipe'; import { MillisecondsPipe } from './milliseconds.pipe'; import { NotAvailablePipe } from './not-available.pipe'; import { OrdinalPipe } from './ordinal.pipe'; @@ -54,7 +55,8 @@ import { UpperFirstPipe } from './upper-first.pipe'; IopsPipe, UpperFirstPipe, RbdConfigurationSourcePipe, - DurationPipe + DurationPipe, + MapPipe ], exports: [ ArrayPipe, @@ -81,7 +83,8 @@ import { UpperFirstPipe } from './upper-first.pipe'; IopsPipe, UpperFirstPipe, RbdConfigurationSourcePipe, - DurationPipe + DurationPipe, + MapPipe ], providers: [ ArrayPipe, @@ -104,7 +107,8 @@ import { UpperFirstPipe } from './upper-first.pipe'; IopsPipe, MillisecondsPipe, NotAvailablePipe, - UpperFirstPipe + UpperFirstPipe, + MapPipe ] }) export class PipesModule {} -- 2.39.5