De-angularize DocViewer (#42116)

* Migrate doc-viewer directive to use React/TypeScript

* Refactor DocViewsRegistryProvider

* Add compatibility for 3rd party plugins still using angular

* Add tests

Angular rendering for usage in React component:

Co-authored-by: spalger <email@spalger.com>
This commit is contained in:
Matthias Wilhelm 2019-08-20 07:47:21 +02:00 committed by GitHub
parent a498d3964a
commit dd26316fd2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 903 additions and 679 deletions

View file

@ -1,272 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import angular from 'angular';
import _ from 'lodash';
import sinon from 'sinon';
import expect from '@kbn/expect';
import ngMock from 'ng_mock';
import 'ui/directives/render_directive';
import '../views/table';
import { DocViewsRegistryProvider } from 'ui/registry/doc_views';
import StubbedLogstashIndexPattern from 'fixtures/stubbed_logstash_index_pattern';
const hit = {
'_index': 'logstash-2014.09.09',
'_type': 'apache',
'_id': '61',
'_score': 1,
'_source': {
'@message': 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. \
Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus \
et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, \
ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. \
Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim justo, \
rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium. \
Integer tincidunt. Cras dapibus. Vivamus elementum semper nisi. Aenean vulputate eleifend tellus. \
Aenean leo ligula, porttitor eu, consequat vitae, eleifend ac, enim. Aliquam lorem ante, \
dapibus in, viverra quis, feugiat a, tellus. Phasellus viverra nulla ut metus varius laoreet. \
Quisque rutrum. Aenean imperdiet. Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. \
Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, \
sit amet adipiscing sem neque sed ipsum. Nam quam nunc, blandit vel, luctus pulvinar, hendrerit id, \
lorem. Maecenas nec odio et ante tincidunt tempus. Donec vitae sapien ut libero venenatis faucibus. \
Nullam quis ante. Etiam sit amet orci eget eros faucibus tincidunt. Duis leo. Sed fringilla mauris sit amet nibh. \
Donec sodales sagittis magna. Sed consequat, leo eget bibendum sodales, augue velit cursus nunc, quis gravida magna mi a libero. \
Fusce vulputate eleifend sapien. Vestibulum purus quam, scelerisque ut, mollis sed, nonummy id, metus. \
Nullam accumsan lorem in dui. Cras ultricies mi eu turpis hendrerit fringilla. Vestibulum ante ipsum primis \
in faucibus orci luctus et ultrices posuere cubilia Curae; In ac dui quis mi consectetuer lacinia. \
Nam pretium turpis et arcu. Duis arcu tortor, suscipit eget, imperdiet nec, imperdiet iaculis, ipsum. \
Sed aliquam ultrices mauris. Integer ante arcu, accumsan a, consectetuer eget, posuere ut, mauris. Praesent adipiscing. \
Phasellus ullamcorper ipsum rutrum nunc. Nunc nonummy metus. Vestibulum volutpat pretium libero. Cras id dui. Aenean ut',
'extension': 'html',
'bytes': 100,
'area': [{ lat: 7, lon: 7 }],
'noMapping': 'hasNoMapping',
'objectArray': [{ foo: true }, { bar: false }],
'relatedContent': `{
"url": "http://www.laweekly.com/news/jonathan-gold-meets-nwa-2385365",
"og:type": "article",
"og:title": "Jonathan Gold meets N.W.A.",
"og:description": "On May 5, 1989 the L.A. Weekly printed a cover story, \
written by Jonathan Gold, about N.W.A., the most notorious band in the U.S., let alone in Los Ange...",
"og:url": "http://www.laweekly.com/news/jonathan-gold-meets-nwa-2385365",
"article:published_time": "2007-12-05T07:59:41-08:00",
"article:modified_time": "2015-01-31T14:57:41-08:00",
"article:section": "News",
"og:image": "http://IMAGES1.laweekly.com/imager/jonathan-gold-meets-nwa/u/original/2415015/03covergold_1.jpg",
"og:image:height": "637",
"og:image:width": "480",
"og:site_name": "LA Weekly",
"twitter:title": "Jonathan Gold meets N.W.A.",
"twitter:description": "On May 5, 1989 the L.A. Weekly printed a cover story, \
written by Jonathan Gold, about N.W.A., the most notorious band in the U.S., let alone in Los Ange...",
"twitter:card": "summary",
"twitter:image": "http://IMAGES1.laweekly.com/imager/jonathan-gold-meets-nwa/u/original/2415015/03covergold_1.jpg",
"twitter:site": "@laweekly"
},
{
"url": "http://www.laweekly.com/news/once-more-in-the-river-2368108",
"og:type": "article",
"og:title": "Once more in the River",
"og:description": "All photos by Mark Mauer. More after the jump...",
"og:url": "http://www.laweekly.com/news/once-more-in-the-river-2368108",
"article:published_time": "2007-10-15T10:46:29-07:00",
"article:modified_time": "2014-10-28T15:00:05-07:00",
"article:section": "News",
"og:image": "http://IMAGES1.laweekly.com/imager/once-more-in-the-river/u/original/2430775/img_2536.jpg",
"og:image:height": "640",
"og:image:width": "480",
"og:site_name": "LA Weekly",
"twitter:title": "Once more in the River",
"twitter:description": "All photos by Mark Mauer. More after the jump...",
"twitter:card": "summary",
"twitter:image": "http://IMAGES1.laweekly.com/imager/once-more-in-the-river/u/original/2430775/img_2536.jpg",
"twitter:site": "@laweekly"
}`,
'_underscore': 1
}
};
// Load the kibana app dependencies.
let $parentScope;
let $scope;
let indexPattern;
let flattened;
let docViews;
const init = function ($elem, props) {
ngMock.inject(function ($rootScope, $compile) {
$parentScope = $rootScope;
_.assign($parentScope, props);
$compile($elem)($parentScope);
$elem.scope().$digest();
$scope = $elem.isolateScope();
});
};
const destroy = function () {
$scope.$destroy();
$parentScope.$destroy();
};
describe('docViews', function () {
let $elem;
let initView;
beforeEach(ngMock.module('kibana'));
beforeEach(function () {
const aggs = 'index-pattern="indexPattern" hit="hit" filter="filter"';
$elem = angular.element(`<render-directive ${aggs} definition="view.directive"></render-directive>`);
ngMock.inject(function (Private) {
indexPattern = Private(StubbedLogstashIndexPattern);
flattened = indexPattern.flattenHit(hit);
docViews = Private(DocViewsRegistryProvider);
});
initView = function initView(view) {
$elem.append(view.directive.template);
init($elem, {
indexPattern: indexPattern,
hit: hit,
view: view,
filter: sinon.spy()
});
};
});
afterEach(function () {
destroy();
});
describe('Table', function () {
beforeEach(function () {
initView(docViews.byName.Table);
});
it('should have a row for each field', function () {
expect($elem.find('tr').length).to.be(_.keys(flattened).length);
});
it('should have the field name in the first column', function () {
_.each(_.keys(flattened), function (field) {
expect($elem.find('[data-test-subj="tableDocViewRow-' + field + '"]').length).to.be(1);
});
});
it('should have the a value for each field', function () {
_.each(_.keys(flattened), function (field) {
const cellValue = $elem
.find('[data-test-subj="tableDocViewRow-' + field + '"]')
.find('.kbnDocViewer__value').text();
// This sucks, but testing the filter chain is too hairy ATM
expect(cellValue.length).to.be.greaterThan(0);
});
});
describe('filtering', function () {
it('should apply a filter when clicking filterable fields', function () {
const row = $elem.find('[data-test-subj="tableDocViewRow-bytes"]');
row.find('.fa-search-plus').first().click();
expect($scope.filter.calledOnce).to.be(true);
row.find('.fa-search-minus').first().click();
expect($scope.filter.calledTwice).to.be(true);
row.find('.fa-asterisk').first().click();
expect($scope.filter.calledThrice).to.be(true);
});
it('should NOT apply a filter when clicking non-filterable fields', function () {
const row = $elem.find('[data-test-subj="tableDocViewRow-area"]');
row.find('.fa-search-plus').first().click();
expect($scope.filter.calledOnce).to.be(false);
row.find('.fa-search-minus').first().click();
expect($scope.filter.calledTwice).to.be(false);
row.find('.fa-asterisk').first().click();
expect($scope.filter.calledOnce).to.be(true);
});
});
describe('collapse row', function () {
it('should not collapse or expand other fields', function () {
const collapseBtns = $elem.find('.discover-table-open-button');
const first = collapseBtns.first()[0];
const last = collapseBtns.last()[0];
first.click();
expect(first.parentElement.lastElementChild.classList.contains('truncate-by-height'))
.to.equal(false);
expect(last.parentElement.lastElementChild.classList.contains('truncate-by-height'))
.to.equal(true);
first.click();
expect(first.parentElement.lastElementChild.classList.contains('truncate-by-height'))
.to.equal(true);
expect(last.parentElement.lastElementChild.classList.contains('truncate-by-height'))
.to.equal(true);
});
it('should collapse an overflowed field details by default', function () {
const collapseBtn = $elem.find('.discover-table-open-button').first()[0];
expect(collapseBtn.parentElement.lastElementChild
.classList.contains('truncate-by-height')).to.equal(true);
});
it('should expand and collapse an overflowed field details', function () {
const collapseBtn = $elem.find('.discover-table-open-button').first()[0];
const spy = sinon.spy($scope, 'toggleViewer');
collapseBtn.click();
expect(spy.calledOnce).to.equal(true);
collapseBtn.click();
expect(spy.calledTwice).to.equal(true);
spy.restore();
collapseBtn.click();
expect(collapseBtn.parentElement.lastElementChild.classList.contains('truncate-by-height'))
.to.equal(false);
collapseBtn.click();
expect(collapseBtn.parentElement.lastElementChild.classList.contains('truncate-by-height'))
.to.equal(true);
});
it('should have collapse button available in View single document mode', function () {
$scope.filter = null;
const collapseBtn = $elem.find('.discover-table-open-button').first()[0];
expect(collapseBtn).not.to.equal(null);
});
});
describe('warnings', function () {
it('displays a warning about field name starting with underscore', function () {
const row = $elem.find('[data-test-subj="tableDocViewRow-_underscore"]');
expect(row.find('.kbnDocViewer__underscore').length).to.be(1);
expect(row.find('.kbnDocViewer__noMapping').length).to.be(0);
expect(row.find('.kbnDocViewer__objectArray').length).to.be(0);
});
it('displays a warning about missing mappings', function () {
const row = $elem.find('[data-test-subj="tableDocViewRow-noMapping"]');
expect(row.find('.kbnDocViewer__underscore').length).to.be(0);
expect(row.find('.kbnDocViewer__noMapping').length).to.be(1);
expect(row.find('.kbnDocViewer__objectArray').length).to.be(0);
});
});
});
});

View file

@ -16,18 +16,18 @@
* specific language governing permissions and limitations
* under the License.
*/
import { addDocView } from 'ui/registry/doc_views';
import { i18n } from '@kbn/i18n';
import { JsonCodeEditor } from './json_code_editor';
import _ from 'lodash';
import { uiRegistry } from './_registry';
export const DocViewsRegistryProvider = uiRegistry({
name: 'docViews',
index: ['name'],
order: ['order'],
constructor() {
this.forEach(docView => {
docView.shouldShow = docView.shouldShow || _.constant(true);
docView.name = docView.name || docView.title;
});
}
/*
* Registration of the the doc view: json
* - used to display an ES hit as pretty printed JSON at Discover
*/
addDocView({
title: i18n.translate('kbnDocViews.json.jsonTitle', {
defaultMessage: 'JSON',
}),
order: 20,
component: JsonCodeEditor,
});

View file

@ -19,8 +19,16 @@
import React from 'react';
import { shallow } from 'enzyme';
import { JsonCodeEditor } from './json_code_editor';
import { IndexPattern } from 'ui/index_patterns';
it('returns the `JsonCodeEditor` component', () => {
const hit = { _index: 'test', _source: { test: 123 } };
expect(shallow(<JsonCodeEditor hit={hit} />)).toMatchSnapshot();
const props = {
hit: { _index: 'test', _source: { test: 123 } },
columns: [],
indexPattern: {} as IndexPattern,
filter: jest.fn(),
onAddColumn: jest.fn(),
onRemoveColumn: jest.fn(),
};
expect(shallow(<JsonCodeEditor {...props} />)).toMatchSnapshot();
});

View file

@ -20,12 +20,9 @@
import { EuiCodeEditor } from '@elastic/eui';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { DocViewRenderProps } from 'ui/registry/doc_views';
export interface JsonCodeEditorProps {
hit: Record<string, any>;
}
export function JsonCodeEditor({ hit }: JsonCodeEditorProps) {
export function JsonCodeEditor({ hit }: DocViewRenderProps) {
return (
<EuiCodeEditor
aria-label={

View file

@ -18,72 +18,60 @@
*/
import _ from 'lodash';
import { DocViewsRegistryProvider } from 'ui/registry/doc_views';
import { addDocView } from 'ui/registry/doc_views';
import '../filters/trust_as_html';
import tableHtml from './table.html';
import { i18n } from '@kbn/i18n';
DocViewsRegistryProvider.register(function () {
const MIN_LINE_LENGTH = 350;
const MIN_LINE_LENGTH = 350;
return {
title: i18n.translate('kbnDocViews.table.tableTitle', {
defaultMessage: 'Table'
}),
order: 10,
directive: {
template: tableHtml,
scope: {
hit: '=',
indexPattern: '=',
filter: '=',
columns: '=',
onAddColumn: '=',
onRemoveColumn: '=',
},
controller: function ($scope) {
$scope.mapping = $scope.indexPattern.fields.byName;
$scope.flattened = $scope.indexPattern.flattenHit($scope.hit);
$scope.formatted = $scope.indexPattern.formatHit($scope.hit);
$scope.fields = _.keys($scope.flattened).sort();
$scope.fieldRowOpen = {};
$scope.fields.forEach(field => $scope.fieldRowOpen[field] = false);
addDocView({
title: i18n.translate('kbnDocViews.table.tableTitle', {
defaultMessage: 'Table',
}),
order: 10,
directive: {
template: tableHtml,
controller: $scope => {
$scope.mapping = $scope.indexPattern.fields.byName;
$scope.flattened = $scope.indexPattern.flattenHit($scope.hit);
$scope.formatted = $scope.indexPattern.formatHit($scope.hit);
$scope.fields = _.keys($scope.flattened).sort();
$scope.fieldRowOpen = {};
$scope.fields.forEach(field => ($scope.fieldRowOpen[field] = false));
$scope.canToggleColumns = function canToggleColumn() {
return (
_.isFunction($scope.onAddColumn)
&& _.isFunction($scope.onRemoveColumn)
);
};
$scope.canToggleColumns = function canToggleColumn() {
return _.isFunction($scope.onAddColumn) && _.isFunction($scope.onRemoveColumn);
};
$scope.toggleColumn = function toggleColumn(columnName) {
if ($scope.columns.includes(columnName)) {
$scope.onRemoveColumn(columnName);
} else {
$scope.onAddColumn(columnName);
}
};
$scope.toggleColumn = function toggleColumn(columnName) {
if ($scope.columns.includes(columnName)) {
$scope.onRemoveColumn(columnName);
} else {
$scope.onAddColumn(columnName);
}
};
$scope.isColumnActive = function isColumnActive(columnName) {
return $scope.columns.includes(columnName);
};
$scope.isColumnActive = function isColumnActive(columnName) {
return $scope.columns.includes(columnName);
};
$scope.showArrayInObjectsWarning = function (row, field) {
const value = $scope.flattened[field];
return Array.isArray(value) && typeof value[0] === 'object';
};
$scope.showArrayInObjectsWarning = function (row, field) {
const value = $scope.flattened[field];
return Array.isArray(value) && typeof value[0] === 'object';
};
$scope.enableDocValueCollapse = function (docValueField) {
const html = (typeof $scope.formatted[docValueField] === 'undefined') ?
$scope.hit[docValueField] : $scope.formatted[docValueField];
return html.length > MIN_LINE_LENGTH;
};
$scope.enableDocValueCollapse = function (docValueField) {
const html =
typeof $scope.formatted[docValueField] === 'undefined'
? $scope.hit[docValueField]
: $scope.formatted[docValueField];
return html.length > MIN_LINE_LENGTH;
};
$scope.toggleViewer = function (field) {
$scope.fieldRowOpen[field] = !$scope.fieldRowOpen[field];
};
}
}
};
$scope.toggleViewer = function (field) {
$scope.fieldRowOpen[field] = !$scope.fieldRowOpen[field];
};
},
},
});

View file

@ -39,25 +39,27 @@ describe('Doc Table', function () {
let stubFieldFormatConverter;
beforeEach(ngMock.module('kibana', 'apps/discover'));
beforeEach(ngMock.inject(function (_config_, $rootScope, Private) {
config = _config_;
$parentScope = $rootScope;
$parentScope.indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider);
mapping = $parentScope.indexPattern.fields.byName;
beforeEach(
ngMock.inject(function (_config_, $rootScope, Private) {
config = _config_;
$parentScope = $rootScope;
$parentScope.indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider);
mapping = $parentScope.indexPattern.fields.byName;
// Stub `getConverterFor` for a field in the indexPattern to return mock data.
// Returns `val` if provided, otherwise generates fake data for the field.
fakeRowVals = getFakeRowVals('formatted', 0, mapping);
stubFieldFormatConverter = function ($root, field, val = null) {
$root.indexPattern.fields.byName[field].format.getConverterFor = () => (...args) => {
if (val) {
return val;
}
const fieldName = _.get(args, '[1].name', null);
return fakeRowVals[fieldName] || '';
// Stub `getConverterFor` for a field in the indexPattern to return mock data.
// Returns `val` if provided, otherwise generates fake data for the field.
fakeRowVals = getFakeRowVals('formatted', 0, mapping);
stubFieldFormatConverter = function ($root, field, val = null) {
$root.indexPattern.fields.byName[field].format.getConverterFor = () => (...args) => {
if (val) {
return val;
}
const fieldName = _.get(args, '[1].name', null);
return fakeRowVals[fieldName] || '';
};
};
};
}));
})
);
// Sets up the directive, take an element, and a list of properties to attach to the parent scope.
const init = function ($elem, props) {
@ -119,11 +121,11 @@ describe('Doc Table', function () {
describe('kbnTableRow', function () {
const $elem = angular.element(
'<tr kbn-table-row="row" ' +
'columns="columns" ' +
'sorting="sorting"' +
'filter="filter"' +
'index-pattern="indexPattern"' +
'></tr>'
'columns="columns" ' +
'sorting="sorting"' +
'filter="filter"' +
'index-pattern="indexPattern"' +
'></tr>'
);
let row;
@ -139,8 +141,10 @@ describe('Doc Table', function () {
});
// Ignore the metaFields (_id, _type, etc) since we don't have a mapping for them
sinon.stub(config, 'get').withArgs('metaFields').returns([]);
sinon
.stub(config, 'get')
.withArgs('metaFields')
.returns([]);
});
afterEach(function () {
destroy();
@ -180,9 +184,7 @@ describe('Doc Table', function () {
expect($details.is('tr')).to.be(true);
expect($details.text()).to.not.be.empty();
});
});
});
});
@ -195,7 +197,6 @@ describe('Doc Table', function () {
'index-pattern="indexPattern"' +
'></tr>'
);
let $details;
let row;
beforeEach(function () {
@ -209,21 +210,28 @@ describe('Doc Table', function () {
maxLength: 50,
});
sinon.stub(config, 'get').withArgs('metaFields').returns(['_id']);
sinon
.stub(config, 'get')
.withArgs('metaFields')
.returns(['_id']);
// Open the row
$scope.toggleRow();
$scope.$digest();
$details = $elem.next();
$elem.next();
});
afterEach(function () {
destroy();
});
it('should render even when the row source contains a field with the same name as a meta field', function () {
/** this no longer works with the new plugin approach
it('should render even when the row source contains a field with the same name as a meta field', function () {
setTimeout(() => {
//this should be overridden by later changes
}, 100);
expect($details.find('tr').length).to.be(_.keys($parentScope.indexPattern.flattenHit($scope.row)).length);
});
}); */
});
describe('row diffing', function () {
@ -232,37 +240,50 @@ describe('Doc Table', function () {
let $root;
let $before;
beforeEach(ngMock.inject(function ($rootScope, $compile, Private) {
$root = $rootScope;
$root.row = getFakeRow(0, mapping);
$root.columns = ['_source'];
$root.sorting = [];
$root.filtering = sinon.spy();
$root.maxLength = 50;
$root.mapping = mapping;
$root.indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider);
beforeEach(
ngMock.inject(function ($rootScope, $compile, Private) {
$root = $rootScope;
$root.row = getFakeRow(0, mapping);
$root.columns = ['_source'];
$root.sorting = [];
$root.filtering = sinon.spy();
$root.maxLength = 50;
$root.mapping = mapping;
$root.indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider);
// Stub field format converters for every field in the indexPattern
Object.keys($root.indexPattern.fields.byName).forEach(f => stubFieldFormatConverter($root, f));
// Stub field format converters for every field in the indexPattern
Object.keys($root.indexPattern.fields.byName).forEach(f =>
stubFieldFormatConverter($root, f)
);
$row = $('<tr>')
.attr({
$row = $('<tr>').attr({
'kbn-table-row': 'row',
'columns': 'columns',
'sorting': 'sorting',
'filtering': 'filtering',
columns: 'columns',
sorting: 'sorting',
filtering: 'filtering',
'index-pattern': 'indexPattern',
});
$scope = $root.$new();
$compile($row)($scope);
$root.$apply();
$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(/^time_formatted/);
}));
$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(/^time_formatted/);
})
);
afterEach(function () {
$row.remove();
@ -277,7 +298,12 @@ describe('Doc Table', function () {
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(3)
.text()
.trim()
).to.match(/^bytes_formatted/);
});
it('handles two new columns at once', function () {
@ -290,30 +316,49 @@ describe('Doc Table', function () {
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_body_formatted/);
expect(
$after
.eq(3)
.text()
.trim()
).to.match(/^bytes_formatted/);
expect(
$after
.eq(4)
.text()
.trim()
).to.match(/^request_body_formatted/);
});
it('handles three new columns in odd places', function () {
$root.columns = [
'@timestamp',
'bytes',
'_source',
'request_body'
];
$root.columns = ['@timestamp', 'bytes', '_source', 'request_body'];
$root.$apply();
const $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
.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_body_formatted/);
expect(
$after
.eq(5)
.text()
.trim()
).to.match(/^request_body_formatted/);
});
it('handles a removed column', function () {
_.pull($root.columns, '_source');
$root.$apply();
@ -359,7 +404,12 @@ describe('Doc Table', function () {
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/);
expect(
$after
.eq(2)
.text()
.trim()
).to.match(/^@timestamp_formatted/);
});
it('handles two columns with the same content', function () {
@ -372,8 +422,18 @@ describe('Doc Table', function () {
const $after = $row.find('td');
expect($after).to.have.length(4);
expect($after.eq(2).text().trim()).to.match(/^bytes_formatted/);
expect($after.eq(3).text().trim()).to.match(/^bytes_formatted/);
expect(
$after
.eq(2)
.text()
.trim()
).to.match(/^bytes_formatted/);
expect(
$after
.eq(3)
.text()
.trim()
).to.match(/^bytes_formatted/);
});
it('handles two columns swapping position', function () {
@ -423,9 +483,24 @@ describe('Doc Table', function () {
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/);
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

@ -32,8 +32,6 @@ import truncateByHeightTemplateHtml from '../components/table_row/truncate_by_he
const module = uiModules.get('app/discover');
// guesstimate at the minimum number of chars wide cells in the table should be
const MIN_LINE_LENGTH = 20;
@ -93,18 +91,14 @@ module.directive('kbnTableRow', function ($compile, $httpParamSerializer, kbnUrl
// empty the details and rebuild it
$detailsTr.html(detailsHtml);
$detailsScope.row = $scope.row;
$detailsScope.uriEncodedId = encodeURIComponent($detailsScope.row._id);
$detailsScope.hit = $scope.row;
$detailsScope.uriEncodedId = encodeURIComponent($detailsScope.hit._id);
$compile($detailsTr)($detailsScope);
};
$scope.$watchMulti([
'indexPattern.timeFieldName',
'row.highlight',
'[]columns'
], function () {
$scope.$watchMulti(['indexPattern.timeFieldName', 'row.highlight', '[]columns'], function () {
createSummaryRow($scope.row, $scope.row._id);
});
@ -135,37 +129,38 @@ module.directive('kbnTableRow', function ($compile, $httpParamSerializer, kbnUrl
$scope.flattenedRow = indexPattern.flattenHit(row);
// We just create a string here because its faster.
const newHtmls = [
openRowHtml
];
const newHtmls = [openRowHtml];
const mapping = indexPattern.fields.byName;
const hideTimeColumn = config.get('doc_table:hideTimeColumn');
if (indexPattern.timeFieldName && !hideTimeColumn) {
newHtmls.push(cellTemplate({
timefield: true,
formatted: _displayField(row, indexPattern.timeFieldName),
filterable: (
mapping[indexPattern.timeFieldName].filterable
&& _.isFunction($scope.filter)
),
column: indexPattern.timeFieldName
}));
newHtmls.push(
cellTemplate({
timefield: true,
formatted: _displayField(row, indexPattern.timeFieldName),
filterable:
mapping[indexPattern.timeFieldName].filterable && _.isFunction($scope.filter),
column: indexPattern.timeFieldName,
})
);
}
$scope.columns.forEach(function (column) {
const isFilterable = $scope.flattenedRow[column] !== undefined
&& mapping[column]
&& mapping[column].filterable
&& _.isFunction($scope.filter);
const isFilterable =
$scope.flattenedRow[column] !== undefined &&
mapping[column] &&
mapping[column].filterable &&
_.isFunction($scope.filter);
newHtmls.push(cellTemplate({
timefield: false,
sourcefield: (column === '_source'),
formatted: _displayField(row, column, true),
filterable: isFilterable,
column
}));
newHtmls.push(
cellTemplate({
timefield: false,
sourcefield: column === '_source',
formatted: _displayField(row, column, true),
filterable: isFilterable,
column,
})
);
});
let $cells = $el.children();
@ -213,12 +208,12 @@ module.directive('kbnTableRow', function ($compile, $httpParamSerializer, kbnUrl
if (truncate && text.length > MIN_LINE_LENGTH) {
return truncateByHeightTemplate({
body: text
body: text,
});
}
return text;
}
}
},
};
});

View file

@ -38,13 +38,15 @@
</div>
</div>
</div>
<doc-viewer
columns="columns"
filter="filter"
hit="row"
index-pattern="indexPattern"
on-add-column="onAddColumn"
on-remove-column="onRemoveColumn"
data-test-subj="docViewer"
></doc-viewer>
<div data-test-subj="docViewer">
<doc-viewer
columns="columns"
filter="filter"
hit="hit"
index-pattern="indexPattern"
on-add-column="onAddColumn"
on-remove-column="onRemoveColumn"
></doc-viewer>
</div>
</td>

View file

@ -54,8 +54,8 @@
</div>
<!-- result -->
<div class="kuiViewContentItem" ng-if="status === 'found'">
<doc-viewer hit="hit" index-pattern="indexPattern" data-test-subj="doc-hit"></doc-viewer>
<div class="kuiViewContentItem" ng-if="status === 'found'" data-test-subj="doc-hit">
<doc-viewer hit="hit" index-pattern="indexPattern"></doc-viewer>
</div>
</div>
</div>

View file

@ -0,0 +1,56 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render <DocViewer/> with 3 different tabs 1`] = `
<div
className="kbnDocViewer"
>
<EuiTabbedContent
autoFocus="initial"
tabs={
Array [
Object {
"content": <DocViewerTab
id={0}
render={[MockFunction]}
renderProps={
Object {
"hit": Object {},
}
}
title="Render function"
/>,
"id": "Render function",
"name": "Render function",
},
Object {
"content": <DocViewerTab
component={[Function]}
id={1}
renderProps={
Object {
"hit": Object {},
}
}
title="React component"
/>,
"id": "React component",
"name": "React component",
},
Object {
"content": <DocViewerTab
id={2}
renderProps={
Object {
"hit": Object {},
}
}
title="Invalid doc view"
/>,
"id": "Invalid doc view",
"name": "Invalid doc view",
},
]
}
/>
</div>
`;

View file

@ -0,0 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Mounting and unmounting DocViewerRenderTab 1`] = `
[MockFunction] {
"calls": Array [
Array [
<div />,
Object {
"hit": Object {},
},
],
],
"results": Array [
Object {
"type": "return",
"value": [MockFunction],
},
],
}
`;

View file

@ -1,93 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import angular from 'angular';
import _ from 'lodash';
import expect from '@kbn/expect';
import ngMock from 'ng_mock';
import 'ui/private';
import { DocViewsRegistryProvider } from 'ui/registry/doc_views';
import { uiRegistry } from 'ui/registry/_registry';
describe('docViewer', function () {
let stubRegistry;
let $elem;
let init;
beforeEach(function () {
ngMock.module('kibana', function (PrivateProvider) {
stubRegistry = uiRegistry({
index: ['name'],
order: ['order'],
constructor() {
this.forEach(docView => {
docView.shouldShow = docView.shouldShow || _.constant(true);
docView.name = docView.name || docView.title;
});
}
});
PrivateProvider.swap(DocViewsRegistryProvider, stubRegistry);
});
// Create the scope
ngMock.inject(function () {});
});
beforeEach(function () {
$elem = angular.element('<doc-viewer></doc-viewer>');
init = function init() {
ngMock.inject(function ($rootScope, $compile) {
$compile($elem)($rootScope);
$elem.scope().$digest();
return $elem;
});
};
});
describe('injecting views', function () {
function registerExtension(def = {}) {
stubRegistry.register(function () {
return _.defaults(def, {
title: 'exampleView',
order: 0,
directive: {
template: `Example`
}
});
});
}
it('should have a tab for the view', function () {
registerExtension();
registerExtension({ title: 'exampleView2' });
init();
expect($elem.find('.euiTabs button').length).to.be(2);
});
it('should activate the first view in order', function () {
registerExtension({ order: 2 });
registerExtension({ title: 'exampleView2' });
init();
expect($elem.find('.euiTabs .euiTab-isSelected').text().trim()).to.be('exampleView2');
});
});
});

View file

@ -1,84 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import $ from 'jquery';
import { uiModules } from 'ui/modules';
import { DocViewsRegistryProvider } from 'ui/registry/doc_views';
import 'ui/directives/render_directive';
uiModules.get('apps/discover')
.directive('docViewer', function (Private) {
const docViews = Private(DocViewsRegistryProvider);
return {
restrict: 'E',
scope: {
hit: '=',
indexPattern: '=',
filter: '=?',
columns: '=?',
onAddColumn: '=?',
onRemoveColumn: '=?',
},
template: function ($el) {
const $viewer = $('<div class="kbnDocViewer">');
$el.append($viewer);
const $tabs = $('<div class="euiTabs euiTabs--small" role="tablist">');
const $content = $('<div class="kbnDocViewer__content" role="tabpanel">');
$viewer.append($tabs);
$viewer.append($content);
docViews.inOrder.forEach(view => {
const $tab = $(
`<button
ng-show="docViews['${view.name}'].shouldShow(hit)"
class="euiTab"
ng-class="{'euiTab euiTab-isSelected': mode === '${view.name}', 'euiTab': true}"
role="tab"
aria-selected="{{mode === '${view.name}'}}"
ng-click="mode='${view.name}'"
>
<span
class="euiTab__content"
>
${view.title}
</span>
</button>`
);
$tabs.append($tab);
const $viewAttrs = `
hit="hit"
index-pattern="indexPattern"
filter="filter"
columns="columns"
on-add-column="onAddColumn"
on-remove-column="onRemoveColumn"
`;
const $ext = $(`<render-directive ${$viewAttrs} ng-if="mode == '${view.name}'" definition="docViews['${view.name}'].directive">
</render-directive>`);
$ext.html(view.directive.template);
$content.append($ext);
});
return $el.html();
},
controller: function ($scope) {
$scope.mode = docViews.inOrder[0].name;
$scope.docViews = docViews.byName;
}
};
});

View file

@ -0,0 +1,62 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { mount, shallow } from 'enzyme';
import { DocViewer } from './doc_viewer';
// @ts-ignore
import { findTestSubject } from '@elastic/eui/lib/test';
import { addDocView, emptyDocViews, DocViewRenderProps } from 'ui/registry/doc_views';
beforeEach(() => {
emptyDocViews();
});
test('Render <DocViewer/> with 3 different tabs', () => {
addDocView({ order: 20, title: 'React component', component: () => <div>test</div> });
addDocView({ order: 10, title: 'Render function', render: jest.fn() });
addDocView({ order: 30, title: 'Invalid doc view' });
const renderProps = { hit: {} } as DocViewRenderProps;
const wrapper = shallow(<DocViewer {...renderProps} />);
expect(wrapper).toMatchSnapshot();
});
test('Render <DocViewer/> with 1 tab displaying error message', () => {
function SomeComponent() {
// this is just a placeholder
return null;
}
addDocView({
order: 10,
title: 'React component',
component: SomeComponent,
});
const renderProps = { hit: {} } as DocViewRenderProps;
const errorMsg = 'Catch me if you can!';
const wrapper = mount(<DocViewer {...renderProps} />);
const error = new Error(errorMsg);
wrapper.find(SomeComponent).simulateError(error);
const errorMsgComponent = findTestSubject(wrapper, 'docViewerError');
expect(errorMsgComponent.text()).toMatch(new RegExp(`${errorMsg}`));
});

View file

@ -0,0 +1,52 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { EuiTabbedContent } from '@elastic/eui';
import { getDocViewsSorted, DocViewRenderProps } from 'ui/registry/doc_views';
import { DocViewerTab } from './doc_viewer_tab';
/**
* Rendering tabs with different views of 1 Elasticsearch hit in Discover.
* The tabs are provided by the `docs_views` registry.
* A view can contain a React `component`, or any JS framework by using
* a `render` function.
*/
export function DocViewer(renderProps: DocViewRenderProps) {
const tabs = getDocViewsSorted(renderProps.hit).map(({ title, render, component }, idx) => {
return {
id: title,
name: title,
content: (
<DocViewerTab
id={idx}
title={title}
component={component}
renderProps={renderProps}
render={render}
/>
),
};
});
return (
<div className="kbnDocViewer">
<EuiTabbedContent tabs={tabs} />
</div>
);
}

View file

@ -16,28 +16,21 @@
* specific language governing permissions and limitations
* under the License.
*/
// @ts-ignore
import { DocViewsRegistryProvider } from 'ui/registry/doc_views';
import { i18n } from '@kbn/i18n';
import { JsonCodeEditor } from './json_code_editor';
/*
* Registration of the the doc view: json
* - used to display an ES hit as pretty printed JSON at Discover
* - registered as angular directive to stay compatible with community plugins
*/
DocViewsRegistryProvider.register(function(reactDirective: any) {
const reactDir = reactDirective(JsonCodeEditor, ['hit']);
// setting of reactDir.scope is required to assign $scope props
// to the react component via render-directive in doc_viewer.js
reactDir.scope = {
hit: '=',
};
return {
title: i18n.translate('kbnDocViews.json.jsonTitle', {
defaultMessage: 'JSON',
}),
order: 20,
directive: reactDir,
};
// @ts-ignore
import { uiModules } from 'ui/modules';
import { DocViewer } from './doc_viewer';
uiModules.get('apps/discover').directive('docViewer', (reactDirective: any) => {
return reactDirective(DocViewer, undefined, {
restrict: 'E',
scope: {
hit: '=',
indexPattern: '=',
filter: '=?',
columns: '=?',
onAddColumn: '=?',
onRemoveColumn: '=?',
},
});
});

View file

@ -0,0 +1,37 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { EuiCallOut, EuiCodeBlock } from '@elastic/eui';
// @ts-ignore
import { formatMsg, formatStack } from '../../../../ui/public/notify/lib';
interface Props {
error: Error | string | null;
}
export function DocViewerError({ error }: Props) {
const errMsg = formatMsg(error);
const errStack = error ? formatStack(error) : '';
return (
<EuiCallOut title={errMsg} color="danger" iconType="cross" data-test-subj="docViewerError">
{errStack && <EuiCodeBlock>{errStack}</EuiCodeBlock>}
</EuiCallOut>
);
}

View file

@ -0,0 +1,40 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { mount } from 'enzyme';
import { DocViewRenderTab } from './doc_viewer_render_tab';
import { DocViewRenderProps } from 'ui/registry/doc_views';
test('Mounting and unmounting DocViewerRenderTab', () => {
const unmountFn = jest.fn();
const renderFn = jest.fn(() => unmountFn);
const renderProps = {
hit: {},
};
const wrapper = mount(
<DocViewRenderTab render={renderFn} renderProps={renderProps as DocViewRenderProps} />
);
expect(renderFn).toMatchSnapshot();
wrapper.unmount();
expect(unmountFn).toBeCalled();
});

View file

@ -0,0 +1,40 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useRef, useEffect } from 'react';
import { DocViewRenderFn, DocViewRenderProps } from 'ui/registry/doc_views';
interface Props {
render: DocViewRenderFn;
renderProps: DocViewRenderProps;
}
/**
* Responsible for rendering a tab provided by a render function.
* So any other framework can be used (E.g. legacy Angular 3rd party plugin code)
* The provided `render` function is called with a reference to the
* component's `HTMLDivElement` as 1st arg and `renderProps` as 2nd arg
*/
export function DocViewRenderTab({ render, renderProps }: Props) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref && ref.current) {
return render(ref.current, renderProps);
}
}, [render, renderProps]);
return <div ref={ref} />;
}

View file

@ -0,0 +1,83 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { DocViewRenderProps, DocViewRenderFn } from 'ui/registry/doc_views';
import { DocViewRenderTab } from './doc_viewer_render_tab';
import { DocViewerError } from './doc_viewer_render_error';
interface Props {
component?: React.ComponentType<DocViewRenderProps>;
id: number;
render?: DocViewRenderFn;
renderProps: DocViewRenderProps;
title: string;
}
interface State {
error: null | Error | string;
hasError: boolean;
}
/**
* Renders the tab content of a doc view.
* Displays an error message when it encounters exceptions, thanks to
* Error Boundaries.
*/
export class DocViewerTab extends React.Component<Props, State> {
state = {
hasError: false,
error: null,
};
static getDerivedStateFromError(error: unknown) {
// Update state so the next render will show the fallback UI.
return { hasError: true, error };
}
shouldComponentUpdate(nextProps: Props, nextState: State) {
return (
nextProps.renderProps.hit._id !== this.props.renderProps.hit._id ||
nextProps.id !== this.props.id ||
nextState.hasError
);
}
render() {
const { component, render, renderProps, title } = this.props;
const { hasError, error } = this.state;
if (hasError && error) {
return <DocViewerError error={error} />;
} else if (!render && !component) {
return (
<DocViewerError
error={`Invalid plugin ${title}, there is neither a (react) component nor a render function provided`}
/>
);
}
if (render) {
// doc view is provided by a render function, e.g. for legacy Angular code
return <DocViewRenderTab render={render} renderProps={renderProps} />;
}
// doc view is provided by a react component
const Component = component as any;
return <Component {...renderProps} />;
}
}

View file

@ -17,4 +17,4 @@
* under the License.
*/
import './doc_viewer';
import './doc_viewer_directive';

View file

@ -26,6 +26,12 @@ import { ChromeNavLinks } from './api/nav';
export interface IInjector {
get<T>(injectable: string): T;
invoke<T, T2>(
injectable: (this: T2, ...args: any[]) => T,
self?: T2,
locals?: { [key: string]: any }
): T;
instantiate(constructor: Function, locals?: { [key: string]: any }): any;
}
declare interface Chrome extends ChromeNavLinks {

View file

@ -0,0 +1,63 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { convertDirectiveToRenderFn } from './doc_views_helpers';
import { DocView, DocViewInput, ElasticSearchHit, DocViewInputFn } from './doc_views_types';
export { DocViewRenderProps, DocView, DocViewRenderFn } from './doc_views_types';
export const docViews: DocView[] = [];
/**
* Extends and adds the given doc view to the registry array
*/
export function addDocView(docView: DocViewInput) {
if (docView.directive) {
// convert angular directive to render function for backwards compatibility
docView.render = convertDirectiveToRenderFn(docView.directive);
}
if (typeof docView.shouldShow !== 'function') {
docView.shouldShow = () => true;
}
docViews.push(docView as DocView);
}
/**
* Empty array of doc views for testing
*/
export function emptyDocViews() {
docViews.length = 0;
}
/**
* Returns a sorted array of doc_views for rendering tabs
*/
export function getDocViewsSorted(hit: ElasticSearchHit): DocView[] {
return docViews
.filter(docView => docView.shouldShow(hit))
.sort((a, b) => (Number(a.order) > Number(b.order) ? 1 : -1));
}
/**
* Provider for compatibility with 3rd Party plugins
*/
export const DocViewsRegistryProvider = {
register: (docViewRaw: DocViewInput | DocViewInputFn) => {
const docView = typeof docViewRaw === 'function' ? docViewRaw() : docViewRaw;
addDocView(docView);
},
};

View file

@ -0,0 +1,94 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { render } from 'react-dom';
import angular, { ICompileService } from 'angular';
import chrome from 'ui/chrome';
import {
DocViewRenderProps,
AngularScope,
AngularController,
AngularDirective,
} from './doc_views_types';
import { DocViewerError } from '../../../core_plugins/kibana/public/doc_viewer/doc_viewer_render_error';
/**
* Compiles and injects the give angular template into the given dom node
* returns a function to cleanup the injected angular element
*/
export async function injectAngularElement(
domNode: Element,
template: string,
scopeProps: DocViewRenderProps,
Controller: AngularController
): Promise<() => void> {
const $injector = await chrome.dangerouslyGetActiveInjector();
const rootScope: AngularScope = $injector.get('$rootScope');
const $compile: ICompileService = $injector.get('$compile');
const newScope = Object.assign(rootScope.$new(), scopeProps);
if (typeof Controller === 'function') {
// when a controller is defined, expose the value it produces to the view as `$ctrl`
// see: https://docs.angularjs.org/api/ng/provider/$compileProvider#component
(newScope as any).$ctrl = $injector.instantiate(Controller, {
$scope: newScope,
});
}
const $target = angular.element(domNode);
const $element = angular.element(template);
newScope.$apply(() => {
const linkFn = $compile($element);
$target.empty().append($element);
linkFn(newScope);
});
return () => {
newScope.$destroy();
};
}
/**
* Converts a given legacy angular directive to a render function
* for usage in a react component. Note that the rendering is async
*/
export function convertDirectiveToRenderFn(directive: AngularDirective) {
return (domNode: Element, props: DocViewRenderProps) => {
let rejected = false;
const cleanupFnPromise = injectAngularElement(
domNode,
directive.template,
props,
directive.controller
);
cleanupFnPromise.catch(e => {
rejected = true;
render(<DocViewerError error={e} />, domNode);
});
return () => {
if (!rejected) {
// for cleanup
// http://roubenmeschian.com/rubo/?p=51
cleanupFnPromise.then(cleanup => cleanup());
}
};
};
}

View file

@ -0,0 +1,60 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { IndexPattern } from 'src/legacy/core_plugins/data/public';
import { ComponentType } from 'react';
import { IScope } from 'angular';
export interface AngularDirective {
controller: (scope: AngularScope) => void;
template: string;
}
export type AngularScope = IScope;
export type AngularController = (scope: AngularScope) => void;
export type ElasticSearchHit = Record<string, string | number | Record<string, unknown>>;
export interface DocViewRenderProps {
columns: string[];
filter: (field: string, value: string | number, operation: string) => void;
hit: ElasticSearchHit;
indexPattern: IndexPattern;
onAddColumn: (columnName: string) => void;
onRemoveColumn: (columnName: string) => void;
}
export type DocViewRenderFn = (
domeNode: HTMLDivElement,
renderProps: DocViewRenderProps
) => () => void;
export interface DocViewInput {
component?: ComponentType<DocViewRenderProps>;
directive?: AngularDirective;
order: number;
render?: DocViewRenderFn;
shouldShow?: (hit: ElasticSearchHit) => boolean;
title: string;
}
export interface DocView extends DocViewInput {
shouldShow: (hit: ElasticSearchHit) => boolean;
}
export type DocViewInputFn = () => DocViewInput;

View file

@ -43,18 +43,18 @@ export default function ({ getService, getPageObjects }) {
it('should be addable via expanded doc table rows', async function () {
await docTable.toggleRowExpanded({ isAnchorRow: true });
const anchorDetailsRow = await docTable.getAnchorDetailsRow();
await docTable.addInclusiveFilter(anchorDetailsRow, TEST_ANCHOR_FILTER_FIELD);
await PageObjects.context.waitUntilContextLoadingHasFinished();
await docTable.toggleRowExpanded({ isAnchorRow: true });
await retry.try(async () => {
expect(await filterBar.hasFilter(TEST_ANCHOR_FILTER_FIELD, TEST_ANCHOR_FILTER_VALUE, true)).to.be(true);
const anchorDetailsRow = await docTable.getAnchorDetailsRow();
await docTable.addInclusiveFilter(anchorDetailsRow, TEST_ANCHOR_FILTER_FIELD);
await PageObjects.context.waitUntilContextLoadingHasFinished();
// await docTable.toggleRowExpanded({ isAnchorRow: true });
expect(
await filterBar.hasFilter(TEST_ANCHOR_FILTER_FIELD, TEST_ANCHOR_FILTER_VALUE, true)
).to.be(true);
const fields = await docTable.getFields();
const hasOnlyFilteredRows = fields
.map(row => row[2])
.every((fieldContent) => fieldContent === TEST_ANCHOR_FILTER_VALUE);
.every(fieldContent => fieldContent === TEST_ANCHOR_FILTER_VALUE);
expect(hasOnlyFilteredRows).to.be(true);
});
});
@ -67,11 +67,13 @@ export default function ({ getService, getPageObjects }) {
await PageObjects.context.waitUntilContextLoadingHasFinished();
retry.try(async () => {
expect(await filterBar.hasFilter(TEST_ANCHOR_FILTER_FIELD, TEST_ANCHOR_FILTER_VALUE, false)).to.be(true);
expect(
await filterBar.hasFilter(TEST_ANCHOR_FILTER_FIELD, TEST_ANCHOR_FILTER_VALUE, false)
).to.be(true);
const fields = await docTable.getFields();
const hasOnlyFilteredRows = fields
.map(row => row[2])
.every((fieldContent) => fieldContent === TEST_ANCHOR_FILTER_VALUE);
.every(fieldContent => fieldContent === TEST_ANCHOR_FILTER_VALUE);
expect(hasOnlyFilteredRows).to.be(false);
});
});