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 @@
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
+
+