From 26b7431f635e46f22f64f2322dac5dbfe18fe64d Mon Sep 17 00:00:00 2001 From: Ryan Mathis Date: Thu, 6 Dec 2018 10:22:18 -0700 Subject: [PATCH 01/38] SHIBUI-1031 Implemented tab for user administration --- ui/src/app/app.routing.ts | 13 ++-- .../container/dashboard.component.html | 29 +++++++++ .../container/dashboard.component.scss | 15 +++++ .../container/dashboard.component.ts | 18 ++++++ ui/src/app/dashboard/dashboard.module.ts | 25 ++++++++ ui/src/app/dashboard/dashboard.routing.ts | 60 +++++++++++++++++++ .../manager/container/manager.component.html | 12 +--- .../app/metadata/manager/manager.routing.ts | 16 +---- ui/src/app/user/admin/admin.component.html | 1 + ui/src/app/user/admin/admin.component.ts | 10 ++++ ui/src/app/user/admin/admin.module.ts | 44 ++++++++++++++ .../container/admin-management.component.html | 14 +++++ .../container/admin-management.component.ts | 17 ++++++ ui/src/app/user/user.component.html | 1 + ui/src/app/user/user.component.ts | 17 ++++++ ui/src/app/user/user.module.ts | 24 ++++++++ ui/src/app/user/user.routing.ts | 20 +++++++ 17 files changed, 306 insertions(+), 30 deletions(-) create mode 100644 ui/src/app/dashboard/container/dashboard.component.html create mode 100644 ui/src/app/dashboard/container/dashboard.component.scss create mode 100644 ui/src/app/dashboard/container/dashboard.component.ts create mode 100644 ui/src/app/dashboard/dashboard.module.ts create mode 100644 ui/src/app/dashboard/dashboard.routing.ts create mode 100644 ui/src/app/user/admin/admin.component.html create mode 100644 ui/src/app/user/admin/admin.component.ts create mode 100644 ui/src/app/user/admin/admin.module.ts create mode 100644 ui/src/app/user/admin/container/admin-management.component.html create mode 100644 ui/src/app/user/admin/container/admin-management.component.ts create mode 100644 ui/src/app/user/user.component.html create mode 100644 ui/src/app/user/user.component.ts create mode 100644 ui/src/app/user/user.module.ts create mode 100644 ui/src/app/user/user.routing.ts diff --git a/ui/src/app/app.routing.ts b/ui/src/app/app.routing.ts index 9903039c9..99b8d88de 100644 --- a/ui/src/app/app.routing.ts +++ b/ui/src/app/app.routing.ts @@ -1,9 +1,12 @@ import { NgModule } from '@angular/core'; -import { Routes, RouterModule } from '@angular/router'; +import { Routes, RouterModule, PreloadAllModules } from '@angular/router'; const routes: Routes = [ - { path: '', redirectTo: 'metadata', pathMatch: 'full' }, - { path: 'dashboard', redirectTo: 'metadata', pathMatch: 'full' }, + { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, + { + path: 'dashboard', + loadChildren: './dashboard/dashboard.module#DashboardModule' + }, { path: 'metadata', loadChildren: './metadata/metadata.module#MetadataModule' @@ -11,7 +14,9 @@ const routes: Routes = [ ]; @NgModule({ - imports: [RouterModule.forRoot(routes)], + imports: [RouterModule.forRoot(routes, { + preloadingStrategy: PreloadAllModules + })], exports: [RouterModule] }) export class AppRoutingModule { } diff --git a/ui/src/app/dashboard/container/dashboard.component.html b/ui/src/app/dashboard/container/dashboard.component.html new file mode 100644 index 000000000..23c38125a --- /dev/null +++ b/ui/src/app/dashboard/container/dashboard.component.html @@ -0,0 +1,29 @@ +
+ + +
\ No newline at end of file diff --git a/ui/src/app/dashboard/container/dashboard.component.scss b/ui/src/app/dashboard/container/dashboard.component.scss new file mode 100644 index 000000000..5bfb3774a --- /dev/null +++ b/ui/src/app/dashboard/container/dashboard.component.scss @@ -0,0 +1,15 @@ +@import '../../../theme/palette'; + +:host { + .lead { + line-height: 36px; + } + + .nav-tabs, .nav-link.active { + border-color: $brand-primary; + } + + .nav-link:hover { + border-bottom-color: $brand-primary; + } +} \ No newline at end of file diff --git a/ui/src/app/dashboard/container/dashboard.component.ts b/ui/src/app/dashboard/container/dashboard.component.ts new file mode 100644 index 000000000..645a19559 --- /dev/null +++ b/ui/src/app/dashboard/container/dashboard.component.ts @@ -0,0 +1,18 @@ +import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { Store } from '@ngrx/store'; + +import * as fromRoot from '../../app.reducer'; + +@Component({ + selector: 'dashboard-page', + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './dashboard.component.html', + styleUrls: ['./dashboard.component.scss'] +}) +export class DashboardPageComponent { + + constructor( + private store: Store + ) { + } +} diff --git a/ui/src/app/dashboard/dashboard.module.ts b/ui/src/app/dashboard/dashboard.module.ts new file mode 100644 index 000000000..0b6d332b4 --- /dev/null +++ b/ui/src/app/dashboard/dashboard.module.ts @@ -0,0 +1,25 @@ +import { NgModule } from '@angular/core'; + +import { I18nModule } from '../i18n/i18n.module'; +import { CustomWidgetRegistry } from '../schema-form/registry'; +import { WidgetRegistry } from 'ngx-schema-form'; +import { DashboardPageComponent } from './container/dashboard.component'; +import { DashboardRoutingModule } from './dashboard.routing'; +import { MetadataModule } from '../metadata/metadata.module'; +import { UserModule } from '../user/user.module'; + +@NgModule({ + imports: [ + DashboardRoutingModule, + MetadataModule, + UserModule, + I18nModule + ], + providers: [ + { provide: WidgetRegistry, useClass: CustomWidgetRegistry } + ], + declarations: [ + DashboardPageComponent + ] +}) +export class DashboardModule { } diff --git a/ui/src/app/dashboard/dashboard.routing.ts b/ui/src/app/dashboard/dashboard.routing.ts new file mode 100644 index 000000000..320daf628 --- /dev/null +++ b/ui/src/app/dashboard/dashboard.routing.ts @@ -0,0 +1,60 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { DashboardPageComponent } from './container/dashboard.component'; +import { ManagerComponent } from '../metadata/manager/container/manager.component'; +import { MetadataPageComponent } from '../metadata/metadata.component'; +import { DashboardResolversListComponent } from '../metadata/manager/container/dashboard-resolvers-list.component'; +import { DashboardProvidersListComponent } from '../metadata/manager/container/dashboard-providers-list.component'; +import { UserPageComponent } from '../user/user.component'; +import { AdminComponent } from '../user/admin/admin.component'; +import { AdminManagementPageComponent } from '../user/admin/container/admin-management.component'; + +const routes: Routes = [ + { + path: '', + component: DashboardPageComponent, + children: [ + { + path: 'metadata', + component: MetadataPageComponent, + children: [ + { + path: 'manager', + component: ManagerComponent, + children: [ + { path: '', redirectTo: 'resolvers', pathMatch: 'prefix' }, + { path: 'resolvers', component: DashboardResolversListComponent }, + { path: 'providers', component: DashboardProvidersListComponent }, + ] + } + ] + }, + { + path: 'users', + component: UserPageComponent, + children: [ + { path: '', redirectTo: 'admin', pathMatch: 'prefix' }, + { + path: 'admin', + component: AdminComponent, + children: [ + { path: '', redirectTo: 'management', pathMatch: 'prefix' }, + { + path: 'management', + component: AdminManagementPageComponent + } + ] + } + ] + } + ], + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + ], + exports: [RouterModule] +}) +export class DashboardRoutingModule { } diff --git a/ui/src/app/metadata/manager/container/manager.component.html b/ui/src/app/metadata/manager/container/manager.component.html index 2c354d7e3..0680b43f9 100644 --- a/ui/src/app/metadata/manager/container/manager.component.html +++ b/ui/src/app/metadata/manager/container/manager.component.html @@ -1,11 +1 @@ - + diff --git a/ui/src/app/metadata/manager/manager.routing.ts b/ui/src/app/metadata/manager/manager.routing.ts index 9f9c21179..67dbf9283 100644 --- a/ui/src/app/metadata/manager/manager.routing.ts +++ b/ui/src/app/metadata/manager/manager.routing.ts @@ -1,17 +1,3 @@ import { Routes } from '@angular/router'; -import { DashboardResolversListComponent } from './container/dashboard-resolvers-list.component'; -import { DashboardProvidersListComponent } from './container/dashboard-providers-list.component'; -import { ManagerComponent } from './container/manager.component'; -export const ManagerRoutes: Routes = [ - { path: '', redirectTo: 'manager', pathMatch: 'prefix' }, - { - path: 'manager', - component: ManagerComponent, - children: [ - { path: '', redirectTo: 'resolvers', pathMatch: 'prefix' }, - { path: 'resolvers', component: DashboardResolversListComponent }, - { path: 'providers', component: DashboardProvidersListComponent }, - ] - } -]; +export const ManagerRoutes: Routes = []; diff --git a/ui/src/app/user/admin/admin.component.html b/ui/src/app/user/admin/admin.component.html new file mode 100644 index 000000000..90c6b6463 --- /dev/null +++ b/ui/src/app/user/admin/admin.component.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/app/user/admin/admin.component.ts b/ui/src/app/user/admin/admin.component.ts new file mode 100644 index 000000000..b3314e7ec --- /dev/null +++ b/ui/src/app/user/admin/admin.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'admin-page', + templateUrl: './admin.component.html', + styleUrls: [] +}) +export class AdminComponent { + constructor() { } +} diff --git a/ui/src/app/user/admin/admin.module.ts b/ui/src/app/user/admin/admin.module.ts new file mode 100644 index 000000000..699628410 --- /dev/null +++ b/ui/src/app/user/admin/admin.module.ts @@ -0,0 +1,44 @@ +import { NgModule, ModuleWithProviders } from '@angular/core'; +import { HttpClientModule } from '@angular/common/http'; +import { RouterModule, Routes } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule } from '@angular/forms'; + +import { SharedModule } from '../../shared/shared.module'; +import { I18nModule } from '../../i18n/i18n.module'; +import { AdminManagementPageComponent } from './container/admin-management.component'; +import { AdminComponent } from './admin.component'; + +@NgModule({ + declarations: [ + AdminManagementPageComponent, + AdminComponent + ], + entryComponents: [ + ], + imports: [ + CommonModule, + ReactiveFormsModule, + RouterModule, + HttpClientModule, + SharedModule, + I18nModule + ] +}) +export class UserAdminModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: RootUserAdminModule, + providers: [] + }; + } +} + +@NgModule({ + imports: [ + UserAdminModule, + // StoreModule.forFeature('admin', reducers), + // EffectsModule.forFeature([]), + ], +}) +export class RootUserAdminModule { } diff --git a/ui/src/app/user/admin/container/admin-management.component.html b/ui/src/app/user/admin/container/admin-management.component.html new file mode 100644 index 000000000..a538f679e --- /dev/null +++ b/ui/src/app/user/admin/container/admin-management.component.html @@ -0,0 +1,14 @@ +
+
+
+
+
+ Admin Management +
+
+
+
+ +
+
+
\ No newline at end of file diff --git a/ui/src/app/user/admin/container/admin-management.component.ts b/ui/src/app/user/admin/container/admin-management.component.ts new file mode 100644 index 000000000..6de38a402 --- /dev/null +++ b/ui/src/app/user/admin/container/admin-management.component.ts @@ -0,0 +1,17 @@ +import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { Store } from '@ngrx/store'; + +import * as fromRoot from '../../../app.reducer'; + +@Component({ + selector: 'admin-management-page', + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './admin-management.component.html', + styleUrls: [] +}) +export class AdminManagementPageComponent { + + constructor( + private store: Store + ) { } +} diff --git a/ui/src/app/user/user.component.html b/ui/src/app/user/user.component.html new file mode 100644 index 000000000..0680b43f9 --- /dev/null +++ b/ui/src/app/user/user.component.html @@ -0,0 +1 @@ + diff --git a/ui/src/app/user/user.component.ts b/ui/src/app/user/user.component.ts new file mode 100644 index 000000000..6dcb47615 --- /dev/null +++ b/ui/src/app/user/user.component.ts @@ -0,0 +1,17 @@ +import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { Store } from '@ngrx/store'; + +import * as fromRoot from '../app.reducer'; + +@Component({ + selector: 'user-page', + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './user.component.html', + styleUrls: [] +}) +export class UserPageComponent { + + constructor( + private store: Store + ) {} +} diff --git a/ui/src/app/user/user.module.ts b/ui/src/app/user/user.module.ts new file mode 100644 index 000000000..fc21cca5b --- /dev/null +++ b/ui/src/app/user/user.module.ts @@ -0,0 +1,24 @@ +import { NgModule } from '@angular/core'; + +import { UserRoutingModule } from './user.routing'; +import { I18nModule } from '../i18n/i18n.module'; +import { CustomWidgetRegistry } from '../schema-form/registry'; +import { WidgetRegistry } from 'ngx-schema-form'; +import { UserPageComponent } from './user.component'; +import { UserAdminModule } from './admin/admin.module'; + + +@NgModule({ + imports: [ + UserRoutingModule, + UserAdminModule.forRoot(), + I18nModule + ], + providers: [ + { provide: WidgetRegistry, useClass: CustomWidgetRegistry } + ], + declarations: [ + UserPageComponent + ] +}) +export class UserModule { } diff --git a/ui/src/app/user/user.routing.ts b/ui/src/app/user/user.routing.ts new file mode 100644 index 000000000..431d0dddd --- /dev/null +++ b/ui/src/app/user/user.routing.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { UserPageComponent } from './user.component'; + + +const routes: Routes = [ + { + path: '', + component: UserPageComponent, + children: [] + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + ], + exports: [RouterModule] +}) +export class UserRoutingModule { } From 2d97630c8324ab43ac209a983fc672f9eb6f7f5c Mon Sep 17 00:00:00 2001 From: Ryan Mathis Date: Mon, 10 Dec 2018 08:53:57 -0700 Subject: [PATCH 02/38] SHIBUI-1031 Fixed routes --- ui/src/app/dashboard/dashboard.routing.ts | 2 ++ .../container/admin-management.component.html | 33 +++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/ui/src/app/dashboard/dashboard.routing.ts b/ui/src/app/dashboard/dashboard.routing.ts index 320daf628..7030aa073 100644 --- a/ui/src/app/dashboard/dashboard.routing.ts +++ b/ui/src/app/dashboard/dashboard.routing.ts @@ -14,10 +14,12 @@ const routes: Routes = [ path: '', component: DashboardPageComponent, children: [ + { path: '', redirectTo: 'metadata', pathMatch: 'prefix' }, { path: 'metadata', component: MetadataPageComponent, children: [ + { path: '', redirectTo: 'manager', pathMatch: 'prefix' }, { path: 'manager', component: ManagerComponent, diff --git a/ui/src/app/user/admin/container/admin-management.component.html b/ui/src/app/user/admin/container/admin-management.component.html index a538f679e..96870e085 100644 --- a/ui/src/app/user/admin/container/admin-management.component.html +++ b/ui/src/app/user/admin/container/admin-management.component.html @@ -3,12 +3,41 @@
- Admin Management + User Maintenance
- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#FirstLastHandle
1MarkOtto@mdo
2JacobThornton@fat
3Larrythe Bird@twitter
\ No newline at end of file From 1440ee58287ce1a79c73bb7abcf08777d2c87ddd Mon Sep 17 00:00:00 2001 From: Ryan Mathis Date: Tue, 11 Dec 2018 11:59:03 -0700 Subject: [PATCH 03/38] Added additional tests --- .../model/dynamic-http.provider.form.spec.ts | 122 ++++++++++++++++++ .../model/file-system.provider.form.spec.ts | 120 +++++++++++++++++ .../model/local-dynamic.provider.form.spec.ts | 120 +++++++++++++++++ 3 files changed, 362 insertions(+) create mode 100644 ui/src/app/metadata/provider/model/dynamic-http.provider.form.spec.ts create mode 100644 ui/src/app/metadata/provider/model/file-system.provider.form.spec.ts create mode 100644 ui/src/app/metadata/provider/model/local-dynamic.provider.form.spec.ts diff --git a/ui/src/app/metadata/provider/model/dynamic-http.provider.form.spec.ts b/ui/src/app/metadata/provider/model/dynamic-http.provider.form.spec.ts new file mode 100644 index 000000000..94d1dfe7b --- /dev/null +++ b/ui/src/app/metadata/provider/model/dynamic-http.provider.form.spec.ts @@ -0,0 +1,122 @@ +import { DynamicHttpMetadataProviderWizard } from './dynamic-http.provider.form'; + + +describe('DynamicHttpMetadataProviderWizard', () => { + + const parser = DynamicHttpMetadataProviderWizard.parser; + const formatter = DynamicHttpMetadataProviderWizard.formatter; + const getValidators = DynamicHttpMetadataProviderWizard.getValidators; + + 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': 'DynamicHttpMetadataResolver', + 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': 'DynamicHttpMetadataResolver', + 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': 'DynamicHttpMetadataResolver', + 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': 'DynamicHttpMetadataResolver', + enabled: true, + resourceId: 'foo' + }; + expect( + formatter(model) + ).toEqual( + model + ); + }); + }); + + describe('getValidators method', () => { + it('should return a list of validators for the ngx-schema-form', () => { + expect(Object.keys(getValidators([]))).toEqual([ + '/', + '/name', + '/xmlId', + '/metadataURL' + ]); + }); + }); +}); diff --git a/ui/src/app/metadata/provider/model/file-system.provider.form.spec.ts b/ui/src/app/metadata/provider/model/file-system.provider.form.spec.ts new file mode 100644 index 000000000..ae8fb4a9a --- /dev/null +++ b/ui/src/app/metadata/provider/model/file-system.provider.form.spec.ts @@ -0,0 +1,120 @@ +import { FileSystemMetadataProviderWizard } from './file-system.provider.form'; + +describe('FileSystemMetadataProviderWizard', () => { + + const parser = FileSystemMetadataProviderWizard.parser; + const formatter = FileSystemMetadataProviderWizard.formatter; + const getValidators = FileSystemMetadataProviderWizard.getValidators; + + 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': 'FileSystemMetadataProvider', + 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': 'FileSystemMetadataProvider', + 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': 'FileSystemMetadataProvider', + 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': 'FileSystemMetadataProvider', + enabled: true, + resourceId: 'foo' + }; + expect( + formatter(model) + ).toEqual( + model + ); + }); + }); + + describe('getValidators method', () => { + it('should return a list of validators for the ngx-schema-form', () => { + expect(Object.keys(getValidators([]))).toEqual([ + '/', + '/name', + '/xmlId' + ]); + }); + }); +}); diff --git a/ui/src/app/metadata/provider/model/local-dynamic.provider.form.spec.ts b/ui/src/app/metadata/provider/model/local-dynamic.provider.form.spec.ts new file mode 100644 index 000000000..3275f2f31 --- /dev/null +++ b/ui/src/app/metadata/provider/model/local-dynamic.provider.form.spec.ts @@ -0,0 +1,120 @@ +import { LocalDynamicMetadataProviderWizard } from './local-dynamic.provider.form'; + +describe('LocalDynamicMetadataProviderWizard', () => { + + const parser = LocalDynamicMetadataProviderWizard.parser; + const formatter = LocalDynamicMetadataProviderWizard.formatter; + const getValidators = LocalDynamicMetadataProviderWizard.getValidators; + + 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': 'LocalDynamicMetadataResolver', + 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': 'LocalDynamicMetadataResolver', + 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': 'LocalDynamicMetadataResolver', + 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': 'LocalDynamicMetadataResolver', + enabled: true, + resourceId: 'foo' + }; + expect( + formatter(model) + ).toEqual( + model + ); + }); + }); + + describe('getValidators method', () => { + it('should return a list of validators for the ngx-schema-form', () => { + expect(Object.keys(getValidators([]))).toEqual([ + '/', + '/name', + '/xmlId' + ]); + }); + }); +}); From 289e4c18dd0776b713ac38eeb41399e09bee1e9c Mon Sep 17 00:00:00 2001 From: Ryan Mathis Date: Tue, 11 Dec 2018 12:00:20 -0700 Subject: [PATCH 04/38] SHIBUI-1031 Added state and service for admin users --- .../user/admin/action/collection.action.ts | 141 ++++++++++++++++++ .../user/admin/effect/collection.effect.ts | 63 ++++++++ ui/src/app/user/admin/model/admin.ts | 9 ++ .../admin/reducer/collection.reducer.spec.ts | 83 +++++++++++ .../user/admin/reducer/collection.reducer.ts | 54 +++++++ ui/src/app/user/admin/reducer/index.ts | 33 ++++ .../user/admin/service/admin.service.spec.ts | 37 +++++ .../app/user/admin/service/admin.service.ts | 47 ++++++ 8 files changed, 467 insertions(+) create mode 100644 ui/src/app/user/admin/action/collection.action.ts create mode 100644 ui/src/app/user/admin/effect/collection.effect.ts create mode 100644 ui/src/app/user/admin/model/admin.ts create mode 100644 ui/src/app/user/admin/reducer/collection.reducer.spec.ts create mode 100644 ui/src/app/user/admin/reducer/collection.reducer.ts create mode 100644 ui/src/app/user/admin/reducer/index.ts create mode 100644 ui/src/app/user/admin/service/admin.service.spec.ts create mode 100644 ui/src/app/user/admin/service/admin.service.ts diff --git a/ui/src/app/user/admin/action/collection.action.ts b/ui/src/app/user/admin/action/collection.action.ts new file mode 100644 index 000000000..96c2ae937 --- /dev/null +++ b/ui/src/app/user/admin/action/collection.action.ts @@ -0,0 +1,141 @@ +import { Action } from '@ngrx/store'; +import { Update } from '@ngrx/entity'; +import { Admin } from '../model/admin'; + +export enum AdminCollectionActionTypes { + SELECT_ADMIN_REQUEST = '[Admin Collection] Select Admin Request', + SELECT_ADMIN_SUCCESS = '[Admin Collection] Select Admin Success', + SELECT_ADMIN_FAIL = '[Admin Collection] Select Admin Fail', + + UPDATE_ADMIN_REQUEST = '[Admin Collection] Update Admin Request', + UPDATE_ADMIN_SUCCESS = '[Admin Collection] Update Admin Success', + UPDATE_ADMIN_FAIL = '[Admin Collection] Update Admin Fail', + + LOAD_ADMIN_REQUEST = '[Admin Collection] Load Admin Request', + LOAD_ADMIN_SUCCESS = '[Admin Collection] Load Admin Success', + LOAD_ADMIN_ERROR = '[Admin Collection] Load Admin Error', + + ADD_ADMIN_REQUEST = '[Admin Collection] Add Admin Request', + ADD_ADMIN_SUCCESS = '[Admin Collection] Add Admin Success', + ADD_ADMIN_FAIL = '[Admin Collection] Add Admin Fail', + + REMOVE_ADMIN_REQUEST = '[Admin Collection] Remove Admin Request', + REMOVE_ADMIN_SUCCESS = '[Admin Collection] Remove Admin Success', + REMOVE_ADMIN_FAIL = '[Admin Collection] Remove Admin Fail', + + CLEAR_ADMINS = '[Admin Collection] Clear Admins' + +} + +export class SelectAdmin implements Action { + readonly type = AdminCollectionActionTypes.SELECT_ADMIN_REQUEST; + + constructor(public payload: string) { } +} + +export class SelectAdminSuccess implements Action { + readonly type = AdminCollectionActionTypes.SELECT_ADMIN_SUCCESS; + + constructor(public payload: Admin) { } +} + +export class SelectAdminFail implements Action { + readonly type = AdminCollectionActionTypes.SELECT_ADMIN_FAIL; + + constructor(public payload: Error) { } +} + +export class LoadAdminRequest implements Action { + readonly type = AdminCollectionActionTypes.LOAD_ADMIN_REQUEST; + + constructor() { } +} + +export class LoadAdminSuccess implements Action { + readonly type = AdminCollectionActionTypes.LOAD_ADMIN_SUCCESS; + + constructor(public payload: Admin[]) { } +} + +export class LoadAdminError implements Action { + readonly type = AdminCollectionActionTypes.LOAD_ADMIN_ERROR; + + constructor(public payload: any) { } +} + +export class UpdateAdminRequest implements Action { + readonly type = AdminCollectionActionTypes.UPDATE_ADMIN_REQUEST; + + constructor(public payload: Admin) { } +} + +export class UpdateAdminSuccess implements Action { + readonly type = AdminCollectionActionTypes.UPDATE_ADMIN_SUCCESS; + + constructor(public payload: Update) { } +} + +export class UpdateAdminFail implements Action { + readonly type = AdminCollectionActionTypes.UPDATE_ADMIN_FAIL; + + constructor(public payload: Admin) { } +} + +export class AddAdminRequest implements Action { + readonly type = AdminCollectionActionTypes.ADD_ADMIN_REQUEST; + + constructor(public payload: Admin) { } +} + +export class AddAdminSuccess implements Action { + readonly type = AdminCollectionActionTypes.ADD_ADMIN_SUCCESS; + + constructor(public payload: Admin) { } +} + +export class AddAdminFail implements Action { + readonly type = AdminCollectionActionTypes.ADD_ADMIN_FAIL; + + constructor(public payload: any) { } +} + +export class RemoveAdminRequest implements Action { + readonly type = AdminCollectionActionTypes.REMOVE_ADMIN_REQUEST; + + constructor(public payload: string) { } +} + +export class RemoveAdminSuccess implements Action { + readonly type = AdminCollectionActionTypes.REMOVE_ADMIN_SUCCESS; + + constructor(public payload: string) { } +} + +export class RemoveAdminFail implements Action { + readonly type = AdminCollectionActionTypes.REMOVE_ADMIN_FAIL; + + constructor(public error: Error) { } +} + +export class ClearAdmins implements Action { + readonly type = AdminCollectionActionTypes.CLEAR_ADMINS; +} + + +export type AdminCollectionActionsUnion = + | LoadAdminRequest + | LoadAdminSuccess + | LoadAdminError + | AddAdminRequest + | AddAdminSuccess + | AddAdminFail + | RemoveAdminRequest + | RemoveAdminSuccess + | RemoveAdminFail + | SelectAdmin + | SelectAdminSuccess + | SelectAdminFail + | UpdateAdminRequest + | UpdateAdminSuccess + | UpdateAdminFail + | ClearAdmins; diff --git a/ui/src/app/user/admin/effect/collection.effect.ts b/ui/src/app/user/admin/effect/collection.effect.ts new file mode 100644 index 000000000..6120faf5b --- /dev/null +++ b/ui/src/app/user/admin/effect/collection.effect.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@angular/core'; +import { Effect, Actions, ofType } from '@ngrx/effects'; +import { Store } from '@ngrx/store'; +import { switchMap, map } from 'rxjs/operators'; + +import * as fromAdmin from '../reducer'; +import { + LoadAdminRequest, + AdminCollectionActionTypes, + LoadAdminSuccess, + UpdateAdminRequest, + UpdateAdminSuccess, + RemoveAdminRequest, + RemoveAdminSuccess +} from '../action/collection.action'; +import { AdminService } from '../service/admin.service'; + + +/* istanbul ignore next */ +@Injectable() +export class AdminCollectionEffects { + + @Effect() + loadAdminRequest$ = this.actions$.pipe( + ofType(AdminCollectionActionTypes.LOAD_ADMIN_REQUEST), + switchMap(() => this.adminService.query().pipe( + map(users => new LoadAdminSuccess(users)) + )) + ); + + @Effect() + updateAdminRequest$ = this.actions$.pipe( + ofType(AdminCollectionActionTypes.UPDATE_ADMIN_REQUEST), + map(action => action.payload), + switchMap(changes => this.adminService.update(changes).pipe( + map(user => new UpdateAdminSuccess({ + id: changes.resourceId, + changes + })) + )) + ); + + @Effect() + removeAdminRequest$ = this.actions$.pipe( + ofType(AdminCollectionActionTypes.REMOVE_ADMIN_REQUEST), + map(action => action.payload), + switchMap(id => this.adminService.remove(id).pipe( + map(user => new RemoveAdminSuccess(id)) + )) + ); + + @Effect() + removeAdminSuccessReload$ = this.actions$.pipe( + ofType(AdminCollectionActionTypes.REMOVE_ADMIN_SUCCESS), + map(action => new LoadAdminRequest()) + ); + + constructor( + private actions$: Actions, + private adminService: AdminService, + private store: Store + ) { } +} diff --git a/ui/src/app/user/admin/model/admin.ts b/ui/src/app/user/admin/model/admin.ts new file mode 100644 index 000000000..5a5a9400a --- /dev/null +++ b/ui/src/app/user/admin/model/admin.ts @@ -0,0 +1,9 @@ +import { User } from '../../../core/model/user'; + +export interface Admin extends User { + createdDate?: string; + updatedDate?: string; + resourceId: string; + + email: string; +} diff --git a/ui/src/app/user/admin/reducer/collection.reducer.spec.ts b/ui/src/app/user/admin/reducer/collection.reducer.spec.ts new file mode 100644 index 000000000..df4d6df3e --- /dev/null +++ b/ui/src/app/user/admin/reducer/collection.reducer.spec.ts @@ -0,0 +1,83 @@ +import { reducer, initialState as snapshot } from './collection.reducer'; +import * as fromAdmin from './collection.reducer'; +import { + AdminCollectionActionTypes, + LoadAdminSuccess, + UpdateAdminSuccess, + RemoveAdminSuccess +} from '../action/collection.action'; +import { Admin } from '../model/admin'; + +let users = [ + { + resourceId: 'abc', + role: 'SUPER_ADMIN', + email: 'foo@bar.com', + name: { + first: 'Jane', + last: 'Doe' + } + }, + { + resourceId: 'def', + role: 'DELEGATED_ADMIN', + email: 'bar@baz.com', + name: { + first: 'John', + last: 'Doe' + } + } +]; + +describe('Admin Collection Reducer', () => { + describe('undefined action', () => { + it('should return the default state', () => { + const result = reducer(snapshot, {} as any); + + expect(result).toEqual(snapshot); + }); + }); + + describe(`${AdminCollectionActionTypes.LOAD_ADMIN_SUCCESS}`, () => { + it('should add the loaded filters to the collection', () => { + spyOn(fromAdmin.adapter, 'addAll').and.callThrough(); + const action = new LoadAdminSuccess(users); + const result = reducer(snapshot, action); + expect(fromAdmin.adapter.addAll).toHaveBeenCalled(); + }); + }); + + describe(`${AdminCollectionActionTypes.UPDATE_ADMIN_SUCCESS}`, () => { + it('should update the filter in the collection', () => { + spyOn(fromAdmin.adapter, 'updateOne').and.callThrough(); + const update = { + id: 'abc', + changes: { role: 'DELEGATED_ADMIN' } + }; + const action = new UpdateAdminSuccess(update); + const result = reducer(snapshot, action); + expect(fromAdmin.adapter.updateOne).toHaveBeenCalled(); + }); + }); + + describe(`${AdminCollectionActionTypes.REMOVE_ADMIN_SUCCESS}`, () => { + it('should set saving to false', () => { + const action = new RemoveAdminSuccess('abc'); + expect(reducer(snapshot, action).saving).toBe(false); + }); + }); + + describe('selector methods', () => { + describe('getSelectedAdminId', () => { + it('should return the state selectedAdminId', () => { + expect(fromAdmin.getSelectedAdminId(snapshot)).toBe(snapshot.selectedAdminId); + }); + }); + + describe('getError', () => { + it('should return the state saving', () => { + expect(fromAdmin.getIsSaving(snapshot)).toBe(snapshot.saving); + }); + }); + }); +}); diff --git a/ui/src/app/user/admin/reducer/collection.reducer.ts b/ui/src/app/user/admin/reducer/collection.reducer.ts new file mode 100644 index 000000000..1e884b907 --- /dev/null +++ b/ui/src/app/user/admin/reducer/collection.reducer.ts @@ -0,0 +1,54 @@ +import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity'; +import { Admin } from '../model/admin'; +import { AdminCollectionActionsUnion, AdminCollectionActionTypes } from '../action/collection.action'; + +export interface CollectionState extends EntityState { + selectedAdminId: string | null; + saving: boolean; +} + +export const adapter: EntityAdapter = createEntityAdapter({ + selectId: (model: Admin) => model.resourceId +}); + +export const initialState: CollectionState = adapter.getInitialState({ + selectedAdminId: null, + saving: false +}); + +export function reducer(state = initialState, action: AdminCollectionActionsUnion): CollectionState { + switch (action.type) { + case AdminCollectionActionTypes.LOAD_ADMIN_SUCCESS: { + let s = adapter.addAll(action.payload, { + ...state, + selectedAdminId: state.selectedAdminId + }); + return s; + } + case AdminCollectionActionTypes.UPDATE_ADMIN_SUCCESS: { + return adapter.updateOne(action.payload, { + ...state, + saving: false + }); + } + case AdminCollectionActionTypes.REMOVE_ADMIN_SUCCESS: { + return adapter.removeOne(action.payload, { + ...state, + saving: false + }); + } + + default: { + return state; + } + } +} + +export const getSelectedAdminId = (state: CollectionState) => state.selectedAdminId; +export const getIsSaving = (state: CollectionState) => state.saving; +export const { + selectIds: selectAdminIds, + selectEntities: selectAdminEntities, + selectAll: selectAllAdmins, + selectTotal: selectAdminTotal +} = adapter.getSelectors(); diff --git a/ui/src/app/user/admin/reducer/index.ts b/ui/src/app/user/admin/reducer/index.ts new file mode 100644 index 000000000..ad9dfd93f --- /dev/null +++ b/ui/src/app/user/admin/reducer/index.ts @@ -0,0 +1,33 @@ +import { createSelector, createFeatureSelector } from '@ngrx/store'; +import * as fromRoot from '../../../core/reducer'; +import * as fromCollection from './collection.reducer'; + +export interface AdminState { + collection: fromCollection.CollectionState; +} + +export const reducers = { + collection: fromCollection.reducer +}; + +export interface State extends fromRoot.State { + 'admin': AdminState; +} + +export const getCollectionFromStateFn = (state: AdminState) => state.collection; + +export const getAdminState = createFeatureSelector('admin'); + +/* + * Select pieces of Admin Collection +*/ +export const getCollectionState = createSelector(getAdminState, getCollectionFromStateFn); +export const getAllAdmins = createSelector(getCollectionState, fromCollection.selectAllAdmins); +export const getCollectionSaving = createSelector(getCollectionState, fromCollection.getIsSaving); + +export const getAdminEntities = createSelector(getCollectionState, fromCollection.selectAdminEntities); +export const getSelectedAdminId = createSelector(getCollectionState, fromCollection.getSelectedAdminId); +export const getSelectedAdmin = createSelector(getAdminEntities, getSelectedAdminId, (entities, selectedId) => { + return selectedId && entities[selectedId]; +}); +export const getAdminIds = createSelector(getCollectionState, fromCollection.selectAdminIds); diff --git a/ui/src/app/user/admin/service/admin.service.spec.ts b/ui/src/app/user/admin/service/admin.service.spec.ts new file mode 100644 index 000000000..77b1a27ea --- /dev/null +++ b/ui/src/app/user/admin/service/admin.service.spec.ts @@ -0,0 +1,37 @@ +import { TestBed, async, inject } from '@angular/core/testing'; +import { HttpModule } from '@angular/http'; +import { AdminService } from './admin.service'; + +describe('Admin Service', () => { + let service: AdminService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpModule], + providers: [ + AdminService + ] + }); + service = TestBed.get(AdminService); + }); + + it('should instantiate', () => { + expect(service).toBeDefined(); + }); + + describe('query method', () => { + it('should return a list of users', () => { + expect(true).toBe(false); + }); + }); + describe('update method', () => { + it('should send an http put request', () => { + expect(true).toBe(false); + }); + }); + describe('remove method', () => { + it('should send an http delete request', () => { + expect(true).toBe(false); + }); + }); +}); diff --git a/ui/src/app/user/admin/service/admin.service.ts b/ui/src/app/user/admin/service/admin.service.ts new file mode 100644 index 000000000..77bd39c05 --- /dev/null +++ b/ui/src/app/user/admin/service/admin.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { Admin } from '../model/admin'; + +let users = [ + { + resourceId: 'abc', + role: 'SUPER_ADMIN', + email: 'foo@bar.com', + name: { + first: 'Jane', + last: 'Doe' + } + }, + { + resourceId: 'def', + role: 'DELEGATED_ADMIN', + email: 'bar@baz.com', + name: { + first: 'John', + last: 'Doe' + } + } +]; + +@Injectable() +export class AdminService { + + constructor() { } + query(): Observable { + return of([ + ...users + ]); + } + + update(user: Admin): Observable { + return of({ + ...users.find(u => u.resourceId === user.resourceId), + ...user + }); + } + + remove(userId: string): Observable { + users = users.filter(u => u.resourceId !== userId); + return of(true); + } +} From 536fdd5e921e1c3554187fe3f72f6457510beabb Mon Sep 17 00:00:00 2001 From: Ryan Mathis Date: Tue, 11 Dec 2018 12:01:31 -0700 Subject: [PATCH 05/38] SHIBUI-1031 Added tests for components --- ui/src/app/user/admin/admin.component.spec.ts | 28 +++++++++ .../admin-management.component.spec.ts | 57 +++++++++++++++++++ ui/src/app/user/user.component.spec.ts | 28 +++++++++ 3 files changed, 113 insertions(+) create mode 100644 ui/src/app/user/admin/admin.component.spec.ts create mode 100644 ui/src/app/user/admin/container/admin-management.component.spec.ts create mode 100644 ui/src/app/user/user.component.spec.ts diff --git a/ui/src/app/user/admin/admin.component.spec.ts b/ui/src/app/user/admin/admin.component.spec.ts new file mode 100644 index 000000000..4cb5cb3e0 --- /dev/null +++ b/ui/src/app/user/admin/admin.component.spec.ts @@ -0,0 +1,28 @@ +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { AdminComponent } from './admin.component'; +import { RouterTestingModule } from '@angular/router/testing'; + +describe('Admin Root Component', () => { + let fixture: ComponentFixture; + let instance: AdminComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule + ], + declarations: [ + AdminComponent + ], + }); + + fixture = TestBed.createComponent(AdminComponent); + instance = fixture.componentInstance; + }); + + it('should compile', () => { + fixture.detectChanges(); + + expect(fixture).toBeDefined(); + }); +}); diff --git a/ui/src/app/user/admin/container/admin-management.component.spec.ts b/ui/src/app/user/admin/container/admin-management.component.spec.ts new file mode 100644 index 000000000..d12a61632 --- /dev/null +++ b/ui/src/app/user/admin/container/admin-management.component.spec.ts @@ -0,0 +1,57 @@ +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { StoreModule, Store, combineReducers } from '@ngrx/store'; +import * as fromAdmin from '../reducer'; +import { AdminManagementPageComponent } from './admin-management.component'; +import { MockI18nModule } from '../../../../testing/i18n.stub'; + +describe('Admin Management Page Component', () => { + let fixture: ComponentFixture; + let store: Store; + let instance: AdminManagementPageComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ + 'admin': combineReducers(fromAdmin.reducers), + }), + FormsModule, + MockI18nModule + ], + declarations: [ + AdminManagementPageComponent + ], + }); + + fixture = TestBed.createComponent(AdminManagementPageComponent); + instance = fixture.componentInstance; + store = TestBed.get(Store); + + spyOn(store, 'dispatch').and.callThrough(); + }); + + it('should compile', () => { + fixture.detectChanges(); + + expect(fixture).toBeDefined(); + }); + + /* + describe('cancel method', () => { + it('should dispatch a cancel changes action', () => { + fixture.detectChanges(); + instance.cancel(); + expect(store.dispatch).toHaveBeenCalled(); + }); + }); + + describe('preview method', () => { + it('should dispatch a cancel changes action', () => { + fixture.detectChanges(); + instance.cancel(); + expect(store.dispatch).toHaveBeenCalled(); + }); + }); + */ +}); diff --git a/ui/src/app/user/user.component.spec.ts b/ui/src/app/user/user.component.spec.ts new file mode 100644 index 000000000..173e1409a --- /dev/null +++ b/ui/src/app/user/user.component.spec.ts @@ -0,0 +1,28 @@ +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { UserPageComponent } from './user.component'; +import { RouterTestingModule } from '@angular/router/testing'; + +describe('User Root Component', () => { + let fixture: ComponentFixture; + let instance: UserPageComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule + ], + declarations: [ + UserPageComponent + ], + }); + + fixture = TestBed.createComponent(UserPageComponent); + instance = fixture.componentInstance; + }); + + it('should compile', () => { + fixture.detectChanges(); + + expect(fixture).toBeDefined(); + }); +}); From 564b818007195913544e414ad38658d1e26ac02e Mon Sep 17 00:00:00 2001 From: Ryan Mathis Date: Tue, 11 Dec 2018 12:01:50 -0700 Subject: [PATCH 06/38] Fixed role --- ui/src/app/dashboard/container/dashboard.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/app/dashboard/container/dashboard.component.html b/ui/src/app/dashboard/container/dashboard.component.html index 23c38125a..0a9e4319f 100644 --- a/ui/src/app/dashboard/container/dashboard.component.html +++ b/ui/src/app/dashboard/container/dashboard.component.html @@ -1,4 +1,4 @@ -
+