diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/controller/support/RestControllersSupport.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/controller/support/RestControllersSupport.java index 7dc1a9abe..6a035d9e2 100644 --- a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/controller/support/RestControllersSupport.java +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/controller/support/RestControllersSupport.java @@ -4,6 +4,7 @@ import edu.internet2.tier.shibboleth.admin.ui.controller.ErrorResponse; import edu.internet2.tier.shibboleth.admin.ui.domain.resolvers.MetadataResolver; import edu.internet2.tier.shibboleth.admin.ui.repository.MetadataResolverRepository; +import org.hibernate.exception.ConstraintViolationException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -11,6 +12,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.client.HttpClientErrorException; +import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.NOT_FOUND; /** @@ -41,6 +43,11 @@ public ResponseEntity notFoundHandler(HttpClientErrorException ex) { throw ex; } + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleDatabaseConstraintViolation(ConstraintViolationException ex) { + return ResponseEntity.status(BAD_REQUEST).body(new ErrorResponse("400", "message.database-constraint")); + } + @ExceptionHandler(Exception.class) public final ResponseEntity handleAllOtherExceptions(Exception ex) { ErrorResponse errorResponse = new ErrorResponse("400", ex.getLocalizedMessage()); diff --git a/backend/src/main/resources/i18n/messages.properties b/backend/src/main/resources/i18n/messages.properties index 31c4de9b8..274d61981 100644 --- a/backend/src/main/resources/i18n/messages.properties +++ b/backend/src/main/resources/i18n/messages.properties @@ -397,6 +397,7 @@ message.wizard-status=Step { index } of { length } message.entity-id-min-unique=You must add at least one entity id target and they must each be unique. message.required-for-scripts=Required for Scripts message.required-for-regex=Required for Regex +message.database-constraint=There was a database constraint problem processing the request. Check the request to ensure that fields that must be unique are truly unique. tooltip.entity-id=Entity ID tooltip.service-provider-name=Service Provider Name (Dashboard Display Only) diff --git a/backend/src/main/resources/i18n/messages_en.properties b/backend/src/main/resources/i18n/messages_en.properties index 01b5c18e2..ca4fa40c6 100644 --- a/backend/src/main/resources/i18n/messages_en.properties +++ b/backend/src/main/resources/i18n/messages_en.properties @@ -403,6 +403,7 @@ message.wizard-status=Step { index } of { length } message.entity-id-min-unique=You must add at least one entity id target and they must each be unique. message.required-for-scripts=Required for Scripts message.required-for-regex=Required for Regex +message.database-constraint=There was a database constraint problem processing the request. Check the request to ensure that fields that must be unique are truly unique. tooltip.entity-id=Entity ID tooltip.service-provider-name=Service Provider Name (Dashboard Display Only) diff --git a/ui/src/app/metadata/filter/container/edit-filter.component.html b/ui/src/app/metadata/filter/container/edit-filter.component.html index 60790fdff..e42137ebf 100644 --- a/ui/src/app/metadata/filter/container/edit-filter.component.html +++ b/ui/src/app/metadata/filter/container/edit-filter.component.html @@ -28,7 +28,7 @@ diff --git a/ui/src/app/metadata/filter/container/edit-filter.component.ts b/ui/src/app/metadata/filter/container/edit-filter.component.ts index a98cbc62d..21983f5a9 100644 --- a/ui/src/app/metadata/filter/container/edit-filter.component.ts +++ b/ui/src/app/metadata/filter/container/edit-filter.component.ts @@ -11,7 +11,7 @@ import { UpdateFilterRequest } from '../action/collection.action'; import { CancelCreateFilter, UpdateFilterChanges } from '../action/filter.action'; import { PreviewEntity } from '../../domain/action/entity.action'; import { EntityAttributesFilterEntity } from '../../domain/entity'; -import { shareReplay } from 'rxjs/operators'; +import { shareReplay, map, withLatestFrom } from 'rxjs/operators'; @Component({ selector: 'edit-filter-page', @@ -33,6 +33,8 @@ export class EditFilterComponent { filter: MetadataFilter; isValid: boolean; + validators$: Observable<{ [key: string]: any }>; + actions: any; constructor( @@ -50,6 +52,15 @@ export class EditFilterComponent { this.isValid = valid.value ? valid.value.length === 0 : true; }); + this.validators$ = this.store.select(fromFilter.getFilterNames).pipe( + withLatestFrom( + this.store.select(fromFilter.getSelectedFilter) + ), + map(([names, provider]) => this.definition.getValidators( + names.filter(n => n !== provider.name) + )) + ); + this.store .select(fromFilter.getFilter) .subscribe(filter => this.filter = filter); diff --git a/ui/src/app/metadata/filter/container/filter.component.html b/ui/src/app/metadata/filter/container/filter.component.html index 3793ba3bf..eb3040a74 100644 --- a/ui/src/app/metadata/filter/container/filter.component.html +++ b/ui/src/app/metadata/filter/container/filter.component.html @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/ui/src/app/metadata/filter/container/filter.component.ts b/ui/src/app/metadata/filter/container/filter.component.ts index 2f2d0aa44..6dc02e037 100644 --- a/ui/src/app/metadata/filter/container/filter.component.ts +++ b/ui/src/app/metadata/filter/container/filter.component.ts @@ -3,35 +3,31 @@ import { ActivatedRoute } from '@angular/router'; import { Observable, Subscription } from 'rxjs'; import { distinctUntilChanged, map } from 'rxjs/operators'; import { Store } from '@ngrx/store'; -import { NgbPopoverConfig } from '@ng-bootstrap/ng-bootstrap'; import { MetadataFilter } from '../../domain/model/metadata-filter'; -import { SelectFilter } from '../action/collection.action'; +import { LoadFilterRequest } from '../action/collection.action'; import * as fromFilter from '../reducer'; - @Component({ selector: 'filter-page', templateUrl: './filter.component.html', - styleUrls: ['./filter.component.scss'], - providers: [NgbPopoverConfig] + styleUrls: [], + providers: [] }) export class FilterComponent implements OnDestroy { actionsSubscription: Subscription; - filter$: Observable; + filters$: Observable; constructor( private store: Store, private route: ActivatedRoute ) { - this.actionsSubscription = this.route.params.pipe( + this.actionsSubscription = this.route.parent.params.pipe( distinctUntilChanged(), map(params => { - return new SelectFilter(params.id); + return new LoadFilterRequest(params.providerId); }) ).subscribe(store); - - this.filter$ = this.store.select(fromFilter.getSelectedFilter); } ngOnDestroy() { diff --git a/ui/src/app/metadata/filter/container/new-filter.component.html b/ui/src/app/metadata/filter/container/new-filter.component.html index 1b0d62945..e56dc4634 100644 --- a/ui/src/app/metadata/filter/container/new-filter.component.html +++ b/ui/src/app/metadata/filter/container/new-filter.component.html @@ -28,7 +28,7 @@ diff --git a/ui/src/app/metadata/filter/container/new-filter.component.ts b/ui/src/app/metadata/filter/container/new-filter.component.ts index aeb56e019..043936fc2 100644 --- a/ui/src/app/metadata/filter/container/new-filter.component.ts +++ b/ui/src/app/metadata/filter/container/new-filter.component.ts @@ -1,7 +1,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { Store } from '@ngrx/store'; import { Subject, Observable, of } from 'rxjs'; -import { takeUntil, shareReplay } from 'rxjs/operators'; +import { takeUntil, shareReplay, withLatestFrom, map, filter } from 'rxjs/operators'; import * as fromFilter from '../reducer'; import { MetadataFilterTypes } from '../model'; @@ -33,6 +33,8 @@ export class NewFilterComponent implements OnDestroy, OnInit { filter: MetadataFilter; isValid: boolean; + validators$: Observable<{ [key: string]: any }>; + constructor( private store: Store, private schemaService: SchemaService @@ -42,6 +44,10 @@ export class NewFilterComponent implements OnDestroy, OnInit { this.schema$ = this.schemaService.get(this.definition.schema).pipe(shareReplay()); this.isSaving$ = this.store.select(fromFilter.getCollectionSaving); this.model$ = of({}); + + this.validators$ = this.store.select(fromFilter.getFilterNames).pipe( + map((names) => this.definition.getValidators(names)) + ); } ngOnInit(): void { diff --git a/ui/src/app/metadata/filter/container/select-filter.component.html b/ui/src/app/metadata/filter/container/select-filter.component.html new file mode 100644 index 000000000..3793ba3bf --- /dev/null +++ b/ui/src/app/metadata/filter/container/select-filter.component.html @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/ui/src/app/metadata/filter/container/filter.component.scss b/ui/src/app/metadata/filter/container/select-filter.component.scss similarity index 100% rename from ui/src/app/metadata/filter/container/filter.component.scss rename to ui/src/app/metadata/filter/container/select-filter.component.scss diff --git a/ui/src/app/metadata/filter/container/select-filter.component.spec.ts b/ui/src/app/metadata/filter/container/select-filter.component.spec.ts new file mode 100644 index 000000000..e69de29bb diff --git a/ui/src/app/metadata/filter/container/select-filter.component.ts b/ui/src/app/metadata/filter/container/select-filter.component.ts new file mode 100644 index 000000000..97fe09d0f --- /dev/null +++ b/ui/src/app/metadata/filter/container/select-filter.component.ts @@ -0,0 +1,39 @@ +import { Component, OnDestroy } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Observable, Subscription } from 'rxjs'; +import { distinctUntilChanged, map } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { NgbPopoverConfig } from '@ng-bootstrap/ng-bootstrap'; + +import { MetadataFilter } from '../../domain/model/metadata-filter'; +import { SelectFilter } from '../action/collection.action'; +import * as fromFilter from '../reducer'; + +@Component({ + selector: 'select-filter-page', + templateUrl: './select-filter.component.html', + styleUrls: ['./select-filter.component.scss'], + providers: [NgbPopoverConfig] +}) +export class SelectFilterComponent implements OnDestroy { + actionsSubscription: Subscription; + filter$: Observable; + + constructor( + private store: Store, + private route: ActivatedRoute + ) { + this.actionsSubscription = this.route.params.pipe( + distinctUntilChanged(), + map(params => { + return new SelectFilter(params.id); + }) + ).subscribe(store); + + this.filter$ = this.store.select(fromFilter.getSelectedFilter); + } + + ngOnDestroy() { + this.actionsSubscription.unsubscribe(); + } +} /* istanbul ignore next */ diff --git a/ui/src/app/metadata/filter/effect/collection.effect.ts b/ui/src/app/metadata/filter/effect/collection.effect.ts index f2975bd34..e04265eb2 100644 --- a/ui/src/app/metadata/filter/effect/collection.effect.ts +++ b/ui/src/app/metadata/filter/effect/collection.effect.ts @@ -166,14 +166,6 @@ export class FilterCollectionEffects { ) ); - /* - @Effect() - reloadOrderAfterChange$ = this.actions$.pipe( - ofType(FilterCollectionActionTypes.SET_ORDER_FILTER_SUCCESS), - map(() => new GetOrderFilterRequest()) - ); - */ - @Effect() setOrder$ = this.actions$.pipe( ofType(FilterCollectionActionTypes.SET_ORDER_FILTER_REQUEST), diff --git a/ui/src/app/metadata/filter/filter.module.ts b/ui/src/app/metadata/filter/filter.module.ts index 34997c563..e090745f2 100644 --- a/ui/src/app/metadata/filter/filter.module.ts +++ b/ui/src/app/metadata/filter/filter.module.ts @@ -13,7 +13,7 @@ import { NgbPopoverModule, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'; import { SearchDialogComponent } from './component/search-dialog.component'; import { SharedModule } from '../../shared/shared.module'; import { EditFilterComponent } from './container/edit-filter.component'; -import { FilterComponent } from './container/filter.component'; +import { SelectFilterComponent } from './container/select-filter.component'; import { SearchIdEffects } from './effect/search.effect'; import { FilterExistsGuard } from './guard/filter-exists.guard'; import { DomainModule } from '../domain/domain.module'; @@ -21,13 +21,15 @@ import { ModuleWithProviders } from '@angular/compiler/src/core'; import { FilterCollectionEffects } from './effect/collection.effect'; import { FormModule } from '../../schema-form/schema-form.module'; import { I18nModule } from '../../i18n/i18n.module'; +import { FilterComponent } from './container/filter.component'; @NgModule({ declarations: [ NewFilterComponent, EditFilterComponent, - FilterComponent, - SearchDialogComponent + SelectFilterComponent, + SearchDialogComponent, + FilterComponent ], entryComponents: [ SearchDialogComponent diff --git a/ui/src/app/metadata/filter/model/entity-attributes.filter.spec.ts b/ui/src/app/metadata/filter/model/entity-attributes.filter.spec.ts index ba27a427d..f21b2db4b 100644 --- a/ui/src/app/metadata/filter/model/entity-attributes.filter.spec.ts +++ b/ui/src/app/metadata/filter/model/entity-attributes.filter.spec.ts @@ -1,8 +1,37 @@ import { EntityAttributesFilter } from './entity-attributes.filter'; describe('Entity Attributes filter form', () => { - it('should return an empty object for validators', () => { - expect(EntityAttributesFilter.getValidators()).toEqual({}); + describe('getValidators', () => { + it('should return an empty object for validators', () => { + expect(Object.keys(EntityAttributesFilter.getValidators())).toEqual([ + '/', + '/name' + ]); + }); + + describe('name `/name` validator', () => { + const validators = EntityAttributesFilter.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 = EntityAttributesFilter.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(); + }); + }); }); describe('transformer', () => { diff --git a/ui/src/app/metadata/filter/model/entity-attributes.filter.ts b/ui/src/app/metadata/filter/model/entity-attributes.filter.ts index 0d559c094..5c40ecfb1 100644 --- a/ui/src/app/metadata/filter/model/entity-attributes.filter.ts +++ b/ui/src/app/metadata/filter/model/entity-attributes.filter.ts @@ -5,8 +5,34 @@ export const EntityAttributesFilter: FormDefinition = { label: 'EntityAttributes', type: 'EntityAttributes', schema: '/api/ui/EntityAttributesFilters', - getValidators(): any { - const validators = {}; + 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) => { + console.log(namesList); + const err = namesList.indexOf(value) > -1 ? { + code: 'INVALID_NAME', + path: `#${property.path}`, + message: 'message.name-must-be-unique', + params: [value] + } : null; + return err; + } + }; return validators; }, parser: (changes: any): MetadataFilter => changes, diff --git a/ui/src/app/metadata/filter/reducer/index.ts b/ui/src/app/metadata/filter/reducer/index.ts index caacf0254..495bf1e52 100644 --- a/ui/src/app/metadata/filter/reducer/index.ts +++ b/ui/src/app/metadata/filter/reducer/index.ts @@ -4,6 +4,7 @@ import * as fromFilter from './filter.reducer'; import * as fromSearch from './search.reducer'; import * as fromCollection from './collection.reducer'; import * as utils from '../../domain/domain.util'; +import { MetadataFilter } from '../../domain/model'; export interface FilterState { filter: fromFilter.FilterState; @@ -71,6 +72,8 @@ export const getAdditionalFilterOrder = createSelector(getFilterEntities, getCol export const getAdditionalFilters = createSelector(getFilterList, getAdditionalFilterOrder, utils.mergeOrderFn); export const getPluginFilterOrder = createSelector(getFilterEntities, getCollectionOrder, pluginOrderFn); +export const getFilterNames = createSelector(getAllFilters, (filters: MetadataFilter[]) => filters.map(f => f.name).filter(f => !!f)); + /* * Combine pieces of State */ diff --git a/ui/src/app/metadata/provider/container/provider-select.component.ts b/ui/src/app/metadata/provider/container/provider-select.component.ts index 8fe540eeb..baba7f626 100644 --- a/ui/src/app/metadata/provider/container/provider-select.component.ts +++ b/ui/src/app/metadata/provider/container/provider-select.component.ts @@ -31,8 +31,6 @@ export class ProviderSelectComponent implements OnDestroy { map(params => new SelectProviderRequest(params.providerId)) ).subscribe(store); - this.route.params.subscribe(params => console.log(params)); - this.provider$ = this.store.select(fromProviders.getSelectedProvider).pipe(skipWhile(p => !p)); this.provider$.subscribe(provider => this.setDefinition(provider)); diff --git a/ui/src/app/metadata/provider/provider.routing.ts b/ui/src/app/metadata/provider/provider.routing.ts index 7acd52fec..1dcbc4fbb 100644 --- a/ui/src/app/metadata/provider/provider.routing.ts +++ b/ui/src/app/metadata/provider/provider.routing.ts @@ -8,9 +8,10 @@ import { ProviderEditStepComponent } from './container/provider-edit-step.compon import { ProviderSelectComponent } from './container/provider-select.component'; import { ProviderFilterListComponent } from './container/provider-filter-list.component'; import { NewFilterComponent } from '../filter/container/new-filter.component'; -import { FilterComponent } from '../filter/container/filter.component'; +import { SelectFilterComponent } from '../filter/container/select-filter.component'; import { EditFilterComponent } from '../filter/container/edit-filter.component'; import { CanDeactivateGuard } from '../../core/service/can-deactivate.guard'; +import { FilterComponent } from '../filter/container/filter.component'; export const ProviderRoutes: Routes = [ { @@ -37,17 +38,24 @@ export const ProviderRoutes: Routes = [ component: ProviderSelectComponent, children: [ { - path: 'edit', - component: ProviderEditComponent, + path: '', + component: FilterComponent, children: [ - { path: '', redirectTo: 'common', pathMatch: 'prefix' }, { - path: ':form', - component: ProviderEditStepComponent + path: 'filter/new', + component: NewFilterComponent + }, + { + path: 'filter/:id', + component: SelectFilterComponent, + canActivate: [], + children: [ + { + path: 'edit', + component: EditFilterComponent + } + ] } - ], - canDeactivate: [ - CanDeactivateGuard ] }, { @@ -55,18 +63,17 @@ export const ProviderRoutes: Routes = [ component: ProviderFilterListComponent }, { - path: 'filter/new', - component: NewFilterComponent - }, - { - path: 'filter/:id', - component: FilterComponent, - canActivate: [], + path: 'edit', + component: ProviderEditComponent, children: [ + { path: '', redirectTo: 'common', pathMatch: 'prefix' }, { - path: 'edit', - component: EditFilterComponent + path: ':form', + component: ProviderEditStepComponent } + ], + canDeactivate: [ + CanDeactivateGuard ] } ]