From 833a9f48a7dba3e8d48c8be7a2abe182085bd1bb Mon Sep 17 00:00:00 2001 From: Ryan Mathis Date: Tue, 20 Mar 2018 17:00:01 +0000 Subject: [PATCH] Merged in feature/SHIBUI-293 (pull request #19) SHIBUI-293 Updated autocomplete for async searching --- .../app/edit-provider/action/editor.action.ts | 9 - .../app/edit-provider/effect/editor.effect.ts | 2 +- .../action/collection.action.ts | 20 -- .../action/filter.action.spec.ts | 14 ++ .../metadata-filter/action/filter.action.ts | 65 +++++ .../metadata-filter/action/search.action.ts | 9 - .../component/search-dialog.component.html | 16 ++ .../component/search-dialog.component.spec.ts | 39 +++ .../component/search-dialog.component.ts | 27 ++ .../container/new-filter.component.html | 25 +- .../container/new-filter.component.spec.ts | 24 +- .../container/new-filter.component.ts | 45 +++- .../metadata-filter/effect/filter.effect.ts | 42 +++- ui/src/app/metadata-filter/filter.module.ts | 13 +- .../reducer/filter.reducer.spec.ts | 75 ++++++ .../metadata-filter/reducer/filter.reducer.ts | 54 +++- ui/src/app/metadata-filter/reducer/index.ts | 10 + .../forms/descriptor-info-form.component.html | 3 +- .../descriptor-info-form.component.spec.ts | 4 +- .../forms/descriptor-info-form.component.ts | 7 + .../forms/metadata-ui-form.component.spec.ts | 44 +++- .../forms/relying-party-form.component.html | 8 +- .../relying-party-form.component.spec.ts | 16 +- .../forms/relying-party-form.component.ts | 12 +- .../metadata-provider.module.ts | 2 + .../metadata-provider/pipe/pretty-xml.pipe.ts | 1 - .../service/entity-id.service.ts | 40 +++ .../service/entity-validators.service.spec.ts | 20 ++ .../service/entity-validators.service.ts | 12 + .../autocomplete-validators.service.spec.ts | 42 ---- .../autocomplete-validators.service.ts | 17 -- .../autocomplete/autocomplete.component.html | 12 +- .../autocomplete.component.spec.ts | 231 ++++++++---------- .../autocomplete/autocomplete.component.ts | 129 ++++------ .../autocomplete/autocomplete.model.spec.ts | 20 ++ .../widget/autocomplete/autocomplete.model.ts | 12 +- ui/src/data/ids.mock.ts | 18 ++ ui/src/testing/modal.stub.ts | 6 + ui/src/theme/_mixins.scss | 17 ++ ui/src/theme/forms.scss | 6 + 40 files changed, 814 insertions(+), 354 deletions(-) delete mode 100644 ui/src/app/metadata-filter/action/collection.action.ts create mode 100644 ui/src/app/metadata-filter/action/filter.action.spec.ts create mode 100644 ui/src/app/metadata-filter/action/filter.action.ts delete mode 100644 ui/src/app/metadata-filter/action/search.action.ts create mode 100644 ui/src/app/metadata-filter/component/search-dialog.component.html create mode 100644 ui/src/app/metadata-filter/component/search-dialog.component.spec.ts create mode 100644 ui/src/app/metadata-filter/component/search-dialog.component.ts create mode 100644 ui/src/app/metadata-filter/reducer/filter.reducer.spec.ts create mode 100644 ui/src/app/metadata-provider/service/entity-id.service.ts delete mode 100644 ui/src/app/widget/autocomplete/autocomplete-validators.service.spec.ts delete mode 100644 ui/src/app/widget/autocomplete/autocomplete-validators.service.ts create mode 100644 ui/src/app/widget/autocomplete/autocomplete.model.spec.ts create mode 100644 ui/src/data/ids.mock.ts create mode 100644 ui/src/theme/_mixins.scss diff --git a/ui/src/app/edit-provider/action/editor.action.ts b/ui/src/app/edit-provider/action/editor.action.ts index 112ce865b..99b641d10 100644 --- a/ui/src/app/edit-provider/action/editor.action.ts +++ b/ui/src/app/edit-provider/action/editor.action.ts @@ -14,12 +14,6 @@ export class UpdateStatus implements Action { constructor(public payload: { [key: string]: string }) { } } -export class UpdateSaved implements Action { - readonly type = UPDATE_SAVED; - - constructor(public payload: boolean) { } -} - export class UpdateChanges implements Action { readonly type = UPDATE_CHANGES; @@ -28,8 +22,6 @@ export class UpdateChanges implements Action { export class CancelChanges implements Action { readonly type = CANCEL_CHANGES; - - constructor(public payload: boolean = true) { } } export class SaveChanges implements Action { @@ -46,7 +38,6 @@ export class ResetChanges implements Action { export type Actions = | UpdateStatus - | UpdateSaved | UpdateChanges | CancelChanges | SaveChanges diff --git a/ui/src/app/edit-provider/effect/editor.effect.ts b/ui/src/app/edit-provider/effect/editor.effect.ts index 3f6630774..3d83f31ca 100644 --- a/ui/src/app/edit-provider/effect/editor.effect.ts +++ b/ui/src/app/edit-provider/effect/editor.effect.ts @@ -13,8 +13,8 @@ export class EditorEffects { @Effect({dispatch: false}) cancelChanges$ = this.actions$ .ofType(editor.CANCEL_CHANGES) - .map(action => action.payload) .switchMap(() => this.router.navigate(['/dashboard'])); + @Effect() updateProviderSuccessRedirect$ = this.actions$ .ofType(provider.UPDATE_PROVIDER_SUCCESS) diff --git a/ui/src/app/metadata-filter/action/collection.action.ts b/ui/src/app/metadata-filter/action/collection.action.ts deleted file mode 100644 index a7309c640..000000000 --- a/ui/src/app/metadata-filter/action/collection.action.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Action } from '@ngrx/store'; - -export const CANCEL_CREATE_FILTER = '[Filter Collection] Cancel Create Filter'; - -export class CancelCreateFilter implements Action { - readonly type = CANCEL_CREATE_FILTER; - - constructor(public payload: boolean) { } -} - -/* -export class SaveChanges implements Action { - readonly type = SAVE_CHANGES; - - constructor(public payload: MetadataProvider) { } -} -*/ - -export type Actions = - | CancelCreateFilter; diff --git a/ui/src/app/metadata-filter/action/filter.action.spec.ts b/ui/src/app/metadata-filter/action/filter.action.spec.ts new file mode 100644 index 000000000..f9bf322c1 --- /dev/null +++ b/ui/src/app/metadata-filter/action/filter.action.spec.ts @@ -0,0 +1,14 @@ +import * as actions from './filter.action'; + +describe('Filter Actions', () => { + it('should provide actions', () => { + expect(new actions.CancelCreateFilter().type).toBe(actions.CANCEL_CREATE_FILTER); + expect(new actions.CancelViewMore().type).toBe(actions.CANCEL_VIEW_MORE); + expect(new actions.LoadEntityIds('foo').type).toBe(actions.LOAD_ENTITY_IDS); + expect(new actions.LoadEntityIdsSuccess([]).type).toBe(actions.LOAD_ENTITY_IDS_SUCCESS); + expect(new actions.LoadEntityIdsError(new Error('Foobar!')).type).toBe(actions.LOAD_ENTITY_IDS_ERROR); + expect(new actions.ViewMoreIds([]).type).toBe(actions.VIEW_MORE_IDS); + expect(new actions.SelectId('foo').type).toBe(actions.SELECT_ID); + expect(new actions.QueryEntityIds([]).type).toBe(actions.QUERY_ENTITY_IDS); + }); +}); diff --git a/ui/src/app/metadata-filter/action/filter.action.ts b/ui/src/app/metadata-filter/action/filter.action.ts new file mode 100644 index 000000000..30ca61070 --- /dev/null +++ b/ui/src/app/metadata-filter/action/filter.action.ts @@ -0,0 +1,65 @@ +import { Action } from '@ngrx/store'; + +export const QUERY_ENTITY_IDS = '[Filter] Query Entity Ids'; +export const VIEW_MORE_IDS = '[Filter] View More Ids Modal'; +export const CANCEL_VIEW_MORE = '[Filter] Cancel View More'; +export const SELECT_ID = '[Filter] Select Entity ID'; +export const CANCEL_CREATE_FILTER = '[Filter] Cancel Create Filter'; + +export const LOAD_ENTITY_IDS = '[Entity ID Collection] Load Entity Ids'; +export const LOAD_ENTITY_IDS_SUCCESS = '[Entity ID Collection] Load Entity Ids Success'; +export const LOAD_ENTITY_IDS_ERROR = '[Entity ID Collection] Load Entity Ids Error'; + +export class QueryEntityIds implements Action { + readonly type = QUERY_ENTITY_IDS; + + constructor(public payload: string[]) { } +} + +export class CancelCreateFilter implements Action { + readonly type = CANCEL_CREATE_FILTER; +} + +export class ViewMoreIds implements Action { + readonly type = VIEW_MORE_IDS; + + constructor(public payload: string[]) {} +} + +export class CancelViewMore implements Action { + readonly type = CANCEL_VIEW_MORE; +} + +export class SelectId implements Action { + readonly type = SELECT_ID; + + constructor(public payload: string) { } +} + +export class LoadEntityIds implements Action { + readonly type = LOAD_ENTITY_IDS; + + constructor(public payload: string) { } +} + +export class LoadEntityIdsSuccess implements Action { + readonly type = LOAD_ENTITY_IDS_SUCCESS; + + constructor(public payload: string[]) { } +} + +export class LoadEntityIdsError implements Action { + readonly type = LOAD_ENTITY_IDS_ERROR; + + constructor(public payload: Error) { } +} + +export type Actions = + | ViewMoreIds + | CancelViewMore + | CancelCreateFilter + | SelectId + | LoadEntityIds + | LoadEntityIdsSuccess + | LoadEntityIdsError + | QueryEntityIds; diff --git a/ui/src/app/metadata-filter/action/search.action.ts b/ui/src/app/metadata-filter/action/search.action.ts deleted file mode 100644 index 8d196524f..000000000 --- a/ui/src/app/metadata-filter/action/search.action.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Action } from '@ngrx/store'; - -export const QUERY_ENTITY_IDS = '[Filter] Query Entity Ids'; - -export class QueryEntityIds implements Action { - readonly type = QUERY_ENTITY_IDS; - - constructor(public payload: string[]) { } -} diff --git a/ui/src/app/metadata-filter/component/search-dialog.component.html b/ui/src/app/metadata-filter/component/search-dialog.component.html new file mode 100644 index 000000000..09590236a --- /dev/null +++ b/ui/src/app/metadata-filter/component/search-dialog.component.html @@ -0,0 +1,16 @@ + + + \ No newline at end of file diff --git a/ui/src/app/metadata-filter/component/search-dialog.component.spec.ts b/ui/src/app/metadata-filter/component/search-dialog.component.spec.ts new file mode 100644 index 000000000..9d3d7d9e8 --- /dev/null +++ b/ui/src/app/metadata-filter/component/search-dialog.component.spec.ts @@ -0,0 +1,39 @@ +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { ReactiveFormsModule, FormBuilder } from '@angular/forms'; +import { NgbModalModule, NgbModal, NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { StoreModule, Store, combineReducers } from '@ngrx/store'; +import { SearchDialogComponent } from './search-dialog.component'; +import { NgbActiveModalStub } from '../../../testing/modal.stub'; +import * as fromFilter from '../reducer'; + +describe('Search Dialog', () => { + let fixture: ComponentFixture; + let instance: SearchDialogComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: NgbActiveModal, useClass: NgbActiveModalStub } + ], + imports: [ + ReactiveFormsModule, + NgbModalModule, + StoreModule.forRoot({ + 'metadata-filter': combineReducers(fromFilter.reducers), + }), + ], + declarations: [ + SearchDialogComponent + ], + }); + + fixture = TestBed.createComponent(SearchDialogComponent); + instance = fixture.componentInstance; + }); + + it('should compile', () => { + fixture.detectChanges(); + + expect(fixture).toBeDefined(); + }); +}); diff --git a/ui/src/app/metadata-filter/component/search-dialog.component.ts b/ui/src/app/metadata-filter/component/search-dialog.component.ts new file mode 100644 index 000000000..5c8e5c7d4 --- /dev/null +++ b/ui/src/app/metadata-filter/component/search-dialog.component.ts @@ -0,0 +1,27 @@ +import { Component, OnChanges, OnInit } from '@angular/core'; +import { NgbModal, NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { Observable } from 'rxjs/Observable'; +import { Store } from '@ngrx/store'; + +import * as fromFilter from '../reducer'; + +@Component({ + selector: 'search-dialog', + templateUrl: './search-dialog.component.html' +}) +export class SearchDialogComponent implements OnInit, OnChanges { + constructor( + public activeModal: NgbActiveModal, + private store: Store + ) { + // console.log(activeModal); + } + + ngOnChanges(): void { + + } + + ngOnInit(): void { + + } +} /* istanbul ignore next */ 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 227165feb..925f9749c 100644 --- a/ui/src/app/metadata-filter/container/new-filter.component.html +++ b/ui/src/app/metadata-filter/container/new-filter.component.html @@ -49,6 +49,7 @@ Search Entity ID + @@ -61,10 +62,14 @@ + limit="10" + [processing]="loading$ | async" + (onChange)="searchEntityIds($event)" + (more)="onViewMore()"> @@ -91,10 +96,18 @@

TESTING:

- Test Options: -
{{ entityIds$ | async | json }}
- Current Form Values: -
{{ form.value | json }}
+
+ Form Errors: +
{{ form.errors | json }}
+
+
+ Test Options: +
{{ entityIds$ | async | json }}
+
+
+ Current Form Values: +
{{ form.value | json }}
+
diff --git a/ui/src/app/metadata-filter/container/new-filter.component.spec.ts b/ui/src/app/metadata-filter/container/new-filter.component.spec.ts index e38ff2b34..d13e0682f 100644 --- a/ui/src/app/metadata-filter/container/new-filter.component.spec.ts +++ b/ui/src/app/metadata-filter/container/new-filter.component.spec.ts @@ -24,7 +24,7 @@ describe('New Metadata Filter Page', () => { ], imports: [ StoreModule.forRoot({ - providers: combineReducers(fromFilter.reducers), + 'metadata-filter': combineReducers(fromFilter.reducers), }), ReactiveFormsModule, ProviderEditorFormModule, @@ -53,4 +53,26 @@ describe('New Metadata Filter Page', () => { expect(store.dispatch).toHaveBeenCalled(); }); }); + + describe('searchEntityIds method', () => { + it('should NOT dispatch a loadEntityIds action until there are 4 or more characters', () => { + fixture.detectChanges(); + instance.searchEntityIds('foo'); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should dispatch a loadEntityIds action', () => { + fixture.detectChanges(); + instance.searchEntityIds('foo-'); + expect(store.dispatch).toHaveBeenCalled(); + }); + }); + + describe('onViewMore method', () => { + it('should dispatch a viewMoreEntityIds action', () => { + fixture.detectChanges(); + instance.onViewMore(); + expect(store.dispatch).toHaveBeenCalled(); + }); + }); }); 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 560a39071..37640a82c 100644 --- a/ui/src/app/metadata-filter/container/new-filter.component.ts +++ b/ui/src/app/metadata-filter/container/new-filter.component.ts @@ -7,7 +7,11 @@ import 'rxjs/add/observable/of'; import * as fromFilter from '../reducer'; import { ProviderFormFragmentComponent } from '../../metadata-provider/component/forms/provider-form-fragment.component'; import { ProviderStatusEmitter, ProviderValueEmitter } from '../../metadata-provider/service/provider-change-emitter.service'; -import { CancelCreateFilter } from '../action/collection.action'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { SearchDialogComponent } from '../component/search-dialog.component'; +import { ViewMoreIds, CancelCreateFilter, LoadEntityIds } from '../action/filter.action'; +import { EntityValidators } from '../../metadata-provider/service/entity-validators.service'; + @Component({ selector: 'new-filter-page', @@ -16,6 +20,11 @@ import { CancelCreateFilter } from '../action/collection.action'; export class NewFilterComponent extends ProviderFormFragmentComponent implements OnInit, OnChanges, OnDestroy { entityIds$: Observable; + ids: string[]; + + showMore$: Observable; + selected$: Observable; + loading$: Observable; constructor( private store: Store, @@ -24,31 +33,55 @@ export class NewFilterComponent extends ProviderFormFragmentComponent implements protected fb: FormBuilder ) { super(fb, statusEmitter, valueEmitter); + + // this.form.statusChanges.subscribe(status => console.log(status)); + + this.showMore$ = this.store.select(fromFilter.getViewingMore); + this.selected$ = this.store.select(fromFilter.getSelected); + this.entityIds$ = this.store.select(fromFilter.getEntityCollection); + this.loading$ = this.store.select(fromFilter.getIsLoading); + + this.entityIds$.subscribe(ids => this.ids = ids); + + this.selected$.subscribe(s => { + this.form.patchValue({ + entityId: s + }); + }); } createForm(): void { this.form = this.fb.group({ - entityId: ['', Validators.required], - filterName: ['', Validators.required] + entityId: ['', [Validators.required]], + filterName: ['', [Validators.required]] }); } ngOnInit(): void { super.ngOnInit(); - this.entityIds$ = Observable.of(['foo', 'bar', 'baz']); + + this.form.get('entityId').setAsyncValidators([EntityValidators.existsInCollection(this.entityIds$)]); } ngOnChanges(): void {} ngOnDestroy(): void {} - onViewMore($event): void {} + searchEntityIds(query: string): void { + if (query.length >= 4) { + this.store.dispatch(new LoadEntityIds(query)); + } + } + + onViewMore(): void { + this.store.dispatch(new ViewMoreIds(this.ids)); + } save(): void { console.log('Save!'); } cancel(): void { - this.store.dispatch(new CancelCreateFilter(true)); + this.store.dispatch(new CancelCreateFilter()); } } diff --git a/ui/src/app/metadata-filter/effect/filter.effect.ts b/ui/src/app/metadata-filter/effect/filter.effect.ts index d2df34d74..766da16d6 100644 --- a/ui/src/app/metadata-filter/effect/filter.effect.ts +++ b/ui/src/app/metadata-filter/effect/filter.effect.ts @@ -1,20 +1,56 @@ import { Injectable } from '@angular/core'; import { Effect, Actions } from '@ngrx/effects'; +import { Observable } from 'rxjs/Observable'; + +import 'rxjs/add/observable/fromPromise'; +import 'rxjs/add/observable/of'; +import 'rxjs/add/operator/map'; +import 'rxjs/add/operator/switchMap'; + +import * as filter from '../action/filter.action'; -import * as filter from '../action/collection.action'; import { Router } from '@angular/router'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { SearchDialogComponent } from '../component/search-dialog.component'; +import { EntityIdService } from '../../metadata-provider/service/entity-id.service'; @Injectable() export class FilterEffects { + private dbounce = 500; + + @Effect() + loadEntityIds$ = this.actions$ + .ofType(filter.LOAD_ENTITY_IDS) + .map(action => action.payload) + .debounceTime(this.dbounce) + .switchMap(query => + this.idService + .query(query) + .map(ids => new filter.LoadEntityIdsSuccess(ids)) + .catch(error => Observable.of(new filter.LoadEntityIdsError(error))) + ); + @Effect({ dispatch: false }) cancelChanges$ = this.actions$ .ofType(filter.CANCEL_CREATE_FILTER) - .map(action => action.payload) .switchMap(() => this.router.navigate(['/dashboard'])); + @Effect() + viewMore$ = this.actions$ + .ofType(filter.VIEW_MORE_IDS) + .map(action => action.payload) + .switchMap(() => + Observable + .fromPromise(this.modalService.open(SearchDialogComponent).result) + .map(id => new filter.SelectId(id)) + .catch(() => Observable.of(new filter.CancelViewMore())) + ); + constructor( private actions$: Actions, - private router: Router + private router: Router, + private modalService: NgbModal, + private idService: EntityIdService ) { } } diff --git a/ui/src/app/metadata-filter/filter.module.ts b/ui/src/app/metadata-filter/filter.module.ts index 86f4e7243..7eac3f9b5 100644 --- a/ui/src/app/metadata-filter/filter.module.ts +++ b/ui/src/app/metadata-filter/filter.module.ts @@ -10,7 +10,8 @@ import { reducers } from './reducer'; import { ProviderFormFragmentComponent } from '../metadata-provider/component/forms/provider-form-fragment.component'; import { ProviderEditorFormModule } from '../metadata-provider/component'; import { FilterEffects } from './effect/filter.effect'; -import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgbPopoverModule, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'; +import { SearchDialogComponent } from './component/search-dialog.component'; export const routes: Routes = [ { @@ -22,9 +23,12 @@ export const routes: Routes = [ @NgModule({ declarations: [ - NewFilterComponent + NewFilterComponent, + SearchDialogComponent + ], + entryComponents: [ + SearchDialogComponent ], - entryComponents: [], imports: [ CommonModule, RouterModule, @@ -33,7 +37,8 @@ export const routes: Routes = [ EffectsModule.forFeature([FilterEffects]), RouterModule.forChild(routes), ProviderEditorFormModule, - NgbPopoverModule + NgbPopoverModule, + NgbModalModule ], providers: [] }) diff --git a/ui/src/app/metadata-filter/reducer/filter.reducer.spec.ts b/ui/src/app/metadata-filter/reducer/filter.reducer.spec.ts new file mode 100644 index 000000000..39372d4d5 --- /dev/null +++ b/ui/src/app/metadata-filter/reducer/filter.reducer.spec.ts @@ -0,0 +1,75 @@ +import { reducer } from './filter.reducer'; +import * as fromFilter from './filter.reducer'; +import * as actions from '../action/filter.action'; + +const snapshot: fromFilter.FilterState = { + entityIds: [], + selected: null, + viewMore: false, + loading: false, + error: null +}; + +describe('Filter Reducer', () => { + describe('undefined action', () => { + it('should return the default state', () => { + const result = reducer(snapshot, {} as any); + + expect(result).toEqual(snapshot); + }); + }); + + describe(`${ actions.VIEW_MORE_IDS } action`, () => { + it('should set viewMore property to true', () => { + const result = reducer(snapshot, new actions.ViewMoreIds([])); + + expect(result.viewMore).toBe(true); + }); + }); + + describe(`${ actions.SELECT_ID } action`, () => { + it('should set viewMore property to false and selected to the provided payload', () => { + const id = 'foo'; + const result = reducer(snapshot, new actions.SelectId(id)); + + expect(result.viewMore).toBe(false); + expect(result.selected).toBe(id); + }); + }); + + describe(`${actions.CANCEL_VIEW_MORE} action`, () => { + it('should set viewMore property to false', () => { + const result = reducer(snapshot, new actions.CancelViewMore()); + + expect(result.viewMore).toBe(false); + }); + }); + + describe(`${actions.LOAD_ENTITY_IDS} action`, () => { + it('should set loading property to true', () => { + const result = reducer(snapshot, new actions.LoadEntityIds('foo')); + + expect(result.loading).toBe(true); + }); + }); + + describe(`${actions.LOAD_ENTITY_IDS_SUCCESS} action`, () => { + it('should set loading property to false and the entityIds property to the provided payload', () => { + const ids = ['foo']; + const result = reducer(snapshot, new actions.LoadEntityIdsSuccess(ids)); + + expect(result.loading).toBe(false); + expect(result.entityIds).toBe(ids); + }); + }); + + describe(`${actions.LoadEntityIdsError} action`, () => { + it('should set loading property to false and the error property to the provided payload', () => { + const err = new Error('Foobar!'); + const result = reducer(snapshot, new actions.LoadEntityIdsError(err)); + + expect(result.loading).toBe(false); + expect(result.error).toBe(err); + }); + }); +}); diff --git a/ui/src/app/metadata-filter/reducer/filter.reducer.ts b/ui/src/app/metadata-filter/reducer/filter.reducer.ts index c84c3faa6..b4a64e1ca 100644 --- a/ui/src/app/metadata-filter/reducer/filter.reducer.ts +++ b/ui/src/app/metadata-filter/reducer/filter.reducer.ts @@ -1,24 +1,74 @@ import { createSelector, createFeatureSelector } from '@ngrx/store'; import { MetadataProvider } from '../../metadata-provider/model/metadata-provider'; -import * as filter from '../action/collection.action'; +import * as filter from '../action/filter.action'; import * as fromRoot from '../../core/reducer'; export interface FilterState { entityIds: string[]; + viewMore: boolean; loading: boolean; - error: string | null; + error: Error | null; + selected: string | null; } export const initialState: FilterState = { entityIds: [], + selected: null, + viewMore: false, loading: false, error: null }; export function reducer(state = initialState, action: filter.Actions): FilterState { switch (action.type) { + case filter.VIEW_MORE_IDS: { + return { + ...state, + viewMore: true + }; + } + case filter.SELECT_ID: { + return { + ...state, + selected: action.payload, + viewMore: false + }; + } + case filter.CANCEL_VIEW_MORE: { + return { + ...state, + viewMore: false + }; + } + case filter.LOAD_ENTITY_IDS: { + return { + ...state, + loading: true + }; + } + case filter.LOAD_ENTITY_IDS_SUCCESS: { + return { + ...state, + loading: false, + error: null, + entityIds: action.payload + }; + } + case filter.LOAD_ENTITY_IDS_ERROR: { + return { + ...state, + loading: false, + error: action.payload + }; + } default: { return state; } } } + +export const getViewMore = (state: FilterState) => state.viewMore; +export const getSelected = (state: FilterState) => state.selected; +export const getEntityIds = (state: FilterState) => state.entityIds; +export const getError = (state: FilterState) => state.error; +export const getLoading = (state: FilterState) => state.loading; diff --git a/ui/src/app/metadata-filter/reducer/index.ts b/ui/src/app/metadata-filter/reducer/index.ts index 084b99714..2f9873975 100644 --- a/ui/src/app/metadata-filter/reducer/index.ts +++ b/ui/src/app/metadata-filter/reducer/index.ts @@ -14,3 +14,13 @@ export interface State extends fromRoot.State { 'metadata-filter': FilterState; } +export const getFiltersFromStateFn = (state: FilterState) => state.filter; + +export const getFilterState = createFeatureSelector('metadata-filter'); +export const getFilterFromState = createSelector(getFilterState, getFiltersFromStateFn); + +export const getViewingMore = createSelector(getFilterFromState, fromFilter.getViewMore); +export const getSelected = createSelector(getFilterFromState, fromFilter.getSelected); +export const getEntityCollection = createSelector(getFilterFromState, fromFilter.getEntityIds); +export const getIsLoading = createSelector(getFilterFromState, fromFilter.getLoading); +export const getError = createSelector(getFilterFromState, fromFilter.getError); diff --git a/ui/src/app/metadata-provider/component/forms/descriptor-info-form.component.html b/ui/src/app/metadata-provider/component/forms/descriptor-info-form.component.html index f7ddde773..5d7d72809 100644 --- a/ui/src/app/metadata-provider/component/forms/descriptor-info-form.component.html +++ b/ui/src/app/metadata-provider/component/forms/descriptor-info-form.component.html @@ -41,9 +41,10 @@ diff --git a/ui/src/app/metadata-provider/component/forms/descriptor-info-form.component.spec.ts b/ui/src/app/metadata-provider/component/forms/descriptor-info-form.component.spec.ts index 0069d7bca..7153ba3db 100644 --- a/ui/src/app/metadata-provider/component/forms/descriptor-info-form.component.spec.ts +++ b/ui/src/app/metadata-provider/component/forms/descriptor-info-form.component.spec.ts @@ -12,6 +12,7 @@ import { DescriptorInfoFormComponent } from './descriptor-info-form.component'; import { AutoCompleteComponent } from '../../../widget/autocomplete/autocomplete.component'; import * as stubs from '../../../../testing/provider.stub'; +import { ValidationClassDirective } from '../../../widget/validation/validation-class.directive'; @Component({ template: `` @@ -63,7 +64,8 @@ describe('Descriptor Info Form Component', () => { declarations: [ DescriptorInfoFormComponent, AutoCompleteComponent, - TestHostComponent + TestHostComponent, + ValidationClassDirective ], }); store = TestBed.get(Store); diff --git a/ui/src/app/metadata-provider/component/forms/descriptor-info-form.component.ts b/ui/src/app/metadata-provider/component/forms/descriptor-info-form.component.ts index 5aaf1423c..4eb64ab2a 100644 --- a/ui/src/app/metadata-provider/component/forms/descriptor-info-form.component.ts +++ b/ui/src/app/metadata-provider/component/forms/descriptor-info-form.component.ts @@ -25,6 +25,8 @@ export class DescriptorInfoFormComponent extends ProviderFormFragmentComponent i 'SAML 1.1' ]; + nameIds$: Observable = Observable.of([]); + constructor( protected fb: FormBuilder, protected statusEmitter: ProviderStatusEmitter, @@ -71,4 +73,9 @@ export class DescriptorInfoFormComponent extends ProviderFormFragmentComponent i removeFormat(index: number): void { this.nameIdFormats.removeAt(index); } + + updateOptions(query: string): void { + this.nameIds$ = this.listValues.searchFormats(Observable.of(query)); + } + } /* istanbul ignore next */ diff --git a/ui/src/app/metadata-provider/component/forms/metadata-ui-form.component.spec.ts b/ui/src/app/metadata-provider/component/forms/metadata-ui-form.component.spec.ts index 8d5774b44..320fb5f08 100644 --- a/ui/src/app/metadata-provider/component/forms/metadata-ui-form.component.spec.ts +++ b/ui/src/app/metadata-provider/component/forms/metadata-ui-form.component.spec.ts @@ -1,3 +1,4 @@ +import { ViewChild, Component, Input } from '@angular/core'; import { TestBed, ComponentFixture } from '@angular/core/testing'; import { ReactiveFormsModule } from '@angular/forms'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -9,10 +10,31 @@ import { ListValuesService } from '../../service/list-values.service'; import { EntityDescriptor } from '../../model/entity-descriptor'; import { MetadataUiFormComponent } from './metadata-ui-form.component'; +import * as stubs from '../../../../testing/provider.stub'; +import { InputDefaultsDirective } from '../../directive/input-defaults.directive'; +import { I18nTextComponent } from '../i18n-text.component'; + +@Component({ + template: `` +}) +class TestHostComponent { + provider = new EntityDescriptor({ + ...stubs.provider + }); + + @ViewChild(MetadataUiFormComponent) + public formUnderTest: MetadataUiFormComponent; + + changeProvider(opts: any): void { + this.provider = Object.assign({}, this.provider, opts); + } +} + describe('Metadata UI Form Component', () => { - let fixture: ComponentFixture; - let instance: MetadataUiFormComponent; + let fixture: ComponentFixture; + let instance: TestHostComponent; let store: Store; + let form: MetadataUiFormComponent; beforeEach(() => { TestBed.configureTestingModule({ @@ -31,19 +53,31 @@ describe('Metadata UI Form Component', () => { NgbPopoverModule ], declarations: [ - MetadataUiFormComponent + MetadataUiFormComponent, + TestHostComponent, + I18nTextComponent, + InputDefaultsDirective ], }); store = TestBed.get(Store); spyOn(store, 'dispatch').and.callThrough(); - fixture = TestBed.createComponent(MetadataUiFormComponent); + fixture = TestBed.createComponent(TestHostComponent); instance = fixture.componentInstance; - instance.provider = new EntityDescriptor({ entityId: 'foo', serviceProviderName: 'bar' }); + form = instance.formUnderTest; fixture.detectChanges(); }); it('should compile', () => { expect(fixture).toBeDefined(); }); + + describe('ngOnChanges lifecycle event', () => { + it('should set the mdui data with a default object when one is not provided', () => { + spyOn(form.form, 'reset'); + instance.changeProvider({}); + fixture.detectChanges(); + expect(form.form.reset).toHaveBeenCalledWith({mdui: {}}); + }); + }); }); diff --git a/ui/src/app/metadata-provider/component/forms/relying-party-form.component.html b/ui/src/app/metadata-provider/component/forms/relying-party-form.component.html index 20831128a..b3f3fab4c 100644 --- a/ui/src/app/metadata-provider/component/forms/relying-party-form.component.html +++ b/ui/src/app/metadata-provider/component/forms/relying-party-form.component.html @@ -73,9 +73,10 @@
@@ -106,9 +107,10 @@
@@ -162,4 +164,4 @@
- + \ No newline at end of file diff --git a/ui/src/app/metadata-provider/component/forms/relying-party-form.component.spec.ts b/ui/src/app/metadata-provider/component/forms/relying-party-form.component.spec.ts index 4add5b0f8..09dbffd0b 100644 --- a/ui/src/app/metadata-provider/component/forms/relying-party-form.component.spec.ts +++ b/ui/src/app/metadata-provider/component/forms/relying-party-form.component.spec.ts @@ -12,6 +12,7 @@ import { RelyingPartyFormComponent } from './relying-party-form.component'; import { AutoCompleteComponent } from '../../../widget/autocomplete/autocomplete.component'; import * as stubs from '../../../../testing/provider.stub'; +import { ValidationClassDirective } from '../../../widget/validation/validation-class.directive'; @Component({ template: `` @@ -63,7 +64,8 @@ describe('Relying Party Form Component', () => { declarations: [ RelyingPartyFormComponent, AutoCompleteComponent, - TestHostComponent + TestHostComponent, + ValidationClassDirective ], }); store = TestBed.get(Store); @@ -96,6 +98,12 @@ describe('Relying Party Form Component', () => { fixture.detectChanges(); expect(form.nameIdFormatList.length).toBe(1); }); + + it('should add a new nameid format with a value supplied', () => { + form.addFormat('foo'); + fixture.detectChanges(); + expect(form.nameIdFormatList.length).toBe(1); + }); }); describe('removeAuthenticationMethod method', () => { @@ -114,6 +122,12 @@ describe('Relying Party Form Component', () => { fixture.detectChanges(); expect(form.authenticationMethodList.length).toBe(1); }); + + it('should add a new auth method with provided value', () => { + form.addAuthenticationMethod('foo'); + fixture.detectChanges(); + expect(form.authenticationMethodList.length).toBe(1); + }); }); describe('getRequiredControl method', () => { diff --git a/ui/src/app/metadata-provider/component/forms/relying-party-form.component.ts b/ui/src/app/metadata-provider/component/forms/relying-party-form.component.ts index d74d556fa..a03f89cc8 100644 --- a/ui/src/app/metadata-provider/component/forms/relying-party-form.component.ts +++ b/ui/src/app/metadata-provider/component/forms/relying-party-form.component.ts @@ -19,8 +19,8 @@ export class RelyingPartyFormComponent extends ProviderFormFragmentComponent imp @Input() provider: MetadataProvider; form: FormGroup; - nameIdFormatOptions = this.listValues.nameIdFormats; - authenticationMethodOptions = this.listValues.authenticationMethods; + nameIds$: Observable = Observable.of([]); + authenticationMethods$: Observable = Observable.of([]); nameIdFormatList: FormArray; authenticationMethodList: FormArray; @@ -92,4 +92,12 @@ export class RelyingPartyFormComponent extends ProviderFormFragmentComponent imp this.setNameIdFormats(overrides.nameIdFormats); this.setAuthenticationMethods(overrides.authenticationMethods); } + + searchNameIds(query: string): void { + this.nameIds$ = this.listValues.searchFormats(Observable.of(query)); + } + + searchAuthMethods(query: string): void { + this.authenticationMethods$ = this.listValues.searchAuthenticationMethods(Observable.of(query)); + } } /* istanbul ignore next */ diff --git a/ui/src/app/metadata-provider/metadata-provider.module.ts b/ui/src/app/metadata-provider/metadata-provider.module.ts index f4672e308..948706417 100644 --- a/ui/src/app/metadata-provider/metadata-provider.module.ts +++ b/ui/src/app/metadata-provider/metadata-provider.module.ts @@ -21,6 +21,7 @@ import { EntityDraftService } from './service/entity-draft.service'; import { PretttyXml } from './pipe/pretty-xml.pipe'; import { UploadProviderComponent } from './container/upload-provider.component'; import { BlankProviderComponent } from './container/blank-provider.component'; +import { EntityIdService } from './service/entity-id.service'; @NgModule({ declarations: [ @@ -51,6 +52,7 @@ export class MetadataProviderModule { return { ngModule: RootProviderModule, providers: [ + EntityIdService, EntityDescriptorService, EntityDraftService, ProviderStatusEmitter, diff --git a/ui/src/app/metadata-provider/pipe/pretty-xml.pipe.ts b/ui/src/app/metadata-provider/pipe/pretty-xml.pipe.ts index a6f56c1d4..d46a81830 100644 --- a/ui/src/app/metadata-provider/pipe/pretty-xml.pipe.ts +++ b/ui/src/app/metadata-provider/pipe/pretty-xml.pipe.ts @@ -4,7 +4,6 @@ import * as XmlFormatter from 'xml-formatter'; @Pipe({ name: 'prettyXml' }) export class PretttyXml implements PipeTransform { transform(value: string): string { - console.log(XmlFormatter); if (!value) { return value; } diff --git a/ui/src/app/metadata-provider/service/entity-id.service.ts b/ui/src/app/metadata-provider/service/entity-id.service.ts new file mode 100644 index 000000000..4426844e9 --- /dev/null +++ b/ui/src/app/metadata-provider/service/entity-id.service.ts @@ -0,0 +1,40 @@ +import 'rxjs/add/observable/of'; +import 'rxjs/add/operator/concat'; +import { Injectable } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs/Observable'; +import { Subject } from 'rxjs/Subject'; +import { IDS } from '../../../data/ids.mock'; +import { Storage } from '../../shared/storage'; +import { environment } from '../../../environments/environment'; + +const MOCK_INTERVAL = 500; + +@Injectable() +export class EntityIdService { + + private endpoint = '/data/ids.json'; + private base = ''; + + private subj: Subject = new Subject(); + + constructor( + private http: HttpClient + ) { } + + query(search: string = ''): Observable { + setTimeout(() => { + let found = IDS.filter((option: string) => option.toLocaleLowerCase().match(search.toLocaleLowerCase())); + this.subj.next(found); + }, MOCK_INTERVAL); + return this.subj.asObservable(); + /* + return this.http + .get(`${this.base}${this.endpoint}s`) + .catch(err => { + console.log('ERROR LOADING IDS:', err); + return Observable.of([] as string[]); + }); + */ + } +} diff --git a/ui/src/app/metadata-provider/service/entity-validators.service.spec.ts b/ui/src/app/metadata-provider/service/entity-validators.service.spec.ts index bfff3a6eb..5c39ac08d 100644 --- a/ui/src/app/metadata-provider/service/entity-validators.service.spec.ts +++ b/ui/src/app/metadata-provider/service/entity-validators.service.spec.ts @@ -89,4 +89,24 @@ describe(`EntityDescriptorService`, () => { }); }))); }); + + describe('createUniqueIdValidator', () => { + it('should detect that a provided id is in the collection', async(inject([FormBuilder], (fb) => { + let obs = Observable.of(ids), + validator = EntityValidators.existsInCollection(obs), + ctrl = fb.control('foo'); + validator(ctrl).subscribe(next => { + expect(next).toBeNull(); + }); + }))); + + it('should detect that a provided id is not in the collection', async(inject([FormBuilder], (fb) => { + let obs = Observable.of(ids), + validator = EntityValidators.existsInCollection(obs), + ctrl = fb.control('hi'); + validator(ctrl).subscribe(next => { + expect(next).toBeTruthy(); + }); + }))); + }); }); diff --git a/ui/src/app/metadata-provider/service/entity-validators.service.ts b/ui/src/app/metadata-provider/service/entity-validators.service.ts index 95dae83f0..82366ff78 100644 --- a/ui/src/app/metadata-provider/service/entity-validators.service.ts +++ b/ui/src/app/metadata-provider/service/entity-validators.service.ts @@ -42,4 +42,16 @@ export class EntityValidators { .take(1); }; } + + static existsInCollection(ids$: Observable) { + return (control: AbstractControl) => { + return ids$ + .map(ids => ids.find(id => id === control.value)) + .map(ids => ids && !!ids.length) + .map((exists: boolean) => { + return exists ? null : { exists: true }; + }) + .take(1); + }; + } } diff --git a/ui/src/app/widget/autocomplete/autocomplete-validators.service.spec.ts b/ui/src/app/widget/autocomplete/autocomplete-validators.service.spec.ts deleted file mode 100644 index a50373d9a..000000000 --- a/ui/src/app/widget/autocomplete/autocomplete-validators.service.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { TestBed, async, inject } from '@angular/core/testing'; -import { Observable } from 'rxjs/Observable'; -import { AbstractControl, FormBuilder, ReactiveFormsModule } from '@angular/forms'; -import 'rxjs/add/observable/of'; - -import * as AutoCompleteValidators from './autocomplete-validators.service'; - -let ids = ['foo', 'bar', 'baz']; - -describe(`existsInCollection`, () => { - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - ReactiveFormsModule - ], - providers: [ - FormBuilder - ] - }); - }); - - describe('createUniqueIdValidator', () => { - it('should detect that a provided id is in the collection', async(inject([FormBuilder], (fb) => { - let obs = Observable.of(ids), - validator = AutoCompleteValidators.existsInCollection(obs), - ctrl = fb.control('foo'); - validator(ctrl).subscribe(next => { - expect(next).toBeNull(); - }); - }))); - - it('should detect that a provided id is not in the collection', async(inject([FormBuilder], (fb) => { - let obs = Observable.of(ids), - validator = AutoCompleteValidators.existsInCollection(obs), - ctrl = fb.control('hi'); - validator(ctrl).subscribe(next => { - expect(next).toBeTruthy(); - }); - }))); - }); -}); diff --git a/ui/src/app/widget/autocomplete/autocomplete-validators.service.ts b/ui/src/app/widget/autocomplete/autocomplete-validators.service.ts deleted file mode 100644 index 399c5fcdb..000000000 --- a/ui/src/app/widget/autocomplete/autocomplete-validators.service.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Observable } from 'rxjs/Observable'; -import { AbstractControl } from '@angular/forms'; -import 'rxjs/add/operator/take'; -import 'rxjs/add/operator/startWith'; -import 'rxjs/add/operator/map'; - -export function existsInCollection (ids$: Observable) { - return(control: AbstractControl) => { - return ids$ - .map(ids => ids.find(id => id === control.value)) - .map(ids => ids && !!ids.length) - .map((exists: boolean) => { - return exists ? null : { exists: true }; - }) - .take(1); - }; -} diff --git a/ui/src/app/widget/autocomplete/autocomplete.component.html b/ui/src/app/widget/autocomplete/autocomplete.component.html index de7557abd..ff40b01f2 100644 --- a/ui/src/app/widget/autocomplete/autocomplete.component.html +++ b/ui/src/app/widget/autocomplete/autocomplete.component.html @@ -2,6 +2,7 @@ diff --git a/ui/src/app/widget/autocomplete/autocomplete.component.spec.ts b/ui/src/app/widget/autocomplete/autocomplete.component.spec.ts index 69e1dbd31..650124d96 100644 --- a/ui/src/app/widget/autocomplete/autocomplete.component.spec.ts +++ b/ui/src/app/widget/autocomplete/autocomplete.component.spec.ts @@ -1,11 +1,12 @@ import { Component, ViewChild, SimpleChange, ElementRef, SimpleChanges } from '@angular/core'; import { TestBed, ComponentFixture } from '@angular/core/testing'; -import { ReactiveFormsModule } from '@angular/forms'; +import { FormsModule, ReactiveFormsModule, FormBuilder } from '@angular/forms'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { StoreModule, Store, combineReducers } from '@ngrx/store'; import { NgbPopoverModule, NgbPopoverConfig } from '@ng-bootstrap/ng-bootstrap/popover/popover.module'; import { AutoCompleteComponent } from './autocomplete.component'; import { NavigatorService } from '../../core/service/navigator.service'; +import { ValidationClassDirective } from '../validation/validation-class.directive'; const iPodAgent = `Mozilla/5.0 (iPhone; CPU iPhone OS 5_0 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko) @@ -16,30 +17,43 @@ const regularAgent = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_3) @Component({ template: ` - - +
+ + +
` }) class TestHostComponent { config: any = { autoSelect: false, options: [], - required: true, defaultValue: '', id: 'foo', allowCustom: false, - noneFoundText: 'None Found' + noneFoundText: 'None Found', + disabled: false }; + constructor(public fb: FormBuilder) {} + + group = this.fb.group({ + search: [''] + }); + @ViewChild(AutoCompleteComponent) public autoCompleteUnderTest: AutoCompleteComponent; configure(opts: any): void { this.config = Object.assign({}, this.config, opts); } + + search(query: string = ''): void {} } describe('AutoComplete Input Component', () => { @@ -55,11 +69,13 @@ describe('AutoComplete Input Component', () => { ], imports: [ NoopAnimationsModule, + FormsModule, ReactiveFormsModule ], declarations: [ AutoCompleteComponent, - TestHostComponent + TestHostComponent, + ValidationClassDirective ], }).compileComponents(); }); @@ -104,92 +120,21 @@ describe('AutoComplete Input Component', () => { }); describe('setDisabledState method', () => { - it('should set the disabled property', () => { - instanceUnderTest.setDisabledState(true); - expect(instanceUnderTest.disabled).toBe(true); + it('should set the disabled property to true', () => { + testHostInstance.group.get('search').disable(); + testHostFixture.detectChanges(); + expect(instanceUnderTest.input.disabled).toBe(true); }); - it('should set the disabled property', () => { - instanceUnderTest.setDisabledState(); - expect(instanceUnderTest.disabled).toBe(false); + it('should set the disabled property to false', () => { + testHostInstance.group.get('search').enable(); + testHostFixture.detectChanges(); + expect(instanceUnderTest.input.disabled).toBe(false); }); - }); - }); - describe('ngOnChanges lifecycle event', () => { - const opts = ['foo', 'bar', 'baz']; - it('should add options to state if provided', () => { - spyOn(instanceUnderTest.state, 'setState'); - testHostInstance.configure({ - options: opts - }); - testHostFixture.detectChanges(); - expect(instanceUnderTest.state.setState).toHaveBeenCalledWith({ - options: opts - }); - }); - - it('should not set the state if no options were provided', () => { - spyOn(instanceUnderTest.state, 'setState'); - testHostInstance.configure({ - focused: null + it('should set the disabled property to false if not provided', () => { + instanceUnderTest.setDisabledState(); + expect(instanceUnderTest.input.disabled).toBe(false); }); - testHostFixture.detectChanges(); - expect(instanceUnderTest.state.setState).not.toHaveBeenCalled(); - }); - }); - - describe('setValidation method', () => { - let mockChanges = { - disabled: new SimpleChange(false, true, false), - required: new SimpleChange(false, true, false), - allowCustom: new SimpleChange(false, false, true), - focused: new SimpleChange( - new ElementRef(document.createElement('input')), - new ElementRef(document.createElement('input')), - false - ) - }; - it('should set disabled', () => { - spyOn(instanceUnderTest.input, 'disable'); - instanceUnderTest.setDisabledState(true); - instanceUnderTest.setValidation(mockChanges); - expect(instanceUnderTest.input.disable).toHaveBeenCalled(); - }); - - it('should enable', () => { - spyOn(instanceUnderTest.input, 'enable'); - instanceUnderTest.setDisabledState(false); - instanceUnderTest.setValidation(mockChanges); - expect(instanceUnderTest.input.enable).toHaveBeenCalled(); - }); - - it('should set required', () => { - spyOn(instanceUnderTest.input, 'setValidators'); - instanceUnderTest.required = true; - instanceUnderTest.setValidation(mockChanges); - expect(instanceUnderTest.input.setValidators).toHaveBeenCalled(); - }); - - it('should remove required', () => { - spyOn(instanceUnderTest.input, 'clearValidators'); - instanceUnderTest.required = false; - instanceUnderTest.setValidation(mockChanges); - expect(instanceUnderTest.input.clearValidators).toHaveBeenCalled(); - }); - - it('should update provided options', () => { - let opts = ['foo']; - spyOn(instanceUnderTest.state, 'setState'); - testHostInstance.configure({options: opts}); - testHostFixture.detectChanges(); - expect(instanceUnderTest.state.setState).toHaveBeenCalledWith({options: opts}); - }); - - it('should set validator based on allowCustom', () => { - spyOn(instanceUnderTest.input, 'clearAsyncValidators'); - instanceUnderTest.allowCustom = true; - instanceUnderTest.setValidation({...mockChanges, allowCustom: new SimpleChange(false, true, false)}); - expect(instanceUnderTest.input.clearAsyncValidators).toHaveBeenCalled(); }); }); @@ -233,40 +178,43 @@ describe('AutoComplete Input Component', () => { describe('handleComponentBlur handler', () => { const opts = ['foo', 'bar', 'baz']; it('should set a new state', () => { - instanceUnderTest.state.setState({options: opts, selected: 0, query: 'foo'}); + instanceUnderTest.state.setState({ selected: 0 }); spyOn(instanceUnderTest.state, 'setState'); instanceUnderTest.handleComponentBlur({menuOpen: true}); expect(instanceUnderTest.state.setState).toHaveBeenCalledWith({ focused: null, selected: null, - menuOpen: true, - query: 'foo' + menuOpen: true }); }); it('should set the menuOpen state to false if not provided', () => { - instanceUnderTest.state.setState({ options: opts, selected: 0, query: 'foo' }); + instanceUnderTest.state.setState({ selected: 0 }); spyOn(instanceUnderTest.state, 'setState'); instanceUnderTest.handleComponentBlur(); expect(instanceUnderTest.state.setState).toHaveBeenCalledWith({ focused: null, selected: null, - menuOpen: false, - query: 'foo' + menuOpen: false }); }); it('should allow custom values when the property is set', () => { + const val = 'hi'; testHostInstance.configure({allowCustom: true}); - instanceUnderTest.state.setState({ options: opts, selected: -1, query: 'hi' }); + instanceUnderTest.input.setValue(val); + instanceUnderTest.state.setState({ selected: -1 }); testHostFixture.detectChanges(); spyOn(instanceUnderTest, 'propagateChange'); instanceUnderTest.handleComponentBlur(); - expect(instanceUnderTest.propagateChange).toHaveBeenCalledWith('hi'); + expect(instanceUnderTest.propagateChange).toHaveBeenCalledWith(val); }); it('should detect if query is in current options', () => { - instanceUnderTest.state.setState({ options: opts, selected: -1, query: 'foo' }); + const val = 'foo'; + instanceUnderTest.input.setValue(val); + testHostInstance.configure({options: [val]}); + instanceUnderTest.state.setState({ selected: -1 }); testHostFixture.detectChanges(); spyOn(instanceUnderTest, 'propagateChange'); instanceUnderTest.handleComponentBlur(); @@ -278,13 +226,13 @@ describe('AutoComplete Input Component', () => { const opts = ['foo', 'bar', 'baz']; it('should call preventDefault on the provided event if the menu is currently open', () => { const ev = { preventDefault: jasmine.createSpy('preventDefault') }; - instanceUnderTest.state.setState({options: opts, menuOpen: true}); + instanceUnderTest.state.setState({ menuOpen: true}); instanceUnderTest.handleEnter(ev); expect(ev.preventDefault).toHaveBeenCalled(); }); it('should NOT call preventDefault on the provided event if the menu is not open', () => { const ev = { preventDefault: jasmine.createSpy('preventDefault') }; - instanceUnderTest.state.setState({ options: opts, menuOpen: false }); + instanceUnderTest.state.setState({ menuOpen: false }); instanceUnderTest.handleEnter(ev); expect(ev.preventDefault).not.toHaveBeenCalled(); }); @@ -292,7 +240,7 @@ describe('AutoComplete Input Component', () => { it('should call componentBlur if there is no selected option and the query is not in the options', () => { const ev = { preventDefault: jasmine.createSpy('preventDefault') }; spyOn(instanceUnderTest, 'handleComponentBlur'); - instanceUnderTest.state.setState({ options: opts, menuOpen: true, query: 'hi', selected: -1 }); + instanceUnderTest.state.setState({ menuOpen: true, selected: -1 }); instanceUnderTest.handleEnter(ev); expect(instanceUnderTest.handleComponentBlur).toHaveBeenCalledWith({ focused: -1, @@ -302,11 +250,16 @@ describe('AutoComplete Input Component', () => { }); it('should call handleOptionClick if there is no selected option but the query is in the options', () => { + const i = 0; + const val = opts[i]; const ev = { preventDefault: jasmine.createSpy('preventDefault') }; + testHostInstance.configure({ options: opts }); + instanceUnderTest.input.setValue(val); + testHostFixture.detectChanges(); spyOn(instanceUnderTest, 'handleOptionClick'); - instanceUnderTest.state.setState({ options: opts, menuOpen: true, query: 'foo', selected: -1 }); + instanceUnderTest.state.setState({ menuOpen: true, selected: -1 }); instanceUnderTest.handleEnter(ev); - expect(instanceUnderTest.handleOptionClick).toHaveBeenCalledWith(0); + expect(instanceUnderTest.handleOptionClick).toHaveBeenCalledWith(i); }); }); @@ -330,30 +283,37 @@ describe('AutoComplete Input Component', () => { const expected = { menuOpen: false, focused: -1, - selected: -1, - query: 'bar' + selected: -1 }; it('should set the state to menuOpen: false, focused to -1, selected to -1, and the query to the selected option', () => { - spyOnProperty(instanceUnderTest.state, 'currentState', 'get').and.returnValue({ - matches: ['foo', 'bar'], - options: ['foo', 'bar', 'baz'], - query: 'bar' - }); + const val = 'foo'; + instanceUnderTest.input.setValue(val); + testHostInstance.configure({ options: [val, 'bar', 'baz'] }); + testHostFixture.detectChanges(); spyOn(instanceUnderTest.state, 'setState'); - instanceUnderTest.handleOptionClick(1); + instanceUnderTest.handleOptionClick(0); expect(instanceUnderTest.state.setState).toHaveBeenCalledWith(expected); }); it('should not call propagateChange if the selected option is not in the list of matches', () => { - spyOnProperty(instanceUnderTest.state, 'currentState', 'get').and.returnValue({ - matches: ['foo', 'bar'], - options: ['foo', 'bar', 'baz'], - query: 'bar' - }); + const val = 'hi'; + instanceUnderTest.input.setValue(val); + testHostInstance.configure({ options: ['foo', 'bar', 'baz'] }); + testHostFixture.detectChanges(); spyOn(instanceUnderTest, 'propagateChange'); instanceUnderTest.handleOptionClick(4); expect(instanceUnderTest.propagateChange).not.toHaveBeenCalled(); }); + + it('should call propagateChange if the selected option is in the list of matches', () => { + const val = 'foo'; + instanceUnderTest.input.setValue(val); + testHostInstance.configure({ options: [val, 'bar', 'baz'] }); + testHostFixture.detectChanges(); + spyOn(instanceUnderTest, 'propagateChange'); + instanceUnderTest.handleOptionClick(0); + expect(instanceUnderTest.propagateChange).toHaveBeenCalledWith(val); + }); }); describe('handleOptionMouseDown method', () => { @@ -386,7 +346,7 @@ describe('AutoComplete Input Component', () => { it('should return the current state', () => { let spy = spyOnProperty(instanceUnderTest.state, 'currentState', 'get'); let state = instanceUnderTest.displayState; - expect(state).toEqual({options: []}); + expect(state).toEqual({}); expect(spy).toHaveBeenCalled(); }); }); @@ -395,7 +355,6 @@ describe('AutoComplete Input Component', () => { it('should return true if not an ios device and autoSelect is set to true', () => { spyOn(instanceUnderTest, 'isIosDevice').and.returnValue(false); testHostInstance.configure({autoSelect: true}); - console.log(testHostInstance.config); testHostFixture.detectChanges(); expect(instanceUnderTest.hasAutoselect).toBe(true); }); @@ -431,24 +390,34 @@ describe('AutoComplete Input Component', () => { describe('queryOption getter', () => { it('should return the query if the query exists in the collection', () => { - instanceUnderTest.state.setState({query: 'foo', options: ['foo', 'bar']}); + testHostInstance.configure({ options: ['foo', 'bar'] }); + instanceUnderTest.input.setValue('foo'); + testHostFixture.detectChanges(); expect(instanceUnderTest.queryOption).toBe('foo'); }); - it('should return null if the query does not in the collection', () => { - instanceUnderTest.state.setState({ query: 'foo', options: ['bar', 'baz'] }); - expect(instanceUnderTest.queryOption).toBe(null); + it('should return null if the query does not exist in the collection', () => { + testHostInstance.configure({ options: ['bar', 'baz'] }); + instanceUnderTest.input.setValue('foo'); + testHostFixture.detectChanges(); + expect(instanceUnderTest.queryOption).toBeNull(); }); it('should return null if the query is undefined/null', () => { - instanceUnderTest.state.setState({ query: null, options: ['bar', 'baz'] }); - expect(instanceUnderTest.queryOption).toBe(null); + testHostInstance.configure({ options: ['bar', 'baz'] }); + instanceUnderTest.input.setValue('foo'); + testHostFixture.detectChanges(); + expect(instanceUnderTest.queryOption).toBeNull(); }); - it('should return null if the options are undefined/null', () => { - instanceUnderTest.state.setState({ query: 'foo', options: null }); - expect(instanceUnderTest.queryOption).toBe(null); + xit('should return null if the options are undefined/null', () => { + testHostInstance.configure({ options: null }); + instanceUnderTest.input.setValue('foo'); + testHostFixture.detectChanges(); + expect(instanceUnderTest.queryOption).toBeNull(); }); it('should return null if the options list is empty', () => { - instanceUnderTest.state.setState({ query: 'foo', options: [] }); - expect(instanceUnderTest.queryOption).toBe(null); + testHostInstance.configure({ options: [] }); + instanceUnderTest.input.setValue('foo'); + testHostFixture.detectChanges(); + expect(instanceUnderTest.queryOption).toBeNull(); }); }); }); diff --git a/ui/src/app/widget/autocomplete/autocomplete.component.ts b/ui/src/app/widget/autocomplete/autocomplete.component.ts index ada38bef3..b97cc3561 100644 --- a/ui/src/app/widget/autocomplete/autocomplete.component.ts +++ b/ui/src/app/widget/autocomplete/autocomplete.component.ts @@ -5,14 +5,14 @@ import { EventEmitter, OnInit, OnDestroy, - OnChanges, AfterViewInit, ViewChild, ViewChildren, QueryList, ElementRef, SimpleChanges, - forwardRef + forwardRef, + ChangeDetectionStrategy } from '@angular/core'; import { FormControl, ControlValueAccessor, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; import { Observable } from 'rxjs/Observable'; @@ -24,7 +24,6 @@ import 'rxjs/add/operator/combineLatest'; import { keyCodes, isPrintableKeyCode } from '../../shared/keycodes'; import { AutoCompleteState, AutoCompleteStateEmitter, defaultState } from './autocomplete.model'; -import * as AutoCompleteValidators from './autocomplete-validators.service'; import { NavigatorService } from '../../core/service/navigator.service'; const POLL_TIMEOUT = 1000; @@ -32,6 +31,7 @@ const MIN_LENGTH = 2; const INPUT_FIELD_INDEX = -1; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'auto-complete', templateUrl: './autocomplete.component.html', styleUrls: ['./autocomplete.component.scss'], @@ -43,23 +43,25 @@ const INPUT_FIELD_INDEX = -1; } ] }) -export class AutoCompleteComponent implements OnInit, OnDestroy, OnChanges, AfterViewInit, ControlValueAccessor { - @Input() required: boolean | null = true; +export class AutoCompleteComponent implements OnInit, OnDestroy, AfterViewInit, ControlValueAccessor { @Input() defaultValue = ''; - @Input() options: string[] = []; + @Input() matches: string[] = []; @Input() id: string; @Input() autoSelect = false; @Input() allowCustom = false; @Input() noneFoundText = 'No Options Found'; + @Input() showMoreText = 'Show More...'; + @Input() limit = 0; + @Input() processing = false; @Output() more: EventEmitter = new EventEmitter(); + @Output() onChange: EventEmitter = new EventEmitter(); @ViewChild('inputField') inputField: ElementRef; @ViewChildren('matchElement', { read: ElementRef }) listItems: QueryList; focused: number; selected: number; - disabled = false; isMenuOpen$: Observable; query$: Observable; @@ -99,14 +101,9 @@ export class AutoCompleteComponent implements OnInit, OnDestroy, OnChanges, Afte this.input .valueChanges .subscribe((query) => { - let matches = []; - if (query && query.length >= MIN_LENGTH && this.state.currentState.options) { - matches = this.state.currentState.options - .filter((option: string) => option.toLocaleLowerCase().match(query.toLocaleLowerCase())); - } else { - matches = []; + if (query && query.length >= MIN_LENGTH) { + this.onChange.emit(query); } - this.state.setState({ matches }); }); this.input.valueChanges.subscribe(newValue => this.handleInputChange(newValue)); this.input.setValue(this.defaultValue); @@ -124,40 +121,6 @@ export class AutoCompleteComponent implements OnInit, OnDestroy, OnChanges, Afte this.listItems.changes.subscribe((changes) => this.setElementReferences(changes)); } - ngOnChanges(changes: SimpleChanges): void { - if (changes.options) { - this.state.setState({options: this.options}); - } - this.setValidation(changes); - } - - setValidation(changes: SimpleChanges): void { - if (changes.required) { - if (this.required) { - this.input.setValidators([Validators.required]); - } else { - this.input.clearValidators(); - } - } - - if (changes.disabled) { - if (this.disabled) { - this.input.disable(); - } else { - this.input.enable(); - } - } - - if (changes.allowCustom || changes.options) { - if (!this.allowCustom) { - let opts$ = Observable.of(this.options); - this.input.setAsyncValidators([AutoCompleteValidators.existsInCollection(opts$)]); - } else { - this.input.clearAsyncValidators(); - } - } - } - writeValue(value: any): void { this.input.setValue(value); } @@ -171,7 +134,11 @@ export class AutoCompleteComponent implements OnInit, OnDestroy, OnChanges, Afte } setDisabledState(isDisabled: boolean = false): void { - this.disabled = isDisabled; + if (isDisabled) { + this.input.disable(); + } else { + this.input.enable(); + } } setElementReferences(changes): void { @@ -183,9 +150,18 @@ export class AutoCompleteComponent implements OnInit, OnDestroy, OnChanges, Afte }); } + handleViewMore($event: MouseEvent): void { + $event.preventDefault(); + $event.stopPropagation(); + this.handleInputBlur(); + this.input.markAsTouched(); + this.more.emit(); + } + handleComponentBlur(newState: any = {}): void { - let { selected, options, query } = this.state.currentState, - change = options && options[selected] ? options[selected] : null; + let { selected } = this.state.currentState, + query = this.input.value, + change = this.matches && this.matches[selected] ? this.matches[selected] : null; if (!change) { if (this.allowCustom) { change = query; @@ -198,14 +174,13 @@ export class AutoCompleteComponent implements OnInit, OnDestroy, OnChanges, Afte this.state.setState({ focused: null, selected: null, - menuOpen: newState.menuOpen || false, - query + menuOpen: newState.menuOpen || false }); } handleOptionBlur(event: FocusEvent, index): void { const elm = event.relatedTarget as HTMLElement; - const { focused, menuOpen, options, selected } = this.state.currentState; + const { focused, menuOpen, selected } = this.state.currentState; const focusingOutsideComponent = event.relatedTarget === null; const focusingInput = elm.id === this.elementReferences[-1].nativeElement.id; const focusingAnotherOption = focused !== index && focused !== -1; @@ -214,18 +189,19 @@ export class AutoCompleteComponent implements OnInit, OnDestroy, OnChanges, Afte const keepMenuOpen = menuOpen && this.isIosDevice(); this.handleComponentBlur({ menuOpen: keepMenuOpen, - query: options[selected] + query: this.matches[selected] }); } } - handleInputBlur(event: FocusEvent): void { - const { focused, menuOpen, options, query, selected } = this.state.currentState; + handleInputBlur(): void { + const { focused, menuOpen, selected } = this.state.currentState; + const query = this.input.value; const focusingAnOption = focused !== -1; const isIosDevice = this.isIosDevice(); - if (!focusingAnOption && options) { + if (!focusingAnOption && this.matches) { const keepMenuOpen = menuOpen && isIosDevice; - const newQuery = isIosDevice ? query : options[selected]; + const newQuery = isIosDevice ? query : this.matches[selected]; this.handleComponentBlur({ menuOpen: keepMenuOpen, query: newQuery @@ -241,15 +217,13 @@ export class AutoCompleteComponent implements OnInit, OnDestroy, OnChanges, Afte handleInputChange(query: string): void { query = query || ''; const queryEmpty = query.length === 0; - const queryChanged = this.state.currentState.query.length !== query.length; const queryLongEnough = query.length >= MIN_LENGTH; const autoselect = this.hasAutoselect; - const optionsAvailable = this.state.currentState.matches.length > 0; - const searchForOptions = (!queryEmpty && queryChanged && queryLongEnough); + const optionsAvailable = this.matches.length > 0; + const searchForOptions = (!queryEmpty && queryLongEnough); this.state.setState({ menuOpen: searchForOptions, - selected: searchForOptions ? ((autoselect && optionsAvailable) ? 0 : -1) : null, - query + selected: searchForOptions ? ((autoselect && optionsAvailable) ? 0 : -1) : null }); if (this.allowCustom) { this.propagateChange(query); @@ -268,8 +242,7 @@ export class AutoCompleteComponent implements OnInit, OnDestroy, OnChanges, Afte }); } handleOptionClick(index: number): void { - let { matches, options } = this.state.currentState; - const selectedOption = matches[index]; + const selectedOption = this.matches[index]; if (selectedOption) { this.propagateChange(selectedOption); } @@ -277,8 +250,7 @@ export class AutoCompleteComponent implements OnInit, OnDestroy, OnChanges, Afte this.state.setState({ focused: -1, selected: -1, - menuOpen: false, - query: selectedOption + menuOpen: false }); } @@ -313,7 +285,7 @@ export class AutoCompleteComponent implements OnInit, OnDestroy, OnChanges, Afte handleDownArrow(event: KeyboardEvent): void { event.preventDefault(); - const isNotAtBottom = this.state.currentState.selected !== this.state.currentState.matches.length - 1; + const isNotAtBottom = this.state.currentState.selected !== this.matches.length - 1; const allowMoveDown = isNotAtBottom && this.state.currentState.menuOpen; if (allowMoveDown) { this.handleOptionFocus(this.state.currentState.selected + 1); @@ -329,12 +301,13 @@ export class AutoCompleteComponent implements OnInit, OnDestroy, OnChanges, Afte } handleEnter(event: KeyboardEvent | { preventDefault: () => {} }): void { - let { options, selected, query, menuOpen } = this.state.currentState; + let { selected, menuOpen } = this.state.currentState, + query = this.input.value; if (menuOpen) { event.preventDefault(); let hasSelectedOption = selected >= 0; if (!hasSelectedOption) { - const queryIndex = options.indexOf(query); + const queryIndex = this.matches.indexOf(query); hasSelectedOption = queryIndex > -1; selected = hasSelectedOption ? queryIndex : selected; } @@ -372,9 +345,14 @@ export class AutoCompleteComponent implements OnInit, OnDestroy, OnChanges, Afte } get queryOption(): string | null { - const { query, options } = this.state.currentState; - const hasQueryAndOptions = query && options && options.length; - return hasQueryAndOptions ? options.indexOf(query) > -1 ? query : null : null; + const query = this.input.value; + if (!query) { + return null; + } + if (!this.matches) { + return null; + } + return this.matches.indexOf(query) > -1 ? query : null; } get hasAutoselect(): boolean { @@ -390,8 +368,7 @@ export class AutoCompleteComponent implements OnInit, OnDestroy, OnChanges, Afte get displayState(): any { return { - ...this.state.currentState, - options: [] + ...this.state.currentState }; } } /* istanbul ignore next */ diff --git a/ui/src/app/widget/autocomplete/autocomplete.model.spec.ts b/ui/src/app/widget/autocomplete/autocomplete.model.spec.ts new file mode 100644 index 000000000..5719d3d7e --- /dev/null +++ b/ui/src/app/widget/autocomplete/autocomplete.model.spec.ts @@ -0,0 +1,20 @@ +import { AutoCompleteStateEmitter, AutoCompleteState, defaultState } from './autocomplete.model'; + +describe('AutoCompleteStateEmitter', () => { + let emitter: AutoCompleteStateEmitter; + + describe('constructor', () => { + it('should init with a default state', () => { + emitter = new AutoCompleteStateEmitter(); + expect(emitter.currentState).toEqual(defaultState); + }); + + it('should init with a provided state', () => { + const st: AutoCompleteState = { + ...defaultState + }; + emitter = new AutoCompleteStateEmitter(st); + expect(emitter.currentState).toEqual(st); + }); + }); +}); diff --git a/ui/src/app/widget/autocomplete/autocomplete.model.ts b/ui/src/app/widget/autocomplete/autocomplete.model.ts index ac6389cc8..225986a55 100644 --- a/ui/src/app/widget/autocomplete/autocomplete.model.ts +++ b/ui/src/app/widget/autocomplete/autocomplete.model.ts @@ -1,14 +1,11 @@ import { ElementRef } from '@angular/core'; import { Subject } from 'rxjs/Subject'; -export const defaultState = { +export const defaultState: AutoCompleteState = { focused: null, selected: null, hovered: null, - menuOpen: false, - query: '', - options: [], - matches: [] + menuOpen: false }; export interface AutoCompleteState { @@ -16,9 +13,6 @@ export interface AutoCompleteState { selected: number | null; hovered: number | null; menuOpen: boolean; - query: string; - options: string[]; - matches: string[]; } export class AutoCompleteStateEmitter { @@ -29,7 +23,7 @@ export class AutoCompleteStateEmitter { changes$ = this.subj.asObservable(); constructor( - private defaults: AutoCompleteState = defaultState + public defaults: AutoCompleteState = defaultState ) { this.state = {...defaults}; } diff --git a/ui/src/data/ids.mock.ts b/ui/src/data/ids.mock.ts new file mode 100644 index 000000000..08b2ed4ab --- /dev/null +++ b/ui/src/data/ids.mock.ts @@ -0,0 +1,18 @@ +export const IDS = [ + 'https://account.example.com/sso', + 'https://www.example.com/', + 'https://www.example.com/somewhere', + 'https://www.example.org/shib/login', + 'https://sso.example.net/baz', + 'https://www.example.com/foo-bar', + 'https://www.example.com/sp/login', + 'https://example.com/activity', + 'https://www.example.com/', + 'https://example.com/sso', + 'https://www.example.com/sso', + 'https://example.com/', + 'https://bee.example.com/bone', + 'https://example.com/random', + 'https://www.example.com/', + 'https://sso.example.net/foo/bar' +]; diff --git a/ui/src/testing/modal.stub.ts b/ui/src/testing/modal.stub.ts index ecdc072bc..8c64cff35 100644 --- a/ui/src/testing/modal.stub.ts +++ b/ui/src/testing/modal.stub.ts @@ -10,3 +10,9 @@ export class NgbModalStub { }; } } + +@Injectable() +export class NgbActiveModalStub { + close = (result: any): void => {}; + dismiss = (reason: any): void => {}; +} diff --git a/ui/src/theme/_mixins.scss b/ui/src/theme/_mixins.scss new file mode 100644 index 000000000..f2e3f112b --- /dev/null +++ b/ui/src/theme/_mixins.scss @@ -0,0 +1,17 @@ +@mixin component-validation-state($state, $color) { + &.is-#{$state} { + input { + border-color: $color; + + &:focus { + border-color: $color; + box-shadow: 0 0 0 $input-focus-width rgba($color, .25); + } + + ~ .#{$state}-feedback, + ~ .#{$state}-tooltip { + display: block; + } + } + } +} \ No newline at end of file diff --git a/ui/src/theme/forms.scss b/ui/src/theme/forms.scss index df3ff813a..aa30c9136 100644 --- a/ui/src/theme/forms.scss +++ b/ui/src/theme/forms.scss @@ -1,5 +1,6 @@ @import './_palette'; @import './_variables'; +@import './_mixins'; .form-section { padding-left: $grid-gutter-width; @@ -44,6 +45,11 @@ } } +.component-control { + @include component-validation-state("valid", $form-feedback-valid-color); + @include component-validation-state("invalid", $form-feedback-invalid-color); +} + @media only screen and (max-width: 1200px) { .form-section:not(:first-child) { border-left: 0px;