Merge branch 'master' into state_state_state

This commit is contained in:
Spencer Alger 2014-10-02 13:14:35 -07:00
commit 6f1e23fe74
23 changed files with 630 additions and 299 deletions

13
K3_FAQ.md Normal file
View file

@ -0,0 +1,13 @@
**Kibana 3 Migration FAQ:**
**Q:** Where is feature X that I loved from Kibana 3?
**A:** It might be coming! Weve published our immediate roadmap as tickets Check out the beta milestones on Github to see if the feature youre missing is coming soon.
**Q:** Is the dashboard schema compatible?
**A:** Unfortunately they are not compatible, in order to achieve the features we wanted it simply was not possible to keep the same schema. Aggregations work fundamentally different from facets, the new dashboard isnt tied to rows and columns and the relationships between searches, visualizations and the dashboard are complex enough that we simply had to design something more flexible.
**Q:** How do I do multi-query?
**A:** The filters aggregations will allow you to input multiple queries and compare them visually. You can even use Elasticsearch JSON in there!
**Q:** What happened to templated/scripted dashboards?
**A:** Check out the URL, the state of each app is stored there, including any filters, queries or columns. This should be a lot easier than constructing scripted dashboards. The encoding of the URL is RISON.

View file

@ -43,6 +43,7 @@ define(function (require) {
stop: readGridsterChangeHandler
},
draggable: {
handle: '.panel-heading, .panel-title',
stop: readGridsterChangeHandler
}
}).data('gridster');

View file

@ -326,9 +326,11 @@ define(function (require) {
}
$scope.rows.forEach(function (hit) {
// when we are resorting on each segment we need to rebuild the
// counts each time
if (sortFn && hit._formatted) return;
// skip this work if we have already done it and we are NOT sorting.
// ---
// when we are sorting results, we need to redo the counts each time because the
// "top 500" may change with each response
if (hit._formatted && !sortFn) return;
// Flatten the fields
var indexPattern = $scope.searchSource.get('index');
@ -633,6 +635,7 @@ define(function (require) {
type: 'histogram',
vislibParams: {
addLegend: false,
addEvents: true,
addBrushing: true,
},
listeners: {

View file

@ -1,71 +1,13 @@
define(function (require) {
var angular = require('angular');
var html = require('text!apps/discover/partials/table.html');
var detailsHtml = require('text!apps/discover/partials/row_details.html');
var moment = require('moment');
var _ = require('lodash');
var $ = require('jquery');
require('directives/truncated');
require('directives/infinite_scroll');
require('apps/discover/directives/table_header');
require('apps/discover/directives/table_row');
var module = require('modules').get('app/discover');
module.directive('kbnTableHeader', function () {
var headerHtml = require('text!apps/discover/partials/table_header.html');
return {
restrict: 'A',
scope: {
columns: '=',
sorting: '=',
mapping: '=',
timefield: '=?'
},
template: headerHtml,
controller: function ($scope) {
$scope.headerClass = function (column) {
if (!$scope.mapping) return;
if ($scope.mapping[column] && !$scope.mapping[column].indexed) return;
var sorting = $scope.sorting;
var defaultClass = ['fa', 'fa-sort', 'table-header-sortchange'];
if (!sorting) return defaultClass;
if (column === sorting[0]) {
return ['fa', sorting[1] === 'asc' ? 'fa-sort-up' : 'fa-sort-down'];
} else {
return defaultClass;
}
};
$scope.moveLeft = function (column) {
var index = _.indexOf($scope.columns, column);
if (index === 0) return;
_.move($scope.columns, index, --index);
};
$scope.moveRight = function (column) {
var index = _.indexOf($scope.columns, column);
if (index === $scope.columns.length - 1) return;
_.move($scope.columns, index, ++index);
};
$scope.sort = function (column) {
if ($scope.mapping[column] && !$scope.mapping[column].indexed) return;
var sorting = $scope.sorting || [];
$scope.sorting = [column, sorting[1] === 'asc' ? 'desc' : 'asc'];
};
}
};
});
/**
* kbnTable directive
*
@ -89,7 +31,7 @@ define(function (require) {
mapping: '=',
timefield: '=?'
},
link: function ($scope, element) {
link: function ($scope, $el) {
$scope.limit = 50;
$scope.addRows = function () {
if ($scope.limit < config.get('discover:sampleSize')) {
@ -100,164 +42,4 @@ define(function (require) {
};
});
/**
* kbnTableRow directive
*
* Display a row in the table
* ```
* <tr ng-repeat="row in rows" kbn-table-row="row"></tr>
* ```
*/
module.directive('kbnTableRow', function ($compile, config) {
// base class for all dom nodes
var DOMNode = window.Node;
return {
restrict: 'A',
scope: {
columns: '=',
filtering: '=',
mapping: '=',
timefield: '=?',
row: '=kbnTableRow'
},
link: function ($scope, element, attrs) {
element.after('<tr>');
var init = function () {
createSummaryRow($scope.row, $scope.row._id);
};
// track a list of id's that are currently open, so that
// render can easily render in the same current state
var opened = [];
// whenever we compile, we should create a child scope that we can then detroy
var $child;
// toggle display of the rows details, a full list of the fields from each row
$scope.toggleRow = function () {
var row = $scope.row;
var id = row._id;
$scope.open = !$scope.open;
var $tr = element;
var $detailsTr = $tr.next();
///
// add/remove $details children
///
$detailsTr.toggle($scope.open);
if (!$scope.open) {
// close the child scope if it exists
$child.$destroy();
// no need to go any further
return;
} else {
$child = $scope.$new();
}
// The fields to loop over
row._fields = row._fields || _.keys(row._source).concat(config.get('metaFields')).sort();
row._mode = 'table';
// empty the details and rebuild it
$detailsTr
.empty()
.append(
$('<td>').attr('colspan', $scope.columns.length + 2).append(detailsHtml)
);
var showFilters = function (mapping) {
var validTypes = ['string', 'number', 'date', 'ip'];
if (!mapping.indexed) return false;
return _.contains(validTypes, mapping.type);
};
$child.row = row;
$child.showFilters = showFilters;
$compile($detailsTr)($child);
};
$scope.filter = function (row, field, operation) {
$scope.filtering(field, row._source[field] || row[field], operation);
};
$scope.$watchCollection('columns', function (columns) {
element.empty();
createSummaryRow($scope.row, $scope.row._id);
});
$scope.$watch('timefield', function (timefield) {
element.empty();
createSummaryRow($scope.row, $scope.row._id);
});
// create a tr element that lists the value for each *column*
function createSummaryRow(row, id) {
var expandTd = $('<td>')
.append(
$('<i class="fa"></span>')
.attr('ng-class', '{"fa-caret-right": !open, "fa-caret-down": open}')
)
.attr('ng-click', 'toggleRow()');
$compile(expandTd)($scope);
element.append(expandTd);
var td = $(document.createElement('td'));
if ($scope.timefield) {
td.addClass('discover-table-timefield');
td.attr('width', '1%');
_displayField(td, row, $scope.timefield);
element.append(td);
}
_.each($scope.columns, function (column) {
td = $(document.createElement('td'));
_displayField(td, row, column);
element.append(td);
});
}
/**
* Fill an element with the value of a field
*/
function _displayField(el, row, field) {
return el.html(
$('<div>').addClass('truncate-by-height')
.text(_getValForField(row, field))
);
}
/**
* get the value of a field from a row, serialize it to a string
* and truncate it if necessary
*
* @param {object} row - the row to pull the value from
* @param {string} field - the name of the field (dot-seperated paths are accepted)
* @return {[type]} a string, which should be inserted as text, or an element
*/
function _getValForField(row, field) {
var val;
// discover formats all of the values and puts them in _formatted for display
val = row._formatted[field] || row[field];
// undefined and null should just be an empty string
val = (val == null) ? '' : val;
return val;
}
init();
}
};
});
});

View file

@ -0,0 +1,56 @@
define(function (require) {
var _ = require('lodash');
var module = require('modules').get('app/discover');
module.directive('kbnTableHeader', function () {
var headerHtml = require('text!apps/discover/partials/table_header.html');
return {
restrict: 'A',
scope: {
columns: '=',
sorting: '=',
mapping: '=',
timefield: '=?'
},
template: headerHtml,
controller: function ($scope) {
$scope.headerClass = function (column) {
if (!$scope.mapping) return;
if ($scope.mapping[column] && !$scope.mapping[column].indexed) return;
var sorting = $scope.sorting;
var defaultClass = ['fa', 'fa-sort', 'table-header-sortchange'];
if (!sorting) return defaultClass;
if (column === sorting[0]) {
return ['fa', sorting[1] === 'asc' ? 'fa-sort-up' : 'fa-sort-down'];
} else {
return defaultClass;
}
};
$scope.moveLeft = function (column) {
var index = _.indexOf($scope.columns, column);
if (index === 0) return;
_.move($scope.columns, index, --index);
};
$scope.moveRight = function (column) {
var index = _.indexOf($scope.columns, column);
if (index === $scope.columns.length - 1) return;
_.move($scope.columns, index, ++index);
};
$scope.sort = function (column) {
if ($scope.mapping[column] && !$scope.mapping[column].indexed) return;
var sorting = $scope.sorting || [];
$scope.sorting = [column, sorting[1] === 'asc' ? 'desc' : 'asc'];
};
}
};
});
});

View file

@ -0,0 +1,223 @@
define(function (require) {
var _ = require('lodash');
var $ = require('jquery');
var htmlEscape = require('utils/html_escape');
var module = require('modules').get('app/discover');
/**
* kbnTableRow directive
*
* Display a row in the table
* ```
* <tr ng-repeat="row in rows" kbn-table-row="row"></tr>
* ```
*/
module.directive('kbnTableRow', function ($compile, config) {
var openRowHtml = require('text!apps/discover/partials/table_row/open.html');
var detailsHtml = require('text!apps/discover/partials/table_row/details.html');
var cellTemplate = _.template(require('text!apps/discover/partials/table_row/cell.html'));
var truncateByHeightTemplate = _.template(require('text!partials/truncate_by_height.html'));
return {
restrict: 'A',
scope: {
columns: '=',
filtering: '=',
mapping: '=',
timefield: '=?',
row: '=kbnTableRow'
},
link: function ($scope, $el, attrs) {
$el.after('<tr>');
$el.empty();
var init = function () {
createSummaryRow($scope.row, $scope.row._id);
};
// when we compile the details, we use this $scope
var $detailsScope;
// when we compile the toggle button in the summary, we use this $scope
var $toggleScope;
// toggle display of the rows details, a full list of the fields from each row
$scope.toggleRow = function () {
var row = $scope.row;
$scope.open = !$scope.open;
var $tr = $el;
var $detailsTr = $tr.next();
///
// add/remove $details children
///
$detailsTr.toggle($scope.open);
if (!$scope.open) {
// close the child scope if it exists
$detailsScope.$destroy();
// no need to go any further
return;
} else {
$detailsScope = $scope.$new();
}
// The fields to loop over
row._fields = row._fields || _.keys(row._source).concat(config.get('metaFields')).sort();
row._mode = 'table';
// empty the details and rebuild it
$detailsTr.html(detailsHtml);
$detailsScope.row = row;
$detailsScope.showFilters = function (mapping) {
var validTypes = ['string', 'number', 'date', 'ip'];
if (!mapping.indexed) return false;
return _.contains(validTypes, mapping.type);
};
$compile($detailsTr)($detailsScope);
};
$scope.filter = function (row, field, operation) {
$scope.filtering(field, row._source[field] || row[field], operation);
};
$scope.$watchCollection('columns', function () {
createSummaryRow($scope.row, $scope.row._id);
});
$scope.$watch('timefield', function () {
createSummaryRow($scope.row, $scope.row._id);
});
// create a tr element that lists the value for each *column*
function createSummaryRow(row) {
// We just create a string here because its faster.
var newHtmls = [
openRowHtml
];
if ($scope.timefield) {
newHtmls.push(cellTemplate({
timefield: true,
formatted: _displayField(row, $scope.timefield)
}));
}
$scope.columns.forEach(function (column) {
newHtmls.push(cellTemplate({
timefield: false,
formatted: _displayField(row, column, true)
}));
});
var $cells = $el.children();
newHtmls.forEach(function (html, i) {
var $cell = $cells.eq(i);
if ($cell.data('discover:html') === html) return;
var reuse = _.find($cells.slice(i + 1), function (cell) {
return $.data(cell, 'discover:html') === html;
});
var $target = reuse ? $(reuse).detach() : $(html);
$target.data('discover:html', html);
var $before = $cells.eq(i - 1);
if ($before.size()) {
$before.after($target);
} else {
$el.append($target);
}
// rebuild cells since we modified the children
$cells = $el.children();
if (i === 0 && !reuse) {
$toggleScope = $scope.$new();
$compile($target)($toggleScope);
}
});
// trim off cells that were not used rest of the cells
$cells.filter(':gt(' + (newHtmls.length - 1) + ')').remove();
}
/**
* Fill an element with the value of a field
*/
function _displayField(row, field, breakWords) {
var text = _getValForField(row, field);
var minLineLength = 20;
if (breakWords) {
text = htmlEscape(text);
var lineSize = 0;
var newText = '';
for (var i = 0, len = text.length; i < len; i++) {
var chr = text.charAt(i);
newText += chr;
switch (chr) {
case ' ':
case '&':
case ';':
case ':':
case ',':
// natural line break, reset line size
lineSize = 0;
break;
default:
lineSize++;
break;
}
if (lineSize > minLineLength) {
// continuous text is longer then we want,
// so break it up with a <wbr>
lineSize = 0;
newText += '<wbr>';
}
}
if (text.length > minLineLength) {
return truncateByHeightTemplate({
body: newText
});
}
text = newText;
}
return text;
}
/**
* get the value of a field from a row, serialize it to a string
* and truncate it if necessary
*
* @param {object} row - the row to pull the value from
* @param {string} field - the name of the field (dot-seperated paths are accepted)
* @return {[type]} a string, which should be inserted as text, or an element
*/
function _getValForField(row, field) {
var val;
// discover formats all of the values and puts them in _formatted for display
val = row._formatted[field] || row[field];
// undefined and null should just be an empty string
val = (val == null) ? '' : val;
return val;
}
init();
}
};
});
});

View file

@ -1,38 +0,0 @@
<div class="discover-table-details">
<ul class="nav nav-tabs discover-table-details-toggle">
<li ng-class="{active: row._mode == 'table'}"><a ng-click="row._mode='table'">Table</a></li>
<li ng-class="{active: row._mode == 'json'}"><a ng-click="row._mode='json'">JSON</a></li>
</ul>
<table class="table table-condensed" ng-show="row._mode == 'table'" bindonce>
<tbody>
<tr ng-repeat="field in row._fields" bindonce>
<td field-name="field"
field-type="mapping[field].type"
width="1%"
class="discover-table-details-field">
</td>
<td width="1%" class="discover-table-details-buttons">
<span bo-show="showFilters(mapping[field])">
<i ng-click="filter(row, field, '+')" class="fa fa-search-plus"></i>
<i ng-click="filter(row, field, '-')" class="fa fa-search-minus"></i>
</span>
<span bo-show="!showFilters(mapping[field])" tooltip="Unindexed fields can not be searched">
<i class="fa fa-search-plus text-muted"></i>
<i class="fa fa-search-minus text-muted"></i>
</span>
</td>
<td>
<i bo-show="!mapping[field]"
tooltip-placement="top"
tooltip="No cached mapping for this field. Refresh your mapping from the Settings > Indices page"
class="fa fa-warning text-color-warning ng-scope"></i>
<span class="discover-table-details-value">{{row._formatted[field] || row[field]}}</span>
</td>
</tr>
</tbody>
</table>
<pre ng-show="row._mode == 'json'">{{row._source | json}}</pre>
</div>

View file

@ -0,0 +1,3 @@
<td <%= timefield ? 'class="discover-table-timefield" width="1%"' : '' %>>
<%= formatted %>
</td>

View file

@ -0,0 +1,40 @@
<td colspan="{{ columns.length + 2 }}">
<div class="discover-table-details">
<ul class="nav nav-tabs discover-table-details-toggle">
<li ng-class="{active: row._mode == 'table'}"><a ng-click="row._mode='table'">Table</a></li>
<li ng-class="{active: row._mode == 'json'}"><a ng-click="row._mode='json'">JSON</a></li>
</ul>
<table class="table table-condensed" ng-show="row._mode == 'table'" bindonce>
<tbody>
<tr ng-repeat="field in row._fields" bindonce>
<td field-name="field"
field-type="mapping[field].type"
width="1%"
class="discover-table-details-field">
</td>
<td width="1%" class="discover-table-details-buttons">
<span bo-show="showFilters(mapping[field])">
<i ng-click="filter(row, field, '+')" class="fa fa-search-plus"></i>
<i ng-click="filter(row, field, '-')" class="fa fa-search-minus"></i>
</span>
<span bo-show="!showFilters(mapping[field])" tooltip="Unindexed fields can not be searched">
<i class="fa fa-search-plus text-muted"></i>
<i class="fa fa-search-minus text-muted"></i>
</span>
</td>
<td>
<i bo-show="!mapping[field]"
tooltip-placement="top"
tooltip="No cached mapping for this field. Refresh your mapping from the Settings > Indices page"
class="fa fa-warning text-color-warning ng-scope"></i>
<span class="discover-table-details-value">{{row._formatted[field] || row[field]}}</span>
</td>
</tr>
</tbody>
</table>
<pre ng-show="row._mode == 'json'">{{row._source | json}}</pre>
</div>
</td>

View file

@ -0,0 +1,6 @@
<td ng-click="toggleRow()">
<i
class="fa discover-table-open-icon"
ng-class="{ 'fa-caret-down': open, 'fa-caret-right': !open }">
</i>
</td>

View file

@ -64,9 +64,16 @@
padding-left: 0px !important;
padding-right: 0px !important;
.discover-table-timefield {
&-timefield {
white-space: nowrap;
}
&-open-icon {
// when switching between open and closed, the toggle changes size
// slightly which is a problem because it forces the entire table to
// re-render which is SLOW
width: 7px;
}
}
.discover-table-footer {

View file

@ -3,10 +3,5 @@
<label for="visTitle">Title</label>
<input class="form-control" input-focus type="text" name="visTitle" ng-model="conf.savedVis.title" required>
</div>
<div class="form-group">
<label for="visDescription">Description</label>
<textarea class="form-control" name="visDescription" ng-model="conf.savedVis.description" placeholder=""></textarea>
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>

View file

@ -25,7 +25,7 @@ define(function (require) {
* @return {Promise} - resolved with the current AppSource
*/
function getAppSource() {
return prom || loadDefaultPattern();
return Promise.resolve(appSource || prom || loadDefaultPattern());
}
/**

View file

@ -11,8 +11,6 @@ define(function (require) {
shareYAxis: true,
addTooltip: true,
addLegend: true,
addEvents: true,
addBrushing: true
},
schemas: new Schemas([
{

View file

@ -117,6 +117,7 @@ define(function (require) {
ColumnChart.prototype.addBarEvents = function (svg, bars, brush) {
var events = this.events;
var dispatch = this.events._attr.dispatch;
var addBrush = this._attr.addBrushing;
var xScale = this.handler.xAxis.xScale;
var startXInv;
@ -131,19 +132,21 @@ define(function (require) {
d3.event.stopPropagation();
})
.on('mousedown.bar', function () {
var bar = d3.select(this);
var startX = d3.mouse(svg.node());
startXInv = xScale.invert(startX[0]);
if (addBrush) {
var bar = d3.select(this);
var startX = d3.mouse(svg.node());
startXInv = xScale.invert(startX[0]);
// Reset the brush value
brush.extent([startXInv, startXInv]);
// Reset the brush value
brush.extent([startXInv, startXInv]);
// Magic!
// Need to call brush on svg to see brush when brushing
// while on top of bars.
// Need to call brush on bar to allow the click event to be registered
svg.call(brush);
bar.call(brush);
// Magic!
// Need to call brush on svg to see brush when brushing
// while on top of bars.
// Need to call brush on bar to allow the click event to be registered
svg.call(brush);
bar.call(brush);
}
})
.on('click.bar', function (d, i) {
dispatch.click(events.eventResponse(d, i));

View file

@ -168,12 +168,13 @@ define(function (require) {
var clipPathBuffer = 5;
var startX = 0;
var startY = 0 - clipPathBuffer;
var id = 'chart-area' + _.uniqueId();
// Creating clipPath
return svg
.attr('clip-path', 'url(#chart-area)')
.attr('clip-path', 'url(#' + id + ')')
.append('clipPath')
.attr('id', 'chart-area')
.attr('id', id)
.append('rect')
.attr('x', startX)
.attr('y', startY)

View file

@ -71,7 +71,7 @@ visualize-spy {
}
.visualize-spy-container {
.flex(1, 1, auto);
.flex(1, 0, auto);
.display(flex);
.flex-direction(column);

View file

@ -0,0 +1,3 @@
<div class="truncate-by-height">
<%= body %>
</div>

View file

@ -0,0 +1,17 @@
define(function (require) {
var _ = require('lodash');
var map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'\'': '&#39;',
'"': '&quot;',
};
var regex = new RegExp('[' + _.keys(map).join('') + ']', 'g');
return function htmlEscape(text) {
return text.replace(regex, function (c) {
return map[c];
});
};
});

View file

@ -1,15 +1,19 @@
define(function (require) {
var angular = require('angular');
var _ = require('lodash');
var longString = Array(200).join('_');
return function (id, mapping) {
var columns = _.keys(mapping);
return {
_formatted: _.zipObject(_.map(columns, function (c) { return [c, c + '_formatted_' + id + longString]; })),
_source: _.zipObject(_.map(columns, function (c) { return [c, c + '_original_' + id + longString]; })),
_id: id,
_index: 'test',
sort: [id]
};
var fake = {
_formatted: _.mapValues(mapping, function (f, c) { return c + '_formatted_' + id + longString; }),
_source: _.mapValues(mapping, function (f, c) { return c + '_original_' + id + longString; }),
_id: id,
_index: 'test',
sort: [id]
};
fake._formatted._source = '_source_formatted_' + id + longString;
return fake;
};
});

View file

@ -103,6 +103,7 @@
'specs/utils/versionmath',
'specs/utils/routes/index',
'specs/utils/sequencer',
'specs/utils/html_escape',
'specs/courier/search_source/_get_normalized_sort',
'specs/factories/base_object',
'specs/state_management/state',

View file

@ -195,7 +195,6 @@ define(function (require) {
expect($scope.columns[0]).to.be('bytes');
});
});
});
describe('kbnTable', function () {
@ -259,12 +258,9 @@ define(function (require) {
expect(tr.length).to.be(100);
done();
});
});
describe('kbnTableRow', function () {
var $elem = angular.element(
'<tr kbn-table-row="row" ' +
'columns="columns" ' +
@ -359,11 +355,209 @@ define(function (require) {
});
});
});
describe('row diffing', function () {
var $row;
var $scope;
var $root;
var $before;
beforeEach(module('kibana', 'apps/discover'));
beforeEach(inject(function ($rootScope, $compile) {
$root = $rootScope;
$root.row = getFakeRow(0, mapping);
$root.columns = ['_source'];
$root.sorting = [];
$root.filtering = sinon.spy();
$root.maxLength = 50;
$root.mapping = mapping;
$root.timefield = 'timestamp';
$row = $('<tr>')
.attr({
'kbn-table-row': 'row',
'columns': 'columns',
'sorting': 'sortin',
'filtering': 'filtering',
'mapping': 'mapping',
'timefield': 'timefield',
});
$scope = $root.$new();
$compile($row)($scope);
$root.$apply();
$before = $row.find('td');
expect($before).to.have.length(3);
expect($before.eq(0).text().trim()).to.be('');
expect($before.eq(1).text().trim()).to.match(/^timestamp_formatted/);
expect($before.eq(2).text().trim()).to.match(/^_source_formatted/);
}));
afterEach(function () {
$row.remove();
});
it('handles a new column', function () {
$root.columns.push('bytes');
$root.$apply();
var $after = $row.find('td');
expect($after).to.have.length(4);
expect($after[0]).to.be($before[0]);
expect($after[1]).to.be($before[1]);
expect($after[2]).to.be($before[2]);
expect($after.eq(3).text().trim()).to.match(/^bytes_formatted/);
});
it('handles two new columns at once', function () {
$root.columns.push('bytes');
$root.columns.push('request');
$root.$apply();
var $after = $row.find('td');
expect($after).to.have.length(5);
expect($after[0]).to.be($before[0]);
expect($after[1]).to.be($before[1]);
expect($after[2]).to.be($before[2]);
expect($after.eq(3).text().trim()).to.match(/^bytes_formatted/);
expect($after.eq(4).text().trim()).to.match(/^request_formatted/);
});
it('handles three new columns in odd places', function () {
$root.columns = [
'timestamp',
'bytes',
'_source',
'request'
];
$root.$apply();
var $after = $row.find('td');
expect($after).to.have.length(6);
expect($after[0]).to.be($before[0]);
expect($after[1]).to.be($before[1]);
expect($after.eq(2).text().trim()).to.match(/^timestamp_formatted/);
expect($after.eq(3).text().trim()).to.match(/^bytes_formatted/);
expect($after[4]).to.be($before[2]);
expect($after.eq(5).text().trim()).to.match(/^request_formatted/);
});
it('handles a removed column', function () {
_.pull($root.columns, '_source');
$root.$apply();
var $after = $row.find('td');
expect($after).to.have.length(2);
expect($after[0]).to.be($before[0]);
expect($after[1]).to.be($before[1]);
});
it('handles two removed columns', function () {
// first add a column
$root.columns.push('timestamp');
$root.$apply();
var $mid = $row.find('td');
expect($mid).to.have.length(4);
$root.columns.pop();
$root.columns.pop();
$root.$apply();
var $after = $row.find('td');
expect($after).to.have.length(2);
expect($after[0]).to.be($before[0]);
expect($after[1]).to.be($before[1]);
});
it('handles three removed random columns', function () {
// first add two column
$root.columns.push('timestamp', 'bytes');
$root.$apply();
var $mid = $row.find('td');
expect($mid).to.have.length(5);
$root.columns[0] = false; // _source
$root.columns[2] = false; // bytes
$root.columns = $root.columns.filter(Boolean);
$root.$apply();
var $after = $row.find('td');
expect($after).to.have.length(3);
expect($after[0]).to.be($before[0]);
expect($after[1]).to.be($before[1]);
expect($after.eq(2).text().trim()).to.match(/^timestamp_formatted/);
});
it('handles two columns with the same content', function () {
$root.row._formatted.bytes = $root.row._formatted._source;
$root.columns.push('bytes');
$root.$apply();
var $after = $row.find('td');
expect($after).to.have.length(4);
expect($after[0]).to.be($before[0]);
expect($after[1]).to.be($before[1]);
expect($after[2]).to.be($before[2]);
expect($after.eq(3).text().trim()).to.match(/^_source_formatted/);
});
it('handles two columns swapping position', function () {
$root.columns.push('bytes');
$root.$apply();
var $mid = $row.find('td');
expect($mid).to.have.length(4);
$root.columns.reverse();
$root.$apply();
var $after = $row.find('td');
expect($after).to.have.length(4);
expect($after[0]).to.be($before[0]);
expect($after[1]).to.be($before[1]);
expect($after[2]).to.be($mid[3]);
expect($after[3]).to.be($mid[2]);
});
it('handles four columns all reversing position', function () {
$root.columns.push('bytes', 'response', 'timestamp');
$root.$apply();
var $mid = $row.find('td');
expect($mid).to.have.length(6);
$root.columns.reverse();
$root.$apply();
var $after = $row.find('td');
expect($after).to.have.length(6);
expect($after[0]).to.be($before[0]);
expect($after[1]).to.be($before[1]);
expect($after[2]).to.be($mid[5]);
expect($after[3]).to.be($mid[4]);
expect($after[4]).to.be($mid[3]);
expect($after[5]).to.be($mid[2]);
});
it('handles multiple columns with the same name', function () {
$root.columns.push('bytes', 'bytes', 'bytes');
$root.$apply();
var $after = $row.find('td');
expect($after).to.have.length(6);
expect($after[0]).to.be($before[0]);
expect($after[1]).to.be($before[1]);
expect($after[2]).to.be($before[2]);
expect($after.eq(3).text().trim()).to.match(/^bytes_formatted/);
expect($after.eq(4).text().trim()).to.match(/^bytes_formatted/);
expect($after.eq(5).text().trim()).to.match(/^bytes_formatted/);
});
});
});
});

View file

@ -0,0 +1,19 @@
define(function (require) {
describe('HTML Escape Util', function () {
var htmlEscape = require('utils/html_escape');
it('removes tags by replacing their angle-brackets', function () {
expect(htmlEscape('<h1>header</h1>')).to.eql('&lt;h1&gt;header&lt;/h1&gt;');
});
it('removes attributes from tags using &quot; and &#39;', function () {
expect(htmlEscape('<h1 onclick="alert(\'hi\');">header</h1>'))
.to.eql('&lt;h1 onclick=&quot;alert(&#39;hi&#39;);&quot;&gt;header&lt;/h1&gt;');
});
it('escapes existing html entities by escaping their leading &', function () {
expect(htmlEscape('&lt;h1&gt;header&lt;/h1&gt;'))
.to.eql('&amp;lt;h1&amp;gt;header&amp;lt;/h1&amp;gt;');
});
});
});