diff --git a/ui/src/app/app.component.spec.ts b/ui/src/app/app.component.spec.ts index 890189a4b..4b1943f20 100644 --- a/ui/src/app/app.component.spec.ts +++ b/ui/src/app/app.component.spec.ts @@ -6,11 +6,13 @@ import { AppComponent } from './app.component'; import { User } from './core/model/user'; import * as fromUser from './core/reducer/user.reducer'; import { NotificationModule } from './notification/notification.module'; +import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; describe('AppComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ + NgbDropdownModule.forRoot(), RouterTestingModule, StoreModule.forRoot({ user: combineReducers(fromUser.reducer), diff --git a/ui/src/app/metadata-filter/action/collection.action.ts b/ui/src/app/metadata-filter/action/collection.action.ts new file mode 100644 index 000000000..a7309c640 --- /dev/null +++ b/ui/src/app/metadata-filter/action/collection.action.ts @@ -0,0 +1,20 @@ +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.ts b/ui/src/app/metadata-filter/action/search.action.ts similarity index 83% rename from ui/src/app/metadata-filter/action/filter.action.ts rename to ui/src/app/metadata-filter/action/search.action.ts index 65046c903..8d196524f 100644 --- a/ui/src/app/metadata-filter/action/filter.action.ts +++ b/ui/src/app/metadata-filter/action/search.action.ts @@ -7,6 +7,3 @@ export class QueryEntityIds implements Action { constructor(public payload: string[]) { } } - -export type Actions = - | QueryEntityIds; 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 a96424fdb..47850c29d 100644 --- a/ui/src/app/metadata-filter/container/new-filter.component.html +++ b/ui/src/app/metadata-filter/container/new-filter.component.html @@ -11,7 +11,73 @@
- Body +
+
+
+
+
+ + + + + Filter Name is required + + +
+
+ + + + + + Entity ID is required + + + + Entity ID must be unique + +
+
+
+
+ + +
+
+
+
+
+

TESTING:

+ 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 642d26b8b..ca5c444ba 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 @@ -1,8 +1,10 @@ import { TestBed, ComponentFixture } from '@angular/core/testing'; -import { ReactiveFormsModule } from '@angular/forms'; +import { ReactiveFormsModule, FormBuilder } from '@angular/forms'; import { StoreModule, Store, combineReducers } from '@ngrx/store'; import { NewFilterComponent } from './new-filter.component'; import * as fromFilter from '../reducer'; +import { ProviderEditorFormModule } from '../../metadata-provider/component'; +import { ProviderStatusEmitter, ProviderValueEmitter } from '../../metadata-provider/service/provider-change-emitter.service'; describe('New Metadata Filter Page', () => { let fixture: ComponentFixture; @@ -11,12 +13,17 @@ describe('New Metadata Filter Page', () => { beforeEach(() => { TestBed.configureTestingModule({ - providers: [], + providers: [ + ProviderStatusEmitter, + ProviderValueEmitter, + FormBuilder + ], imports: [ StoreModule.forRoot({ providers: combineReducers(fromFilter.reducers), }), - ReactiveFormsModule + ReactiveFormsModule, + ProviderEditorFormModule ], declarations: [NewFilterComponent], }); @@ -33,4 +40,12 @@ describe('New Metadata Filter Page', () => { expect(fixture).toBeDefined(); }); + + describe('cancel method', () => { + it('should dispatch a cancel changes action', () => { + fixture.detectChanges(); + instance.cancel(); + 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 65be0f260..560a39071 100644 --- a/ui/src/app/metadata-filter/container/new-filter.component.ts +++ b/ui/src/app/metadata-filter/container/new-filter.component.ts @@ -1,18 +1,54 @@ -import { Component } from '@angular/core'; +import { Component, OnInit, OnChanges, OnDestroy } from '@angular/core'; +import { FormBuilder, Validators } from '@angular/forms'; import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs/Observable'; +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'; @Component({ selector: 'new-filter-page', templateUrl: './new-filter.component.html' }) -export class NewFilterComponent { +export class NewFilterComponent extends ProviderFormFragmentComponent implements OnInit, OnChanges, OnDestroy { + + entityIds$: Observable; + constructor( - private store: Store - ) {} + private store: Store, + protected statusEmitter: ProviderStatusEmitter, + protected valueEmitter: ProviderValueEmitter, + protected fb: FormBuilder + ) { + super(fb, statusEmitter, valueEmitter); + } + + createForm(): void { + this.form = this.fb.group({ + entityId: ['', Validators.required], + filterName: ['', Validators.required] + }); + } + + ngOnInit(): void { + super.ngOnInit(); + this.entityIds$ = Observable.of(['foo', 'bar', 'baz']); + } + + ngOnChanges(): void {} + + ngOnDestroy(): void {} + + onViewMore($event): void {} - save(): void {} + save(): void { + console.log('Save!'); + } - cancel(): void {} + cancel(): void { + this.store.dispatch(new CancelCreateFilter(true)); + } } diff --git a/ui/src/app/metadata-filter/effect/filter.effect.ts b/ui/src/app/metadata-filter/effect/filter.effect.ts new file mode 100644 index 000000000..d2df34d74 --- /dev/null +++ b/ui/src/app/metadata-filter/effect/filter.effect.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { Effect, Actions } from '@ngrx/effects'; + +import * as filter from '../action/collection.action'; +import { Router } from '@angular/router'; + +@Injectable() +export class FilterEffects { + + @Effect({ dispatch: false }) + cancelChanges$ = this.actions$ + .ofType(filter.CANCEL_CREATE_FILTER) + .map(action => action.payload) + .switchMap(() => this.router.navigate(['/dashboard'])); + + constructor( + private actions$: Actions, + private router: Router + ) { } +} diff --git a/ui/src/app/metadata-filter/filter.module.ts b/ui/src/app/metadata-filter/filter.module.ts index 3e5da5f96..ae3d8edab 100644 --- a/ui/src/app/metadata-filter/filter.module.ts +++ b/ui/src/app/metadata-filter/filter.module.ts @@ -7,6 +7,9 @@ import { EffectsModule } from '@ngrx/effects'; import { NewFilterComponent } from './container/new-filter.component'; 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'; export const routes: Routes = [ { @@ -26,8 +29,9 @@ export const routes: Routes = [ RouterModule, ReactiveFormsModule, StoreModule.forFeature('metadata-filter', reducers), - EffectsModule.forFeature([]), - RouterModule.forChild(routes) + EffectsModule.forFeature([FilterEffects]), + RouterModule.forChild(routes), + ProviderEditorFormModule ], providers: [] }) diff --git a/ui/src/app/metadata-filter/reducer/filter.reducer.ts b/ui/src/app/metadata-filter/reducer/filter.reducer.ts index 5160096b0..c84c3faa6 100644 --- a/ui/src/app/metadata-filter/reducer/filter.reducer.ts +++ b/ui/src/app/metadata-filter/reducer/filter.reducer.ts @@ -1,14 +1,18 @@ import { createSelector, createFeatureSelector } from '@ngrx/store'; import { MetadataProvider } from '../../metadata-provider/model/metadata-provider'; -import * as filter from '../action/filter.action'; +import * as filter from '../action/collection.action'; import * as fromRoot from '../../core/reducer'; export interface FilterState { entityIds: string[]; + loading: boolean; + error: string | null; } export const initialState: FilterState = { - entityIds: [] + entityIds: [], + loading: false, + error: null }; export function reducer(state = initialState, action: filter.Actions): FilterState { diff --git a/ui/src/app/widget/autocomplete/autocomplete-validators.service.spec.ts b/ui/src/app/widget/autocomplete/autocomplete-validators.service.spec.ts new file mode 100644 index 000000000..a50373d9a --- /dev/null +++ b/ui/src/app/widget/autocomplete/autocomplete-validators.service.spec.ts @@ -0,0 +1,42 @@ +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 new file mode 100644 index 000000000..399c5fcdb --- /dev/null +++ b/ui/src/app/widget/autocomplete/autocomplete-validators.service.ts @@ -0,0 +1,17 @@ +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.spec.ts b/ui/src/app/widget/autocomplete/autocomplete.component.spec.ts index 72c39d088..3b9a44a70 100644 --- a/ui/src/app/widget/autocomplete/autocomplete.component.spec.ts +++ b/ui/src/app/widget/autocomplete/autocomplete.component.spec.ts @@ -1,4 +1,4 @@ -import { Component, ViewChild } from '@angular/core'; +import { Component, ViewChild, SimpleChange, ElementRef } from '@angular/core'; import { TestBed, ComponentFixture } from '@angular/core/testing'; import { ReactiveFormsModule } from '@angular/forms'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -97,32 +97,42 @@ describe('AutoComplete Input Component', () => { }); }); - describe ('onChanges lifecycle event', () => { + 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.ngOnChanges({}); + instanceUnderTest.setValidation(mockChanges); expect(instanceUnderTest.input.disable).toHaveBeenCalled(); }); it('should enable', () => { spyOn(instanceUnderTest.input, 'enable'); instanceUnderTest.setDisabledState(false); - instanceUnderTest.ngOnChanges({}); + instanceUnderTest.setValidation(mockChanges); expect(instanceUnderTest.input.enable).toHaveBeenCalled(); }); it('should set required', () => { spyOn(instanceUnderTest.input, 'setValidators'); instanceUnderTest.required = true; - instanceUnderTest.ngOnChanges({}); + instanceUnderTest.setValidation(mockChanges); expect(instanceUnderTest.input.setValidators).toHaveBeenCalled(); }); it('should remove required', () => { spyOn(instanceUnderTest.input, 'clearValidators'); instanceUnderTest.required = false; - instanceUnderTest.ngOnChanges({}); + instanceUnderTest.setValidation(mockChanges); expect(instanceUnderTest.input.clearValidators).toHaveBeenCalled(); }); @@ -133,6 +143,13 @@ describe('AutoComplete Input Component', () => { 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(); + }); }); describe('handleKeyDown method', () => { diff --git a/ui/src/app/widget/autocomplete/autocomplete.component.ts b/ui/src/app/widget/autocomplete/autocomplete.component.ts index 2e3256a8a..68d14c8a3 100644 --- a/ui/src/app/widget/autocomplete/autocomplete.component.ts +++ b/ui/src/app/widget/autocomplete/autocomplete.component.ts @@ -24,6 +24,7 @@ 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'; const POLL_TIMEOUT = 1000; const MIN_LENGTH = 2; @@ -50,6 +51,8 @@ export class AutoCompleteComponent implements OnInit, OnDestroy, OnChanges, Afte @Input() allowCustom = false; @Input() noneFoundText = 'No Options Found'; + @Output() more: EventEmitter = new EventEmitter(); + @ViewChild('inputField') inputField: ElementRef; @ViewChildren('matchElement', { read: ElementRef }) listItems: QueryList; @@ -96,9 +99,11 @@ export class AutoCompleteComponent implements OnInit, OnDestroy, OnChanges, Afte .valueChanges .subscribe((query) => { let matches = []; - if (query && query.length >= MIN_LENGTH) { + 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 = []; } this.state.setState({ matches }); }); @@ -138,16 +143,33 @@ export class AutoCompleteComponent implements OnInit, OnDestroy, OnChanges, Afte } } - if (this.required) { - this.input.setValidators([Validators.required]); - } else { - this.input.clearValidators(); + this.setValidation(changes); + } + + setValidation(changes: SimpleChanges): void { + if (changes.required) { + if (this.required) { + this.input.setValidators([Validators.required]); + } else { + this.input.clearValidators(); + } } - if (this.disabled) { - this.input.disable(); - } else { - this.input.enable(); + 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(); + } } } @@ -177,20 +199,19 @@ export class AutoCompleteComponent implements OnInit, OnDestroy, OnChanges, Afte } handleComponentBlur(newState: any = {}): void { - let { selected, options } = this.state.currentState, - query = newState.query || this.state.currentState.query; - if (options[selected]) { - this.propagateChange(options[selected]); - } else if (this.allowCustom) { - this.propagateChange(query); + let { selected, options, query } = this.state.currentState, + change = options && options[selected] ? options[selected] : null; + if (!change && (this.allowCustom || this.queryOption)) { + change = this.queryOption; } + this.propagateChange(change); + this.propagateTouched(null); this.state.setState({ focused: null, selected: null, menuOpen: newState.menuOpen || false, - query: query + query }); - this.propagateTouched(null); } handleOptionBlur(event: FocusEvent, index): void { @@ -213,13 +234,18 @@ export class AutoCompleteComponent implements OnInit, OnDestroy, OnChanges, Afte const { focused, menuOpen, options, query, selected } = this.state.currentState; const focusingAnOption = focused !== -1; const isIosDevice = this.isIosDevice(); - if (!focusingAnOption) { + if (!focusingAnOption && options) { const keepMenuOpen = menuOpen && isIosDevice; const newQuery = isIosDevice ? query : options[selected]; this.handleComponentBlur({ menuOpen: keepMenuOpen, query: newQuery }); + } else { + this.handleComponentBlur({ + menuOpen: false, + query + }); } } @@ -313,11 +339,17 @@ export class AutoCompleteComponent implements OnInit, OnDestroy, OnChanges, Afte } handleEnter(event: KeyboardEvent): void { - if (this.state.currentState.menuOpen) { + let { options, selected, query, menuOpen } = this.state.currentState; + if (menuOpen) { event.preventDefault(); - const hasSelectedOption = this.state.currentState.selected >= 0; + let hasSelectedOption = selected >= 0; + if (!hasSelectedOption) { + const queryIndex = options.indexOf(query); + hasSelectedOption = queryIndex > -1; + selected = hasSelectedOption ? queryIndex : selected; + } if (hasSelectedOption) { - this.handleOptionClick(event, this.state.currentState.selected); + this.handleOptionClick(event, selected); } else { this.handleComponentBlur({ focused: -1, @@ -348,6 +380,12 @@ export class AutoCompleteComponent implements OnInit, OnDestroy, OnChanges, Afte return !!(navigator.userAgent.match(/(iPod|iPhone|iPad)/g) && navigator.userAgent.match(/AppleWebKit/g)); } + 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; + } + get hasAutoselect(): boolean { return this.isIosDevice() ? false : this.autoSelect; } diff --git a/ui/src/locale/en.xlf b/ui/src/locale/en.xlf index bdb4d759d..f6d05993a 100644 --- a/ui/src/locale/en.xlf +++ b/ui/src/locale/en.xlf @@ -2003,6 +2003,30 @@ 60 + + Filter Name + Filter Name + + app/metadata-filter/container/new-filter.component.ts + 30 + + + + (Dashboard Display Only) + (Dashboard Display Only) + + app/metadata-filter/container/new-filter.component.ts + 32 + + + + Search Entity ID + Search Entity ID + + app/metadata-filter/container/new-filter.component.ts + 46 + +