From: Stephan Müller Date: Tue, 15 Oct 2019 13:37:44 +0000 (+0200) Subject: mgr/dashboard: CephFS snapshot management UI X-Git-Tag: v15.1.0~834^2 X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=refs%2Fpull%2F30996%2Fhead;p=ceph.git mgr/dashboard: CephFS snapshot management UI CephFS snapshots can now be created on a directory basis. Multiple snapshots can be deleted at once. Fixes: https://tracker.ceph.com/issues/41791 Signed-off-by: Volker Theile Signed-off-by: Stephan Müller --- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.html index e5954761dc35..80d7a6005a71 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.html @@ -25,9 +25,17 @@ Snapshots - + + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.spec.ts index 90d283e320c4..7f3eba70e290 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.spec.ts @@ -1,10 +1,19 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NgBootstrapFormValidationModule } from 'ng-bootstrap-form-validation'; import { NodeEvent, Tree, TreeModel, TreeModule } from 'ng2-tree'; +import { BsModalRef, BsModalService, ModalModule } from 'ngx-bootstrap/modal'; +import { ToastrModule } from 'ngx-toastr'; import { of } from 'rxjs'; -import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper'; +import { + configureTestBed, + i18nProviders, + modalServiceShow, + PermissionHelper +} from '../../../../testing/unit-test-helper'; import { CephfsService } from '../../../shared/api/cephfs.service'; import { CephfsDir, @@ -17,8 +26,10 @@ import { CephfsDirectoriesComponent } from './cephfs-directories.component'; describe('CephfsDirectoriesComponent', () => { let component: CephfsDirectoriesComponent; let fixture: ComponentFixture; + let cephfsService: CephfsService; let lsDirSpy; let originalDate; + let modal; // Get's private attributes or functions const get = { @@ -31,6 +42,8 @@ describe('CephfsDirectoriesComponent', () => { let mockData: { nodes: TreeModel[]; parent: Tree; + createdSnaps: CephfsSnapshot[] | any[]; + deletedSnaps: CephfsSnapshot[] | any[]; }; // Object contains mock functions @@ -40,22 +53,34 @@ describe('CephfsDirectoriesComponent', () => { const name = 'someSnapshot'; const snapshots = []; for (let i = 0; i < howMany; i++) { - const path = `${dirPath}/.snap/${name}${i}`; + const snapName = `${name}${i + 1}`; + const path = `${dirPath}/.snap/${snapName}`; const created = new Date( +new Date() - 3600 * 24 * 1000 * howMany * (howMany - i) ).toString(); - snapshots.push({ name, path, created }); + snapshots.push({ name: snapName, path, created }); } return snapshots; }, dir: (path: string, name: string, modifier: number): CephfsDir => { const dirPath = `${path === '/' ? '' : path}/${name}`; + let snapshots = mockLib.snapshots(path, modifier); + const extraSnapshots = mockData.createdSnaps.filter((s) => s.path === dirPath); + if (extraSnapshots.length > 0) { + snapshots = snapshots.concat(extraSnapshots); + } + const deletedSnapshots = mockData.deletedSnaps + .filter((s) => s.path === dirPath) + .map((s) => s.name); + if (deletedSnapshots.length > 0) { + snapshots = snapshots.filter((s) => !deletedSnapshots.includes(s.name)); + } return { name, path: dirPath, parent: path, quotas: mockLib.quotas(1024 * modifier, 10 * modifier), - snapshots: mockLib.snapshots(path, modifier) + snapshots: snapshots }; }, // Only used inside other mocks @@ -80,6 +105,26 @@ describe('CephfsDirectoriesComponent', () => { }); return of(data); }, + mkSnapshot: (_id, path, name) => { + mockData.createdSnaps.push({ + name, + path, + created: new Date().toString() + }); + return of(name); + }, + rmSnapshot: (_id, path, name) => { + mockData.deletedSnaps.push({ + name, + path, + created: new Date().toString() + }); + return of(name); + }, + modalShow: (comp, init) => { + modal = modalServiceShow(comp, init); + return modal.ref; + }, date: (arg) => (arg ? new originalDate(arg) : new Date('2022-02-22T00:00:00')), getControllerByPath: (path: string) => { return { @@ -89,7 +134,9 @@ describe('CephfsDirectoriesComponent', () => { }, // Only used inside other mocks to mock "tree.expand" of every node expand: (path: string) => { - component.updateDirectory(path, (nodes) => (mockData.nodes = mockData.nodes.concat(nodes))); + component.updateDirectory(path, (nodes) => { + mockData.nodes = mockData.nodes.concat(nodes); + }); }, getNodeEvent: (path: string): NodeEvent => { const tree = mockData.nodes.find((n) => n.id === path) as Tree; @@ -119,6 +166,15 @@ describe('CephfsDirectoriesComponent', () => { id: dir.path, value: name }); + }, + createSnapshotThroughModal: (name: string) => { + component.createSnapshot(); + modal.component.onSubmitForm({ name }); + }, + deleteSnapshotsThroughModal: (snapshots: CephfsSnapshot[]) => { + component.snapshot.selection.selected = snapshots; + component.deleteSnapshotModal(); + modal.component.callSubmitAction(); } }; @@ -128,6 +184,8 @@ describe('CephfsDirectoriesComponent', () => { nodeLength: (n: number) => expect(mockData.nodes.length).toBe(n), lsDirCalledTimes: (n: number) => expect(lsDirSpy).toHaveBeenCalledTimes(n), requestedPaths: (expected: string[]) => expect(get.requestedPaths()).toEqual(expected), + snapshotsByName: (snaps: string[]) => + expect(component.selectedDir.snapshots.map((s) => s.name)).toEqual(snaps), quotaSettings: ( fileValue: number | string, fileOrigin: string, @@ -141,20 +199,35 @@ describe('CephfsDirectoriesComponent', () => { }; configureTestBed({ - imports: [HttpClientTestingModule, SharedModule, TreeModule], + imports: [ + HttpClientTestingModule, + SharedModule, + RouterTestingModule, + TreeModule, + NgBootstrapFormValidationModule.forRoot(), + ToastrModule.forRoot(), + ModalModule.forRoot() + ], declarations: [CephfsDirectoriesComponent], - providers: [i18nProviders] + providers: [i18nProviders, BsModalRef] }); beforeEach(() => { mockData = { nodes: undefined, - parent: undefined + parent: undefined, + createdSnaps: [], + deletedSnaps: [] }; originalDate = Date; spyOn(global, 'Date').and.callFake(mockLib.date); - lsDirSpy = spyOn(TestBed.get(CephfsService), 'lsDir').and.callFake(mockLib.lsDir); + cephfsService = TestBed.get(CephfsService); + lsDirSpy = spyOn(cephfsService, 'lsDir').and.callFake(mockLib.lsDir); + spyOn(cephfsService, 'mkSnapshot').and.callFake(mockLib.mkSnapshot); + spyOn(cephfsService, 'rmSnapshot').and.callFake(mockLib.rmSnapshot); + + spyOn(TestBed.get(BsModalService), 'show').and.callFake(mockLib.modalShow); fixture = TestBed.createComponent(CephfsDirectoriesComponent); component = fixture.componentInstance; @@ -169,6 +242,58 @@ describe('CephfsDirectoriesComponent', () => { expect(component).toBeTruthy(); }); + describe('mock self test', () => { + it('tests snapshots mock', () => { + expect(mockLib.snapshots('/a', 1).map((s) => ({ name: s.name, path: s.path }))).toEqual([ + { + name: 'someSnapshot1', + path: '/a/.snap/someSnapshot1' + } + ]); + expect(mockLib.snapshots('/a/b', 3).map((s) => ({ name: s.name, path: s.path }))).toEqual([ + { + name: 'someSnapshot1', + path: '/a/b/.snap/someSnapshot1' + }, + { + name: 'someSnapshot2', + path: '/a/b/.snap/someSnapshot2' + }, + { + name: 'someSnapshot3', + path: '/a/b/.snap/someSnapshot3' + } + ]); + }); + + it('tests dir mock', () => { + const path = '/a/b/c'; + mockData.createdSnaps = [{ path, name: 's1' }, { path, name: 's2' }]; + mockData.deletedSnaps = [{ path, name: 'someSnapshot2' }, { path, name: 's2' }]; + const dir = mockLib.dir('/a/b', 'c', 2); + expect(dir.path).toBe('/a/b/c'); + expect(dir.parent).toBe('/a/b'); + expect(dir.quotas).toEqual({ max_bytes: 2048, max_files: 20 }); + expect(dir.snapshots.map((s) => s.name)).toEqual(['someSnapshot1', 's1']); + }); + + it('tests lsdir mock', () => { + let dirs: CephfsDir[] = []; + mockLib.lsDir(2, '/a').subscribe((x) => (dirs = x)); + expect(dirs.map((d) => d.path)).toEqual([ + '/a/c', + '/a/a', + '/a/b', + '/a/c/c', + '/a/c/a', + '/a/c/b', + '/a/a/c', + '/a/a/a', + '/a/a/b' + ]); + }); + }); + it('calls lsDir only if an id exits', () => { component.ngOnChanges(); assert.lsDirCalledTimes(0); @@ -320,4 +445,76 @@ describe('CephfsDirectoriesComponent', () => { }); }); }); + + describe('snapshots', () => { + beforeEach(() => { + mockLib.changeId(1); + mockLib.selectNode('/a'); + }); + + it('should create a snapshot', () => { + mockLib.createSnapshotThroughModal('newSnap'); + expect(cephfsService.mkSnapshot).toHaveBeenCalledWith(1, '/a', 'newSnap'); + assert.snapshotsByName(['someSnapshot1', 'newSnap']); + }); + + it('should delete a snapshot', () => { + mockLib.createSnapshotThroughModal('deleteMe'); + mockLib.deleteSnapshotsThroughModal([component.selectedDir.snapshots[1]]); + assert.snapshotsByName(['someSnapshot1']); + }); + + it('should delete all snapshots', () => { + mockLib.createSnapshotThroughModal('deleteAll'); + mockLib.deleteSnapshotsThroughModal(component.selectedDir.snapshots); + assert.snapshotsByName([]); + }); + + afterEach(() => { + // Makes sure the directory is updated correctly + expect(component.selectedDir).toEqual(get.nodeIds()[component.selectedDir.path]); + }); + }); + + it('should test all snapshot table actions combinations', () => { + const permissionHelper: PermissionHelper = new PermissionHelper(component.permission); + const tableActions = permissionHelper.setPermissionsAndGetActions( + component.snapshot.tableActions + ); + + expect(tableActions).toEqual({ + 'create,update,delete': { + actions: ['Create', 'Delete'], + primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Create' } + }, + 'create,update': { + actions: ['Create'], + primary: { multiple: 'Create', executing: 'Create', single: 'Create', no: 'Create' } + }, + 'create,delete': { + actions: ['Create', 'Delete'], + primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Create' } + }, + create: { + actions: ['Create'], + primary: { multiple: 'Create', executing: 'Create', single: 'Create', no: 'Create' } + }, + 'update,delete': { + actions: ['Delete'], + primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Delete' } + }, + update: { + actions: [], + primary: { multiple: '', executing: '', single: '', no: '' } + }, + delete: { + actions: ['Delete'], + primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Delete' } + }, + 'no-permissions': { + actions: [], + primary: { multiple: '', executing: '', single: '', no: '' } + } + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.ts index b70286f56574..58f370de0b90 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.ts @@ -1,15 +1,25 @@ import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core'; import { I18n } from '@ngx-translate/i18n-polyfill'; -import { SortDirection, SortPropDir } from '@swimlane/ngx-datatable'; import * as _ from 'lodash'; +import * as moment from 'moment'; import { NodeEvent, Tree, TreeComponent, TreeModel } from 'ng2-tree'; +import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal'; import { CephfsService } from '../../../shared/api/cephfs.service'; +import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component'; +import { FormModalComponent } from '../../../shared/components/form-modal/form-modal.component'; +import { Icons } from '../../../shared/enum/icons.enum'; +import { NotificationType } from '../../../shared/enum/notification-type.enum'; +import { CdTableAction } from '../../../shared/models/cd-table-action'; import { CdTableColumn } from '../../../shared/models/cd-table-column'; -import { CephfsDir } from '../../../shared/models/cephfs-directory-models'; +import { CdTableSelection } from '../../../shared/models/cd-table-selection'; +import { CephfsDir, CephfsSnapshot } from '../../../shared/models/cephfs-directory-models'; +import { Permission } from '../../../shared/models/permissions'; import { CdDatePipe } from '../../../shared/pipes/cd-date.pipe'; import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe'; +import { AuthStorageService } from '../../../shared/services/auth-storage.service'; +import { NotificationService } from '../../../shared/services/notification.service'; @Component({ selector: 'cd-cephfs-directories', @@ -25,29 +35,40 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges { @Input() id: number; + private modalRef: BsModalRef; private dirs: CephfsDir[]; private nodeIds: { [path: string]: CephfsDir }; private requestedPaths: string[]; + private selectedNode: Tree; + permission: Permission; selectedDir: CephfsDir; - tree: TreeModel; settings: { name: string; value: number | string; origin: string; }[]; - settingsColumns: CdTableColumn[]; - snapshot: { columns: CdTableColumn[]; sortProperties: SortPropDir[] }; + snapshot: { + columns: CdTableColumn[]; + selection: CdTableSelection; + tableActions: CdTableAction[]; + updateSelection: Function; + }; + tree: TreeModel; constructor( + private authStorageService: AuthStorageService, + private modalService: BsModalService, private cephfsService: CephfsService, private cdDatePipe: CdDatePipe, private i18n: I18n, + private notificationService: NotificationService, private dimlessBinaryPipe: DimlessBinaryPipe ) {} ngOnInit() { + this.permission = this.authStorageService.getPermissions().cephfs; this.settingsColumns = [ { prop: 'name', @@ -88,10 +109,25 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges { pipe: this.cdDatePipe } ], - sortProperties: [ + selection: new CdTableSelection(), + updateSelection: (selection: CdTableSelection) => { + this.snapshot.selection = selection; + }, + tableActions: [ + { + name: this.i18n('Create'), + icon: Icons.add, + permission: 'create', + canBePrimary: (selection) => !selection.hasSelection, + click: () => this.createSnapshot() + }, { - dir: SortDirection.desc, - prop: 'created' + name: this.i18n('Delete'), + icon: Icons.destroy, + permission: 'delete', + click: () => this.deleteSnapshotModal(), + canBePrimary: (selection) => selection.hasSelection, + disable: (selection) => !selection.hasSelection } ] }; @@ -148,11 +184,11 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges { private loadDirectory(data: CephfsDir[], path: string, callback: (x: any[]) => void) { if (path !== '/') { - // Removes duplicate directories + // As always to levels are loaded all sub-directories of the current called path are + // already loaded, that's why they are filtered out. data = data.filter((dir) => dir.parent !== path); } - const dirs = this.dirs.concat(data); - this.dirs = dirs; + this.dirs = this.dirs.concat(data); this.getChildren(path, callback); } @@ -187,6 +223,7 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges { this.treeComponent.getControllerByNodeId(node.id).expand(); this.setSettings(node); this.selectedDir = this.getDirectory(node); + this.selectedNode = node; } private setSettings(node: Tree) { @@ -236,4 +273,81 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges { const path = node.id as string; return this.nodeIds[path]; } + + createSnapshot() { + // Create a snapshot. Auto-generate a snapshot name by default. + const path = this.selectedDir.path; + this.modalService.show(FormModalComponent, { + initialState: { + titleText: this.i18n('Create Snapshot'), + message: this.i18n('Please enter the name of the snapshot.'), + fields: [ + { + type: 'inputText', + name: 'name', + value: `${moment().toISOString(true)}`, + required: true + } + ], + submitButtonText: this.i18n('Create Snapshot'), + onSubmit: (values) => { + this.cephfsService.mkSnapshot(this.id, path, values.name).subscribe((name) => { + this.notificationService.show( + NotificationType.success, + this.i18n('Created snapshot "{{name}}" for "{{path}}"', { + name: name, + path: path + }) + ); + this.forceDirRefresh(); + }); + } + } + }); + } + + /** + * Forces an update of the current selected directory + * + * As all nodes point by their path on an directory object, the easiest way is to update + * the objects by merge with their latest change. + */ + private forceDirRefresh() { + const path = this.selectedNode.parent.id as string; + this.cephfsService.lsDir(this.id, path).subscribe((data) => + data.forEach((d) => { + Object.assign(this.dirs.find((sub) => sub.path === d.path), d); + }) + ); + } + + deleteSnapshotModal() { + this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, { + initialState: { + itemDescription: 'CephFs Snapshot', + itemNames: this.snapshot.selection.selected.map( + (snapshot: CephfsSnapshot) => snapshot.name + ), + submitAction: () => this.deleteSnapshot() + } + }); + } + + deleteSnapshot() { + const path = this.selectedDir.path; + this.snapshot.selection.selected.forEach((snapshot: CephfsSnapshot) => { + const name = snapshot.name; + this.cephfsService.rmSnapshot(this.id, path, name).subscribe(() => { + this.notificationService.show( + NotificationType.success, + this.i18n('Deleted snapshot "{{name}}" for "{{path}}"', { + name: name, + path: path + }) + ); + }); + }); + this.modalRef.hide(); + this.forceDirRefresh(); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts index 5910f5986422..f348584ca292 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts @@ -61,6 +61,21 @@ describe('CephfsService', () => { const req = httpTesting.expectOne('api/cephfs/1/ls_dir?depth=2'); expect(req.request.method).toBe('GET'); service.lsDir(2, '/some/path').subscribe(); - httpTesting.expectOne('api/cephfs/2/ls_dir?depth=2&path=%2Fsome%2Fpath'); + httpTesting.expectOne('api/cephfs/2/ls_dir?depth=2&path=%252Fsome%252Fpath'); + }); + + it('should call mkSnapshot', () => { + service.mkSnapshot(3, '/some/path').subscribe(); + const req = httpTesting.expectOne('api/cephfs/3/mk_snapshot?path=%252Fsome%252Fpath'); + expect(req.request.method).toBe('POST'); + + service.mkSnapshot(4, '/some/other/path', 'snap').subscribe(); + httpTesting.expectOne('api/cephfs/4/mk_snapshot?path=%252Fsome%252Fother%252Fpath&name=snap'); + }); + + it('should call rmSnapshot', () => { + service.rmSnapshot(1, '/some/path', 'snap').subscribe(); + const req = httpTesting.expectOne('api/cephfs/1/rm_snapshot?path=%252Fsome%252Fpath&name=snap'); + expect(req.request.method).toBe('POST'); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts index 997ba3ce73f0..1c1da64ae77b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts @@ -1,11 +1,14 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import * as _ from 'lodash'; import { Observable } from 'rxjs'; +import { cdEncode } from '../decorators/cd-encode'; import { CephfsDir } from '../models/cephfs-directory-models'; import { ApiModule } from './api.module'; +@cdEncode @Injectable({ providedIn: ApiModule }) @@ -45,4 +48,20 @@ export class CephfsService { getMdsCounters(id) { return this.http.get(`${this.baseURL}/${id}/mds_counters`); } + + mkSnapshot(id, path, name?) { + let params = new HttpParams(); + params = params.append('path', path); + if (!_.isUndefined(name)) { + params = params.append('name', name); + } + return this.http.post(`${this.baseURL}/${id}/mk_snapshot`, null, { params: params }); + } + + rmSnapshot(id, path, name) { + let params = new HttpParams(); + params = params.append('path', path); + params = params.append('name', name); + return this.http.post(`${this.baseURL}/${id}/rm_snapshot`, null, { params: params }); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts index 7c66297c6276..2edff8c53ab1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts @@ -18,6 +18,7 @@ import { BackButtonComponent } from './back-button/back-button.component'; import { ConfigOptionComponent } from './config-option/config-option.component'; import { ConfirmationModalComponent } from './confirmation-modal/confirmation-modal.component'; import { CriticalConfirmationModalComponent } from './critical-confirmation-modal/critical-confirmation-modal.component'; +import { FormModalComponent } from './form-modal/form-modal.component'; import { GrafanaComponent } from './grafana/grafana.component'; import { HelperComponent } from './helper/helper.component'; import { LanguageSelectorComponent } from './language-selector/language-selector.component'; @@ -67,7 +68,8 @@ import { ViewCacheComponent } from './view-cache/view-cache.component'; BackButtonComponent, RefreshSelectorComponent, ConfigOptionComponent, - AlertPanelComponent + AlertPanelComponent, + FormModalComponent ], providers: [], exports: [ @@ -88,6 +90,11 @@ import { ViewCacheComponent } from './view-cache/view-cache.component'; ConfigOptionComponent, AlertPanelComponent ], - entryComponents: [ModalComponent, CriticalConfirmationModalComponent, ConfirmationModalComponent] + entryComponents: [ + ModalComponent, + CriticalConfirmationModalComponent, + ConfirmationModalComponent, + FormModalComponent + ] }) export class ComponentsModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html new file mode 100755 index 000000000000..d1931943dc7c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html @@ -0,0 +1,46 @@ + + + {{ titleText }} + + +
+ + +
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.scss new file mode 100755 index 000000000000..e69de29bb2d1 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.spec.ts new file mode 100755 index 000000000000..70c1872023cc --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.spec.ts @@ -0,0 +1,93 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { NgBootstrapFormValidationModule } from 'ng-bootstrap-form-validation'; +import { BsModalRef, ModalModule } from 'ngx-bootstrap/modal'; + +import { + configureTestBed, + FixtureHelper, + i18nProviders +} from '../../../../testing/unit-test-helper'; +import { SharedModule } from '../../shared.module'; +import { FormModalComponent } from './form-modal.component'; + +describe('InputModalComponent', () => { + let component: FormModalComponent; + let fixture: ComponentFixture; + let fh: FixtureHelper; + let submitted; + + const initialState = { + titleText: 'Some title', + message: 'Some description', + fields: [ + { + type: 'inputText', + name: 'requiredField', + value: 'some-value', + required: true + }, + { + type: 'inputText', + name: 'optionalField', + label: 'Optional' + } + ], + submitButtonText: 'Submit button name', + onSubmit: (values) => (submitted = values) + }; + + configureTestBed({ + imports: [ + ModalModule.forRoot(), + NgBootstrapFormValidationModule.forRoot(), + RouterTestingModule, + ReactiveFormsModule, + SharedModule + ], + providers: [i18nProviders, BsModalRef] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(FormModalComponent); + component = fixture.componentInstance; + Object.assign(component, initialState); + fixture.detectChanges(); + fh = new FixtureHelper(fixture); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('has the defined title', () => { + fh.expectTextToBe('.modal-title', 'Some title'); + }); + + it('has the defined description', () => { + fh.expectTextToBe('.modal-body > p', 'Some description'); + }); + + it('should display both inputs', () => { + fh.expectElementVisible('#requiredField', true); + fh.expectElementVisible('#optionalField', true); + }); + + it('has one defined label field', () => { + fh.expectTextToBe('.col-form-label', 'Optional'); + }); + + it('has a predefined values for requiredField', () => { + fh.expectFormFieldToBe('#requiredField', 'some-value'); + }); + + it('gives back all form values on submit', () => { + component.onSubmitForm(component.formGroup.value); + expect(submitted).toEqual({ + requiredField: 'some-value', + optionalField: null + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.ts new file mode 100755 index 000000000000..a4da08b1160c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.ts @@ -0,0 +1,57 @@ +import { Component, OnInit } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; + +import * as _ from 'lodash'; +import { BsModalRef } from 'ngx-bootstrap/modal'; + +import { CdFormBuilder } from '../../forms/cd-form-builder'; + +interface CdFormFieldConfig { + type: 'textInput'; + name: string; + label?: string; + value?: any; + required?: boolean; +} + +@Component({ + selector: 'cd-form-modal', + templateUrl: './form-modal.component.html', + styleUrls: ['./form-modal.component.scss'] +}) +export class FormModalComponent implements OnInit { + // Input + titleText: string; + message: string; + fields: CdFormFieldConfig[]; + submitButtonText: string; + onSubmit: Function; + + // Internal + formGroup: FormGroup; + + constructor(public bsModalRef: BsModalRef, private formBuilder: CdFormBuilder) {} + + 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 }); + }); + this.formGroup = this.formBuilder.group(controlsConfig); + } + + ngOnInit() { + this.createForm(); + } + + onSubmitForm(values) { + this.bsModalRef.hide(); + if (_.isFunction(this.onSubmit)) { + this.onSubmit(values); + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts b/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts index 20605b028f9d..0b987f757199 100644 --- a/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts +++ b/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts @@ -237,6 +237,10 @@ export class FixtureHelper { expect(props['value'] || props['checked'].toString()).toBe(value); } + expectTextToBe(css: string, value: string) { + expect(this.getText(css)).toBe(value); + } + clickElement(css: string) { this.getElementByCss(css).triggerEventHandler('click', null); this.fixture.detectChanges();