diff --git a/backend/src/main/resources/i18n/messages_en.properties b/backend/src/main/resources/i18n/messages_en.properties index b5bade203..30ff4d658 100644 --- a/backend/src/main/resources/i18n/messages_en.properties +++ b/backend/src/main/resources/i18n/messages_en.properties @@ -328,6 +328,7 @@ label.dynamic-attributes=Dynamic Attributes label.metadata-filter-plugins=Metadata Filter Plugins label.advanced-settings=Advanced Settings label.edit-metadata-provider=Edit Metadata Provider +label.edit-metadata-source=Edit Metadata Source label.http-settings-advanced=Http Settings (Advanced) label.metadata-ui=User Interface / MDUI Information diff --git a/ui/src/app/app.component.html b/ui/src/app/app.component.html index 598cd1eb2..67ae33e22 100644 --- a/ui/src/app/app.component.html +++ b/ui/src/app/app.component.html @@ -55,6 +55,7 @@
+
diff --git a/ui/src/app/app.component.spec.ts b/ui/src/app/app.component.spec.ts index 929507eeb..9e501e543 100644 --- a/ui/src/app/app.component.spec.ts +++ b/ui/src/app/app.component.spec.ts @@ -7,10 +7,11 @@ import { AppComponent } from './app.component'; import * as fromRoot from './core/reducer'; import { NotificationModule } from './notification/notification.module'; import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; -import { MockTranslatePipe, MockI18nService, MockI18nModule } from '../testing/i18n.stub'; +import { MockI18nService, MockI18nModule } from '../testing/i18n.stub'; import { I18nService } from './i18n/service/i18n.service'; import { NavigationService } from './core/service/navigation.service'; import { NavigationServiceStub } from '../testing/navigation-service.stub'; +import { MockPageTitleComponent } from '../testing/page-title-component.stub'; @Component({ template: ` @@ -46,6 +47,7 @@ describe('AppComponent', () => { ], declarations: [ AppComponent, + MockPageTitleComponent, TestHostComponent ], }).compileComponents(); diff --git a/ui/src/app/app.component.ts b/ui/src/app/app.component.ts index 0f12387dd..c1d0349f1 100644 --- a/ui/src/app/app.component.ts +++ b/ui/src/app/app.component.ts @@ -37,7 +37,6 @@ export class AppComponent implements OnInit { constructor( private store: Store, private i18nService: I18nService, - private router: Router, private navService: NavigationService ) { this.version$ = this.store.select(fromRoot.getVersionInfo); diff --git a/ui/src/app/app.module.ts b/ui/src/app/app.module.ts index 47130b4c7..99011223b 100644 --- a/ui/src/app/app.module.ts +++ b/ui/src/app/app.module.ts @@ -1,6 +1,6 @@ import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; -import { StoreModule } from '@ngrx/store'; +import { StoreModule, Store } from '@ngrx/store'; import { EffectsModule } from '@ngrx/effects'; import { StoreDevtoolsModule } from '@ngrx/store-devtools'; import { StoreRouterConnectingModule, RouterStateSerializer } from '@ngrx/router-store'; @@ -24,7 +24,6 @@ import { WizardModule } from './wizard/wizard.module'; import { FormModule } from './schema-form/schema-form.module'; import { environment } from '../environments/environment.prod'; import { I18nModule } from './i18n/i18n.module'; -import { NavigationService } from './core/service/navigation.service'; @NgModule({ declarations: [ @@ -46,6 +45,7 @@ import { NavigationService } from './core/service/navigation.service'; }), EffectsModule.forRoot([]), BrowserModule, + CoreModule, CoreModule.forRoot(), StoreRouterConnectingModule.forRoot(), NgbDropdownModule, @@ -77,4 +77,4 @@ import { NavigationService } from './core/service/navigation.service'; ], bootstrap: [AppComponent] }) -export class AppModule { } +export class AppModule {} diff --git a/ui/src/app/core/action/location.action.ts b/ui/src/app/core/action/location.action.ts new file mode 100644 index 000000000..b9da73b3d --- /dev/null +++ b/ui/src/app/core/action/location.action.ts @@ -0,0 +1,14 @@ +import { Action } from '@ngrx/store'; + +export enum LocationActionTypes { + SET_TITLE = '[Location] Set Title' +} + +export class SetTitle implements Action { + readonly type = LocationActionTypes.SET_TITLE; + + constructor(public payload: string) { } +} + +export type LocationActionUnion = + | SetTitle; diff --git a/ui/src/app/core/component/page-title.component.spec.ts b/ui/src/app/core/component/page-title.component.spec.ts new file mode 100644 index 000000000..2f0d6a668 --- /dev/null +++ b/ui/src/app/core/component/page-title.component.spec.ts @@ -0,0 +1,65 @@ +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 '../../wizard/reducer'; +import * as fromI18n from '../../i18n/reducer'; +import * as fromCore from '../reducer'; +import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; +import { PageTitleComponent } from './page-title.component'; +import { MockI18nModule, MockI18nService } from '../../../testing/i18n.stub'; +import { I18nService } from '../../i18n/service/i18n.service'; + +@Component({ + template: `` +}) +class TestHostComponent { + @ViewChild(PageTitleComponent, { static: true }) + public componentUnderTest: PageTitleComponent; +} + +describe('Page Title Component', () => { + + let fixture: ComponentFixture; + let instance: TestHostComponent; + let app: PageTitleComponent; + let store: Store; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + NgbDropdownModule, + RouterTestingModule, + StoreModule.forRoot({ + wizard: combineReducers(fromWizard.reducers), + core: combineReducers(fromCore.reducers), + i18n: combineReducers(fromI18n.reducers) + }), + MockI18nModule + ], + declarations: [ + PageTitleComponent, + TestHostComponent + ], + providers: [ + { + provide: I18nService, + useClass: MockI18nService + } + ] + }).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(); + }); +}); diff --git a/ui/src/app/core/component/page-title.component.ts b/ui/src/app/core/component/page-title.component.ts new file mode 100644 index 000000000..676bcfc23 --- /dev/null +++ b/ui/src/app/core/component/page-title.component.ts @@ -0,0 +1,80 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; + +import { Store } from '@ngrx/store'; + +import * as fromRoot from '../reducer'; +import { Observable, Subscription } from 'rxjs'; +import { Router, ActivatedRoute, NavigationEnd } from '@angular/router'; +import { I18nService } from '../../i18n/service/i18n.service'; +import { filter, map, mergeMap, startWith, combineLatest } from 'rxjs/operators'; +import { getCurrent } from '../../wizard/reducer'; +import { getMessages } from '../../i18n/reducer'; +import { SetTitle } from '../action/location.action'; + +@Component({ + selector: 'page-title', + template: `

{{ pageTitle$ | async }}

`, + styleUrls: [] +}) +export class PageTitleComponent implements OnInit, OnDestroy { + + pageTitle$: Observable = this.store.select(fromRoot.getLocationTitle); + + initial = true; + sub: Subscription; + + constructor( + private store: Store, + private router: Router, + private activatedRoute: ActivatedRoute, + private translateService: I18nService + ) { + this.pageTitle$.subscribe(title => { + const heading = document.getElementById('main-page-title'); + if (heading && title) { + if (!this.initial) { + heading.focus(); + } + this.initial = false; + } + if (title) { + document.title = `Shibboleth IDP UI | ${title}`; + } + }); + } + + ngOnInit(): void { + this.sub = this.router.events.pipe( + filter(e => e instanceof NavigationEnd), + map(() => this.activatedRoute), + map((route) => { + while (route.firstChild) { + route = route.firstChild; + } + return route; + }), + filter((route) => route.outlet === 'primary'), + mergeMap((route) => route.data), + combineLatest( + this.store.select(getCurrent).pipe( + filter(c => !!c), + startWith({ label: '' }) + ), + this.store.select(getMessages).pipe( + startWith({}) + ) + ), + map(([data, currentWizardPage, messages]) => { + const title = data.subtitle && currentWizardPage ? + `${data.title} - ${this.translateService.translate(currentWizardPage.label, null, messages)}` + : + data.title; + return new SetTitle(title); + }) + ).subscribe(this.store); + } + + ngOnDestroy(): void { + this.sub.unsubscribe(); + } +} diff --git a/ui/src/app/core/core.module.ts b/ui/src/app/core/core.module.ts index fef2fd240..c18bb8852 100644 --- a/ui/src/app/core/core.module.ts +++ b/ui/src/app/core/core.module.ts @@ -16,8 +16,11 @@ import { ModalService } from './service/modal.service'; import { DifferentialService } from './service/differential.service'; import { NavigatorService } from './service/navigator.service'; import { NavigationService } from './service/navigation.service'; +import { PageTitleComponent } from './component/page-title.component'; -export const COMPONENTS = []; +export const COMPONENTS = [ + PageTitleComponent +]; @NgModule({ imports: [ diff --git a/ui/src/app/core/reducer/index.spec.ts b/ui/src/app/core/reducer/index.spec.ts index 5a1fbef3e..8f9fb9f8f 100644 --- a/ui/src/app/core/reducer/index.spec.ts +++ b/ui/src/app/core/reducer/index.spec.ts @@ -1,6 +1,7 @@ import * as fromIndex from './index'; import * as fromUser from './user.reducer'; import * as fromVersion from './version.reducer'; +import * as fromLocation from './location.reducer'; import * as fromConfig from './configuration.reducer'; import { VersionInfo } from '../model/version'; @@ -8,7 +9,8 @@ describe('Core index reducers', () => { const state: fromIndex.CoreState = { user: fromUser.initialState as fromUser.UserState, version: fromVersion.initialState as fromVersion.VersionState, - config: fromConfig.initialState as fromConfig.ConfigState + config: fromConfig.initialState as fromConfig.ConfigState, + location: fromLocation.initialState as fromLocation.LocationState }; describe('getUserStateFn function', () => { it('should return the user state', () => { diff --git a/ui/src/app/core/reducer/index.ts b/ui/src/app/core/reducer/index.ts index bbe53eae6..ee794a567 100644 --- a/ui/src/app/core/reducer/index.ts +++ b/ui/src/app/core/reducer/index.ts @@ -6,12 +6,14 @@ import { import * as fromUser from './user.reducer'; import * as fromVersion from './version.reducer'; import * as fromConfig from './configuration.reducer'; +import * as fromLocation from './location.reducer'; import * as fromRoot from '../../app.reducer'; export interface CoreState { user: fromUser.UserState; version: fromVersion.VersionState; config: fromConfig.ConfigState; + location: fromLocation.LocationState; } export interface State extends fromRoot.State { @@ -21,13 +23,15 @@ export interface State extends fromRoot.State { export const reducers = { user: fromUser.reducer, version: fromVersion.reducer, - config: fromConfig.reducer + config: fromConfig.reducer, + location: fromLocation.reducer }; export const getCoreFeature = createFeatureSelector('core'); export const getUserStateFn = (state: CoreState) => state.user; export const getVersionStateFn = (state: CoreState) => state.version; export const getConfigStateFn = (state: CoreState) => state.config; +export const getLocationStateFn = (state: CoreState) => state.location; export const getUserState = createSelector(getCoreFeature, getUserStateFn); export const getUser = createSelector(getUserState, fromUser.getUser); @@ -49,3 +53,6 @@ export const getUserRoles = createSelector(getRoles, filterRolesFn); export const getCurrentUserRole = createSelector(getUser, u => u ? u.role : null); export const isCurrentUserAdmin = createSelector(getUser, isUserAdminFn); + +export const getLocationState = createSelector(getCoreFeature, getLocationStateFn); +export const getLocationTitle = createSelector(getLocationState, fromLocation.getTitle); diff --git a/ui/src/app/core/reducer/location.reducer.ts b/ui/src/app/core/reducer/location.reducer.ts new file mode 100644 index 000000000..dfc6cfbd5 --- /dev/null +++ b/ui/src/app/core/reducer/location.reducer.ts @@ -0,0 +1,23 @@ +import { LocationActionUnion, LocationActionTypes } from '../action/location.action'; + +export interface LocationState { + title: string; +} + +export const initialState: LocationState = { + title: '' +}; + +export function reducer(state = initialState, action: LocationActionUnion): LocationState { + switch (action.type) { + case LocationActionTypes.SET_TITLE: + return { + ...state, + title: action.payload + }; + default: + return state; + } +} + +export const getTitle = (state: LocationState) => state.title; diff --git a/ui/src/app/dashboard/dashboard.routing.ts b/ui/src/app/dashboard/dashboard.routing.ts index 4a541b2e3..1563c73c5 100644 --- a/ui/src/app/dashboard/dashboard.routing.ts +++ b/ui/src/app/dashboard/dashboard.routing.ts @@ -26,8 +26,17 @@ const routes: Routes = [ component: ManagerComponent, children: [ { path: '', redirectTo: 'resolvers', pathMatch: 'prefix' }, - { path: 'resolvers', component: DashboardResolversListComponent }, - { path: 'providers', component: DashboardProvidersListComponent, canActivate: [AdminGuard] }, + { + path: 'resolvers', + component: DashboardResolversListComponent, + data: { title: 'Metadata Source Dashboard' } + }, + { + path: 'providers', + component: DashboardProvidersListComponent, + canActivate: [AdminGuard], + data: { title: 'Metadata Provider Dashboard' } + }, ] } ] @@ -42,11 +51,13 @@ const routes: Routes = [ children: [ { path: 'management', - component: AdminManagementPageComponent + component: AdminManagementPageComponent, + data: { title: 'User Administration Dashboard' } }, { path: 'actions', - component: ActionRequiredPageComponent + component: ActionRequiredPageComponent, + data: { title: 'Administrator Actions Required Dashboard' } } ] } diff --git a/ui/src/app/i18n/reducer/message.reducer.ts b/ui/src/app/i18n/reducer/message.reducer.ts index a57bdb4c6..d4debe3a1 100644 --- a/ui/src/app/i18n/reducer/message.reducer.ts +++ b/ui/src/app/i18n/reducer/message.reducer.ts @@ -13,7 +13,7 @@ export interface MessageState { export const initialState: MessageState = { fetching: false, - messages: null, + messages: {}, error: null, locale: null }; diff --git a/ui/src/app/metadata/configuration/configuration.routing.ts b/ui/src/app/metadata/configuration/configuration.routing.ts index fec3c84da..886303790 100644 --- a/ui/src/app/metadata/configuration/configuration.routing.ts +++ b/ui/src/app/metadata/configuration/configuration.routing.ts @@ -22,19 +22,23 @@ export const ConfigurationRoutes: Routes = [ }, { path: 'options', - component: MetadataOptionsComponent + component: MetadataOptionsComponent, + data: { title: `Metadata Configuration Options` } }, { path: 'xml', - component: MetadataXmlComponent + component: MetadataXmlComponent, + data: { title: `Metadata Configuration XML` } }, { path: 'history', - component: MetadataHistoryComponent + component: MetadataHistoryComponent, + data: { title: `Metadata History` } }, { path: 'compare', - component: MetadataComparisonComponent + component: MetadataComparisonComponent, + data: { title: `Metadata Comparison` } }, { path: 'version/:version', @@ -42,11 +46,13 @@ export const ConfigurationRoutes: Routes = [ children: [ { path: 'options', - component: VersionOptionsComponent + component: VersionOptionsComponent, + data: { title: `Metadata Version Options` } }, { path: 'restore', - component: RestoreComponent + component: RestoreComponent, + data: { title: `Restore Metadata Version` } }, { path: 'edit', @@ -61,7 +67,8 @@ export const ConfigurationRoutes: Routes = [ component: RestoreEditStepComponent, resolve: { index: IndexResolver - } + }, + data: { title: `Edit Metadata` } } ] } diff --git a/ui/src/app/metadata/provider/provider.routing.ts b/ui/src/app/metadata/provider/provider.routing.ts index 036750a3e..df7dd3ec0 100644 --- a/ui/src/app/metadata/provider/provider.routing.ts +++ b/ui/src/app/metadata/provider/provider.routing.ts @@ -31,7 +31,8 @@ export const ProviderRoutes: Routes = [ children: [ { path: 'new', - component: ProviderWizardStepComponent + component: ProviderWizardStepComponent, + data: { title: `Create Provider`, subtitle: true } } ] }, @@ -45,7 +46,8 @@ export const ProviderRoutes: Routes = [ children: [ { path: 'filter/new', - component: NewFilterComponent + component: NewFilterComponent, + data: { title: `Create New Filter` } }, { path: 'filter/:id', @@ -54,7 +56,8 @@ export const ProviderRoutes: Routes = [ children: [ { path: 'edit', - component: EditFilterComponent + component: EditFilterComponent, + data: { title: `Edit Metadata Filter` } } ] } @@ -62,7 +65,8 @@ export const ProviderRoutes: Routes = [ }, { path: 'filters', - component: ProviderFilterListComponent + component: ProviderFilterListComponent, + data: { title: `Metadata Filter List` } }, { path: 'edit', @@ -71,7 +75,8 @@ export const ProviderRoutes: Routes = [ { path: '', redirectTo: 'common', pathMatch: 'prefix' }, { path: ':form', - component: ProviderEditStepComponent + component: ProviderEditStepComponent, + data: { title: `Edit Metadata Provider`, subtitle: true } } ], canDeactivate: [ diff --git a/ui/src/app/metadata/resolver/container/resolver-edit.component.html b/ui/src/app/metadata/resolver/container/resolver-edit.component.html index 88eb1946a..85084e0f8 100644 --- a/ui/src/app/metadata/resolver/container/resolver-edit.component.html +++ b/ui/src/app/metadata/resolver/container/resolver-edit.component.html @@ -3,7 +3,8 @@
- Edit Metadata Resolver - {{ (resolver$ | async).serviceProviderName }} + + Edit Metadata Source - {{ (resolver$ | async).serviceProviderName }}
diff --git a/ui/src/app/metadata/resolver/container/resolver-edit.component.ts b/ui/src/app/metadata/resolver/container/resolver-edit.component.ts index 775c956d3..6d5e802d2 100644 --- a/ui/src/app/metadata/resolver/container/resolver-edit.component.ts +++ b/ui/src/app/metadata/resolver/container/resolver-edit.component.ts @@ -53,9 +53,6 @@ export class ResolverEditComponent implements OnDestroy, CanComponentDeactivate this.status$ = this.store.select(fromResolver.getInvalidEntityForms); this.isSaving$ = this.store.select(fromResolver.getEntityIsSaving); - let startIndex$ = this.route.firstChild.params.pipe(map(p => p.form)); - startIndex$.pipe(takeUntil(this.ngUnsubscribe)).subscribe(index => this.store.dispatch(new SetIndex(index))); - this.store .select(fromWizard.getCurrentWizardSchema) .pipe(filter(s => !!s)) diff --git a/ui/src/app/metadata/resolver/container/resolver-wizard.component.ts b/ui/src/app/metadata/resolver/container/resolver-wizard.component.ts index 46416f78a..b56391b27 100644 --- a/ui/src/app/metadata/resolver/container/resolver-wizard.component.ts +++ b/ui/src/app/metadata/resolver/container/resolver-wizard.component.ts @@ -98,16 +98,6 @@ export class ResolverWizardComponent implements OnDestroy, CanComponentDeactivat this.resolver$ = this.store.select(fromCollections.getSelectedDraft); - this.route.params - .pipe( - takeUntil(this.ngUnsubscribe), - map(params => params.index), - distinctUntilChanged() - ) - .subscribe(index => { - this.store.dispatch(new SetIndex(index)); - }); - this.changes$.pipe( takeUntil(this.ngUnsubscribe), skipWhile(() => this.saving), diff --git a/ui/src/app/metadata/resolver/resolver.routing.ts b/ui/src/app/metadata/resolver/resolver.routing.ts index fc92ebd02..efd3c6b72 100644 --- a/ui/src/app/metadata/resolver/resolver.routing.ts +++ b/ui/src/app/metadata/resolver/resolver.routing.ts @@ -14,6 +14,7 @@ import { ResolverEditComponent } from './container/resolver-edit.component'; import { ResolverEditStepComponent } from './container/resolver-edit-step.component'; import { ResolverSelectComponent } from './container/resolver-select.component'; import { MetadataResolverPageComponent } from './resolver.component'; +import { IndexResolver } from '../configuration/service/index-resolver.service'; export const ResolverRoutes: Routes = [ { @@ -28,32 +29,38 @@ export const ResolverRoutes: Routes = [ { path: 'blank/:index', component: ResolverWizardComponent, + resolve: [IndexResolver], canDeactivate: [ CanDeactivateGuard ], children: [ { path: '', - component: ResolverWizardStepComponent + component: ResolverWizardStepComponent, + data: { title: `Create Metadata Source`, subtitle: true }, + resolve: [] } ] }, { path: 'upload', component: UploadResolverComponent, - canDeactivate: [] + canDeactivate: [], + data: { title: `Upload Metadata Source` } }, { path: 'copy', component: CopyResolverComponent, - canDeactivate: [] + canDeactivate: [], + data: { title: `Copy Metadata Source` } } ] }, { path: 'new/copy/confirm', component: ConfirmCopyComponent, - canActivate: [CopyIsSetGuard] + canActivate: [CopyIsSetGuard], + data: { title: `Confirm Metadata Source Copy` } }, { path: ':id', @@ -65,8 +72,10 @@ export const ResolverRoutes: Routes = [ children: [ { path: '', redirectTo: 'common', pathMatch: 'prefix' }, { - path: ':form', - component: ResolverEditStepComponent + path: ':index', + resolve: [IndexResolver], + component: ResolverEditStepComponent, + data: { title: `Edit Metadata Source`, subtitle: true } } ], canDeactivate: [ diff --git a/ui/src/app/schema-form/widget/number/float.component.ts b/ui/src/app/schema-form/widget/number/float.component.ts index 9df6f72bb..9ad124211 100644 --- a/ui/src/app/schema-form/widget/number/float.component.ts +++ b/ui/src/app/schema-form/widget/number/float.component.ts @@ -20,20 +20,11 @@ export class CustomFloatComponent extends IntegerWidget implements AfterViewInit } ngAfterViewInit() { - super.ngAfterViewInit(); const control = this.control; - this.formProperty.valueChanges.subscribe((newValue) => { - if (typeof this._displayValue !== 'undefined') { - // Ignore the model value, use the display value instead - if (control.value !== this._displayValue) { - control.setValue(this._displayValue, { emitEvent: false }); - } - } else { - if (control.value !== newValue) { - control.setValue(newValue, { emitEvent: false }); - } - } - }); + + if (this.formProperty.value) { + control.setValue(this.formProperty.value, { emitEvent: false }); + } this.formProperty.errorsChanges.subscribe((errors) => { control.setErrors(errors, { emitEvent: true }); const messages = (errors || []) @@ -46,13 +37,15 @@ export class CustomFloatComponent extends IntegerWidget implements AfterViewInit control.valueChanges.subscribe((newValue) => { const native = (this.element.nativeElement); this._displayValue = newValue; - this.formProperty.setValue(newValue, false); if (newValue === '' && native.validity.badInput) { + this.formProperty.setValue(native.valueAsNumber, false); this.formProperty.extendErrors([{ code: 'INVALID_NUMBER', path: `#${this.formProperty.path}`, message: 'Invalid number', }]); + } else { + this.formProperty.setValue(newValue, false); } }); } diff --git a/ui/src/index.html b/ui/src/index.html index 112e25f7b..705257e2b 100644 --- a/ui/src/index.html +++ b/ui/src/index.html @@ -2,7 +2,7 @@ - Ui + Shibboleth IDP UI diff --git a/ui/src/testing/page-title-component.stub.ts b/ui/src/testing/page-title-component.stub.ts new file mode 100644 index 000000000..d3a1a748a --- /dev/null +++ b/ui/src/testing/page-title-component.stub.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'page-title', + template: '', + styleUrls: [] +}) +export class MockPageTitleComponent {}