From 10c1241abc53837044b176fec6213d604517ddaa Mon Sep 17 00:00:00 2001 From: Ryan Mathis Date: Tue, 14 Aug 2018 17:11:36 +0000 Subject: [PATCH] Merged in feature/SHIBUI-331 (pull request #155) SHIBUI-331 Implemented unsaved changes modal in provider editor * SHIBUI-331 Implemented unsaved changes modal in provider editor Approved-by: Shibui Jenkins Approved-by: Ryan Mathis --- .../filter/entity-attributes-filter.spec.ts | 4 ++ .../domain/service/provider.service.ts | 6 +- .../dashboard-providers-list.component.ts | 1 - .../component/unsaved-provider.dialog.html | 16 +++++ .../component/unsaved-provider.dialog.spec.ts | 46 +++++++++++++ .../component/unsaved-provider.dialog.ts | 27 ++++++++ .../container/provider-edit.component.spec.ts | 50 ++++++++++++++- .../container/provider-edit.component.ts | 37 +++++++++-- .../app/metadata/provider/provider.module.ts | 8 ++- .../app/metadata/provider/provider.routing.ts | 7 +- .../provider/reducer/entity.reducer.ts | 2 +- .../resolver/container/editor.component.ts | 1 - .../wizard/component/wizard.component.spec.ts | 64 +++++++++++++++++++ .../app/wizard/component/wizard.component.ts | 8 ++- 14 files changed, 259 insertions(+), 18 deletions(-) create mode 100644 ui/src/app/metadata/provider/component/unsaved-provider.dialog.html create mode 100644 ui/src/app/metadata/provider/component/unsaved-provider.dialog.spec.ts create mode 100644 ui/src/app/metadata/provider/component/unsaved-provider.dialog.ts diff --git a/ui/src/app/metadata/domain/entity/filter/entity-attributes-filter.spec.ts b/ui/src/app/metadata/domain/entity/filter/entity-attributes-filter.spec.ts index 04d1234be..22eae4cda 100644 --- a/ui/src/app/metadata/domain/entity/filter/entity-attributes-filter.spec.ts +++ b/ui/src/app/metadata/domain/entity/filter/entity-attributes-filter.spec.ts @@ -13,5 +13,9 @@ describe('EntityAttributesFilter Entity', () => { expect(entity).toBeDefined(); expect(entity.resourceId).toBe('foo'); expect(entity.enabled).toBe(entity.filterEnabled); + expect(entity.id).toBe(entity.resourceId); + expect(entity.getId()).toBe(entity.entityId); + expect(entity.getDisplayId()).toBe(entity.entityId); + expect(entity.isDraft()).toBe(false); }); }); diff --git a/ui/src/app/metadata/domain/service/provider.service.ts b/ui/src/app/metadata/domain/service/provider.service.ts index 3d83d3c55..ede4bb172 100644 --- a/ui/src/app/metadata/domain/service/provider.service.ts +++ b/ui/src/app/metadata/domain/service/provider.service.ts @@ -1,11 +1,13 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; import { MetadataProvider } from '../../domain/model'; import { FileBackedHttpMetadataProvider } from '../model/providers'; import { ProviderOrder } from '../model/metadata-order'; + @Injectable() export class MetadataProviderService { @@ -17,7 +19,9 @@ export class MetadataProviderService { private http: HttpClient ) {} query(): Observable { - return this.http.get(`${this.base}${this.endpoint}`, {}); + return this.http.get(`${this.base}${this.endpoint}`).pipe( + map(providers => providers.filter(p => p['@type'] !== 'BaseMetadataResolver')) + ); } find(id: string): Observable { diff --git a/ui/src/app/metadata/manager/container/dashboard-providers-list.component.ts b/ui/src/app/metadata/manager/container/dashboard-providers-list.component.ts index 1815d4ccf..88ec0b19e 100644 --- a/ui/src/app/metadata/manager/container/dashboard-providers-list.component.ts +++ b/ui/src/app/metadata/manager/container/dashboard-providers-list.component.ts @@ -28,7 +28,6 @@ export class DashboardProvidersListComponent implements OnInit { ngOnInit(): void { this.providers$ = this.store.select(getOrderedProviders); this.providersOpen$ = this.store.select(getOpenProviders); - this.providers$.subscribe(p => console.log(p)); } view(id: string, page: string): void { diff --git a/ui/src/app/metadata/provider/component/unsaved-provider.dialog.html b/ui/src/app/metadata/provider/component/unsaved-provider.dialog.html new file mode 100644 index 000000000..f556f49cd --- /dev/null +++ b/ui/src/app/metadata/provider/component/unsaved-provider.dialog.html @@ -0,0 +1,16 @@ + +
+ + + +
+
\ No newline at end of file diff --git a/ui/src/app/metadata/provider/component/unsaved-provider.dialog.spec.ts b/ui/src/app/metadata/provider/component/unsaved-provider.dialog.spec.ts new file mode 100644 index 000000000..e9f0e4554 --- /dev/null +++ b/ui/src/app/metadata/provider/component/unsaved-provider.dialog.spec.ts @@ -0,0 +1,46 @@ +import { Component, ViewChild } from '@angular/core'; +import { TestBed, async, ComponentFixture} from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { SharedModule } from '../../../shared/shared.module'; +import { UnsavedProviderComponent } from './unsaved-provider.dialog'; +import { NgbActiveModalStub } from '../../../../testing/modal.stub'; + +@Component({ + template: ` + + ` +}) +class TestHostComponent { + @ViewChild(UnsavedProviderComponent) + public componentUnderTest: UnsavedProviderComponent; +} + +describe('Unsaved Provider Dialog Component', () => { + + let fixture: ComponentFixture; + let instance: TestHostComponent; + let cmp: UnsavedProviderComponent; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [], + declarations: [ + UnsavedProviderComponent, + TestHostComponent + ], + providers: [ + { provide: NgbActiveModal, useClass: NgbActiveModalStub } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + instance = fixture.componentInstance; + cmp = instance.componentUnderTest; + fixture.detectChanges(); + })); + + it('should instantiate the component', async(() => { + expect(cmp).toBeTruthy(); + })); +}); diff --git a/ui/src/app/metadata/provider/component/unsaved-provider.dialog.ts b/ui/src/app/metadata/provider/component/unsaved-provider.dialog.ts new file mode 100644 index 000000000..4b8d12da3 --- /dev/null +++ b/ui/src/app/metadata/provider/component/unsaved-provider.dialog.ts @@ -0,0 +1,27 @@ +import { Component, Input } from '@angular/core'; + +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { Store, Action } from '@ngrx/store'; +import { Subject } from 'rxjs/Subject'; + +import * as fromEditor from '../reducer'; + +@Component({ + selector: 'unsaved-provider', + templateUrl: './unsaved-provider.dialog.html' +}) +export class UnsavedProviderComponent { + readonly subject: Subject = new Subject(); + + constructor( + public activeModal: NgbActiveModal + ) { } + + close(): void { + this.activeModal.close(); + } + + dismiss(): void { + this.activeModal.dismiss(); + } +} diff --git a/ui/src/app/metadata/provider/container/provider-edit.component.spec.ts b/ui/src/app/metadata/provider/container/provider-edit.component.spec.ts index a62734833..05cb75832 100644 --- a/ui/src/app/metadata/provider/container/provider-edit.component.spec.ts +++ b/ui/src/app/metadata/provider/container/provider-edit.component.spec.ts @@ -1,10 +1,10 @@ import { Component, ViewChild } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, Router, ActivatedRouteSnapshot } from '@angular/router'; import { APP_BASE_HREF } from '@angular/common'; -import { TestBed, async, ComponentFixture } from '@angular/core/testing'; +import { TestBed, async, ComponentFixture, fakeAsync } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { StoreModule, Store, combineReducers } from '@ngrx/store'; -import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { ProviderEditComponent } from './provider-edit.component'; import * as fromRoot from '../reducer'; import * as fromWizard from '../../../wizard/reducer'; @@ -12,6 +12,9 @@ import { SharedModule } from '../../../shared/shared.module'; import { ActivatedRouteStub } from '../../../../testing/activated-route.stub'; import { FileBackedHttpMetadataProviderEditor } from '../model'; import { ProviderEditorNavComponent } from '../component/provider-editor-nav.component'; +import { NgbModalStub } from '../../../../testing/modal.stub'; +import { MetadataProvider } from '../../domain/model'; +import { of } from 'rxjs'; @Component({ template: ` @@ -32,6 +35,7 @@ describe('Provider Edit Component', () => { let router: Router; let activatedRoute: ActivatedRouteStub = new ActivatedRouteStub(); let child: ActivatedRouteStub = new ActivatedRouteStub(); + let modal: NgbModal; child.testParamMap = { form: 'common' }; activatedRoute.firstChild = child; @@ -60,6 +64,7 @@ describe('Provider Edit Component', () => { ProviderEditorNavComponent ], providers: [ + { provide: NgbModal, useClass: NgbModalStub }, { provide: ActivatedRoute, useValue: activatedRoute }, { provide: APP_BASE_HREF, useValue: '/' } ] @@ -67,6 +72,7 @@ describe('Provider Edit Component', () => { store = TestBed.get(Store); router = TestBed.get(Router); + modal = TestBed.get(NgbModal); spyOn(store, 'dispatch'); fixture = TestBed.createComponent(TestHostComponent); @@ -104,8 +110,46 @@ describe('Provider Edit Component', () => { describe('cancel method', () => { it('should route to the metadata manager', () => { spyOn(router, 'navigate'); + spyOn(app, 'clear'); app.cancel(); expect(router.navigate).toHaveBeenCalled(); + expect(app.clear).toHaveBeenCalled(); + }); + }); + + describe('clear method', () => { + it('should dispatch actions to clear the reducer state', () => { + app.clear(); + expect(store.dispatch).toHaveBeenCalled(); + }); + }); + + describe('canDeactivate method', () => { + it('should check if the current route is another edit page', (done) => { + let route = new ActivatedRouteStub(), + snapshot = route.snapshot; + let result = app.canDeactivate(null, { url: 'edit', root: null }, { url: 'edit', root: null }); + result.subscribe(can => { + expect(can).toBe(true); + done(); + }); + fixture.detectChanges(); + }); + + it('should open a modal', (done) => { + app.latest = { name: 'bar' }; + spyOn(store, 'select').and.returnValue(of(false)); + spyOn(modal, 'open').and.returnValue({ result: Promise.resolve('closed') }); + fixture.detectChanges(); + let route = new ActivatedRouteStub(), + snapshot = route.snapshot; + let result = app.canDeactivate(null, { url: 'edit', root: null }, { url: 'foo', root: null }); + result.subscribe(can => { + expect(can).toBe(false); + expect(modal.open).toHaveBeenCalled(); + done(); + }); + fixture.detectChanges(); }); }); }); diff --git a/ui/src/app/metadata/provider/container/provider-edit.component.ts b/ui/src/app/metadata/provider/container/provider-edit.component.ts index 07a79a7cf..56616fa8b 100644 --- a/ui/src/app/metadata/provider/container/provider-edit.component.ts +++ b/ui/src/app/metadata/provider/container/provider-edit.component.ts @@ -1,6 +1,6 @@ import { Component, OnDestroy } from '@angular/core'; -import { Router, ActivatedRoute } from '@angular/router'; -import { Observable } from 'rxjs'; +import { Router, ActivatedRoute, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; +import { Observable, of } from 'rxjs'; import { skipWhile, map, combineLatest } from 'rxjs/operators'; import { Store } from '@ngrx/store'; import * as fromWizard from '../../../wizard/reducer'; @@ -12,6 +12,10 @@ import { ClearProvider } from '../action/entity.action'; import { Wizard } from '../../../wizard/model'; import { UpdateProviderRequest } from '../action/collection.action'; import { NAV_FORMATS } from '../component/provider-editor-nav.component'; +import { NgbModal } from '../../../../../node_modules/@ng-bootstrap/ng-bootstrap'; +import { UnsavedDialogComponent } from '../../resolver/component/unsaved-dialog.component'; +import { UnsavedProviderComponent } from '../component/unsaved-provider.dialog'; +import { CanComponentDeactivate } from '../../../core/service/can-deactivate.guard'; @Component({ selector: 'provider-edit', @@ -19,7 +23,7 @@ import { NAV_FORMATS } from '../component/provider-editor-nav.component'; styleUrls: [] }) -export class ProviderEditComponent implements OnDestroy { +export class ProviderEditComponent implements OnDestroy, CanComponentDeactivate { provider$: Observable; definition$: Observable>; @@ -36,7 +40,8 @@ export class ProviderEditComponent implements OnDestroy { constructor( private store: Store, private router: Router, - private route: ActivatedRoute + private route: ActivatedRoute, + private modalService: NgbModal ) { this.provider$ = this.store.select(fromProvider.getSelectedProvider).pipe(skipWhile(d => !d)); this.definition$ = this.store.select(fromWizard.getWizardDefinition).pipe(skipWhile(d => !d)); @@ -77,6 +82,10 @@ export class ProviderEditComponent implements OnDestroy { } ngOnDestroy() { + this.clear(); + } + + clear(): void { this.store.dispatch(new ClearProvider()); this.store.dispatch(new ClearWizard()); this.store.dispatch(new ClearEditor()); @@ -87,7 +96,27 @@ export class ProviderEditComponent implements OnDestroy { } cancel(): void { + this.clear(); this.router.navigate(['metadata', 'manager', 'providers']); } + + canDeactivate( + currentRoute: ActivatedRouteSnapshot, + currentState: RouterStateSnapshot, + nextState: RouterStateSnapshot + ): Observable { + if (nextState.url.match('edit')) { return of(true); } + if (Object.keys({ ...this.latest }).length > 0) { + let modal = this.modalService.open(UnsavedProviderComponent); + modal.result.then( + () => { + this.clear(); + this.router.navigate([nextState.url]); + }, + () => console.warn('denied') + ); + } + return this.store.select(fromProvider.getEntityIsSaved); + } } diff --git a/ui/src/app/metadata/provider/provider.module.ts b/ui/src/app/metadata/provider/provider.module.ts index dfd95fa8f..95d037256 100644 --- a/ui/src/app/metadata/provider/provider.module.ts +++ b/ui/src/app/metadata/provider/provider.module.ts @@ -28,6 +28,7 @@ import { EntityEffects } from './effect/entity.effect'; import { ProviderFilterListComponent } from './container/provider-filter-list.component'; import { ProviderEditorNavComponent } from './component/provider-editor-nav.component'; +import { UnsavedProviderComponent } from './component/unsaved-provider.dialog'; @NgModule({ declarations: [ @@ -40,9 +41,12 @@ import { ProviderEditorNavComponent } from './component/provider-editor-nav.comp ProviderSelectComponent, ProviderFilterListComponent, SummaryPropertyComponent, - ProviderEditorNavComponent + ProviderEditorNavComponent, + UnsavedProviderComponent + ], + entryComponents: [ + UnsavedProviderComponent ], - entryComponents: [], imports: [ ReactiveFormsModule, CommonModule, diff --git a/ui/src/app/metadata/provider/provider.routing.ts b/ui/src/app/metadata/provider/provider.routing.ts index 8e6ecc3f5..7acd52fec 100644 --- a/ui/src/app/metadata/provider/provider.routing.ts +++ b/ui/src/app/metadata/provider/provider.routing.ts @@ -10,6 +10,7 @@ import { ProviderFilterListComponent } from './container/provider-filter-list.co import { NewFilterComponent } from '../filter/container/new-filter.component'; import { FilterComponent } from '../filter/container/filter.component'; import { EditFilterComponent } from '../filter/container/edit-filter.component'; +import { CanDeactivateGuard } from '../../core/service/can-deactivate.guard'; export const ProviderRoutes: Routes = [ { @@ -44,6 +45,9 @@ export const ProviderRoutes: Routes = [ path: ':form', component: ProviderEditStepComponent } + ], + canDeactivate: [ + CanDeactivateGuard ] }, { @@ -61,8 +65,7 @@ export const ProviderRoutes: Routes = [ children: [ { path: 'edit', - component: EditFilterComponent, - canDeactivate: [] + component: EditFilterComponent } ] } diff --git a/ui/src/app/metadata/provider/reducer/entity.reducer.ts b/ui/src/app/metadata/provider/reducer/entity.reducer.ts index d5b63eca1..175c769d8 100644 --- a/ui/src/app/metadata/provider/reducer/entity.reducer.ts +++ b/ui/src/app/metadata/provider/reducer/entity.reducer.ts @@ -41,7 +41,7 @@ export function reducer(state = initialState, action: EntityActionUnion): Entity } } -export const isEntitySaved = (state: EntityState) => !Object.keys(state.changes).length && !state.saving; +export const isEntitySaved = (state: EntityState) => state.changes ? !Object.keys(state.changes).length && !state.saving : true; export const getEntityChanges = (state: EntityState) => state.changes; export const isEditorSaving = (state: EntityState) => state.saving; export const getUpdatedEntity = (state: EntityState) => state.changes; diff --git a/ui/src/app/metadata/resolver/container/editor.component.ts b/ui/src/app/metadata/resolver/container/editor.component.ts index 6aa252abe..5d4b6e1c3 100644 --- a/ui/src/app/metadata/resolver/container/editor.component.ts +++ b/ui/src/app/metadata/resolver/container/editor.component.ts @@ -76,7 +76,6 @@ export class EditorComponent implements OnInit, OnDestroy { ); this.providerName$ = this.resolver$.pipe(map(p => p.serviceProviderName)); - this.changes$ = this.store.select(fromResolver.getEditorChanges); this.editorIndex$ = this.route.params.pipe(map(params => Number(params.index))); this.currentPage$ = this.editorIndex$.pipe(map(index => EditorDef.find(r => r.index === index))); this.editor = EditorDef; diff --git a/ui/src/app/wizard/component/wizard.component.spec.ts b/ui/src/app/wizard/component/wizard.component.spec.ts index e69de29bb..ef513015e 100644 --- a/ui/src/app/wizard/component/wizard.component.spec.ts +++ b/ui/src/app/wizard/component/wizard.component.spec.ts @@ -0,0 +1,64 @@ +import { Component, ViewChild } from '@angular/core'; +import { TestBed, async, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { StoreModule, Store, combineReducers } from '@ngrx/store'; + +import * as fromWizard from '../reducer'; +import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; +import { WizardComponent, ICONS } from './wizard.component'; + +@Component({ + template: ` + + ` +}) +class TestHostComponent { + @ViewChild(WizardComponent) + public componentUnderTest: WizardComponent; +} + +describe('Wizard Component', () => { + + let fixture: ComponentFixture; + let instance: TestHostComponent; + let app: WizardComponent; + let store: Store; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + NgbDropdownModule.forRoot(), + RouterTestingModule, + StoreModule.forRoot({ + wizard: combineReducers(fromWizard.reducers) + }) + ], + declarations: [ + WizardComponent, + TestHostComponent + ], + }).compileComponents(); + + store = TestBed.get(Store); + spyOn(store, 'dispatch'); + + fixture = TestBed.createComponent(TestHostComponent); + instance = fixture.componentInstance; + app = instance.componentUnderTest; + fixture.detectChanges(); + })); + + it('should compile without error', () => { + expect(app).toBeTruthy(); + }); + + describe('getIcon method', () => { + it('should return the check string for the last index', () => { + expect(app.getIcon({ index: 'foo' }, { index: 'foo' })).toEqual(ICONS.CHECK); + }); + it('should return the index icon for other indexes', () => { + expect(app.getIcon({ index: 'foo' }, { index: 'bar' })).toEqual(ICONS.INDEX); + expect(app.getIcon({ index: 'foo' }, null)).toEqual(ICONS.INDEX); + }); + }); +}); diff --git a/ui/src/app/wizard/component/wizard.component.ts b/ui/src/app/wizard/component/wizard.component.ts index a75a3bb2d..7b4aa8ff4 100644 --- a/ui/src/app/wizard/component/wizard.component.ts +++ b/ui/src/app/wizard/component/wizard.component.ts @@ -53,9 +53,11 @@ export class WizardComponent { this.currentIcon$ = this.current$.pipe( withLatestFrom(this.last$), - map(([current, last]) => { - return (last && current.index === last.index) ? ICONS.CHECK : ICONS.INDEX; - }) + map(([current, last]) => this.getIcon(current, last)) ); } + + getIcon(current, last): string { + return (last && current.index === last.index) ? ICONS.CHECK : ICONS.INDEX; + } }