From 7945464480e9ae59087aab73acb151710767e9b6 Mon Sep 17 00:00:00 2001 From: Ryan Mathis Date: Thu, 19 Jul 2018 17:40:57 +0000 Subject: [PATCH] Merged in feature/SHIBUI-625 (pull request #119) SHIBUI-625 Provider Editor * SHIBUI-625 Initial stub of editor * SHIBUI-625 Implemented pages for provider editing * SHIBUI-625 Implemented editor navigation * SHIBUI-625 Implemented editor for metadata providers * Fixed tests, consolidated validation for provider names * SHIBUI-625 Added unit tests Approved-by: Shibui Jenkins Approved-by: Ryan Mathis --- .../model/providers/base-metadata-provider.ts | 5 + .../file-backed-http-metadata-provider.ts | 8 +- .../metadata/domain/model/providers/index.ts | 3 +- .../dashboard-providers-list.component.html | 5 +- .../provider/action/collection.action.ts | 31 ++- .../metadata/provider/action/entity.action.ts | 8 - .../provider-wizard-summary.component.spec.ts | 27 +- .../provider-wizard-summary.component.ts | 2 +- .../provider-edit-step.component.html | 8 + .../provider-edit-step.component.spec.ts | 90 +++++++ .../container/provider-edit-step.component.ts | 93 +++++++ .../container/provider-edit.component.html | 88 +++++++ .../container/provider-edit.component.spec.ts | 115 +++++++++ .../container/provider-edit.component.ts | 107 ++++++++ .../provider-filter-list.component.html | 5 + .../provider-filter-list.component.spec.ts | 56 ++++ .../provider-filter-list.component.ts | 14 + .../container/provider-select.component.html | 1 + .../provider-select.component.spec.ts | 56 ++++ .../container/provider-select.component.ts | 33 +++ .../provider-wizard-step.component.html | 14 +- .../provider-wizard-step.component.ts | 36 +-- .../container/provider-wizard.component.html | 42 +-- .../container/provider-wizard.component.ts | 7 +- .../container/provider.component.html | 15 +- .../provider/container/provider.component.ts | 8 +- .../provider/effect/collection.effect.ts | 60 ++++- .../metadata/provider/effect/entity.effect.ts | 29 +++ .../provider/model/base.provider.form.spec.ts | 142 ++++++++++ .../provider/model/base.provider.form.ts | 60 +++++ .../model/file-backed-http.provider.form.ts | 67 +++-- ui/src/app/metadata/provider/model/index.ts | 9 +- .../metadata/provider/model/provider.form.ts | 6 +- .../app/metadata/provider/provider.module.ts | 16 +- .../app/metadata/provider/provider.routing.ts | 27 +- .../provider/reducer/collection.reducer.ts | 7 + .../provider/reducer/entity.reducer.ts | 12 +- ui/src/app/wizard/model/wizard.ts | 4 +- ui/src/app/wizard/reducer/index.spec.ts | 1 - .../filebacked-http-advanced.schema.json | 6 + .../filebacked-http-common.editor.schema.json | 244 ++++++++++++++++++ ui/src/testing/activated-route.stub.ts | 13 +- 42 files changed, 1440 insertions(+), 140 deletions(-) create mode 100644 ui/src/app/metadata/domain/model/providers/base-metadata-provider.ts create mode 100644 ui/src/app/metadata/provider/container/provider-edit-step.component.html create mode 100644 ui/src/app/metadata/provider/container/provider-edit-step.component.spec.ts create mode 100644 ui/src/app/metadata/provider/container/provider-edit-step.component.ts create mode 100644 ui/src/app/metadata/provider/container/provider-edit.component.html create mode 100644 ui/src/app/metadata/provider/container/provider-edit.component.spec.ts create mode 100644 ui/src/app/metadata/provider/container/provider-edit.component.ts create mode 100644 ui/src/app/metadata/provider/container/provider-filter-list.component.html create mode 100644 ui/src/app/metadata/provider/container/provider-filter-list.component.spec.ts create mode 100644 ui/src/app/metadata/provider/container/provider-filter-list.component.ts create mode 100644 ui/src/app/metadata/provider/container/provider-select.component.html create mode 100644 ui/src/app/metadata/provider/container/provider-select.component.spec.ts create mode 100644 ui/src/app/metadata/provider/container/provider-select.component.ts create mode 100644 ui/src/app/metadata/provider/effect/entity.effect.ts create mode 100644 ui/src/app/metadata/provider/model/base.provider.form.spec.ts create mode 100644 ui/src/app/metadata/provider/model/base.provider.form.ts create mode 100644 ui/src/assets/schema/provider/filebacked-http-advanced.schema.json create mode 100644 ui/src/assets/schema/provider/filebacked-http-common.editor.schema.json diff --git a/ui/src/app/metadata/domain/model/providers/base-metadata-provider.ts b/ui/src/app/metadata/domain/model/providers/base-metadata-provider.ts new file mode 100644 index 000000000..02d9ef01d --- /dev/null +++ b/ui/src/app/metadata/domain/model/providers/base-metadata-provider.ts @@ -0,0 +1,5 @@ +import { MetadataProvider } from '../metadata-provider'; + +export interface BaseMetadataProvider extends MetadataProvider { + metadataFilters: any[]; +} diff --git a/ui/src/app/metadata/domain/model/providers/file-backed-http-metadata-provider.ts b/ui/src/app/metadata/domain/model/providers/file-backed-http-metadata-provider.ts index 7d107036b..1d1e39291 100644 --- a/ui/src/app/metadata/domain/model/providers/file-backed-http-metadata-provider.ts +++ b/ui/src/app/metadata/domain/model/providers/file-backed-http-metadata-provider.ts @@ -1,5 +1,7 @@ -import { MetadataProvider } from '../metadata-provider'; +import { BaseMetadataProvider } from './base-metadata-provider'; -export interface FileBackedHttpMetadataProvider extends MetadataProvider { - metadataFilters: any[]; +export interface FileBackedHttpMetadataProvider extends BaseMetadataProvider { + id: string; + metadataURL: string; + reloadableMetadataResolverAttributes: any; } diff --git a/ui/src/app/metadata/domain/model/providers/index.ts b/ui/src/app/metadata/domain/model/providers/index.ts index 0cd02d47d..6bce32434 100644 --- a/ui/src/app/metadata/domain/model/providers/index.ts +++ b/ui/src/app/metadata/domain/model/providers/index.ts @@ -1 +1,2 @@ -export * from './file-backed-http-metadata-provider'; \ No newline at end of file +export * from './file-backed-http-metadata-provider'; +export * from './base-metadata-provider'; diff --git a/ui/src/app/metadata/manager/container/dashboard-providers-list.component.html b/ui/src/app/metadata/manager/container/dashboard-providers-list.component.html index cf446c1d1..5690a336e 100644 --- a/ui/src/app/metadata/manager/container/dashboard-providers-list.component.html +++ b/ui/src/app/metadata/manager/container/dashboard-providers-list.component.html @@ -8,7 +8,10 @@
-
{{ providers$ | async | json }}
+ + {{ provider.name }} +
diff --git a/ui/src/app/metadata/provider/action/collection.action.ts b/ui/src/app/metadata/provider/action/collection.action.ts index 99e71c758..7eaf7d1df 100644 --- a/ui/src/app/metadata/provider/action/collection.action.ts +++ b/ui/src/app/metadata/provider/action/collection.action.ts @@ -7,9 +7,13 @@ export enum ProviderCollectionActionTypes { UPDATE_PROVIDER_SUCCESS = '[Metadata Provider] Update Success', UPDATE_PROVIDER_FAIL = '[Metadata Provider] Update Fail', - LOAD_PROVIDER_REQUEST = '[Metadata Provider Collection] Provider REQUEST', - LOAD_PROVIDER_SUCCESS = '[Metadata Provider Collection] Provider SUCCESS', - LOAD_PROVIDER_ERROR = '[Metadata Provider Collection] Provider ERROR', + LOAD_PROVIDER_REQUEST = '[Metadata Provider Collection] Provider Load REQUEST', + LOAD_PROVIDER_SUCCESS = '[Metadata Provider Collection] Provider Load SUCCESS', + LOAD_PROVIDER_ERROR = '[Metadata Provider Collection] Provider Load ERROR', + + SELECT_PROVIDER_REQUEST = '[Metadata Provider Collection] Provider SELECT REQUEST', + SELECT_PROVIDER_SUCCESS = '[Metadata Provider Collection] Provider SELECT SUCCESS', + SELECT_PROVIDER_ERROR = '[Metadata Provider Collection] Provider SELECT ERROR', ADD_PROVIDER_REQUEST = '[Metadata Provider Collection] Add Provider', ADD_PROVIDER_SUCCESS = '[Metadata Provider Collection] Add Provider Success', @@ -38,6 +42,24 @@ export class LoadProviderError implements Action { constructor(public payload: any) { } } +export class SelectProviderRequest implements Action { + readonly type = ProviderCollectionActionTypes.SELECT_PROVIDER_REQUEST; + + constructor(public payload: any) { } +} + +export class SelectProviderSuccess implements Action { + readonly type = ProviderCollectionActionTypes.SELECT_PROVIDER_SUCCESS; + + constructor(public payload: Update) { } +} + +export class SelectProviderError implements Action { + readonly type = ProviderCollectionActionTypes.SELECT_PROVIDER_ERROR; + + constructor(public payload: any) { } +} + export class UpdateProviderRequest implements Action { readonly type = ProviderCollectionActionTypes.UPDATE_PROVIDER_REQUEST; @@ -96,6 +118,9 @@ export type ProviderCollectionActionsUnion = | LoadProviderRequest | LoadProviderSuccess | LoadProviderError + | SelectProviderRequest + | SelectProviderSuccess + | SelectProviderError | AddProviderRequest | AddProviderSuccess | AddProviderFail diff --git a/ui/src/app/metadata/provider/action/entity.action.ts b/ui/src/app/metadata/provider/action/entity.action.ts index 84f7dbd4a..44c2ca90d 100644 --- a/ui/src/app/metadata/provider/action/entity.action.ts +++ b/ui/src/app/metadata/provider/action/entity.action.ts @@ -2,18 +2,11 @@ import { Action } from '@ngrx/store'; import { MetadataProvider } from '../../domain/model'; export enum EntityActionTypes { - SELECT_PROVIDER = '[Provider Entity] Select Provider', UPDATE_PROVIDER = '[Provider Entity] Update Provider', CLEAR_PROVIDER = '[Provider Entity] Clear', RESET_CHANGES = '[Provider Entity] Reset Changes' } -export class SelectProvider implements Action { - readonly type = EntityActionTypes.SELECT_PROVIDER; - - constructor(public payload: MetadataProvider) { } -} - export class UpdateProvider implements Action { readonly type = EntityActionTypes.UPDATE_PROVIDER; @@ -29,7 +22,6 @@ export class ResetChanges implements Action { } export type EntityActionUnion = - | SelectProvider | UpdateProvider | ClearProvider | ResetChanges; diff --git a/ui/src/app/metadata/provider/component/provider-wizard-summary.component.spec.ts b/ui/src/app/metadata/provider/component/provider-wizard-summary.component.spec.ts index d951464ad..5c13cc74a 100644 --- a/ui/src/app/metadata/provider/component/provider-wizard-summary.component.spec.ts +++ b/ui/src/app/metadata/provider/component/provider-wizard-summary.component.spec.ts @@ -5,7 +5,7 @@ import { StoreModule, Store, combineReducers } from '@ngrx/store'; import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; -import { ProviderWizardSummaryComponent } from './provider-wizard-summary.component'; +import { ProviderWizardSummaryComponent, getStepProperties } from './provider-wizard-summary.component'; import * as fromRoot from '../reducer'; import { SchemaFormModule, WidgetRegistry, DefaultWidgetRegistry } from 'ngx-schema-form'; import * as fromWizard from '../../../wizard/reducer'; @@ -76,6 +76,31 @@ describe('Provider Wizard Summary Component', () => { expect(app).toBeTruthy(); })); + describe('getStepProperties function', () => { + it('should return an empty array of schema or schema.properties is not defined', () => { + expect(getStepProperties(null, {})).toEqual([]); + expect(getStepProperties({}, {})).toEqual([]); + }); + + it('should return a formatted list of properties', () => { + expect(getStepProperties(SCHEMA, {}).length).toBe(2); + }); + }); + + describe('gotoPage function', () => { + it('should emit an empty string if page is null', () => { + spyOn(app.onPageSelect, 'emit'); + app.gotoPage(); + expect(app.onPageSelect.emit).toHaveBeenCalledWith(''); + }); + + it('should emit the provided page', () => { + spyOn(app.onPageSelect, 'emit'); + app.gotoPage('foo'); + expect(app.onPageSelect.emit).toHaveBeenCalledWith('foo'); + }); + }); + describe('ngOnChanges', () => { it('should set columns and sections if summary is provided', () => { instance.summary = { diff --git a/ui/src/app/metadata/provider/component/provider-wizard-summary.component.ts b/ui/src/app/metadata/provider/component/provider-wizard-summary.component.ts index 822e51337..c74c4935b 100644 --- a/ui/src/app/metadata/provider/component/provider-wizard-summary.component.ts +++ b/ui/src/app/metadata/provider/component/provider-wizard-summary.component.ts @@ -13,7 +13,7 @@ interface Section { properties: Property[]; } -function getStepProperties(schema: any, model: any): Property[] { +export function getStepProperties(schema: any, model: any): Property[] { if (!schema || !schema.properties) { return []; } return Object.keys(schema.properties).map(property => ({ name: schema.properties[property].title, diff --git a/ui/src/app/metadata/provider/container/provider-edit-step.component.html b/ui/src/app/metadata/provider/container/provider-edit-step.component.html new file mode 100644 index 000000000..5d07730fd --- /dev/null +++ b/ui/src/app/metadata/provider/container/provider-edit-step.component.html @@ -0,0 +1,8 @@ + + + \ No newline at end of file diff --git a/ui/src/app/metadata/provider/container/provider-edit-step.component.spec.ts b/ui/src/app/metadata/provider/container/provider-edit-step.component.spec.ts new file mode 100644 index 000000000..012f1f1c0 --- /dev/null +++ b/ui/src/app/metadata/provider/container/provider-edit-step.component.spec.ts @@ -0,0 +1,90 @@ +import { Component, ViewChild } from '@angular/core'; +import { TestBed, async, ComponentFixture, fakeAsync, tick } 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 { ProviderEditStepComponent } from './provider-edit-step.component'; +import * as fromRoot from '../reducer'; +import * as fromWizard from '../../../wizard/reducer'; +import { SchemaFormModule, WidgetRegistry, DefaultWidgetRegistry } from 'ngx-schema-form'; +import { SharedModule } from '../../../shared/shared.module'; +import { SetDefinition } from '../../../wizard/action/wizard.action'; +import { FileBackedHttpMetadataProviderEditor } from '../model'; + +@Component({ + template: ` + + ` +}) +class TestHostComponent { + @ViewChild(ProviderEditStepComponent) + public componentUnderTest: ProviderEditStepComponent; +} + +describe('Provider Edit Step Component', () => { + + let fixture: ComponentFixture; + let instance: TestHostComponent; + let app: ProviderEditStepComponent; + let store: Store; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + NgbDropdownModule.forRoot(), + RouterTestingModule, + SchemaFormModule.forRoot(), + SharedModule, + StoreModule.forRoot({ + provider: combineReducers(fromRoot.reducers), + wizard: combineReducers(fromWizard.reducers, { + wizard: { + index: 'common', + disabled: false, + definition: FileBackedHttpMetadataProviderEditor, + schemaCollection: [] + } + }) + }) + ], + declarations: [ + ProviderEditStepComponent, + TestHostComponent + ], + providers: [ + { provide: WidgetRegistry, useClass: DefaultWidgetRegistry } + ] + }).compileComponents(); + + store = TestBed.get(Store); + spyOn(store, 'dispatch'); + + fixture = TestBed.createComponent(TestHostComponent); + instance = fixture.componentInstance; + app = instance.componentUnderTest; + fixture.detectChanges(); + })); + + it('should instantiate the component', async(() => { + expect(app).toBeTruthy(); + })); + + describe('updateStatus method', () => { + it('should update the status with provided errors', () => { + app.currentPage = 'common'; + app.updateStatus({value: 'common'}); + app.updateStatus({value: 'foo'}); + expect(store.dispatch).toHaveBeenCalledTimes(2); + }); + }); + + describe('valueChangeEmitted$ subject', () => { + it('should update the provider', fakeAsync(() => { + app.valueChangeSubject.next({value: { name: 'foo' } }); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + expect(store.dispatch).toHaveBeenCalled(); + })); + }); +}); diff --git a/ui/src/app/metadata/provider/container/provider-edit-step.component.ts b/ui/src/app/metadata/provider/container/provider-edit-step.component.ts new file mode 100644 index 000000000..63256e8a6 --- /dev/null +++ b/ui/src/app/metadata/provider/container/provider-edit-step.component.ts @@ -0,0 +1,93 @@ +import { Component, OnDestroy } from '@angular/core'; +import { Observable, Subject } from 'rxjs'; +import { Store } from '@ngrx/store'; + +import * as fromProvider from '../reducer'; +import { UpdateStatus } from '../action/editor.action'; +import { Wizard } from '../../../wizard/model'; +import { MetadataProvider } from '../../domain/model'; + +import * as fromWizard from '../../../wizard/reducer'; +import { withLatestFrom, map, skipWhile, distinctUntilChanged } from 'rxjs/operators'; +import { UpdateProvider } from '../action/entity.action'; + +@Component({ + selector: 'provider-edit-step', + templateUrl: './provider-edit-step.component.html', + styleUrls: [] +}) + +export class ProviderEditStepComponent implements OnDestroy { + valueChangeSubject = new Subject>(); + private valueChangeEmitted$ = this.valueChangeSubject.asObservable(); + + statusChangeSubject = new Subject>(); + private statusChangeEmitted$ = this.statusChangeSubject.asObservable(); + + currentPage: string; + + namesList: string[] = []; + + schema$: Observable; + provider$: Observable; + model$: Observable; + definition$: Observable>; + changes$: Observable; + + validators$: Observable<{ [key: string]: any }>; + + constructor( + private store: Store + ) { + this.schema$ = this.store.select(fromProvider.getSchema); + this.definition$ = this.store.select(fromWizard.getWizardDefinition); + this.changes$ = this.store.select(fromProvider.getEntityChanges); + this.provider$ = this.store.select(fromProvider.getSelectedProvider); + + this.validators$ = this.store.select(fromProvider.getProviderNames).pipe( + withLatestFrom(this.definition$, this.provider$), + map(([names, def, provider]) => def.getValidators(names.filter(n => n !== provider.name))) + ); + + this.model$ = this.schema$.pipe( + withLatestFrom( + this.store.select(fromProvider.getSelectedProvider), + this.store.select(fromWizard.getModel), + this.changes$, + this.definition$ + ), + map(([schema, provider, model, changes, definition]) => ({ + model: { + ...model, + ...provider, + ...changes + }, + definition + })), + skipWhile(({ model, definition }) => !definition || !model), + map(({ model, definition }) => definition.translate.formatter(model)) + ); + + this.valueChangeEmitted$.pipe( + map(changes => changes.value), + withLatestFrom(this.definition$), + skipWhile(([ changes, definition ]) => !definition || !changes), + map(([ changes, definition ]) => definition.translate.parser(changes)) + ) + .subscribe(changes => this.store.dispatch(new UpdateProvider(changes))); + + this.statusChangeEmitted$.pipe(distinctUntilChanged()).subscribe(errors => this.updateStatus(errors)); + + this.store.select(fromWizard.getWizardIndex).subscribe(i => this.currentPage = i); + } + + updateStatus(errors: any): void { + const status = { [this.currentPage]: !(errors.value) ? 'VALID' : 'INVALID' }; + this.store.dispatch(new UpdateStatus(status)); + } + + ngOnDestroy() { + this.valueChangeSubject.complete(); + } +} + diff --git a/ui/src/app/metadata/provider/container/provider-edit.component.html b/ui/src/app/metadata/provider/container/provider-edit.component.html new file mode 100644 index 000000000..3778fe440 --- /dev/null +++ b/ui/src/app/metadata/provider/container/provider-edit.component.html @@ -0,0 +1,88 @@ +
+
+
+
+ + Edit Metadata Provider - {{ (provider$ | async).name }} +
+
+
+
+
+
+ +
+
+ +   + +
+
+
+ + All forms must be valid before changes can be saved! +
+
+
+
+
+
+ +
{{ status$ | async | json }}
+
+
+ +
+
+
+
\ No newline at end of file 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 new file mode 100644 index 000000000..096a269c7 --- /dev/null +++ b/ui/src/app/metadata/provider/container/provider-edit.component.spec.ts @@ -0,0 +1,115 @@ +import { Component, ViewChild } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { APP_BASE_HREF } from '@angular/common'; +import { TestBed, async, ComponentFixture } 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 { ProviderEditComponent } from './provider-edit.component'; +import * as fromRoot from '../reducer'; +import * as fromWizard from '../../../wizard/reducer'; +import { SharedModule } from '../../../shared/shared.module'; +import { ActivatedRouteStub } from '../../../../testing/activated-route.stub'; +import { FileBackedHttpMetadataProviderEditor } from '../model'; + +@Component({ + template: ` + + ` +}) +class TestHostComponent { + @ViewChild(ProviderEditComponent) + public componentUnderTest: ProviderEditComponent; +} + +describe('Provider Edit Component', () => { + + let fixture: ComponentFixture; + let instance: TestHostComponent; + let app: ProviderEditComponent; + let store: Store; + let router: Router; + let activatedRoute: ActivatedRouteStub = new ActivatedRouteStub(); + let child: ActivatedRouteStub = new ActivatedRouteStub(); + child.testParamMap = { form: 'common' }; + activatedRoute.firstChild = child; + + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + NgbDropdownModule.forRoot(), + RouterTestingModule, + SharedModule, + StoreModule.forRoot({ + provider: combineReducers(fromRoot.reducers), + wizard: combineReducers(fromWizard.reducers, { + wizard: { + index: 'common', + disabled: false, + definition: FileBackedHttpMetadataProviderEditor, + schemaCollection: [] + } + }) + }) + ], + declarations: [ + ProviderEditComponent, + TestHostComponent + ], + providers: [ + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: APP_BASE_HREF, useValue: '/' } + ] + }).compileComponents(); + + store = TestBed.get(Store); + router = TestBed.get(Router); + spyOn(store, 'dispatch'); + + fixture = TestBed.createComponent(TestHostComponent); + instance = fixture.componentInstance; + app = instance.componentUnderTest; + fixture.detectChanges(); + })); + + it('should instantiate the component', async(() => { + expect(app).toBeTruthy(); + })); + + describe('setIndex method', () => { + it('should interrupt event default and dispatch an event', () => { + const ev = { + preventDefault: jasmine.createSpy('preventDefault'), + stopPropagation: jasmine.createSpy('stopPropagation') + }; + app.setIndex(ev, 'common'); + expect(ev.preventDefault).toHaveBeenCalled(); + expect(ev.stopPropagation).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalled(); + }); + }); + + describe('go method', () => { + it('should route to the given child form', () => { + spyOn(router, 'navigate'); + app.go('common'); + expect(router.navigate).toHaveBeenCalled(); + }); + }); + + describe('save method', () => { + it('should route to the given child form', () => { + app.save(); + expect(store.dispatch).toHaveBeenCalled(); + }); + }); + + describe('cancel method', () => { + it('should route to the metadata manager', () => { + spyOn(router, 'navigate'); + app.cancel(); + expect(router.navigate).toHaveBeenCalled(); + }); + }); +}); diff --git a/ui/src/app/metadata/provider/container/provider-edit.component.ts b/ui/src/app/metadata/provider/container/provider-edit.component.ts new file mode 100644 index 000000000..79f5cb2a6 --- /dev/null +++ b/ui/src/app/metadata/provider/container/provider-edit.component.ts @@ -0,0 +1,107 @@ +import { Component, OnDestroy } from '@angular/core'; +import { Router, ActivatedRoute } from '@angular/router'; +import { Observable } from 'rxjs'; +import { skipWhile, map, combineLatest } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import * as fromWizard from '../../../wizard/reducer'; +import * as fromProvider from '../reducer'; +import { ClearWizard, SetDefinition, SetIndex } from '../../../wizard/action/wizard.action'; +import { ClearEditor, LoadSchemaRequest } from '../action/editor.action'; +import { MetadataProvider } from '../../domain/model'; +import { ClearProvider } from '../action/entity.action'; +import { MetadataProviderEditorTypes } from '../model'; +import { Wizard, WizardStep } from '../../../wizard/model'; +import { UpdateProviderRequest } from '../action/collection.action'; + +@Component({ + selector: 'provider-edit', + templateUrl: './provider-edit.component.html', + styleUrls: [] +}) + +export class ProviderEditComponent implements OnDestroy { + + provider$: Observable; + definition$: Observable>; + index$: Observable; + invalidForms$: Observable; + currentPage$: Observable; + + valid$: Observable; + isInvalid$: Observable; + status$: Observable; + + latest: MetadataProvider; + + constructor( + private store: Store, + private router: Router, + private route: ActivatedRoute + ) { + this.provider$ = this.store.select(fromProvider.getSelectedProvider).pipe(skipWhile(d => !d)); + this.definition$ = this.store.select(fromWizard.getWizardDefinition).pipe(skipWhile(d => !d)); + this.index$ = this.store.select(fromWizard.getWizardIndex).pipe(skipWhile(i => !i)); + this.valid$ = this.store.select(fromProvider.getEditorIsValid); + this.isInvalid$ = this.valid$.pipe(map(v => !v)); + this.status$ = this.store.select(fromProvider.getInvalidEditorForms); + + let startIndex$ = this.route.firstChild ? + this.route.firstChild.params.pipe(map(p => p.form || 'filter-list')) : + this.definition$.pipe(map(d => d.steps[0].id)); + + startIndex$ + .subscribe(index => { + this.store.dispatch(new SetIndex(index)); + }); + + this.provider$ + .subscribe(provider => { + this.store.dispatch(new SetDefinition({ + ...MetadataProviderEditorTypes.find(def => def.type === provider['@type']) + })); + }); + + this.index$.subscribe(id => this.go(id)); + + this.store + .select(fromWizard.getCurrentWizardSchema) + .pipe(skipWhile(s => !s)) + .subscribe(s => { + if (s) { + this.store.dispatch(new LoadSchemaRequest(s)); + } + }); + + this.store.select(fromProvider.getEntityChanges).subscribe(changes => this.latest = changes); + + this.invalidForms$ = this.store.select(fromProvider.getInvalidEditorForms); + this.currentPage$ = this.index$.pipe( + combineLatest(this.definition$, (index, definition) => (definition.steps.find(r => r.id === index))) + ); + } + + go(id: string): void { + this.router.navigate(['./', id], { relativeTo: this.route }); + } + + setIndex($event: Event, id: string): void { + $event.preventDefault(); + $event.stopPropagation(); + this.store.dispatch(new SetIndex(id)); + } + + ngOnDestroy() { + this.store.dispatch(new ClearProvider()); + this.store.dispatch(new ClearWizard()); + this.store.dispatch(new ClearEditor()); + } + + save(): void { + this.store.dispatch(new UpdateProviderRequest(this.latest)); + } + + cancel(): void { + this.router.navigate(['metadata', 'manager', 'providers']); + } +} + diff --git a/ui/src/app/metadata/provider/container/provider-filter-list.component.html b/ui/src/app/metadata/provider/container/provider-filter-list.component.html new file mode 100644 index 000000000..a1c8e0b1d --- /dev/null +++ b/ui/src/app/metadata/provider/container/provider-filter-list.component.html @@ -0,0 +1,5 @@ +
+
+ Filter list. +
+
\ No newline at end of file diff --git a/ui/src/app/metadata/provider/container/provider-filter-list.component.spec.ts b/ui/src/app/metadata/provider/container/provider-filter-list.component.spec.ts new file mode 100644 index 000000000..dd8c2ef95 --- /dev/null +++ b/ui/src/app/metadata/provider/container/provider-filter-list.component.spec.ts @@ -0,0 +1,56 @@ +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 { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; +import { ProviderFilterListComponent } from './provider-filter-list.component'; +import * as fromRoot from '../reducer'; +import * as fromWizard from '../../../wizard/reducer'; + +@Component({ + template: ` + + ` +}) +class TestHostComponent { + @ViewChild(ProviderFilterListComponent) + public componentUnderTest: ProviderFilterListComponent; +} + +describe('Provider Filter List Component', () => { + + let fixture: ComponentFixture; + let instance: TestHostComponent; + let app: ProviderFilterListComponent; + let store: Store; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + NgbDropdownModule.forRoot(), + RouterTestingModule, + StoreModule.forRoot({ + provider: combineReducers(fromRoot.reducers), + wizard: combineReducers(fromWizard.reducers) + }) + ], + declarations: [ + ProviderFilterListComponent, + TestHostComponent + ], + providers: [] + }).compileComponents(); + + store = TestBed.get(Store); + spyOn(store, 'dispatch'); + + fixture = TestBed.createComponent(TestHostComponent); + instance = fixture.componentInstance; + app = instance.componentUnderTest; + fixture.detectChanges(); + })); + + it('should instantiate the component', async(() => { + expect(app).toBeTruthy(); + })); +}); diff --git a/ui/src/app/metadata/provider/container/provider-filter-list.component.ts b/ui/src/app/metadata/provider/container/provider-filter-list.component.ts new file mode 100644 index 000000000..a35bb9fc3 --- /dev/null +++ b/ui/src/app/metadata/provider/container/provider-filter-list.component.ts @@ -0,0 +1,14 @@ +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import * as fromProvider from '../reducer'; + +@Component({ + selector: 'provider-filter-list', + templateUrl: './provider-filter-list.component.html', + styleUrls: [] +}) +export class ProviderFilterListComponent { + constructor( + private store: Store + ) { } +} diff --git a/ui/src/app/metadata/provider/container/provider-select.component.html b/ui/src/app/metadata/provider/container/provider-select.component.html new file mode 100644 index 000000000..90c6b6463 --- /dev/null +++ b/ui/src/app/metadata/provider/container/provider-select.component.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/app/metadata/provider/container/provider-select.component.spec.ts b/ui/src/app/metadata/provider/container/provider-select.component.spec.ts new file mode 100644 index 000000000..b8e07460b --- /dev/null +++ b/ui/src/app/metadata/provider/container/provider-select.component.spec.ts @@ -0,0 +1,56 @@ +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 { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; +import { ProviderSelectComponent } from './provider-select.component'; +import * as fromRoot from '../reducer'; +import * as fromWizard from '../../../wizard/reducer'; + +@Component({ + template: ` + + ` +}) +class TestHostComponent { + @ViewChild(ProviderSelectComponent) + public componentUnderTest: ProviderSelectComponent; +} + +describe('Provider Select Component', () => { + + let fixture: ComponentFixture; + let instance: TestHostComponent; + let app: ProviderSelectComponent; + let store: Store; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + NgbDropdownModule.forRoot(), + RouterTestingModule, + StoreModule.forRoot({ + provider: combineReducers(fromRoot.reducers), + wizard: combineReducers(fromWizard.reducers) + }) + ], + declarations: [ + ProviderSelectComponent, + TestHostComponent + ], + providers: [] + }).compileComponents(); + + store = TestBed.get(Store); + spyOn(store, 'dispatch'); + + fixture = TestBed.createComponent(TestHostComponent); + instance = fixture.componentInstance; + app = instance.componentUnderTest; + fixture.detectChanges(); + })); + + it('should instantiate the component', async(() => { + expect(app).toBeTruthy(); + })); +}); diff --git a/ui/src/app/metadata/provider/container/provider-select.component.ts b/ui/src/app/metadata/provider/container/provider-select.component.ts new file mode 100644 index 000000000..dc0bcaa6b --- /dev/null +++ b/ui/src/app/metadata/provider/container/provider-select.component.ts @@ -0,0 +1,33 @@ +import { Component, OnDestroy } from '@angular/core'; +import { Subscription } from 'rxjs'; +import { Store } from '@ngrx/store'; + +import { ActivatedRoute } from '@angular/router'; +import { map, distinctUntilChanged } from 'rxjs/operators'; +import { SelectProviderRequest } from '../action/collection.action'; +import * as fromProviders from '../reducer'; + +@Component({ + selector: 'provider-select', + templateUrl: './provider-select.component.html', + styleUrls: [] +}) + +export class ProviderSelectComponent implements OnDestroy { + actionsSubscription: Subscription; + + constructor( + store: Store, + route: ActivatedRoute + ) { + this.actionsSubscription = route.params.pipe( + distinctUntilChanged(), + map(params => new SelectProviderRequest(params.providerId)) + ).subscribe(store); + } + + ngOnDestroy() { + this.actionsSubscription.unsubscribe(); + } +} + diff --git a/ui/src/app/metadata/provider/container/provider-wizard-step.component.html b/ui/src/app/metadata/provider/container/provider-wizard-step.component.html index 831e71fbb..5d07730fd 100644 --- a/ui/src/app/metadata/provider/container/provider-wizard-step.component.html +++ b/ui/src/app/metadata/provider/container/provider-wizard-step.component.html @@ -1,6 +1,8 @@ - \ No newline at end of file + + + \ No newline at end of file diff --git a/ui/src/app/metadata/provider/container/provider-wizard-step.component.ts b/ui/src/app/metadata/provider/container/provider-wizard-step.component.ts index 7d09b8ec1..dce4187d7 100644 --- a/ui/src/app/metadata/provider/container/provider-wizard-step.component.ts +++ b/ui/src/app/metadata/provider/container/provider-wizard-step.component.ts @@ -10,7 +10,7 @@ import { SetDefinition } from '../../../wizard/action/wizard.action'; import { UpdateStatus } from '../action/editor.action'; import { Wizard } from '../../../wizard/model'; import { MetadataProvider } from '../../domain/model'; -import { MetadataProviderTypes, MetadataProviderWizard } from '../model'; +import { MetadataProviderWizardTypes, MetadataProviderWizard } from '../model'; import { UpdateProvider } from '../action/entity.action'; import { pick } from '../../../shared/util'; @@ -37,32 +37,7 @@ export class ProviderWizardStepComponent implements OnDestroy { namesList: string[] = []; - validators = { - '/': (value, property, form_current) => { - let errors; - // iterate all customer - Object.keys(value).forEach((key) => { - const item = value[key]; - const validatorKey = `/${key}`; - const validator = this.validators.hasOwnProperty(validatorKey) ? this.validators[validatorKey] : null; - const error = validator ? validator(item, { path: `/${key}` }, form_current) : null; - if (error) { - errors = errors || []; - errors.push(error); - } - }); - return errors; - }, - '/name': (value, property, form) => { - const err = this.namesList.indexOf(value) > -1 ? { - code: 'INVALID_NAME', - path: `#${property.path}`, - message: 'Name must be unique.', - params: [value] - } : null; - return err; - } - }; + validators$: Observable<{ [key: string]: any }>; constructor( private store: Store, @@ -71,7 +46,10 @@ export class ProviderWizardStepComponent implements OnDestroy { this.definition$ = this.store.select(fromWizard.getWizardDefinition); this.changes$ = this.store.select(fromProvider.getEntityChanges); - this.store.select(fromProvider.getProviderNames).subscribe(list => this.namesList = list); + this.validators$ = this.store.select(fromProvider.getProviderNames).pipe( + withLatestFrom(this.definition$), + map(([names, def]) => def.getValidators(names)) + ); this.model$ = this.schema$.pipe( withLatestFrom( @@ -106,7 +84,7 @@ export class ProviderWizardStepComponent implements OnDestroy { resetSelectedType(changes: any, schema: any, definition: any): { changes: any, definition: any } { const type = changes.value['@type']; if (type && type !== definition.type) { - const newDefinition = MetadataProviderTypes.find(def => def.type === type); + const newDefinition = MetadataProviderWizardTypes.find(def => def.type === type); if (newDefinition) { this.store.dispatch(new SetDefinition({ ...MetadataProviderWizard, diff --git a/ui/src/app/metadata/provider/container/provider-wizard.component.html b/ui/src/app/metadata/provider/container/provider-wizard.component.html index 196081a1f..7d1528b03 100644 --- a/ui/src/app/metadata/provider/container/provider-wizard.component.html +++ b/ui/src/app/metadata/provider/container/provider-wizard.component.html @@ -1,17 +1,29 @@ -
- -
-
-
- +
+
+
+
+ Add a new metadata provider +
- - -
+
+
+ +
+
+
+ +
+
+ + +
+
+ + diff --git a/ui/src/app/metadata/provider/container/provider-wizard.component.ts b/ui/src/app/metadata/provider/container/provider-wizard.component.ts index ee18b72b0..4a599f33e 100644 --- a/ui/src/app/metadata/provider/container/provider-wizard.component.ts +++ b/ui/src/app/metadata/provider/container/provider-wizard.component.ts @@ -4,7 +4,7 @@ import { Store } from '@ngrx/store'; import * as fromProvider from '../reducer'; import * as fromWizard from '../../../wizard/reducer'; -import { SetIndex, SetDisabled, ClearWizard } from '../../../wizard/action/wizard.action'; +import { SetIndex, SetDisabled, ClearWizard, SetDefinition } from '../../../wizard/action/wizard.action'; import { LoadSchemaRequest, ClearEditor } from '../action/editor.action'; import { startWith } from 'rxjs/operators'; import { Wizard, WizardStep } from '../../../wizard/model'; @@ -13,7 +13,7 @@ import { ClearProvider } from '../action/entity.action'; import { Router, ActivatedRoute } from '@angular/router'; import { map } from 'rxjs/operators'; import { AddProviderRequest } from '../action/collection.action'; - +import { MetadataProviderWizard } from '../model'; @Component({ selector: 'provider-wizard', @@ -67,6 +67,9 @@ export class ProviderWizardComponent implements OnDestroy { ); this.changes$.subscribe(c => this.provider = c); + + this.store.dispatch(new SetDefinition(MetadataProviderWizard)); + this.store.dispatch(new SetIndex(MetadataProviderWizard.steps[0].id)); } ngOnDestroy() { diff --git a/ui/src/app/metadata/provider/container/provider.component.html b/ui/src/app/metadata/provider/container/provider.component.html index 203223445..8d471248b 100644 --- a/ui/src/app/metadata/provider/container/provider.component.html +++ b/ui/src/app/metadata/provider/container/provider.component.html @@ -1,14 +1,3 @@ -
-
-
-
-
- Add a new metadata provider -
-
-
-
- -
-
+
+
\ No newline at end of file diff --git a/ui/src/app/metadata/provider/container/provider.component.ts b/ui/src/app/metadata/provider/container/provider.component.ts index afaf48a27..c23dc7066 100644 --- a/ui/src/app/metadata/provider/container/provider.component.ts +++ b/ui/src/app/metadata/provider/container/provider.component.ts @@ -1,9 +1,6 @@ import { Component } from '@angular/core'; import { Store } from '@ngrx/store'; import * as fromProvider from '../reducer'; -import { SetDefinition, SetIndex } from '../../../wizard/action/wizard.action'; - -import { MetadataProviderWizard } from '../model'; @Component({ selector: 'provider-page', @@ -13,8 +10,5 @@ import { MetadataProviderWizard } from '../model'; export class ProviderComponent { constructor( private store: Store - ) { - this.store.dispatch(new SetDefinition(MetadataProviderWizard)); - this.store.dispatch(new SetIndex(MetadataProviderWizard.steps[0].id)); - } + ) {} } diff --git a/ui/src/app/metadata/provider/effect/collection.effect.ts b/ui/src/app/metadata/provider/effect/collection.effect.ts index 1a737839e..d4eb5ff0f 100644 --- a/ui/src/app/metadata/provider/effect/collection.effect.ts +++ b/ui/src/app/metadata/provider/effect/collection.effect.ts @@ -1,9 +1,10 @@ import { Injectable } from '@angular/core'; import { Effect, Actions, ofType } from '@ngrx/effects'; +import { Store } from '@ngrx/store'; import { Router } from '@angular/router'; import { of } from 'rxjs'; -import { map, catchError, switchMap, tap } from 'rxjs/operators'; +import { map, catchError, switchMap, tap, withLatestFrom } from 'rxjs/operators'; import { ProviderCollectionActionsUnion, ProviderCollectionActionTypes, @@ -12,9 +13,17 @@ import { AddProviderFail, LoadProviderRequest, LoadProviderSuccess, - LoadProviderError + LoadProviderError, + SelectProviderRequest, + SelectProviderSuccess, + SelectProviderError, + UpdateProviderRequest, + UpdateProviderSuccess, + UpdateProviderFail } from '../action/collection.action'; import { MetadataProviderService } from '../../domain/service/provider.service'; +import * as fromProvider from '../reducer'; + /* istanbul ignore next */ @Injectable() @@ -33,6 +42,20 @@ export class CollectionEffects { ) ); + @Effect() + selectProviders$ = this.actions$.pipe( + ofType(ProviderCollectionActionTypes.SELECT_PROVIDER_REQUEST), + map(action => action.payload), + switchMap(id => + this.providerService + .find(id) + .pipe( + map(provider => new SelectProviderSuccess({ id, changes: provider })), + catchError(error => of(new SelectProviderError(error))) + ) + ) + ); + @Effect() createProvider$ = this.actions$.pipe( ofType(ProviderCollectionActionTypes.ADD_PROVIDER_REQUEST), @@ -51,7 +74,37 @@ export class CollectionEffects { createProviderSuccessRedirect$ = this.actions$.pipe( ofType(ProviderCollectionActionTypes.ADD_PROVIDER_SUCCESS), map(action => action.payload), - tap(provider => this.router.navigate(['metadata'])) + tap(provider => this.router.navigate(['metadata', 'manager', 'providers'])) + ); + + @Effect() + updateProvider$ = this.actions$.pipe( + ofType(ProviderCollectionActionTypes.UPDATE_PROVIDER_REQUEST), + map(action => action.payload), + withLatestFrom(this.store.select(fromProvider.getSelectedProvider)), + map(([updates, original]) => ({ ...original, ...updates })), + switchMap(provider => + this.providerService + .update(provider) + .pipe( + map(p => new UpdateProviderSuccess({id: p.id, changes: p})), + catchError((e) => of(new UpdateProviderFail(e))) + ) + ) + ); + + @Effect({ dispatch: false }) + updateProviderSuccessReload$ = this.actions$.pipe( + ofType(ProviderCollectionActionTypes.UPDATE_PROVIDER_SUCCESS), + map(action => action.payload), + tap(provider => this.store.dispatch(new LoadProviderRequest())) + ); + + @Effect({ dispatch: false }) + updateProviderSuccessRedirect$ = this.actions$.pipe( + ofType(ProviderCollectionActionTypes.UPDATE_PROVIDER_SUCCESS), + map(action => action.payload), + tap(provider => this.router.navigate(['metadata', 'manager', 'providers'])) ); @Effect() @@ -64,6 +117,7 @@ export class CollectionEffects { constructor( private actions$: Actions, private router: Router, + private store: Store, private providerService: MetadataProviderService ) { } } /* istanbul ignore next */ diff --git a/ui/src/app/metadata/provider/effect/entity.effect.ts b/ui/src/app/metadata/provider/effect/entity.effect.ts new file mode 100644 index 000000000..36f5faa5a --- /dev/null +++ b/ui/src/app/metadata/provider/effect/entity.effect.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; +import { Effect, Actions, ofType } from '@ngrx/effects'; +import { Store } from '@ngrx/store'; +import { map, switchMap } from 'rxjs/operators'; +import { of } from 'rxjs'; + +import { SelectProviderSuccess, ProviderCollectionActionTypes } from '../action/collection.action'; + +import * as fromProvider from '../reducer'; +import { MetadataProvider } from '../../domain/model'; +import { UpdateProvider } from '../action/entity.action'; + +@Injectable() +export class EntityEffects { + + /* + @Effect() + loadModelSuccess$ = this.actions$.pipe( + ofType(ProviderCollectionActionTypes.SELECT_PROVIDER_SUCCESS), + map(action => action.payload.changes), + switchMap((provider: MetadataProvider) => of(new UpdateProvider(provider))) + ); + */ + + constructor( + private store: Store, + private actions$: Actions + ) { } +} diff --git a/ui/src/app/metadata/provider/model/base.provider.form.spec.ts b/ui/src/app/metadata/provider/model/base.provider.form.spec.ts new file mode 100644 index 000000000..307368ef2 --- /dev/null +++ b/ui/src/app/metadata/provider/model/base.provider.form.spec.ts @@ -0,0 +1,142 @@ +import { BaseMetadataProviderEditor } from './base.provider.form'; + +describe('BaseMetadataProviderForm', () => { + + const parser = BaseMetadataProviderEditor.translate.parser; + const formatter = BaseMetadataProviderEditor.translate.formatter; + + const requiredValidUntilFilter = { + maxValidityInterval: 1, + '@type': 'RequiredValidUntil' + }; + + const signatureValidationFilter = { + requireSignedRoot: true, + certificateFile: 'foo', + '@type': 'SignatureValidation' + }; + + const entityRoleWhiteListFilter = { + retainedRoles: ['foo', 'bar'], + removeRolelessEntityDescriptors: true, + removeEmptyEntitiesDescriptors: true, + '@type': 'EntityRoleWhiteList' + }; + + describe('parser', () => { + it('should transform the filters object to an array', () => { + let model = { + name: 'foo', + '@type': 'FileBackedHttpMetadataProvider', + enabled: true, + resourceId: 'foo', + metadataFilters: { + RequiredValidUntil: requiredValidUntilFilter, + SignatureValidation: signatureValidationFilter, + EntityRoleWhiteList: entityRoleWhiteListFilter + } + }; + expect( + parser(model) + ).toEqual( + { + ...model, + metadataFilters: [ + requiredValidUntilFilter, + signatureValidationFilter, + entityRoleWhiteListFilter + ] + } + ); + }); + + it('should return the object if metadataFilters is not provided', () => { + let model = { + name: 'foo', + '@type': 'FileBackedHttpMetadataProvider', + enabled: true, + resourceId: 'foo' + }; + expect( + parser(model) + ).toEqual( + model + ); + }); + }); + + describe('formatter', () => { + it('should transform the filters object to an array', () => { + let model = { + name: 'foo', + '@type': 'FileBackedHttpMetadataProvider', + enabled: true, + resourceId: 'foo', + metadataFilters: [ + requiredValidUntilFilter, + signatureValidationFilter, + entityRoleWhiteListFilter + ] + }; + expect( + formatter(model) + ).toEqual( + { + ...model, + metadataFilters: { + RequiredValidUntil: requiredValidUntilFilter, + SignatureValidation: signatureValidationFilter, + EntityRoleWhiteList: entityRoleWhiteListFilter + } + } + ); + }); + + it('should return the object if metadataFilters is not provided', () => { + let model = { + name: 'foo', + '@type': 'FileBackedHttpMetadataProvider', + enabled: true, + resourceId: 'foo' + }; + expect( + formatter(model) + ).toEqual( + model + ); + }); + }); + + describe('getValidators', () => { + it('should return a set of validator functions for the provider type', () => { + const validators = BaseMetadataProviderEditor.getValidators([]); + expect(validators).toBeDefined(); + expect(validators['/']).toBeDefined(); + expect(validators['/name']).toBeDefined(); + }); + + describe('name `/name` validator', () => { + const validators = BaseMetadataProviderEditor.getValidators(['foo', 'bar']); + + it('should return an invalid object when provided values are invalid based on name', () => { + expect(validators['/name']('foo', { path: '/name' })).toBeDefined(); + }); + + it('should return null when provided values are valid based on name', () => { + expect(validators['/name']('baz', { path: '/name' })).toBeNull(); + }); + }); + + describe('parent `/` validator', () => { + const validators = BaseMetadataProviderEditor.getValidators(['foo', 'bar']); + + it('should return a list of child errors', () => { + expect(validators['/']({name: 'foo'}, { path: '/name' }, {}).length).toBe(1); + }); + + it('should ignore properties that don\'t exist a list of child errors', () => { + expect(validators['/']({ foo: 'bar' }, { path: '/foo' }, {})).toBeUndefined(); + }); + }); + }); +}); diff --git a/ui/src/app/metadata/provider/model/base.provider.form.ts b/ui/src/app/metadata/provider/model/base.provider.form.ts new file mode 100644 index 000000000..a542ebb47 --- /dev/null +++ b/ui/src/app/metadata/provider/model/base.provider.form.ts @@ -0,0 +1,60 @@ +import { Wizard } from '../../../wizard/model'; +import { BaseMetadataProvider } from '../../domain/model/providers'; + +export const BaseMetadataProviderEditor: Wizard = { + label: 'BaseMetadataProvider', + type: 'BaseMetadataResolver', + getValidators(namesList: string[]): any { + const validators = { + '/': (value, property, form_current) => { + let errors; + // iterate all customer + Object.keys(value).forEach((key) => { + const item = value[key]; + const validatorKey = `/${key}`; + const validator = validators.hasOwnProperty(validatorKey) ? validators[validatorKey] : null; + const error = validator ? validator(item, { path: `/${key}` }, form_current) : null; + if (error) { + errors = errors || []; + errors.push(error); + } + }); + return errors; + }, + '/name': (value, property, form) => { + const err = namesList.indexOf(value) > -1 ? { + code: 'INVALID_NAME', + path: `#${property.path}`, + message: 'Name must be unique.', + params: [value] + } : null; + return err; + } + }; + return validators; + }, + translate: { + parser: (changes: any): BaseMetadataProvider => changes.metadataFilters ? ({ + ...changes, + metadataFilters: [ + ...Object.keys(changes.metadataFilters).reduce((collection, filterName) => ([ + ...collection, + { + ...changes.metadataFilters[filterName], + '@type': filterName + } + ]), []) + ] + }) : changes, + formatter: (changes: BaseMetadataProvider): any => changes.metadataFilters ? ({ + ...changes, + metadataFilters: { + ...(changes.metadataFilters || []).reduce((collection, filter) => ({ + ...collection, + [filter['@type']]: filter + }), {}) + } + }) : changes + }, + steps: [] +}; diff --git a/ui/src/app/metadata/provider/model/file-backed-http.provider.form.ts b/ui/src/app/metadata/provider/model/file-backed-http.provider.form.ts index 41723be8f..5af8642bb 100644 --- a/ui/src/app/metadata/provider/model/file-backed-http.provider.form.ts +++ b/ui/src/app/metadata/provider/model/file-backed-http.provider.form.ts @@ -1,32 +1,11 @@ import { Wizard } from '../../../wizard/model'; import { FileBackedHttpMetadataProvider } from '../../domain/model/providers/file-backed-http-metadata-provider'; +import { BaseMetadataProviderEditor } from './base.provider.form'; export const FileBackedHttpMetadataProviderWizard: Wizard = { + ...BaseMetadataProviderEditor, label: 'FileBackedHttpMetadataProvider', type: 'FileBackedHttpMetadataResolver', - translate: { - parser: (changes: any): FileBackedHttpMetadataProvider => changes.metadataFilters ? ({ - ...changes, - metadataFilters: [ - ...Object.keys(changes.metadataFilters).reduce((collection, filterName) => ([ - ...collection, - { - ...changes.metadataFilters[filterName], - '@type': filterName - } - ]), []) - ] - }) : changes, - formatter: (changes: FileBackedHttpMetadataProvider): any => changes.metadataFilters ? ({ - ...changes, - metadataFilters: { - ...(changes.metadataFilters || []).reduce((collection, filter) => ({ - ...collection, - [filter['@type']]: filter - }), {}) - } - }) : changes - }, steps: [ { id: 'common', @@ -60,3 +39,45 @@ export const FileBackedHttpMetadataProviderWizard: Wizard = { + ...FileBackedHttpMetadataProviderWizard, + steps: [ + { + id: 'filter-list', + label: 'Filter List', + index: 0 + }, + { + id: 'common', + label: 'Common Attributes', + index: 1, + initialValues: [], + schema: 'assets/schema/provider/filebacked-http-common.editor.schema.json' + }, + { + id: 'reloading', + label: 'Reloading Attributes', + index: 2, + initialValues: [], + schema: 'assets/schema/provider/filebacked-http-reloading.schema.json' + }, + { + id: 'filters', + label: 'Metadata Filter Plugins', + index: 3, + initialValues: [ + { key: 'metadataFilters', value: [] } + ], + schema: 'assets/schema/provider/filebacked-http-filters.schema.json' + }, + { + id: 'advanced', + label: 'Advanced Settings', + index: 4, + initialValues: [], + schema: 'assets/schema/provider/filebacked-http-advanced.schema.json' + } + ] +}; diff --git a/ui/src/app/metadata/provider/model/index.ts b/ui/src/app/metadata/provider/model/index.ts index 508650ca7..a6c438df4 100644 --- a/ui/src/app/metadata/provider/model/index.ts +++ b/ui/src/app/metadata/provider/model/index.ts @@ -1,8 +1,15 @@ import { FileBackedHttpMetadataProviderWizard } from './file-backed-http.provider.form'; +import { FileBackedHttpMetadataProviderEditor } from './file-backed-http.provider.form'; +import { BaseMetadataProviderEditor } from './base.provider.form'; -export const MetadataProviderTypes = [ +export const MetadataProviderWizardTypes = [ FileBackedHttpMetadataProviderWizard ]; +export const MetadataProviderEditorTypes = [ + FileBackedHttpMetadataProviderEditor, + BaseMetadataProviderEditor +]; + export * from './file-backed-http.provider.form'; export * from './provider.form'; diff --git a/ui/src/app/metadata/provider/model/provider.form.ts b/ui/src/app/metadata/provider/model/provider.form.ts index 698d20134..f7e0ae773 100644 --- a/ui/src/app/metadata/provider/model/provider.form.ts +++ b/ui/src/app/metadata/provider/model/provider.form.ts @@ -1,14 +1,12 @@ import { Wizard, WizardStep } from '../../../wizard/model'; import { MetadataProvider } from '../../domain/model'; import { Metadata } from '../../domain/domain.type'; +import { BaseMetadataProviderEditor } from './base.provider.form'; export const MetadataProviderWizard: Wizard = { + ...BaseMetadataProviderEditor, label: 'MetadataProvider', type: 'MetadataProvider', - translate: { - parser: changes => changes, - formatter: model => model - }, steps: [ { id: 'new', diff --git a/ui/src/app/metadata/provider/provider.module.ts b/ui/src/app/metadata/provider/provider.module.ts index 104bae13d..288adf30b 100644 --- a/ui/src/app/metadata/provider/provider.module.ts +++ b/ui/src/app/metadata/provider/provider.module.ts @@ -19,7 +19,12 @@ import { CustomWidgetRegistry } from '../../schema-form/registry'; import { SummaryPropertyComponent } from './component/summary-property.component'; import { CollectionEffects } from './effect/collection.effect'; import { SharedModule } from '../../shared/shared.module'; - +import { ProviderEditComponent } from './container/provider-edit.component'; +import { ProviderSelectComponent } from './container/provider-select.component'; +import { ProviderEditStepComponent } from './container/provider-edit-step.component'; +import { EntityEffects } from './effect/entity.effect'; +import { ProviderFilterListComponent } from './container/provider-filter-list.component'; +import { NgbDropdownModule } from '../../../../node_modules/@ng-bootstrap/ng-bootstrap'; @NgModule({ declarations: [ @@ -27,6 +32,10 @@ import { SharedModule } from '../../shared/shared.module'; ProviderWizardComponent, ProviderWizardStepComponent, ProviderWizardSummaryComponent, + ProviderEditComponent, + ProviderEditStepComponent, + ProviderSelectComponent, + ProviderFilterListComponent, SummaryPropertyComponent ], entryComponents: [], @@ -36,7 +45,8 @@ import { SharedModule } from '../../shared/shared.module'; WizardModule, RouterModule, SharedModule, - FormModule + FormModule, + NgbDropdownModule ], exports: [] }) @@ -55,7 +65,7 @@ export class ProviderModule { imports: [ ProviderModule, StoreModule.forFeature('provider', fromProvider.reducers), - EffectsModule.forFeature([EditorEffects, CollectionEffects]) + EffectsModule.forFeature([EntityEffects, EditorEffects, CollectionEffects]) ] }) export class RootProviderModule { } diff --git a/ui/src/app/metadata/provider/provider.routing.ts b/ui/src/app/metadata/provider/provider.routing.ts index 98cdddd85..cf491a92f 100644 --- a/ui/src/app/metadata/provider/provider.routing.ts +++ b/ui/src/app/metadata/provider/provider.routing.ts @@ -3,6 +3,10 @@ import { Routes } from '@angular/router'; import { ProviderComponent } from './container/provider.component'; import { ProviderWizardComponent } from './container/provider-wizard.component'; import { ProviderWizardStepComponent } from './container/provider-wizard-step.component'; +import { ProviderEditComponent } from './container/provider-edit.component'; +import { ProviderEditStepComponent } from './container/provider-edit-step.component'; +import { ProviderSelectComponent } from './container/provider-select.component'; +import { ProviderFilterListComponent } from './container/provider-filter-list.component'; export const ProviderRoutes: Routes = [ { @@ -11,8 +15,7 @@ export const ProviderRoutes: Routes = [ children: [ { path: 'wizard', - redirectTo: `wizard/new`, - pathMatch: 'prefix' + redirectTo: `wizard/new` }, { path: 'wizard', @@ -24,6 +27,26 @@ export const ProviderRoutes: Routes = [ component: ProviderWizardStepComponent } ] + }, + { + path: ':providerId', + component: ProviderSelectComponent, + children: [ + { + path: 'edit', + component: ProviderEditComponent, + children: [ + { + path: 'filter-list', + component: ProviderFilterListComponent + }, + { + path: ':form', + component: ProviderEditStepComponent + } + ] + } + ] } ] } diff --git a/ui/src/app/metadata/provider/reducer/collection.reducer.ts b/ui/src/app/metadata/provider/reducer/collection.reducer.ts index 6bc1af8e7..8cc0c6f0c 100644 --- a/ui/src/app/metadata/provider/reducer/collection.reducer.ts +++ b/ui/src/app/metadata/provider/reducer/collection.reducer.ts @@ -23,6 +23,13 @@ export const initialState: CollectionState = adapter.getInitialState({ export function reducer(state = initialState, action: ProviderCollectionActionsUnion): CollectionState { switch (action.type) { + case ProviderCollectionActionTypes.SELECT_PROVIDER_SUCCESS: { + return adapter.upsertOne(action.payload, { + ...state, + selectedProviderId: action.payload.id.toString() + }); + } + case ProviderCollectionActionTypes.LOAD_PROVIDER_SUCCESS: { return adapter.addAll(action.payload, { ...state, diff --git a/ui/src/app/metadata/provider/reducer/entity.reducer.ts b/ui/src/app/metadata/provider/reducer/entity.reducer.ts index 5aa7eceb1..d5b63eca1 100644 --- a/ui/src/app/metadata/provider/reducer/entity.reducer.ts +++ b/ui/src/app/metadata/provider/reducer/entity.reducer.ts @@ -3,13 +3,11 @@ import { EntityActionTypes, EntityActionUnion } from '../action/entity.action'; export interface EntityState { saving: boolean; - base: MetadataProvider; changes: MetadataProvider; } export const initialState: EntityState = { saving: false, - base: null, changes: null }; @@ -28,14 +26,6 @@ export function reducer(state = initialState, action: EntityActionUnion): Entity } }; } - case EntityActionTypes.SELECT_PROVIDER: { - return { - ...state, - base: { - ...action.payload - } - }; - } case EntityActionTypes.UPDATE_PROVIDER: { return { ...state, @@ -54,4 +44,4 @@ export function reducer(state = initialState, action: EntityActionUnion): Entity export const isEntitySaved = (state: EntityState) => !Object.keys(state.changes).length && !state.saving; export const getEntityChanges = (state: EntityState) => state.changes; export const isEditorSaving = (state: EntityState) => state.saving; -export const getUpdatedEntity = (state: EntityState) => ({ ...state.base, ...state.changes }); +export const getUpdatedEntity = (state: EntityState) => state.changes; diff --git a/ui/src/app/wizard/model/wizard.ts b/ui/src/app/wizard/model/wizard.ts index 8b5fe344e..3c9477f5a 100644 --- a/ui/src/app/wizard/model/wizard.ts +++ b/ui/src/app/wizard/model/wizard.ts @@ -6,13 +6,15 @@ export interface Wizard { parser(changes: Partial, schema?: any), formatter(changes: Partial, schema?: any) }; + + getValidators?(params: any): { [key: string]: any }; } export interface WizardStep { id: string; label: string; initialValues?: WizardValue[]; - schema: string; + schema?: string; index: number; } diff --git a/ui/src/app/wizard/reducer/index.spec.ts b/ui/src/app/wizard/reducer/index.spec.ts index 78ff09441..9c8b246f9 100644 --- a/ui/src/app/wizard/reducer/index.spec.ts +++ b/ui/src/app/wizard/reducer/index.spec.ts @@ -84,7 +84,6 @@ describe('wizard index selectors', () => { describe('getModelFn method', () => { it('should return the model', () => { const step = FileBackedHttpMetadataProviderWizard.steps.find(s => s.id === 'filters'); - console.log(step); expect(selectors.getModelFn(step)).toEqual({ metadataFilters: [] }); }); }); diff --git a/ui/src/assets/schema/provider/filebacked-http-advanced.schema.json b/ui/src/assets/schema/provider/filebacked-http-advanced.schema.json new file mode 100644 index 000000000..601aae3db --- /dev/null +++ b/ui/src/assets/schema/provider/filebacked-http-advanced.schema.json @@ -0,0 +1,6 @@ +{ + "type": "object", + "title": "Advanced Settings", + "order": [], + "properties": {} +} diff --git a/ui/src/assets/schema/provider/filebacked-http-common.editor.schema.json b/ui/src/assets/schema/provider/filebacked-http-common.editor.schema.json new file mode 100644 index 000000000..77590af65 --- /dev/null +++ b/ui/src/assets/schema/provider/filebacked-http-common.editor.schema.json @@ -0,0 +1,244 @@ +{ + "type": "object", + "order": [ + "name", + "@type", + "id", + "metadataURL", + "initializeFromBackupFile", + "backingFile", + "backupFileInitNextRefreshDelay", + "requireValidMetadata", + "failFastInitialization", + "useDefaultPredicateRegistry", + "satisfyAnyPredicates" + ], + "required": [ + "id", + "metadataURL" + ], + "anyOf": [ + { + "properties": { + "initializeFromBackupFile": { + "enum": [ + true + ] + } + }, + "required": [ + "backingFile" + ] + }, + { + "properties": { + "initializeFromBackupFile": { + "enum": [ + false + ] + } + } + } + ], + "fieldsets": [ + { + "type": "section", + "fields": [ + "name", + "@type" + ] + }, + { + "fields": [ + "id", + "metadataURL", + "initializeFromBackupFile", + "backingFile", + "backupFileInitNextRefreshDelay", + "requireValidMetadata", + "failFastInitialization", + "useDefaultPredicateRegistry", + "satisfyAnyPredicates" + ] + } + ], + "properties": { + "name": { + "title": "Metadata Provider Name (Dashboard Display Only)", + "description": "Metadata Provider Name", + "type": "string", + "widget": { + "id": "string", + "help": "Must be unique." + } + }, + "@type": { + "title": "Metadata Provider Type", + "description": "Metadata Provider Type", + "placeholder": "Select a metadata provider type", + "type": "string", + "readOnly": true, + "widget": { + "id": "select" + }, + "oneOf": [ + { + "enum": [ + "FileBackedHttpMetadataResolver" + ], + "description": "FileBackedHttpMetadataProvider" + } + ] + }, + "id": { + "title": "ID", + "description": "Identifier for logging, identification for command line reload, etc.", + "type": "string", + "default": "", + "minLength": 1 + }, + "metadataURL": { + "title": "Metadata URL", + "description": "Identifier for logging, identification for command line reload, etc.", + "type": "string", + "default": "", + "minLength": 1 + }, + "initializeFromBackupFile": { + "title": "Initialize From Backup File?", + "description": "Flag indicating whether initialization should first attempt to load metadata from the backup file. If true, foreground initialization will be performed by loading the backing file, and then a refresh from the remote HTTP server will be scheduled to execute in a background thread, after a configured delay. This can improve IdP startup times when the remote HTTP file is large in size.", + "type": "boolean", + "widget": { + "id": "boolean-radio" + }, + "oneOf": [ + { + "enum": [ + true + ], + "description": "True" + }, + { + "enum": [ + false + ], + "description": "False" + } + ], + "default": true + }, + "backingFile": { + "title": "Backing File", + "description": "Specifies where the backing file is located. If the remote server is unavailable at startup, the backing file is loaded instead.", + "type": "string", + "default": "", + "visibleIf": { + "initializeFromBackupFile": [ + true + ] + } + }, + "backupFileInitNextRefreshDelay": { + "title": "Backup File Init Next Refresh Delay", + "description": "Delay duration after which to schedule next HTTP refresh when initialized from the backing file.", + "type": "string", + "visibleIf": { + "initializeFromBackupFile": [ + true + ] + } + }, + "requireValidMetadata": { + "title": "Require Valid Metadata?", + "description": "Whether candidate metadata found by the resolver must be valid in order to be returned (where validity is implementation specific, but in SAML cases generally depends on a validUntil attribute.) If this flag is true, then invalid candidate metadata will not be returned.", + "type": "boolean", + "widget": { + "id": "boolean-radio" + }, + "oneOf": [ + { + "enum": [ + true + ], + "description": "True" + }, + { + "enum": [ + false + ], + "description": "False" + } + ], + "default": true + }, + "failFastInitialization": { + "title": "Fail Fast Initialization?", + "description": "Whether to fail initialization of the underlying MetadataResolverService (and possibly the IdP as a whole) if the initialization of a metadata provider fails. When false, the IdP may start, and will continue to attempt to reload valid metadata if configured to do so, but operations that require valid metadata will fail until it does.", + "type": "boolean", + "widget": { + "id": "boolean-radio" + }, + "oneOf": [ + { + "enum": [ + true + ], + "description": "True" + }, + { + "enum": [ + false + ], + "description": "False" + } + ], + "default": true + }, + "useDefaultPredicateRegistry": { + "title": "Use Default Predicate Registry?", + "description": "Whether to fail initialization of the underlying MetadataResolverService (and possibly the IdP as a whole) if the initialization of a metadata provider fails. When false, the IdP may start, and will continue to attempt to reload valid metadata if configured to do so, but operations that require valid metadata will fail until it does.", + "type": "boolean", + "widget": { + "id": "boolean-radio" + }, + "oneOf": [ + { + "enum": [ + true + ], + "description": "True" + }, + { + "enum": [ + false + ], + "description": "False" + } + ], + "default": true + }, + "satisfyAnyPredicates": { + "title": "Satisy Any Predicates?", + "description": "Flag which determines whether predicates used in filtering are connected by a logical 'OR' (true) or by logical 'AND' (false).", + "type": "boolean", + "widget": { + "id": "boolean-radio" + }, + "oneOf": [ + { + "enum": [ + true + ], + "description": "True" + }, + { + "enum": [ + false + ], + "description": "False" + } + ], + "default": false + } + } +} \ No newline at end of file diff --git a/ui/src/testing/activated-route.stub.ts b/ui/src/testing/activated-route.stub.ts index 511b78744..f41853bf9 100644 --- a/ui/src/testing/activated-route.stub.ts +++ b/ui/src/testing/activated-route.stub.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject } from 'rxjs/BehaviorSubject'; -import { convertToParamMap, ParamMap } from '@angular/router'; +import { convertToParamMap, ParamMap, ActivatedRoute } from '@angular/router'; @Injectable() export class ActivatedRouteStub { @@ -13,6 +13,9 @@ export class ActivatedRouteStub { // Test parameters private _testParamMap: ParamMap; + + private _firstChild: ActivatedRouteStub; + get testParamMap() { return this._testParamMap; } set testParamMap(params: {}) { this._testParamMap = convertToParamMap(params); @@ -27,4 +30,12 @@ export class ActivatedRouteStub { get params() { return this.paramMap; } + + get firstChild(): ActivatedRouteStub { + return this._firstChild; + } + + set firstChild(stub: ActivatedRouteStub) { + this._firstChild = stub; + } }