Merge pull request #212 from w33ble/feature/170

Typeahead History - FINALLY!
This commit is contained in:
Joe Fleming 2014-08-14 11:02:02 -07:00
commit c2d27c7bbf
23 changed files with 1018 additions and 73 deletions

View file

@ -31,4 +31,6 @@
- **test/unit/specs/apps/dashboard/directives/panel.js**
- This should not be needed, timefilter is only included here [L14](https://github.com/elasticsearch/kibana4/blob/master/test/unit/specs/apps/dashboard/directives/panel.js#L14)
- **test/unit/specs/directives/timepicker.js**
- This should not be needed, timefilter is only included here, it should move [L17](https://github.com/elasticsearch/kibana4/blob/master/test/unit/specs/directives/timepicker.js#L17)
- This should not be needed, timefilter is only included here, it should move [L17](https://github.com/elasticsearch/kibana4/blob/master/test/unit/specs/directives/timepicker.js#L17)
- **test/unit/specs/directives/typeahead.js**
- This should not be needed, timefilter is only included here, it should move [L12](https://github.com/elasticsearch/kibana4/blob/master/test/unit/specs/directives/typeahead.js#L12)

View file

@ -60,9 +60,11 @@
</div>
</nav>
<config
config-template="globalConfigTemplate" config-object="opts">
</config>
<div class="application" ng-view></div>
</div>
</body>

View file

@ -1,23 +1,32 @@
<div dashboard-app class="dashboard-container">
<div dashboard-app class="app-container dashboard-container">
<nav ng-show="!appEmbedded" class="navbar navbar-default navbar-static-top">
<navbar>
<span class="name">
{{dash.title}}
</span>
<form class="fill inline-form" ng-submit="filterResults()" name="queryInput">
<div class="input-group"
ng-class="queryInput.$invalid ? 'has-error' : ''">
<input query-input
placeholder="Filter..."
type="text"
class="form-control"
ng-model="state.query">
<button class="btn btn-default" type="submit"
ng-disabled="queryInput.$invalid">
<span class="fa fa-search"></span>
</button>
<form name="queryInput"
class="fill inline-form"
ng-submit="filterResults()">
<div class="typeahead" kbn-typeahead="dashboard">
<div class="input-group"
ng-class="queryInput.$invalid ? 'has-error' : ''">
<input type="text"
placeholder="Filter..."
class="form-control"
ng-model="state.query"
kbn-typeahead-input
query-input>
<button type="submit" class="btn btn-default" ng-disabled="queryInput.$invalid">
<span class="fa fa-search"></span>
</button>
</div>
<kbn-typeahead-items></kbn-typeahead-items>
</div>
</form>
<div class="button-group">
@ -29,6 +38,8 @@
</div>
</navbar>
</nav>
<config config-template="configTemplate" config-object="opts"></config>
<dashboard-grid></dashboard-grid>
</div>

View file

@ -7,6 +7,7 @@ define(function (require) {
require('components/courier/courier');
require('components/config/config');
require('components/notify/notify');
require('components/typeahead/typeahead');
require('apps/dashboard/directives/grid');
require('apps/dashboard/directives/panel');
@ -18,7 +19,8 @@ define(function (require) {
'ngRoute',
'kibana/courier',
'kibana/config',
'kibana/notify'
'kibana/notify',
'kibana/typeahead'
]);
require('routes')

View file

@ -1,17 +1,21 @@
<div ng-controller="discover">
<div ng-controller="discover" class="app-container">
<navbar>
<form class="fill inline-form" ng-submit="fetch()" name="discoverSearch">
<div class="input-group"
ng-class="discoverSearch.$invalid ? 'has-error' : ''">
<input query-input="searchSource"
ng-model="state.query"
placeholder="Search..."
type="text"
class="form-control">
<button type="submit"
ng-disabled="discoverSearch.$invalid">
<span class="fa fa-search"></span></button>
<button type="button" ng-click="resetQuery()"><span class="fa fa-ban"></span></button>
<div class="typeahead" kbn-typeahead="discover">
<div class="input-group"
ng-class="discoverSearch.$invalid ? 'has-error' : ''">
<input query-input="searchSource"
kbn-typeahead-input
ng-model="state.query"
placeholder="Search..."
type="text"
class="form-control">
<button type="submit"
ng-disabled="discoverSearch.$invalid">
<span class="fa fa-search"></span></button>
<button type="button" ng-click="resetQuery()"><span class="fa fa-ban"></span></button>
</div>
<kbn-typeahead-items></kbn-typeahead-items>
</div>
</form>
@ -21,6 +25,7 @@
<button ng-click="toggleConfig()"><i class="fa fa-gear"></i></button>
</div>
</navbar>
<config config-template="configTemplate" config-object="opts" config-close="configClose" config-submit="fetch"></config>
<div class="container-fluid">
@ -75,12 +80,14 @@
>
</select> -->
</center>
<div ng-show="searchSource.activeFetchCount" class="discover-overlay">
<div class="spinner large">
</div>
<div class="spinner large"> </div>
</div>
<visualize vis="vis" es-resp="mergedEsResp"></visualize>
</div>
<div class="discover-table"
fixed-scroll='table'
fixed-scroll-trigger="state.columns">
@ -95,6 +102,7 @@
timefield="opts.timefield"
mapping="fieldsByName">
</kbn-table>
<div ng-if="rows.length == opts.sampleSize" class="discover-table-footer">
<center>
These are the first {{opts.sampleSize}} results matching your query,

View file

@ -1,10 +1,13 @@
<nav class="navbar navbar-default navbar-static-top subnav">
<div class="container-fluid">
<ul class="nav navbar-nav">
<li ng-repeat="s in sections" ng-class="s.class">
<a class="navbar-link" ng-href="{{s.url}}">{{s.display}}</a>
</li>
</ul>
</div>
</nav>
<div class="settings-section-container {{section.name}}-settings" ng-transclude></div>
<div class="app-container">
<nav class="navbar navbar-default navbar-static-top subnav">
<div class="container-fluid">
<ul class="nav navbar-nav">
<li ng-repeat="s in sections" ng-class="s.class">
<a class="navbar-link" ng-href="{{s.url}}">{{s.display}}</a>
</li>
</ul>
</div>
</nav>
<div class="settings-section-container {{section.name}}-settings" ng-transclude></div>
</div>

View file

@ -1,13 +1,12 @@
<div ng-controller="VisualizeEditor" class="vis-editor">
<div ng-if="!appEmbedded">
<div ng-controller="VisualizeEditor" class="vis-editor">
<div ng-if="!appEmbedded" class="app-container">
<navbar>
<span ng-if="!!vis.title" class="name" ng-bind="vis.title"></span>
<div class="fill bitty-modal-container">
<div ng-if="linked && !unlinking"
ng-dblclick="unlink()"
tooltip="Double click to unlink this visualization from the saved search"
class="bitty-modal visualize-linked">
ng-dblclick="unlink()"
tooltip="Double click to unlink this visualization from the saved search"
class="bitty-modal visualize-linked">
<i class="fa fa-link"></i>
&nbsp;
This visualization is linked to a saved search:
@ -22,17 +21,22 @@
</div>
<form ng-submit="doVisualize()" class="inline-form" name="queryInput">
<div class="input-group"
ng-class="queryInput.$invalid ? 'has-error' : ''">
<input query-input="vis.searchSource"
<div class="typeahead" kbn-typeahead="visualize">
<div class="input-group"
ng-class="queryInput.$invalid ? 'has-error' : ''">
<input query-input="vis.searchSource"
kbn-typeahead-input
placeholder="Search..."
type="text"
class="form-control"
ng-model="state.query">
<button class="btn btn-default" type="submit"
<button class="btn btn-default" type="submit"
ng-disabled="queryInput.$invalid">
<span class="fa fa-search"></span>
</button>
<span class="fa fa-search"></span>
</button>
</div>
<kbn-typeahead-items></kbn-typeahead-items>
</div>
</form>
</div>
@ -45,10 +49,12 @@
<button ng-click="doVisualize()"><i class="fa fa-refresh"></i></button>
</div>
</navbar>
<config
config-template="configTemplate"
config-object="conf">
</config>
<div class="vis-editor-content">
<div class="vis-sidebar">
<div class="sidebar-container">
@ -56,24 +62,25 @@
<ul class="list-unstyled">
<li ng-repeat="category in visConfigCategories.displayOrder" class="sidebar-item">
<vis-config-category
vis="vis"
category="vis[category.name]"
fields="fields">
vis="vis"
category="vis[category.name]"
fields="fields">
</vis-config-category>
</li>
<li class="sidebar-item">
<button
ng-click="doVisualize()"
ng-if="vis.dirty"
ng-disabled="httpActive.length || visualizeEditor.$invalid"
class="sidebar-item-button success">
Apply
ng-click="doVisualize()"
ng-if="vis.dirty"
ng-disabled="httpActive.length || visualizeEditor.$invalid"
class="sidebar-item-button success">
Apply
</button>
</li>
</ul>
</form>
</div>
</div>
<div class="vis-canvas">
<vis-canvas><visualize vis="vis"></visualize></vis-canvas>
</div>
@ -87,4 +94,4 @@
</div>
</div>
</div>
</div>
</div>

View file

@ -14,6 +14,8 @@ define(function (require) {
'histogram:maxBars': 100,
'csv:separator': ',',
'csv:quoteValues': true
'csv:quoteValues': true,
'history:limit': 10
});
});

View file

@ -4,7 +4,7 @@ define(function (require) {
var inherits = require('lodash').inherits;
return function DocSourceFactory(Private, Promise, es) {
return function DocSourceFactory(Private, Promise, es, storage) {
var sendToEs = Private(require('components/courier/data_source/_doc_send_to_es'));
var SourceAbstract = Private(require('components/courier/data_source/_abstract'));
@ -18,6 +18,7 @@ define(function (require) {
this.onUpdate = this.onResults;
this.onResults = void 0;
}
inherits(DocSource, SourceAbstract);
/*****
@ -115,20 +116,20 @@ define(function (require) {
};
/**
* Fetches the stored version from localStorage
* Fetches the stored version from storage
* @return {[type]} [description]
*/
DocSource.prototype._getStoredVersion = function () {
var key = this._versionKey();
if (!key) return;
var v = localStorage.getItem(key);
var v = storage.get(key);
this._version = v ? _.parseInt(v) : void 0;
return this._version;
};
/**
* Stores the version into localStorage
* Stores the version into storage
* @param {number, NaN} version - the current version number, NaN works well forcing a refresh
* @return {undefined}
*/
@ -138,7 +139,7 @@ define(function (require) {
var key = this._versionKey();
if (!key) return;
this._version = version;
localStorage.setItem(key, version);
storage.set(key, version);
};
/**
@ -147,7 +148,7 @@ define(function (require) {
DocSource.prototype._clearVersion = function () {
var key = this._versionKey();
if (!key) return;
localStorage.removeItem(key);
storage.remove(key);
};
return DocSource;

View file

@ -0,0 +1,48 @@
define(function (require) {
var modules = require('modules');
var _ = require('lodash');
modules.get('kibana/persisted_log')
.factory('PersistedLog', function ($window, storage) {
function PersistedLog(name, options) {
options = options || {};
this.name = name;
this.maxLength = options.maxLength || 0;
this.filterDuplicates = options.filterDuplicates || false;
this.items = storage.get(this.name) || [];
}
PersistedLog.prototype.add = function (val) {
if (val == null) {
return this.items;
}
var stack = this.items;
// remove any matching items from the stack if option is set
if (this.filterDuplicates) {
stack = _.pull(this.items, val);
stack = _.filter(stack, function (item) {
return !_.isEqual(item, val);
});
}
stack.unshift(val);
// if maxLength is set, truncate the stack
if (this.maxLength > 0) {
stack = stack.slice(0, this.maxLength);
}
// persist the stack
storage.set(this.name, stack);
return this.items = stack;
};
PersistedLog.prototype.get = function () {
return this.items;
};
return PersistedLog;
});
});

View file

@ -0,0 +1,37 @@
define(function (require) {
var modules = require('modules');
modules.get('kibana/storage')
.service('storage', function ($window) {
function Storage() {
var self = this;
self.store = $window.localStorage;
self.get = function (key) {
try {
return JSON.parse(self.store.getItem(key));
} catch (e) {
return null;
}
};
self.set = function (key, value) {
try {
return self.store.setItem(key, JSON.stringify(value));
} catch (e) {
return false;
}
};
self.remove = function (key) {
return self.store.removeItem(key);
};
self.clear = function () {
return self.store.clear();
};
}
return new Storage();
});
});

View file

@ -0,0 +1,47 @@
define(function (require) {
var _ = require('lodash');
var typeahead = require('modules').get('kibana/typeahead');
require('components/notify/directives');
typeahead.directive('kbnTypeaheadInput', function ($rootScope) {
return {
restrict: 'A',
require: ['^ngModel', '^kbnTypeahead'],
link: function ($scope, $el, $attr, deps) {
var model = deps[0];
var typeaheadCtrl = deps[1];
typeaheadCtrl.setInputModel(model);
// handle keypresses
$el.on('keydown', function (ev) {
typeaheadCtrl.keypressHandler(ev);
digest();
});
// update focus state based on the input focus state
$el.on('focus', function () {
typeaheadCtrl.setFocused(true);
digest();
});
$el.on('blur', function () {
typeaheadCtrl.setFocused(false);
digest();
});
// unbind event listeners
$scope.$on('$destroy', function () {
$el.off();
});
function digest() {
$rootScope.$$phase || $scope.$digest();
}
}
};
});
});

View file

@ -0,0 +1,20 @@
define(function (require) {
var _ = require('lodash');
var typeahead = require('modules').get('kibana/typeahead');
var listTemplate = require('text!components/typeahead/partials/typeahead-items.html');
require('components/notify/directives');
typeahead.directive('kbnTypeaheadItems', function () {
return {
restrict: 'E',
require: '^kbnTypeahead',
replace: true,
template: listTemplate,
link: function ($scope, $el, attr, typeaheadCtrl) {
$scope.typeahead = typeaheadCtrl;
}
};
});
});

View file

@ -0,0 +1,17 @@
<div
ng-show="typeahead.isVisible()"
ng-mouseenter="typeahead.setMouseover(true);"
ng-mouseleave="typeahead.setMouseover(false);"
class="typeahead-items"
>
<div
ng-repeat="item in typeahead.getItems()"
ng-class="{active: item === typeahead.active}"
ng-click="typeahead.selectItem(item, $event);"
ng-mouseenter="typeahead.activateItem(item);"
class="typeahead-item"
>
{{item}}
</div>
</div>

View file

@ -0,0 +1,240 @@
define(function (require) {
var _ = require('lodash');
var typeahead = require('modules').get('kibana/typeahead');
require('components/typeahead/_input');
require('components/typeahead/_items');
typeahead.directive('kbnTypeahead', function () {
var keyMap = {
ESC: 27,
UP: 38,
DOWN: 40,
TAB: 9,
ENTER: 13
};
return {
restrict: 'A',
scope: {
historyKey: '@kbnTypeahead'
},
controllerAs: 'typeahead',
controller: function ($scope, $element, $timeout, PersistedLog, config) {
var self = this;
self.form = $element.closest('form');
self.query = '';
self.hidden = true;
self.focused = false;
self.mousedOver = false;
// instantiate history and add items to the scope
self.history = new PersistedLog('typeahead:' + $scope.historyKey, {
maxLength: config.get('history:limit'),
filterDuplicates: true
});
$scope.items = self.history.get();
$scope.filteredItems = [];
self.setInputModel = function (model) {
$scope.inputModel = model;
// watch for changes to the query parameter, delegate to typeaheadCtrl
$scope.$watch('inputModel.$viewValue', self.filterItemsByQuery);
};
self.setHidden = function (hidden) {
self.hidden = !!(hidden);
};
self.setFocused = function (focused) {
self.focused = !!(focused);
};
self.setMouseover = function (mousedOver) {
self.mousedOver = !!(mousedOver);
};
// activation methods
self.activateItem = function (item) {
self.active = item;
};
self.getActiveIndex = function () {
if (!self.active) {
return;
}
return $scope.filteredItems.indexOf(self.active);
};
self.getItems = function () {
return $scope.filteredItems;
};
self.activateNext = function () {
var index = self.getActiveIndex();
if (index == null) {
index = 0;
} else if (index < $scope.filteredItems.length - 1) {
++index;
}
self.activateItem($scope.filteredItems[index]);
};
self.activatePrev = function () {
var index = self.getActiveIndex();
if (index > 0 && index != null) {
--index;
} else if (index === 0) {
self.active = false;
return;
}
self.activateItem($scope.filteredItems[index]);
};
self.isActive = function (item) {
return item === self.active;
};
// selection methods
self.selectItem = function (item, ev) {
self.hidden = true;
self.active = false;
$scope.inputModel.$setViewValue(item);
$scope.inputModel.$render();
self.persistEntry();
if (ev && ev.type === 'click') {
$timeout(function () {
self.submitForm();
});
}
};
self.submitForm = function () {
if (self.form.length) {
self.form.submit();
}
};
self.persistEntry = function () {
if ($scope.inputModel.$viewValue.length) {
// push selection into the history
$scope.items = self.history.add($scope.inputModel.$viewValue);
}
};
self.selectActive = function () {
if (self.active) {
self.selectItem(self.active);
}
};
self.keypressHandler = function (ev) {
var keyCode = ev.which || ev.keyCode;
if (self.focused) {
self.hidden = false;
}
// hide on escape
if (_.contains([keyMap.ESC], keyCode)) {
self.hidden = true;
self.active = false;
}
// change selection with arrow up/down
// on down key, attempt to load all items if none are loaded
if (_.contains([keyMap.DOWN], keyCode) && $scope.filteredItems.length === 0) {
$scope.filteredItems = $scope.items;
$scope.$digest();
} else if (_.contains([keyMap.UP, keyMap.DOWN], keyCode)) {
if (self.isVisible() && $scope.filteredItems.length) {
ev.preventDefault();
if (keyCode === keyMap.DOWN) {
self.activateNext();
} else {
self.activatePrev();
}
}
}
// persist selection on enter, when not selecting from the list
if (_.contains([keyMap.ENTER], keyCode)) {
if (! self.active) {
self.persistEntry();
}
}
// select on enter or tab
if (_.contains([keyMap.ENTER, keyMap.TAB], keyCode)) {
self.selectActive();
self.hidden = true;
}
};
self.filterItemsByQuery = function (query) {
// cache query so we can call it again if needed
if (query) {
self.query = query;
}
// if the query is empty, clear the list items
if (!self.query.length) {
$scope.filteredItems = [];
return;
}
// update the filteredItems using the query
var beginningMatches = $scope.items.filter(function (item) {
return item.indexOf(query) === 0;
});
var otherMatches = $scope.items.filter(function (item) {
return item.indexOf(query) > 0;
});
$scope.filteredItems = beginningMatches.concat(otherMatches);
};
self.isVisible = function () {
return !self.hidden && ($scope.filteredItems.length > 0) && (self.focused || self.mousedOver);
};
// handle updates to parent scope history
$scope.$watch('items', function (items) {
if (self.query) {
self.filterItemsByQuery(self.query);
}
});
// watch for changes to the filtered item list
$scope.$watch('filteredItems', function (filteredItems) {
// if list is empty, or active item is missing, unset active item
if (!filteredItems.length || !_.contains(filteredItems, self.active)) {
self.active = false;
}
});
},
link: function ($scope, $el, attr) {
// should be defined via setInput() method
if (!$scope.inputModel) {
throw new Error('kbn-typeahead-input must be defined');
}
$scope.$watch('typeahead.isVisible()', function (vis) {
$el.toggleClass('visible', vis);
});
}
};
});
});

View file

@ -11,6 +11,8 @@ define(function (require) {
require('components/notify/notify');
require('components/state_management/app_state_factory');
require('components/filter_bar/filter_bar');
require('components/storage/storage');
require('components/persisted_log/persisted_log');
require('directives/info');
require('directives/spinner');
require('directives/paginate');
@ -49,7 +51,7 @@ define(function (require) {
config.init()
]).then(function () {
$scope.setupComplete = true;
$injector.invoke(function ($rootScope, courier, config, configFile, $timeout, $location, timefilter, globalState) {
$injector.invoke(function ($rootScope, courier, config, configFile, storage, $timeout, $location, timefilter, globalState) {
$rootScope.globalState = globalState;
@ -86,16 +88,16 @@ define(function (require) {
var lastPathFor = function (app, path) {
var key = 'lastPath:' + app.id;
if (path === void 0) {
app.lastPath = localStorage.getItem(key) || '/' + app.id;
app.lastPath = storage.get(key) || '/' + app.id;
return app.lastPath;
} else {
app.lastPath = path;
return localStorage.setItem(key, path);
return storage.set(key, path);
}
};
$scope.apps = configFile.apps;
// initialize each apps lastPath (fetch it from localStorage)
// initialize each apps lastPath (fetch it from storage)
$scope.apps.forEach(function (app) { lastPathFor(app); });
function onRouteChange() {

View file

@ -0,0 +1,35 @@
.typeahead {
position: relative;
.typeahead-items {
border: 1px solid @popover-border-color;
color: @text-color;
background-color: @body-bg;
position: absolute;
z-index: @zindex-typeahead;
width: 100%;
.typeahead-item {
border-bottom: 1px solid lighten(@popover-border-color, 60%);
padding: @padding-base-vertical @padding-base-horizontal;
}
.typeahead-item:last-child {
border-bottom: 0px;
}
.typeahead-item.active {
background-color: @well-bg;
}
}
}
.button-group .typeahead.visible .input-group,
.inline-form .typeahead.visible .input-group {
> :first-child {
.border-bottom-radius(0);
}
> :last-child {
.border-bottom-radius(0);
}
}

View file

@ -84,11 +84,12 @@ ul.navbar-inline li {
position: relative;
z-index: 0;
.navbar {
> .navbar {
position: relative;
z-index: 1;
}
.application {
> .application {
position: relative;
z-index: 0;
}
@ -142,8 +143,25 @@ notifications {
}
}
.app-container {
> * {
position: relative;
z-index: 0;
}
> config {
z-index: 1;
}
> nav,
> navbar {
z-index: 2 !important;
}
}
@import "./_table.less";
@import "./_notify.less";
@import "./_typeahead.less";
//== Nav tweaks
.nav-condensed > li > a {

View file

@ -248,6 +248,7 @@
@zindex-navbar: 1000;
@zindex-dropdown: 1000;
@zindex-popover: 1010;
@zindex-typeahead: 1020;
@zindex-tooltip: 1030;
@zindex-navbar-fixed: 1030;
@zindex-modal-background: 1040;

View file

@ -63,12 +63,15 @@
'specs/apps/discover/hit_sort_fn',
'specs/directives/timepicker',
'specs/directives/truncate',
'specs/directives/typeahead',
'specs/directives/css_truncate',
'specs/directives/spinner',
'specs/filters/field_type',
'specs/filters/uriescape',
'specs/filters/moment',
'specs/filters/start_from',
'specs/services/storage',
'specs/services/persisted_log',
'specs/utils/datemath',
'specs/utils/interval',
'specs/utils/versionmath',

View file

@ -0,0 +1,218 @@
define(function (require) {
var angular = require('angular');
var sinon = require('sinon/sinon');
// Load the kibana app dependencies.
require('angular-route');
// Load kibana and its applications
require('index');
require('components/typeahead/typeahead');
// TODO: This should not be needed, timefilter is only included here, it should move
require('apps/discover/index');
var typeaheadHistoryCount = 10;
var typeaheadName = 'unittest';
var $parentScope;
var $typeaheadScope, $elem;
var $typeaheadInputScope;
var typeaheadCtrl, PersistedLog;
var markup = '<div class="typeahead" kbn-typeahead="' + typeaheadName + '">' +
'<input type="text" placeholder="Filter..." class="form-control" ng-model="query" kbn-typeahead-input>' +
'<kbn-typeahead-items></kbn-typeahead-items>' +
'</div>';
var typeaheadItems = ['abc', 'def', 'ghi'];
var init = function () {
// Load the application
module('kibana');
module('kibana/typeahead', function ($provide) {
$provide.factory('PersistedLog', function () {
function PersistedLogMock(name, options) {
this.name = name;
this.options = options;
}
PersistedLogMock.prototype.add = sinon.stub();
PersistedLogMock.prototype.get = sinon.stub().returns(typeaheadItems);
return PersistedLogMock;
});
$provide.service('config', function () {
this.get = sinon.stub().returns(typeaheadHistoryCount);
});
});
// Create the scope
inject(function ($injector, $controller, $rootScope, $compile) {
// Give us a scope
$parentScope = $rootScope;
$elem = angular.element(markup);
PersistedLog = $injector.get('PersistedLog');
$compile($elem)($parentScope);
$elem.scope().$digest();
$typeaheadScope = $elem.isolateScope();
typeaheadCtrl = $elem.controller('kbnTypeahead');
});
};
describe('typeahead directive', function () {
describe('typeahead requirements', function () {
describe('missing input', function () {
var goodMarkup = markup;
before(function () {
markup = '<div class="typeahead" kbn-typeahead="' + typeaheadName + '">' +
'<kbn-typeahead-items></kbn-typeahead-items>' +
'</div>';
});
after(function () {
markup = goodMarkup;
});
it('should throw with message', function () {
expect(init).to.throwException(/kbn-typeahead-input must be defined/);
});
});
});
describe('internal functionality', function () {
beforeEach(function () {
init();
});
describe('PersistedLog', function () {
it('should instantiate PersistedLog', function () {
expect(typeaheadCtrl.history.name).to.equal('typeahead:' + typeaheadName);
expect(typeaheadCtrl.history.options.maxLength).to.equal(typeaheadHistoryCount);
expect(typeaheadCtrl.history.options.filterDuplicates).to.equal(true);
});
it('should read data when directive is instantiated', function () {
expect(typeaheadCtrl.history.get.callCount).to.be(1);
});
it('should not save empty entries', function () {
var entries = typeaheadItems.slice(0);
entries.push('', 'jkl');
for (var i = 0; i < entries.length; i++) {
$typeaheadScope.inputModel.$setViewValue(entries[i]);
typeaheadCtrl.persistEntry();
}
expect(typeaheadCtrl.history.add.callCount).to.be(4);
});
});
describe('controller scope', function () {
it('should contain the input model', function () {
expect($typeaheadScope.inputModel).to.be.an('object');
expect($typeaheadScope.inputModel).to.have.keys(['$viewValue', '$modelValue', '$setViewValue']);
});
it('should save data to the scope', function () {
// $scope.items is set via history.add, so mock the output
typeaheadCtrl.history.add.returns(typeaheadItems);
// a single call will call history.add, which will respond with the mocked data
$typeaheadScope.inputModel.$setViewValue(typeaheadItems[0]);
typeaheadCtrl.persistEntry();
expect($typeaheadScope.items).to.be.an('array');
expect($typeaheadScope.items.length).to.be(typeaheadItems.length);
});
it('should order fitlered results', function () {
var entries = ['ac/dc', 'anthrax', 'abba', 'phantogram', 'skrillex'];
var allEntries = typeaheadItems.concat(entries);
var startMatches = allEntries.filter(function (item) {
return /^a/.test(item);
});
typeaheadCtrl.history.add.returns(allEntries);
for (var i = 0; i < entries.length; i++) {
$typeaheadScope.inputModel.$setViewValue(entries[i]);
typeaheadCtrl.persistEntry();
}
typeaheadCtrl.filterItemsByQuery('a');
expect($typeaheadScope.filteredItems).to.contain('phantogram');
var nonStarterIndex = $typeaheadScope.filteredItems.indexOf('phantogram');
startMatches.forEach(function (item) {
expect($typeaheadScope.filteredItems).to.contain(item);
expect($typeaheadScope.filteredItems.indexOf(item)).to.be.below(nonStarterIndex);
});
expect($typeaheadScope.filteredItems).not.to.contain('skrillex');
});
});
describe('list appearance', function () {
beforeEach(function () {
typeaheadCtrl.history.add.returns(typeaheadItems);
$typeaheadScope.inputModel.$setViewValue(typeaheadItems[0]);
typeaheadCtrl.persistEntry();
// make sure the data looks how we expect
expect($typeaheadScope.items.length).to.be(3);
});
it('should default to hidden', function () {
expect(typeaheadCtrl.isVisible()).to.be(false);
});
it('should appear when not hidden, has matches input and focused', function () {
typeaheadCtrl.setHidden(false);
expect(typeaheadCtrl.isVisible()).to.be(false);
typeaheadCtrl.filterItemsByQuery(typeaheadItems[0]);
expect(typeaheadCtrl.isVisible()).to.be(false);
// only visible when all conditions match
typeaheadCtrl.setFocused(true);
expect(typeaheadCtrl.isVisible()).to.be(true);
typeaheadCtrl.setFocused(false);
expect(typeaheadCtrl.isVisible()).to.be(false);
});
it('should appear when not hidden, has matches input and moused over', function () {
typeaheadCtrl.setHidden(false);
expect(typeaheadCtrl.isVisible()).to.be(false);
typeaheadCtrl.filterItemsByQuery(typeaheadItems[0]);
expect(typeaheadCtrl.isVisible()).to.be(false);
// only visible when all conditions match
typeaheadCtrl.setMouseover(true);
expect(typeaheadCtrl.isVisible()).to.be(true);
typeaheadCtrl.setMouseover(false);
expect(typeaheadCtrl.isVisible()).to.be(false);
});
it('should hide when no matches', function () {
typeaheadCtrl.setHidden(false);
typeaheadCtrl.setFocused(true);
typeaheadCtrl.filterItemsByQuery(typeaheadItems[0]);
expect(typeaheadCtrl.isVisible()).to.be(true);
typeaheadCtrl.filterItemsByQuery('a8h4o8ah48thal4i7rlia4ujru4glia47gf');
expect(typeaheadCtrl.isVisible()).to.be(false);
});
});
});
});
});

View file

@ -0,0 +1,115 @@
define(function (require) {
var sinon = require('sinon/sinon');
var storage;
var config;
var PersistedLog;
var historyName = 'testHistory';
var historyLimit = 10;
var payload = [
{ first: 'clark', last: 'kent' },
{ first: 'peter', last: 'parker' },
{ first: 'bruce', last: 'wayne' }
];
require('components/persisted_log/persisted_log');
function init() {
module('kibana/persisted_log', function ($provide) {
// mock storage service
$provide.service('storage', function () {
this.get = sinon.stub();
this.set = sinon.stub();
this.remove = sinon.spy();
this.clear = sinon.spy();
});
});
inject(function ($injector) {
storage = $injector.get('storage');
PersistedLog = $injector.get('PersistedLog');
});
}
describe('PersistedLog', function () {
beforeEach(function () {
init();
});
describe('expected API', function () {
it('has expected methods', function () {
var log = new PersistedLog(historyName);
expect(log.add).to.be.a('function');
expect(log.get).to.be.a('function');
});
});
describe('internal functionality', function () {
it('reads from storage', function () {
var log = new PersistedLog(historyName);
expect(storage.get.calledOnce).to.be(true);
expect(storage.get.calledWith(historyName)).to.be(true);
});
it('writes to storage', function () {
var log = new PersistedLog(historyName);
var newItem = { first: 'diana', last: 'prince' };
var data = log.add(newItem);
expect(storage.set.calledOnce).to.be(true);
expect(data).to.eql([newItem]);
});
});
describe('persisting data', function () {
it('fetches records from storage', function () {
storage.get.returns(payload);
var log = new PersistedLog(historyName);
var items = log.get();
expect(items.length).to.equal(3);
expect(items).to.eql(payload);
});
it('prepends new records', function () {
storage.get.returns(payload.slice(0));
var log = new PersistedLog(historyName);
var newItem = { first: 'selina', last: 'kyle' };
var items = log.add(newItem);
expect(items.length).to.equal(payload.length + 1);
expect(items[0]).to.eql(newItem);
});
});
describe('stack options', function () {
it('should observe the maxLength option', function () {
var bulkData = [];
for (var i = 0; i < historyLimit; i++) {
bulkData.push(['record ' + i]);
}
storage.get.returns(bulkData);
var log = new PersistedLog(historyName, { maxLength: historyLimit });
log.add(['new array 1']);
var items = log.add(['new array 2']);
expect(items.length).to.equal(historyLimit);
});
it('should observe the filterDuplicates option', function () {
storage.get.returns(payload.slice(0));
var log = new PersistedLog(historyName, { filterDuplicates: true });
var newItem = payload[1];
var items = log.add(newItem);
expect(items.length).to.equal(payload.length);
});
});
});
});

View file

@ -0,0 +1,106 @@
define(function (require) {
var sinon = require('sinon/sinon');
var storage;
var $window;
var payload = { first: 'john', last: 'smith' };
require('components/storage/storage');
function init() {
module('kibana/storage', function ($provide) {
// mock $window.localStorage for storage
$provide.value('$window', {
localStorage: {
getItem: sinon.stub(),
setItem: sinon.spy(),
removeItem: sinon.spy(),
clear: sinon.spy()
}
});
});
inject(function ($injector) {
storage = $injector.get('storage');
$window = $injector.get('$window');
});
}
describe('StorageService', function () {
beforeEach(function () {
init();
});
describe('expected API', function () {
it('should have expected methods', function () {
expect(storage.get).to.be.a('function');
expect(storage.set).to.be.a('function');
expect(storage.remove).to.be.a('function');
expect(storage.clear).to.be.a('function');
});
});
describe('call behavior', function () {
it('should call getItem on the store', function () {
storage.get('name');
expect($window.localStorage.getItem.callCount).to.equal(1);
});
it('should call setItem on the store', function () {
storage.set('name', 'john smith');
expect($window.localStorage.setItem.callCount).to.equal(1);
});
it('should call removeItem on the store', function () {
storage.remove('name');
expect($window.localStorage.removeItem.callCount).to.equal(1);
});
it('should call clear on the store', function () {
storage.clear();
expect($window.localStorage.clear.callCount).to.equal(1);
});
});
describe('json data', function () {
it('should parse JSON when reading from the store', function () {
var getItem = $window.localStorage.getItem;
getItem.returns(JSON.stringify(payload));
var data = storage.get('name');
expect(data).to.eql(payload);
});
it('should write JSON string to the store', function () {
var setItem = $window.localStorage.setItem;
var key = 'name';
var value = payload;
storage.set(key, value);
var call = setItem.getCall(0);
expect(call.args[0]).to.equal(key);
expect(call.args[1]).to.equal(JSON.stringify(value));
});
});
describe('expected responses', function () {
it('should return null when not exists', function () {
var data = storage.get('notexists');
expect(data).to.equal(null);
});
it('should return null when invalid JSON', function () {
var getItem = $window.localStorage.getItem;
getItem.returns('not: json');
var data = storage.get('name');
expect(data).to.equal(null);
});
});
});
});