mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
* [Management] Saved objects to React/EUI! (#17426)
* Not working proto code
* More proto code
* Work in progress
* Just go back to non interactive searching, much easier
* This should be on the server
* Revert "[@kbn/ui-framework] move ui-framework to a package (#17085)"
This reverts commit ef3339bd7a
.
* Revert "Revert "[@kbn/ui-framework] move ui-framework to a package (#17085)""
This reverts commit ce9ce14e1060c426090b55a5367de3ff4329e681.
* Use BasicTable properly
* Table improvements
* Small tweaks to the table
* Improvements
* Flyout mostly working
* Remove in memory table
* Getting close
* Tweaks
* Revamping server code, still need to support editing
* Progress
* Fix export
* Updates and passing functional tests
* Better links in relationships flyout
* Add skip import option
* Fixes around importing and removing unnecessary code
* Remove tags for now
* Tests for lib/
* Some fixes
* Ensure we clear index pattern cache
* Parity with master
* Revert any changes in package.json
* Reset any changes in this file
* Move the new argumen to the end to prevent test failures
* Fix functional tests
* Add relationship tests
* Fix tests
* API integration tests for relationships
* Ensure we're properly waiting for things to happen
* Fix test issue
* Wait for the table to finish loading instead of the whole page
* Tests for objects_table
* Componentry tests
* Ensure this is grabbing the right field
* Update snapshot
* Fixes with importing index patterns
* PR feedback
* PR feedback
* PR feedback
* Update snapshot
* PR feedback
* Update snapshot
* Respect the savedObjects:perPage config
* Updates from PR feedback
* More updates from PR feedback
* Make this more efficient
* Add debugging for functional test failures
* Wait longer
* Wrap each button accessor with a retry.try
* Try wrapping this in a retry.try
* Debug
* Lets make sure it is visible
* Maybe the short timeout is affecting this - use the default timeout which should be higher and allow more time for the animation to finish
* Rewrite this per suggestions from stacey
* Update snapshots
This commit is contained in:
parent
eae18989a3
commit
8455bcdbba
74 changed files with 6302 additions and 890 deletions
|
@ -9,6 +9,7 @@ import { scrollSearchApi } from './server/routes/api/scroll_search';
|
|||
import { importApi } from './server/routes/api/import';
|
||||
import { exportApi } from './server/routes/api/export';
|
||||
import { homeApi } from './server/routes/api/home';
|
||||
import { managementApi } from './server/routes/api/management';
|
||||
import { scriptsApi } from './server/routes/api/scripts';
|
||||
import { registerSuggestionsApi } from './server/routes/api/suggestions';
|
||||
import { registerFieldFormats } from './server/field_formats/register';
|
||||
|
@ -133,6 +134,7 @@ export default function (kibana) {
|
|||
importApi(server);
|
||||
exportApi(server);
|
||||
homeApi(server);
|
||||
managementApi(server);
|
||||
registerSuggestionsApi(server);
|
||||
registerFieldFormats(server);
|
||||
registerTutorials(server);
|
||||
|
|
|
@ -1,212 +1,5 @@
|
|||
<kbn-management-app section="kibana" class="kuiView">
|
||||
<kbn-management-objects class="kuiViewContent kuiViewContent--constrainedWidth">
|
||||
<!-- Header -->
|
||||
<div class="kuiViewContentItem kuiBar kuiVerticalRhythm">
|
||||
<div class="kuiBarSection">
|
||||
<h1 class="euiTitle">
|
||||
Edit Saved Objects
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="kuiBarSection">
|
||||
<button
|
||||
class="kuiButton kuiButton--basic kuiButton--iconText"
|
||||
data-test-subj="exportAllObjects"
|
||||
ng-click="exportAll()"
|
||||
>
|
||||
<span class="kuiButton__inner">
|
||||
<span aria-hidden="true" class="kuiButton__icon kuiIcon fa-download"></span>
|
||||
<span>Export Everything</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<file-upload
|
||||
on-read="importAll(fileContents)"
|
||||
upload-selector="[data-import-saved-objects-button]"
|
||||
>
|
||||
<button
|
||||
class="kuiButton kuiButton--basic kuiButton--iconText"
|
||||
data-import-saved-objects-button
|
||||
>
|
||||
<span class="kuiButton__inner">
|
||||
<span aria-hidden="true" class="kuiButton__icon kuiIcon fa-upload"></span>
|
||||
<span>Import</span>
|
||||
</span>
|
||||
</button>
|
||||
</file-upload>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Intro -->
|
||||
<div class="euiText">
|
||||
<p class="kuiViewContentItem">
|
||||
From here you can delete saved objects, such as saved searches. You can also edit the raw data of saved objects. Typically objects are only modified via their associated application, which is probably what you should use instead of this screen. Each tab is limited to 100 results. You can use the filter to find objects not in the default list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="euiSpacer euiSpacer--m"></div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="kuiViewContentItem kuiVerticalRhythm">
|
||||
<div class="kuiTabs">
|
||||
<button
|
||||
class="kuiTab kbn-management-tab"
|
||||
ng-class="{ 'kuiTab-isSelected': state.tab === service.title }"
|
||||
ng-repeat="service in services"
|
||||
ng-click="changeTab(service)"
|
||||
data-test-subj="objectsTab-{{ service.title }}"
|
||||
>
|
||||
{{ service.title }}
|
||||
<small aria-label="{{:: service.data.length + ' of ' + service.total + ' ' + service.title }}">
|
||||
({{service.data.length}}<span ng-show="service.total > service.data.length"> of {{service.total}}</span>)
|
||||
</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ControlledTable -->
|
||||
<div
|
||||
class="kuiViewContentItem kuiControlledTable kuiVerticalRhythm"
|
||||
ng-repeat="service in services track by $index"
|
||||
ng-show="state.tab === service.title"
|
||||
>
|
||||
<!-- ToolBar -->
|
||||
<div class="kuiToolBar">
|
||||
<div class="kuiToolBarSearch">
|
||||
<div
|
||||
class="kuiToolBarSearchBox"
|
||||
role="search"
|
||||
>
|
||||
<div class="kuiToolBarSearchBox__icon kuiIcon fa-search"></div>
|
||||
<input
|
||||
class="kuiToolBarSearchBox__input"
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
aria-label="Search"
|
||||
ng-model="managementObjectsController.advancedFilter"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kuiToolBarSection">
|
||||
<!-- Bulk delete button -->
|
||||
<button
|
||||
class="kuiButton kuiButton--danger kuiButton--iconText"
|
||||
ng-click="bulkDelete()"
|
||||
aria-label="Delete selected objects"
|
||||
ng-disabled="selectedItems.length == 0"
|
||||
>
|
||||
<span class="kuiButton__inner">
|
||||
<span aria-hidden="true" class="kuiButton__icon kuiIcon fa-trash"></span>
|
||||
<span>Delete</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Bulk export button -->
|
||||
<button
|
||||
class="kuiButton kuiButton--basic kuiButton--iconText"
|
||||
ng-click="bulkExport()"
|
||||
aria-label="Export selected objects"
|
||||
ng-disabled="selectedItems.length == 0"
|
||||
>
|
||||
<span class="kuiButton__inner">
|
||||
<span aria-hidden="true" class="kuiButton__icon kuiIcon fa-download"></span>
|
||||
<span>Export</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="kuiToolBarSection">
|
||||
<!-- We need an empty section for the buttons to be positioned consistently. -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NoResults -->
|
||||
<div
|
||||
class="kuiPanel kuiPanel--centered kuiPanel--withToolBar"
|
||||
ng-if="!service.data.length"
|
||||
>
|
||||
<div class="kuiTableInfo">
|
||||
No {{service.title}} matched your search.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<table class="kuiTable" ng-if="service.data.length" data-test-subj="objectsTable-{{service.title}}">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="kuiTableHeaderCell kuiTableHeaderCell--checkBox">
|
||||
<div class="kuiTableHeaderCell__liner">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Select All"
|
||||
class="kuiCheckBox"
|
||||
ng-checked="managementObjectsController.areAllRowsChecked()"
|
||||
ng-click="toggleAll()"
|
||||
aria-label="{{managementObjectsController.areAllRowsChecked() ? 'Deselect all rows' : 'Select all rows'}}"
|
||||
>
|
||||
</div>
|
||||
</th>
|
||||
|
||||
<th scope="col" class="kuiTableHeaderCell">
|
||||
<div class="kuiTableHeaderCell__liner">
|
||||
Title
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr
|
||||
ng-repeat="item in service.data | orderBy:'title'"
|
||||
class="kuiTableRow"
|
||||
data-test-subj="objectsTableRow"
|
||||
>
|
||||
<td class="kuiTableRowCell kuiTableRowCell--checkBox">
|
||||
<div class="kuiTableRowCell__liner">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="kuiCheckBox"
|
||||
ng-click="toggleItem(item)"
|
||||
ng-checked="selectedItems.indexOf(item) >= 0"
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
<td class="kuiTableRowCell">
|
||||
<div class="kuiTableRowCell__liner">
|
||||
<a class="kuiLink" href="" ng-click="edit(service, item)">
|
||||
{{ item.title }}
|
||||
</a>
|
||||
|
||||
<button
|
||||
class="kuiMicroButton kuiTableRowHoverReveal"
|
||||
ng-click="open(item)"
|
||||
aria-label="View"
|
||||
tooltip="View in app"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="kuiIcon fa-eye"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- ToolBarFooter -->
|
||||
<div class="kuiToolBarFooter">
|
||||
<div class="kuiToolBarFooterSection">
|
||||
<div class="kuiToolBarText" ng-hide="selectedItems.length === 0">
|
||||
{{ selectedItems.length }} selected
|
||||
</div>
|
||||
</div>
|
||||
<div class="kuiToolBarFooterSection">
|
||||
<!-- We need an empty section for the buttons to be positioned consistently. -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="reactSavedObjectsTable"></div>
|
||||
</kbn-management-objects>
|
||||
</kbn-management-app>
|
||||
|
|
|
@ -1,338 +1,85 @@
|
|||
import { saveAs } from '@elastic/filesaver';
|
||||
import { find, flattenDeep, pluck, sortBy } from 'lodash';
|
||||
import angular from 'angular';
|
||||
import { savedObjectManagementRegistry } from '../../saved_object_registry';
|
||||
import objectIndexHTML from './_objects.html';
|
||||
import 'ui/directives/file_upload';
|
||||
import uiRoutes from 'ui/routes';
|
||||
import chrome from 'ui/chrome';
|
||||
import { toastNotifications } from 'ui/notify';
|
||||
import { SavedObjectsClientProvider } from 'ui/saved_objects';
|
||||
import { uiModules } from 'ui/modules';
|
||||
import { showChangeIndexModal } from './show_change_index_modal';
|
||||
import { SavedObjectNotFound } from 'ui/errors';
|
||||
import React from 'react';
|
||||
import { render, unmountComponentAtNode } from 'react-dom';
|
||||
import { ObjectsTable } from './components/objects_table';
|
||||
import { getInAppUrl } from './lib/get_in_app_url';
|
||||
|
||||
const indexPatternsResolutions = {
|
||||
indexPatterns: function (Private) {
|
||||
const savedObjectsClient = Private(SavedObjectsClientProvider);
|
||||
const REACT_OBJECTS_TABLE_DOM_ELEMENT_ID = 'reactSavedObjectsTable';
|
||||
|
||||
return savedObjectsClient.find({
|
||||
type: 'index-pattern',
|
||||
fields: ['title'],
|
||||
perPage: 10000
|
||||
}).then(response => response.savedObjects);
|
||||
}
|
||||
};
|
||||
function updateObjectsTable($scope, $injector) {
|
||||
const Private = $injector.get('Private');
|
||||
const indexPatterns = $injector.get('indexPatterns');
|
||||
const $http = $injector.get('$http');
|
||||
const kbnUrl = $injector.get('kbnUrl');
|
||||
const config = $injector.get('config');
|
||||
|
||||
const savedObjectsClient = Private(SavedObjectsClientProvider);
|
||||
const services = savedObjectManagementRegistry.all().map(obj => $injector.get(obj.service));
|
||||
const allServices = savedObjectManagementRegistry.all();
|
||||
const typeToServiceName = type => allServices.reduce((serviceName, service) => {
|
||||
return service.title.includes(type) ? service.service : serviceName;
|
||||
}, null);
|
||||
|
||||
$scope.$$postDigest(() => {
|
||||
const node = document.getElementById(REACT_OBJECTS_TABLE_DOM_ELEMENT_ID);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
render(
|
||||
<ObjectsTable
|
||||
savedObjectsClient={savedObjectsClient}
|
||||
services={services}
|
||||
indexPatterns={indexPatterns}
|
||||
$http={$http}
|
||||
perPageConfig={config.get('savedObjects:perPage')}
|
||||
basePath={chrome.getBasePath()}
|
||||
newIndexPatternUrl={kbnUrl.eval('#/management/kibana/index')}
|
||||
getEditUrl={(id, type) => {
|
||||
if (type === 'index-pattern') {
|
||||
return kbnUrl.eval(`#/management/kibana/indices/${id}`);
|
||||
}
|
||||
const serviceName = typeToServiceName(type);
|
||||
if (!serviceName) {
|
||||
toastNotifications.addWarning(`Unknown saved object type: ${type}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return kbnUrl.eval(`#/management/kibana/objects/${serviceName}/${id}`);
|
||||
}}
|
||||
goInApp={(id, type) => {
|
||||
kbnUrl.change(getInAppUrl(id, type));
|
||||
$scope.$apply();
|
||||
}}
|
||||
/>,
|
||||
node,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function destroyObjectsTable() {
|
||||
const node = document.getElementById(REACT_OBJECTS_TABLE_DOM_ELEMENT_ID);
|
||||
node && unmountComponentAtNode(node);
|
||||
}
|
||||
|
||||
uiRoutes
|
||||
.when('/management/kibana/objects', {
|
||||
template: objectIndexHTML,
|
||||
resolve: indexPatternsResolutions
|
||||
});
|
||||
|
||||
uiRoutes
|
||||
.when('/management/kibana/objects/:service', {
|
||||
redirectTo: '/management/kibana/objects'
|
||||
});
|
||||
.when('/management/kibana/objects', { template: objectIndexHTML })
|
||||
.when('/management/kibana/objects/:service', { redirectTo: '/management/kibana/objects' });
|
||||
|
||||
uiModules.get('apps/management')
|
||||
.directive('kbnManagementObjects', function ($route, kbnIndex, Notifier, Private, kbnUrl, Promise, confirmModal) {
|
||||
const savedObjectsClient = Private(SavedObjectsClientProvider);
|
||||
|
||||
.directive('kbnManagementObjects', function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
controllerAs: 'managementObjectsController',
|
||||
controller: function ($scope, $injector, $q, AppState) {
|
||||
const notify = new Notifier({ location: 'Saved Objects' });
|
||||
|
||||
// TODO: Migrate all scope variables to the controller.
|
||||
const $state = $scope.state = new AppState();
|
||||
$scope.currentTab = null;
|
||||
$scope.selectedItems = [];
|
||||
|
||||
this.areAllRowsChecked = function areAllRowsChecked() {
|
||||
if ($scope.currentTab.data.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return $scope.selectedItems.length === $scope.currentTab.data.length;
|
||||
};
|
||||
|
||||
const getData = function (filter) {
|
||||
const services = savedObjectManagementRegistry.all().map(function (obj) {
|
||||
const service = $injector.get(obj.service);
|
||||
return service.findAll(filter).then(function (data) {
|
||||
return {
|
||||
service: service,
|
||||
serviceName: obj.service,
|
||||
title: obj.title,
|
||||
type: service.type,
|
||||
data: data.hits,
|
||||
total: data.total
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
$q.all(services).then(function (data) {
|
||||
$scope.services = sortBy(data, 'title');
|
||||
if ($state.tab) $scope.currentTab = find($scope.services, { title: $state.tab });
|
||||
|
||||
$scope.$watch('state.tab', function (tab) {
|
||||
if (!tab) $scope.changeTab($scope.services[0]);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const refreshData = () => {
|
||||
return getData(this.advancedFilter);
|
||||
};
|
||||
|
||||
// TODO: Migrate all scope methods to the controller.
|
||||
$scope.toggleAll = function () {
|
||||
if ($scope.selectedItems.length === $scope.currentTab.data.length) {
|
||||
$scope.selectedItems.length = 0;
|
||||
} else {
|
||||
$scope.selectedItems = [].concat($scope.currentTab.data);
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: Migrate all scope methods to the controller.
|
||||
$scope.toggleItem = function (item) {
|
||||
const i = $scope.selectedItems.indexOf(item);
|
||||
if (i >= 0) {
|
||||
$scope.selectedItems.splice(i, 1);
|
||||
} else {
|
||||
$scope.selectedItems.push(item);
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: Migrate all scope methods to the controller.
|
||||
$scope.open = function (item) {
|
||||
kbnUrl.change(item.url.substr(1));
|
||||
};
|
||||
|
||||
// TODO: Migrate all scope methods to the controller.
|
||||
$scope.edit = function (service, item) {
|
||||
const params = {
|
||||
service: service.serviceName,
|
||||
id: item.id
|
||||
};
|
||||
|
||||
kbnUrl.change('/management/kibana/objects/{{ service }}/{{ id }}', params);
|
||||
};
|
||||
|
||||
// TODO: Migrate all scope methods to the controller.
|
||||
$scope.bulkDelete = function () {
|
||||
function doBulkDelete() {
|
||||
$scope.currentTab.service.delete(pluck($scope.selectedItems, 'id'))
|
||||
.then(refreshData)
|
||||
.then(function () {
|
||||
$scope.selectedItems.length = 0;
|
||||
})
|
||||
.catch(error => notify.error(error));
|
||||
}
|
||||
|
||||
const confirmModalOptions = {
|
||||
confirmButtonText: 'Delete',
|
||||
onConfirm: doBulkDelete,
|
||||
title: `Delete selected ${$scope.currentTab.title}?`
|
||||
};
|
||||
confirmModal(
|
||||
`You can't recover deleted ${$scope.currentTab.title}.`,
|
||||
confirmModalOptions
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: Migrate all scope methods to the controller.
|
||||
$scope.bulkExport = function () {
|
||||
const objs = $scope.selectedItems.map(item => {
|
||||
return { type: $scope.currentTab.type, id: item.id };
|
||||
});
|
||||
|
||||
retrieveAndExportDocs(objs);
|
||||
};
|
||||
|
||||
// TODO: Migrate all scope methods to the controller.
|
||||
$scope.exportAll = () => Promise
|
||||
.map($scope.services, service => service.service
|
||||
.scanAll('')
|
||||
.then(result => result.hits)
|
||||
)
|
||||
.then(results => saveToFile(flattenDeep(results)))
|
||||
.catch(error => notify.error(error));
|
||||
|
||||
function retrieveAndExportDocs(objs) {
|
||||
if (!objs.length) return notify.error('No saved objects to export.');
|
||||
|
||||
savedObjectsClient.bulkGet(objs)
|
||||
.then(function (response) {
|
||||
saveToFile(response.savedObjects.map(obj => {
|
||||
return {
|
||||
_id: obj.id,
|
||||
_type: obj.type,
|
||||
_source: obj.attributes
|
||||
};
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function saveToFile(results) {
|
||||
const blob = new Blob([angular.toJson(results, true)], { type: 'application/json' });
|
||||
saveAs(blob, 'export.json');
|
||||
}
|
||||
|
||||
// TODO: Migrate all scope methods to the controller.
|
||||
$scope.importAll = function (fileContents) {
|
||||
let docs;
|
||||
try {
|
||||
docs = JSON.parse(fileContents);
|
||||
} catch (e) {
|
||||
notify.error('The file could not be processed.');
|
||||
return;
|
||||
}
|
||||
|
||||
// make sure we have an array, show an error otherwise
|
||||
if (!Array.isArray(docs)) {
|
||||
notify.error('Saved objects file format is invalid and cannot be imported.');
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
confirmModal(
|
||||
'', {
|
||||
confirmButtonText: `Yes, overwrite all objects`,
|
||||
cancelButtonText: `No, prompt for each object`,
|
||||
onConfirm: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
title: 'Automatically overwrite all saved objects?'
|
||||
}
|
||||
);
|
||||
})
|
||||
.then((overwriteAll) => {
|
||||
// Keep a record of the index patterns assigned to our imported saved objects that do not
|
||||
// exist. We will provide a way for the user to manually select a new index pattern for those
|
||||
// saved objects.
|
||||
const conflictedIndexPatterns = [];
|
||||
// We want to do the same for saved searches, but we want to keep them separate because they need
|
||||
// to be applied _first_ because other saved objects can be depedent on those saved searches existing
|
||||
const conflictedSearchDocs = [];
|
||||
// It's possbile to have saved objects that link to saved searches which then link to index patterns
|
||||
// and those could error out, but the error comes as an index pattern not found error. We can't resolve
|
||||
// those the same as way as normal index pattern not found errors, but when those are fixed, it's very
|
||||
// likely that these saved objects will work once resaved so keep them around to resave them.
|
||||
const conflictedSavedObjectsLinkedToSavedSearches = [];
|
||||
|
||||
function importDocument(swallowErrors, doc) {
|
||||
const { service } = find($scope.services, { type: doc._type }) || {};
|
||||
|
||||
if (!service) {
|
||||
const msg = `Skipped import of "${doc._source.title}" (${doc._id})`;
|
||||
const reason = `Invalid type: "${doc._type}"`;
|
||||
|
||||
notify.warning(`${msg}, ${reason}`, {
|
||||
lifetime: 0,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return service.get()
|
||||
.then(function (obj) {
|
||||
obj.id = doc._id;
|
||||
return obj.applyESResp(doc)
|
||||
.then(() => {
|
||||
return obj.save({ confirmOverwrite: !overwriteAll });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (swallowErrors && err instanceof SavedObjectNotFound) {
|
||||
switch (err.savedObjectType) {
|
||||
case 'search':
|
||||
conflictedSearchDocs.push(doc);
|
||||
return;
|
||||
case 'index-pattern':
|
||||
if (obj.savedSearchId) {
|
||||
conflictedSavedObjectsLinkedToSavedSearches.push(obj);
|
||||
} else {
|
||||
conflictedIndexPatterns.push({ obj, doc });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// swallow errors here so that the remaining promise chain executes
|
||||
err.message = `Importing ${obj.title} (${obj.id}) failed: ${err.message}`;
|
||||
notify.error(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function groupByType(docs) {
|
||||
const defaultDocTypes = {
|
||||
searches: [],
|
||||
other: [],
|
||||
};
|
||||
|
||||
return docs.reduce((types, doc) => {
|
||||
switch (doc._type) {
|
||||
case 'search':
|
||||
types.searches.push(doc);
|
||||
break;
|
||||
default:
|
||||
types.other.push(doc);
|
||||
}
|
||||
return types;
|
||||
}, defaultDocTypes);
|
||||
}
|
||||
|
||||
function resolveConflicts(objs, { obj }) {
|
||||
const oldIndexId = obj.searchSource.getOwn('index');
|
||||
const newIndexId = objs.find(({ oldId }) => oldId === oldIndexId).newId;
|
||||
// If the user did not select a new index pattern in the modal, the id
|
||||
// will be same as before, so don't try to update it
|
||||
if (newIndexId === oldIndexId) {
|
||||
return;
|
||||
}
|
||||
return obj.hydrateIndexPattern(newIndexId)
|
||||
.then(() => saveObject(obj));
|
||||
}
|
||||
|
||||
function saveObject(obj) {
|
||||
return obj.save({ confirmOverwrite: !overwriteAll });
|
||||
}
|
||||
|
||||
const docTypes = groupByType(docs);
|
||||
|
||||
return Promise.map(docTypes.searches, importDocument.bind(null, true))
|
||||
.then(() => Promise.map(docTypes.other, importDocument.bind(null, true)))
|
||||
.then(() => {
|
||||
if (conflictedIndexPatterns.length) {
|
||||
return new Promise((resolve, reject) => {
|
||||
showChangeIndexModal(
|
||||
(objs) => {
|
||||
Promise.map(conflictedIndexPatterns, resolveConflicts.bind(null, objs))
|
||||
.then(Promise.map(conflictedSavedObjectsLinkedToSavedSearches, saveObject))
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
},
|
||||
conflictedIndexPatterns,
|
||||
$route.current.locals.indexPatterns,
|
||||
);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then(() => Promise.map(conflictedSearchDocs, importDocument.bind(null, false)))
|
||||
.then(refreshData)
|
||||
.catch(notify.error);
|
||||
});
|
||||
};
|
||||
|
||||
// TODO: Migrate all scope methods to the controller.
|
||||
$scope.changeTab = function (tab) {
|
||||
$scope.currentTab = tab;
|
||||
$scope.selectedItems.length = 0;
|
||||
$state.tab = tab.title;
|
||||
$state.save();
|
||||
};
|
||||
|
||||
$scope.$watch('managementObjectsController.advancedFilter', function (filter) {
|
||||
getData(filter);
|
||||
});
|
||||
controller: function ($scope, $injector) {
|
||||
updateObjectsTable($scope, $injector);
|
||||
$scope.$on('$destroy', destroyObjectsTable);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
|
|
@ -1,214 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { groupBy, mapValues, take, get } from 'lodash';
|
||||
|
||||
import {
|
||||
KuiModal,
|
||||
KuiModalHeader,
|
||||
KuiModalHeaderTitle,
|
||||
KuiModalBody,
|
||||
KuiModalFooter,
|
||||
KuiButton,
|
||||
KuiModalOverlay,
|
||||
KuiTable,
|
||||
KuiTableBody,
|
||||
KuiTableHeader,
|
||||
KuiTableHeaderCell,
|
||||
KuiTableRow,
|
||||
KuiTableRowCell,
|
||||
KuiControlledTable,
|
||||
KuiToolBar,
|
||||
KuiToolBarSection,
|
||||
KuiPager,
|
||||
} from '@kbn/ui-framework/components';
|
||||
|
||||
import { keyCodes } from '@elastic/eui';
|
||||
|
||||
export class ChangeIndexModal extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const byId = groupBy(props.conflictedObjects, ({ obj }) => obj.searchSource.getOwn('index'));
|
||||
this.state = {
|
||||
page: 0,
|
||||
perPage: 10,
|
||||
objects: mapValues(byId, (list, indexPatternId) => {
|
||||
return {
|
||||
newIndexPatternId: get(props, 'indices[0].id'),
|
||||
list: list.map(({ doc }) => {
|
||||
return {
|
||||
id: indexPatternId,
|
||||
type: doc._type,
|
||||
name: doc._source.title,
|
||||
};
|
||||
})
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
changeIndex = () => {
|
||||
const result = Object.keys(this.state.objects).map(indexPatternId => ({
|
||||
oldId: indexPatternId,
|
||||
newId: this.state.objects[indexPatternId].newIndexPatternId,
|
||||
}));
|
||||
this.props.onChange(result);
|
||||
};
|
||||
|
||||
onIndexChange = (id, event) => {
|
||||
event.persist();
|
||||
this.setState(state => {
|
||||
return {
|
||||
objects: {
|
||||
...state.objects,
|
||||
[id]: {
|
||||
...state.objects[id],
|
||||
newIndexPatternId: event.target.value,
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
onKeyDown = (event) => {
|
||||
if (event.keyCode === keyCodes.ESCAPE) {
|
||||
this.props.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { page, perPage } = this.state;
|
||||
const totalIndexPatterns = Object.keys(this.state.objects).length;
|
||||
const indexPatternIds = Object.keys(this.state.objects).slice(page, page + perPage);
|
||||
const rows = indexPatternIds.map((indexPatternId, key) => {
|
||||
const objects = this.state.objects[indexPatternId].list;
|
||||
const sample = take(objects, 5).map((obj, key) => <span key={key}>{obj.name}<br/></span>);
|
||||
|
||||
return (
|
||||
<KuiTableRow key={key}>
|
||||
<KuiTableRowCell>
|
||||
{indexPatternId}
|
||||
</KuiTableRowCell>
|
||||
<KuiTableRowCell>
|
||||
{objects.length}
|
||||
</KuiTableRowCell>
|
||||
<KuiTableRowCell>
|
||||
{sample}
|
||||
</KuiTableRowCell>
|
||||
<KuiTableRowCell>
|
||||
<select
|
||||
className="kuiSelect kuiSelect--medium"
|
||||
data-test-subj="managementChangeIndexSelection"
|
||||
value={this.state.objects[indexPatternId].newIndexPatternId}
|
||||
onChange={this.onIndexChange.bind(this, indexPatternId)}
|
||||
>
|
||||
<option value={indexPatternId}>Skip import</option>
|
||||
{this.props.indices.map((index, i) => {
|
||||
return (
|
||||
<option key={i} value={index.id}>
|
||||
{index.get('title')}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</KuiTableRowCell>
|
||||
</KuiTableRow>
|
||||
);
|
||||
});
|
||||
|
||||
const TableComponent = () => (
|
||||
<KuiTable className="kuiVerticalRhythm">
|
||||
<KuiTableHeader>
|
||||
<KuiTableHeaderCell style={{ maxWidth: '300px', width: '300px' }}>
|
||||
ID
|
||||
</KuiTableHeaderCell>
|
||||
<KuiTableHeaderCell style={{ maxWidth: '50px', width: '50px' }}>
|
||||
Count
|
||||
</KuiTableHeaderCell>
|
||||
<KuiTableHeaderCell>
|
||||
Sample of affected objects
|
||||
</KuiTableHeaderCell>
|
||||
<KuiTableHeaderCell style={{ maxWidth: '200px', width: '200px' }}>
|
||||
New index pattern
|
||||
</KuiTableHeaderCell>
|
||||
</KuiTableHeader>
|
||||
<KuiTableBody>
|
||||
{rows}
|
||||
</KuiTableBody>
|
||||
</KuiTable>
|
||||
);
|
||||
|
||||
return (
|
||||
<KuiModalOverlay>
|
||||
<KuiModal
|
||||
data-tests-subj="managementChangeIndexModal"
|
||||
aria-label="Index does not exist"
|
||||
className="managementChangeIndexModal"
|
||||
onKeyDown={this.onKeyDown}
|
||||
onClose={this.props.onClose}
|
||||
>
|
||||
<KuiModalHeader>
|
||||
<KuiModalHeaderTitle>
|
||||
Index Pattern Conflicts
|
||||
</KuiModalHeaderTitle>
|
||||
</KuiModalHeader>
|
||||
<KuiModalBody>
|
||||
<p className="kuiText kuiVerticalRhythm">
|
||||
The following saved objects use index patterns that do not exist.
|
||||
Please select the index patterns you'd like re-associated them with.
|
||||
</p>
|
||||
{ totalIndexPatterns > perPage
|
||||
? (
|
||||
<KuiControlledTable className="kuiVerticalRhythm">
|
||||
<KuiToolBar>
|
||||
<KuiToolBarSection>
|
||||
<KuiPager
|
||||
startNumber={page + 1}
|
||||
hasPreviousPage={page >= 1}
|
||||
hasNextPage={page < totalIndexPatterns}
|
||||
endNumber={Math.min(totalIndexPatterns, page + perPage)}
|
||||
totalItems={totalIndexPatterns}
|
||||
onNextPage={() => this.setState({ page: page + 1 })}
|
||||
onPreviousPage={() => this.setState({ page: page - 1 })}
|
||||
/>
|
||||
</KuiToolBarSection>
|
||||
</KuiToolBar>
|
||||
<TableComponent/>
|
||||
</KuiControlledTable>
|
||||
) : (
|
||||
<TableComponent/>
|
||||
)
|
||||
}
|
||||
</KuiModalBody>
|
||||
|
||||
<KuiModalFooter>
|
||||
<KuiButton
|
||||
buttonType="hollow"
|
||||
data-test-subj="changeIndexCancelButton"
|
||||
onClick={this.props.onClose}
|
||||
>
|
||||
Cancel
|
||||
</KuiButton>
|
||||
<KuiButton
|
||||
buttonType="primary"
|
||||
data-test-subj="changeIndexConfirmButton"
|
||||
onClick={this.changeIndex}
|
||||
>
|
||||
Confirm all changes
|
||||
</KuiButton>
|
||||
</KuiModalFooter>
|
||||
</KuiModal>
|
||||
</KuiModalOverlay>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ChangeIndexModal.propTypes = {
|
||||
onChange: PropTypes.func,
|
||||
onClose: PropTypes.func,
|
||||
conflictedObjects: PropTypes.arrayOf(PropTypes.shape({
|
||||
obj: PropTypes.object.isRequired,
|
||||
doc: PropTypes.object.isRequired,
|
||||
})).isRequired,
|
||||
indices: PropTypes.array
|
||||
};
|
|
@ -0,0 +1,215 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ObjectsTable delete should show a confirm modal 1`] = `
|
||||
<EuiConfirmModal
|
||||
buttonColor="primary"
|
||||
cancelButtonText="Cancel"
|
||||
confirmButtonText="Delete"
|
||||
defaultFocusedButton="confirm"
|
||||
onCancel={[Function]}
|
||||
onConfirm={[Function]}
|
||||
title="Delete saved objects"
|
||||
>
|
||||
<p>
|
||||
This action will delete the following saved objects:
|
||||
</p>
|
||||
<EuiInMemoryTable
|
||||
columns={
|
||||
Array [
|
||||
Object {
|
||||
"field": "type",
|
||||
"name": "Type",
|
||||
"render": [Function],
|
||||
"width": "50px",
|
||||
},
|
||||
Object {
|
||||
"field": "id",
|
||||
"name": "Id/Name",
|
||||
},
|
||||
]
|
||||
}
|
||||
items={
|
||||
Array [
|
||||
Object {
|
||||
"id": "1",
|
||||
"type": "index-pattern",
|
||||
},
|
||||
Object {
|
||||
"id": "3",
|
||||
"type": "dashboard",
|
||||
},
|
||||
]
|
||||
}
|
||||
pagination={true}
|
||||
responsive={true}
|
||||
sorting={false}
|
||||
/>
|
||||
</EuiConfirmModal>
|
||||
`;
|
||||
|
||||
exports[`ObjectsTable export should allow the user to choose when exporting all 1`] = `
|
||||
<EuiConfirmModal
|
||||
buttonColor="primary"
|
||||
cancelButtonText="Cancel"
|
||||
confirmButtonText="Export All"
|
||||
defaultFocusedButton="confirm"
|
||||
onCancel={[Function]}
|
||||
onConfirm={[Function]}
|
||||
title="Export All"
|
||||
>
|
||||
<p>
|
||||
Select which types to export. The number in parentheses indicates how many of this type are available to export.
|
||||
</p>
|
||||
<EuiCheckboxGroup
|
||||
idToSelectedMap={
|
||||
Object {
|
||||
"dashboard": true,
|
||||
"index-pattern": true,
|
||||
"search": true,
|
||||
"visualization": true,
|
||||
}
|
||||
}
|
||||
onChange={[Function]}
|
||||
options={
|
||||
Array [
|
||||
Object {
|
||||
"id": "index-pattern",
|
||||
"label": "index-pattern (0)",
|
||||
},
|
||||
Object {
|
||||
"id": "visualization",
|
||||
"label": "visualization (0)",
|
||||
},
|
||||
Object {
|
||||
"id": "dashboard",
|
||||
"label": "dashboard (0)",
|
||||
},
|
||||
Object {
|
||||
"id": "search",
|
||||
"label": "search (0)",
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</EuiConfirmModal>
|
||||
`;
|
||||
|
||||
exports[`ObjectsTable import should show the flyout 1`] = `
|
||||
<Flyout
|
||||
close={[Function]}
|
||||
done={[Function]}
|
||||
indexPatterns={
|
||||
Object {
|
||||
"cache": Object {
|
||||
"clearAll": [MockFunction],
|
||||
},
|
||||
}
|
||||
}
|
||||
newIndexPatternUrl=""
|
||||
services={Array []}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`ObjectsTable relationships should show the flyout 1`] = `
|
||||
<Relationships
|
||||
close={[Function]}
|
||||
getEditUrl={[Function]}
|
||||
getRelationships={[Function]}
|
||||
goInApp={[Function]}
|
||||
id="1"
|
||||
title="MySearch"
|
||||
type="search"
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`ObjectsTable should render normally 1`] = `
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"padding": "0 1rem",
|
||||
}
|
||||
}
|
||||
>
|
||||
<Header
|
||||
onExportAll={[Function]}
|
||||
onImport={[Function]}
|
||||
onRefresh={[Function]}
|
||||
totalCount={4}
|
||||
/>
|
||||
<EuiSpacer
|
||||
size="xs"
|
||||
/>
|
||||
<Table
|
||||
filterOptions={
|
||||
Array [
|
||||
Object {
|
||||
"name": "index-pattern",
|
||||
"value": "index-pattern",
|
||||
"view": "index-pattern (0)",
|
||||
},
|
||||
Object {
|
||||
"name": "visualization",
|
||||
"value": "visualization",
|
||||
"view": "visualization (0)",
|
||||
},
|
||||
Object {
|
||||
"name": "dashboard",
|
||||
"value": "dashboard",
|
||||
"view": "dashboard (0)",
|
||||
},
|
||||
Object {
|
||||
"name": "search",
|
||||
"value": "search",
|
||||
"view": "search (0)",
|
||||
},
|
||||
]
|
||||
}
|
||||
getEditUrl={[Function]}
|
||||
goInApp={[Function]}
|
||||
isSearching={false}
|
||||
items={
|
||||
Array [
|
||||
Object {
|
||||
"icon": "indexPatternApp",
|
||||
"id": "1",
|
||||
"title": "MyIndexPattern*",
|
||||
"type": "index-pattern",
|
||||
},
|
||||
Object {
|
||||
"icon": "search",
|
||||
"id": "2",
|
||||
"title": "MySearch",
|
||||
"type": "search",
|
||||
},
|
||||
Object {
|
||||
"icon": "dashboardApp",
|
||||
"id": "3",
|
||||
"title": "MyDashboard",
|
||||
"type": "dashboard",
|
||||
},
|
||||
Object {
|
||||
"icon": "visualizeApp",
|
||||
"id": "4",
|
||||
"title": "MyViz",
|
||||
"type": "visualization",
|
||||
},
|
||||
]
|
||||
}
|
||||
onDelete={[Function]}
|
||||
onExport={[Function]}
|
||||
onQueryChange={[Function]}
|
||||
onShowRelationships={[Function]}
|
||||
onTableChange={[Function]}
|
||||
pageIndex={0}
|
||||
pageSize={15}
|
||||
selectedSavedObjects={Array []}
|
||||
selectionConfig={
|
||||
Object {
|
||||
"itemId": "id",
|
||||
"onSelectionChange": [Function],
|
||||
}
|
||||
}
|
||||
totalItemCount={4}
|
||||
/>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,412 @@
|
|||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { ObjectsTable, INCLUDED_TYPES } from '../objects_table';
|
||||
|
||||
jest.mock('../components/header', () => ({
|
||||
Header: () => 'Header',
|
||||
}));
|
||||
|
||||
jest.mock('ui/errors', () => ({
|
||||
SavedObjectNotFound: class SavedObjectNotFound extends Error {
|
||||
constructor(options) {
|
||||
super();
|
||||
for (const option in options) {
|
||||
if (options.hasOwnProperty(option)) {
|
||||
this[option] = options[option];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('ui/chrome', () => ({
|
||||
addBasePath: () => ''
|
||||
}));
|
||||
|
||||
jest.mock('../../../../indices/create_index_pattern_wizard/lib/ensure_minimum_time', () => ({
|
||||
ensureMinimumTime: async promises => {
|
||||
if (Array.isArray(promises)) {
|
||||
return await Promise.all(promises);
|
||||
}
|
||||
return await promises;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../../lib/retrieve_and_export_docs', () => ({
|
||||
retrieveAndExportDocs: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../lib/scan_all_types', () => ({
|
||||
scanAllTypes: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../lib/get_saved_object_counts', () => ({
|
||||
getSavedObjectCounts: jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
'index-pattern': 0,
|
||||
'visualization': 0,
|
||||
'dashboard': 0,
|
||||
'search': 0,
|
||||
};
|
||||
})
|
||||
}));
|
||||
|
||||
jest.mock('../../../lib/save_to_file', () => ({
|
||||
saveToFile: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../lib/get_relationships', () => ({
|
||||
getRelationships: jest.fn(),
|
||||
}));
|
||||
|
||||
const allSavedObjects = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'index-pattern',
|
||||
attributes: {
|
||||
title: `MyIndexPattern*`
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'search',
|
||||
attributes: {
|
||||
title: `MySearch`
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'dashboard',
|
||||
attributes: {
|
||||
title: `MyDashboard`
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'visualization',
|
||||
attributes: {
|
||||
title: `MyViz`
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
const $http = () => {};
|
||||
$http.post = jest.fn().mockImplementation(() => ([]));
|
||||
const defaultProps = {
|
||||
savedObjectsClient: {
|
||||
find: jest.fn().mockImplementation(({ type }) => {
|
||||
// We pass in type when fetching counts
|
||||
if (type) {
|
||||
return {
|
||||
total: 1,
|
||||
savedObjects: [
|
||||
{
|
||||
id: '1',
|
||||
type,
|
||||
attributes: {
|
||||
title: `Title${type}`
|
||||
}
|
||||
},
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
total: allSavedObjects.length,
|
||||
savedObjects: allSavedObjects,
|
||||
};
|
||||
}),
|
||||
},
|
||||
indexPatterns: {
|
||||
cache: {
|
||||
clearAll: jest.fn(),
|
||||
}
|
||||
},
|
||||
$http,
|
||||
basePath: '',
|
||||
newIndexPatternUrl: '',
|
||||
kbnIndex: '',
|
||||
services: [],
|
||||
getEditUrl: () => {},
|
||||
goInApp: () => {},
|
||||
};
|
||||
|
||||
describe('ObjectsTable', () => {
|
||||
beforeEach(() => {
|
||||
defaultProps.savedObjectsClient.find.mockClear();
|
||||
});
|
||||
|
||||
it('should render normally', async () => {
|
||||
const component = shallow(
|
||||
<ObjectsTable
|
||||
{...defaultProps}
|
||||
perPageConfig={15}
|
||||
/>
|
||||
);
|
||||
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
// Ensure the state changes are reflected
|
||||
component.update();
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('export', () => {
|
||||
it('should export selected objects', async () => {
|
||||
const mockSelectedSavedObjects = [
|
||||
{ id: '1', type: 'index-pattern' },
|
||||
{ id: '3', type: 'dashboard' }
|
||||
];
|
||||
|
||||
const mockSavedObjects = mockSelectedSavedObjects.map(obj => ({
|
||||
_id: obj.id,
|
||||
_type: obj._type,
|
||||
_source: {},
|
||||
}));
|
||||
|
||||
const mockSavedObjectsClient = {
|
||||
...defaultProps.savedObjectsClient,
|
||||
bulkGet: jest.fn().mockImplementation(() => ({
|
||||
savedObjects: mockSavedObjects,
|
||||
}))
|
||||
};
|
||||
|
||||
const { retrieveAndExportDocs } = require('../../../lib/retrieve_and_export_docs');
|
||||
|
||||
const component = shallow(
|
||||
<ObjectsTable
|
||||
{...defaultProps}
|
||||
savedObjectsClient={mockSavedObjectsClient}
|
||||
/>
|
||||
);
|
||||
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
// Ensure the state changes are reflected
|
||||
component.update();
|
||||
|
||||
// Set some as selected
|
||||
component.instance().onSelectionChanged(mockSelectedSavedObjects);
|
||||
|
||||
await component.instance().onExport();
|
||||
|
||||
expect(mockSavedObjectsClient.bulkGet).toHaveBeenCalledWith(mockSelectedSavedObjects);
|
||||
expect(retrieveAndExportDocs).toHaveBeenCalledWith(mockSavedObjects, mockSavedObjectsClient);
|
||||
});
|
||||
|
||||
it('should allow the user to choose when exporting all', async () => {
|
||||
const component = shallow(
|
||||
<ObjectsTable
|
||||
{...defaultProps}
|
||||
/>
|
||||
);
|
||||
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
// Ensure the state changes are reflected
|
||||
component.update();
|
||||
|
||||
component.find('Header').prop('onExportAll')();
|
||||
component.update();
|
||||
|
||||
expect(component.find('EuiConfirmModal')).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should export all', async () => {
|
||||
const { scanAllTypes } = require('../../../lib/scan_all_types');
|
||||
const { saveToFile } = require('../../../lib/save_to_file');
|
||||
const component = shallow(
|
||||
<ObjectsTable
|
||||
{...defaultProps}
|
||||
/>
|
||||
);
|
||||
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
// Ensure the state changes are reflected
|
||||
component.update();
|
||||
|
||||
// Set up mocks
|
||||
scanAllTypes.mockImplementation(() => allSavedObjects);
|
||||
|
||||
await component.instance().onExportAll();
|
||||
|
||||
expect(scanAllTypes).toHaveBeenCalledWith(defaultProps.$http, INCLUDED_TYPES);
|
||||
expect(saveToFile).toHaveBeenCalledWith(JSON.stringify(allSavedObjects, null, 2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('import', () => {
|
||||
it('should show the flyout', async () => {
|
||||
const component = shallow(
|
||||
<ObjectsTable
|
||||
{...defaultProps}
|
||||
/>
|
||||
);
|
||||
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
// Ensure the state changes are reflected
|
||||
component.update();
|
||||
|
||||
component.instance().showImportFlyout();
|
||||
component.update();
|
||||
|
||||
expect(component.find('Flyout')).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should hide the flyout', async () => {
|
||||
const component = shallow(
|
||||
<ObjectsTable
|
||||
{...defaultProps}
|
||||
/>
|
||||
);
|
||||
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
// Ensure the state changes are reflected
|
||||
component.update();
|
||||
|
||||
component.instance().hideImportFlyout();
|
||||
component.update();
|
||||
|
||||
expect(component.find('Flyout').length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('relationships', () => {
|
||||
it('should fetch relationships', async () => {
|
||||
const { getRelationships } = require('../../../lib/get_relationships');
|
||||
|
||||
const component = shallow(
|
||||
<ObjectsTable
|
||||
{...defaultProps}
|
||||
/>
|
||||
);
|
||||
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
// Ensure the state changes are reflected
|
||||
component.update();
|
||||
|
||||
await component.instance().getRelationships('search', '1');
|
||||
expect(getRelationships).toHaveBeenCalledWith('search', '1', defaultProps.$http, defaultProps.basePath);
|
||||
});
|
||||
|
||||
it('should show the flyout', async () => {
|
||||
const component = shallow(
|
||||
<ObjectsTable
|
||||
{...defaultProps}
|
||||
/>
|
||||
);
|
||||
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
// Ensure the state changes are reflected
|
||||
component.update();
|
||||
|
||||
component.instance().onShowRelationships('1', 'search', 'MySearch');
|
||||
component.update();
|
||||
|
||||
expect(component.find('Relationships')).toMatchSnapshot();
|
||||
expect(component.state('relationshipId')).toBe('1');
|
||||
expect(component.state('relationshipType')).toBe('search');
|
||||
expect(component.state('relationshipTitle')).toBe('MySearch');
|
||||
});
|
||||
|
||||
it('should hide the flyout', async () => {
|
||||
const component = shallow(
|
||||
<ObjectsTable
|
||||
{...defaultProps}
|
||||
/>
|
||||
);
|
||||
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
// Ensure the state changes are reflected
|
||||
component.update();
|
||||
|
||||
component.instance().onHideRelationships();
|
||||
component.update();
|
||||
|
||||
expect(component.find('Relationships').length).toBe(0);
|
||||
expect(component.state('relationshipId')).toBe(undefined);
|
||||
expect(component.state('relationshipType')).toBe(undefined);
|
||||
expect(component.state('relationshipTitle')).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should show a confirm modal', async () => {
|
||||
const component = shallow(
|
||||
<ObjectsTable
|
||||
{...defaultProps}
|
||||
/>
|
||||
);
|
||||
|
||||
const mockSelectedSavedObjects = [
|
||||
{ id: '1', type: 'index-pattern' },
|
||||
{ id: '3', type: 'dashboard' }
|
||||
];
|
||||
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
// Ensure the state changes are reflected
|
||||
component.update();
|
||||
|
||||
// Set some as selected
|
||||
component.instance().onSelectionChanged(mockSelectedSavedObjects);
|
||||
await component.instance().onDelete();
|
||||
component.update();
|
||||
|
||||
expect(component.find('EuiConfirmModal')).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should delete selected objects', async () => {
|
||||
const mockSelectedSavedObjects = [
|
||||
{ id: '1', type: 'index-pattern' },
|
||||
{ id: '3', type: 'dashboard' }
|
||||
];
|
||||
|
||||
const mockSavedObjects = mockSelectedSavedObjects.map(obj => ({
|
||||
id: obj.id,
|
||||
type: obj.type,
|
||||
source: {},
|
||||
}));
|
||||
|
||||
const mockSavedObjectsClient = {
|
||||
...defaultProps.savedObjectsClient,
|
||||
bulkGet: jest.fn().mockImplementation(() => ({
|
||||
savedObjects: mockSavedObjects,
|
||||
})),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
|
||||
const component = shallow(
|
||||
<ObjectsTable
|
||||
{...defaultProps}
|
||||
savedObjectsClient={mockSavedObjectsClient}
|
||||
/>
|
||||
);
|
||||
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
// Ensure the state changes are reflected
|
||||
component.update();
|
||||
|
||||
// Set some as selected
|
||||
component.instance().onSelectionChanged(mockSelectedSavedObjects);
|
||||
|
||||
await component.instance().delete();
|
||||
|
||||
expect(defaultProps.indexPatterns.cache.clearAll).toHaveBeenCalled();
|
||||
expect(mockSavedObjectsClient.bulkGet).toHaveBeenCalledWith(mockSelectedSavedObjects);
|
||||
expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith(mockSavedObjects[0].type, mockSavedObjects[0].id);
|
||||
expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith(mockSavedObjects[1].type, mockSavedObjects[1].id);
|
||||
expect(component.state('selectedSavedObjects').length).toBe(0);
|
||||
expect(defaultProps.savedObjectsClient.find.mock.calls.length).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,245 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Flyout conflicts should allow conflict resolution 1`] = `
|
||||
<EuiFlyout
|
||||
onClose={[MockFunction]}
|
||||
size="m"
|
||||
>
|
||||
<EuiFlyoutHeader>
|
||||
<EuiTitle
|
||||
size="m"
|
||||
>
|
||||
<h2>
|
||||
Import saved objects
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
<React.Fragment>
|
||||
<EuiSpacer
|
||||
size="s"
|
||||
/>
|
||||
<EuiCallOut
|
||||
color="warning"
|
||||
iconType="help"
|
||||
size="m"
|
||||
title="Index Pattern Conflicts"
|
||||
>
|
||||
<p>
|
||||
The following saved objects use index patterns that do not exist. Please select the index patterns you'd like re-associated with them. You can
|
||||
|
||||
<EuiLink
|
||||
color="primary"
|
||||
href=""
|
||||
type="button"
|
||||
>
|
||||
create a new index pattern
|
||||
</EuiLink>
|
||||
|
||||
if necessary.
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
</React.Fragment>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<EuiInMemoryTable
|
||||
columns={
|
||||
Array [
|
||||
Object {
|
||||
"description": "ID of the index pattern",
|
||||
"field": "existingIndexPatternId",
|
||||
"name": "ID",
|
||||
"sortable": true,
|
||||
},
|
||||
Object {
|
||||
"description": "How many affected objects",
|
||||
"field": "list",
|
||||
"name": "Count",
|
||||
"render": [Function],
|
||||
},
|
||||
Object {
|
||||
"description": "Sample of affected objects",
|
||||
"field": "list",
|
||||
"name": "Sample of affected objects",
|
||||
"render": [Function],
|
||||
},
|
||||
Object {
|
||||
"field": "existingIndexPatternId",
|
||||
"name": "New index pattern",
|
||||
"render": [Function],
|
||||
},
|
||||
]
|
||||
}
|
||||
items={
|
||||
Array [
|
||||
Object {
|
||||
"existingIndexPatternId": "MyIndexPattern*",
|
||||
"list": Array [
|
||||
Object {
|
||||
"id": "MyIndexPattern*",
|
||||
"name": "MyIndexPattern*",
|
||||
"type": "index-pattern",
|
||||
},
|
||||
],
|
||||
"newIndexPatternId": undefined,
|
||||
},
|
||||
]
|
||||
}
|
||||
pagination={
|
||||
Object {
|
||||
"pageSizeOptions": Array [
|
||||
5,
|
||||
10,
|
||||
25,
|
||||
],
|
||||
}
|
||||
}
|
||||
responsive={true}
|
||||
sorting={false}
|
||||
/>
|
||||
</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup
|
||||
alignItems="stretch"
|
||||
component="div"
|
||||
direction="row"
|
||||
gutterSize="l"
|
||||
justifyContent="spaceBetween"
|
||||
responsive={true}
|
||||
wrap={false}
|
||||
>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiButtonEmpty
|
||||
color="primary"
|
||||
iconSide="left"
|
||||
onClick={[MockFunction]}
|
||||
size="s"
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiButton
|
||||
color="primary"
|
||||
data-test-subj="importSavedObjectsConfirmBtn"
|
||||
fill={true}
|
||||
iconSide="left"
|
||||
isLoading={false}
|
||||
onClick={[Function]}
|
||||
size="s"
|
||||
type="button"
|
||||
>
|
||||
Confirm all changes
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
`;
|
||||
|
||||
exports[`Flyout conflicts should handle errors 1`] = `
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
iconType="cross"
|
||||
size="m"
|
||||
title="Sorry, there was an error"
|
||||
>
|
||||
<p>
|
||||
foobar
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
`;
|
||||
|
||||
exports[`Flyout should render import step 1`] = `
|
||||
<EuiFlyout
|
||||
onClose={[MockFunction]}
|
||||
size="m"
|
||||
>
|
||||
<EuiFlyoutHeader>
|
||||
<EuiTitle
|
||||
size="m"
|
||||
>
|
||||
<h2>
|
||||
Import saved objects
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<EuiForm>
|
||||
<EuiFormRow
|
||||
describedByIds={Array []}
|
||||
fullWidth={false}
|
||||
hasEmptyLabelSpace={false}
|
||||
label="Please select a JSON file to import"
|
||||
>
|
||||
<EuiFilePicker
|
||||
compressed={false}
|
||||
initialPromptText="Import"
|
||||
onChange={[Function]}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
describedByIds={Array []}
|
||||
fullWidth={false}
|
||||
hasEmptyLabelSpace={false}
|
||||
>
|
||||
<EuiSwitch
|
||||
checked={true}
|
||||
data-test-subj="importSavedObjectsOverwriteToggle"
|
||||
label="Automatically overwrite all saved objects?"
|
||||
name="overwriteAll"
|
||||
onChange={[Function]}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiForm>
|
||||
</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup
|
||||
alignItems="stretch"
|
||||
component="div"
|
||||
direction="row"
|
||||
gutterSize="l"
|
||||
justifyContent="spaceBetween"
|
||||
responsive={true}
|
||||
wrap={false}
|
||||
>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiButtonEmpty
|
||||
color="primary"
|
||||
iconSide="left"
|
||||
onClick={[MockFunction]}
|
||||
size="s"
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiButton
|
||||
color="primary"
|
||||
data-test-subj="importSavedObjectsImportBtn"
|
||||
fill={true}
|
||||
iconSide="left"
|
||||
isLoading={false}
|
||||
onClick={[Function]}
|
||||
size="s"
|
||||
type="button"
|
||||
>
|
||||
Import
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
`;
|
|
@ -0,0 +1,284 @@
|
|||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { Flyout } from '../flyout';
|
||||
|
||||
jest.mock('ui/errors', () => ({
|
||||
SavedObjectNotFound: class SavedObjectNotFound extends Error {
|
||||
constructor(options) {
|
||||
super();
|
||||
for (const option in options) {
|
||||
if (options.hasOwnProperty(option)) {
|
||||
this[option] = options[option];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('ui/chrome', () => ({
|
||||
addBasePath: () => {},
|
||||
}));
|
||||
|
||||
jest.mock('../../../../../lib/import_file', () => ({
|
||||
importFile: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../../lib/resolve_saved_objects', () => ({
|
||||
resolveSavedObjects: jest.fn(),
|
||||
resolveSavedSearches: jest.fn(),
|
||||
resolveIndexPatternConflicts: jest.fn(),
|
||||
saveObjects: jest.fn(),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
close: jest.fn(),
|
||||
done: jest.fn(),
|
||||
services: [],
|
||||
newIndexPatternUrl: '',
|
||||
indexPatterns: {
|
||||
getFields: jest.fn().mockImplementation(() => [{ id: '1' }, { id: '2' }]),
|
||||
},
|
||||
};
|
||||
|
||||
const mockFile = {
|
||||
path: '/home/foo.txt',
|
||||
};
|
||||
|
||||
describe('Flyout', () => {
|
||||
it('should render import step', async () => {
|
||||
const component = shallow(<Flyout {...defaultProps} />);
|
||||
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
// Ensure the state changes are reflected
|
||||
component.update();
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should toggle the overwrite all control', async () => {
|
||||
const component = shallow(<Flyout {...defaultProps} />);
|
||||
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
// Ensure the state changes are reflected
|
||||
component.update();
|
||||
|
||||
expect(component.state('isOverwriteAllChecked')).toBe(true);
|
||||
component.find('EuiSwitch').simulate('change');
|
||||
expect(component.state('isOverwriteAllChecked')).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow picking a file', async () => {
|
||||
const component = shallow(<Flyout {...defaultProps} />);
|
||||
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
// Ensure the state changes are reflected
|
||||
component.update();
|
||||
|
||||
expect(component.state('file')).toBe(undefined);
|
||||
component.find('EuiFilePicker').simulate('change', [mockFile]);
|
||||
expect(component.state('file')).toBe(mockFile);
|
||||
});
|
||||
|
||||
it('should handle invalid files', async () => {
|
||||
const { importFile } = require('../../../../../lib/import_file');
|
||||
const component = shallow(<Flyout {...defaultProps} />);
|
||||
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
// Ensure the state changes are reflected
|
||||
component.update();
|
||||
|
||||
importFile.mockImplementation(() => {
|
||||
throw new Error('foobar');
|
||||
});
|
||||
|
||||
await component.instance().import();
|
||||
expect(component.state('error')).toBe('The file could not be processed.');
|
||||
|
||||
importFile.mockImplementation(() => ({
|
||||
invalid: true,
|
||||
}));
|
||||
|
||||
await component.instance().import();
|
||||
expect(component.state('error')).toBe(
|
||||
'Saved objects file format is invalid and cannot be imported.'
|
||||
);
|
||||
});
|
||||
|
||||
describe('conflicts', () => {
|
||||
const { importFile } = require('../../../../../lib/import_file');
|
||||
const {
|
||||
resolveSavedObjects,
|
||||
resolveSavedSearches,
|
||||
resolveIndexPatternConflicts,
|
||||
saveObjects,
|
||||
} = require('../../../../../lib/resolve_saved_objects');
|
||||
|
||||
const mockData = [
|
||||
{
|
||||
_id: '1',
|
||||
_type: 'search',
|
||||
},
|
||||
{
|
||||
_id: '2',
|
||||
_type: 'index-pattern',
|
||||
},
|
||||
{
|
||||
_id: '3',
|
||||
_type: 'invalid',
|
||||
},
|
||||
];
|
||||
|
||||
const mockConflictedIndexPatterns = [
|
||||
{
|
||||
doc: {
|
||||
_type: 'index-pattern',
|
||||
_id: '1',
|
||||
_source: {
|
||||
title: 'MyIndexPattern*',
|
||||
},
|
||||
},
|
||||
obj: {
|
||||
searchSource: {
|
||||
getOwn: () => 'MyIndexPattern*',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockConflictedSavedObjectsLinkedToSavedSearches = [2];
|
||||
const mockConflictedSearchDocs = [3];
|
||||
|
||||
beforeEach(() => {
|
||||
importFile.mockImplementation(() => mockData);
|
||||
resolveSavedObjects.mockImplementation(() => ({
|
||||
conflictedIndexPatterns: mockConflictedIndexPatterns,
|
||||
conflictedSavedObjectsLinkedToSavedSearches: mockConflictedSavedObjectsLinkedToSavedSearches,
|
||||
conflictedSearchDocs: mockConflictedSearchDocs,
|
||||
importedObjectCount: 2,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should figure out conflicts', async () => {
|
||||
const component = shallow(<Flyout {...defaultProps} />);
|
||||
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
// Ensure the state changes are reflected
|
||||
component.update();
|
||||
|
||||
component.setState({ file: mockFile });
|
||||
await component.instance().import();
|
||||
|
||||
expect(importFile).toHaveBeenCalledWith(mockFile);
|
||||
// Remove the last element from data since it should be filtered out
|
||||
expect(resolveSavedObjects).toHaveBeenCalledWith(
|
||||
mockData.slice(0, 2),
|
||||
true,
|
||||
defaultProps.services,
|
||||
defaultProps.indexPatterns
|
||||
);
|
||||
|
||||
expect(component.state()).toMatchObject({
|
||||
conflictedIndexPatterns: mockConflictedIndexPatterns,
|
||||
conflictedSavedObjectsLinkedToSavedSearches: mockConflictedSavedObjectsLinkedToSavedSearches,
|
||||
conflictedSearchDocs: mockConflictedSearchDocs,
|
||||
importCount: 2,
|
||||
isLoading: false,
|
||||
wasImportSuccessful: false,
|
||||
conflicts: [
|
||||
{
|
||||
existingIndexPatternId: 'MyIndexPattern*',
|
||||
newIndexPatternId: undefined,
|
||||
list: [
|
||||
{
|
||||
id: 'MyIndexPattern*',
|
||||
name: 'MyIndexPattern*',
|
||||
type: 'index-pattern',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow conflict resolution', async () => {
|
||||
const component = shallow(<Flyout {...defaultProps} />);
|
||||
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
// Ensure the state changes are reflected
|
||||
component.update();
|
||||
|
||||
component.setState({ file: mockFile });
|
||||
await component.instance().import();
|
||||
|
||||
// Ensure it looks right
|
||||
component.update();
|
||||
expect(component).toMatchSnapshot();
|
||||
|
||||
// Ensure we can change the resolution
|
||||
component
|
||||
.instance()
|
||||
.onIndexChanged('MyIndexPattern*', { target: { value: '2' } });
|
||||
expect(component.state('conflicts')[0].newIndexPatternId).toBe('2');
|
||||
|
||||
// Let's resolve now
|
||||
await component
|
||||
.find('EuiButton[data-test-subj="importSavedObjectsConfirmBtn"]')
|
||||
.simulate('click');
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
expect(resolveIndexPatternConflicts).toHaveBeenCalledWith(
|
||||
component.instance().resolutions,
|
||||
mockConflictedIndexPatterns,
|
||||
true
|
||||
);
|
||||
expect(saveObjects).toHaveBeenCalledWith(
|
||||
mockConflictedSavedObjectsLinkedToSavedSearches,
|
||||
true
|
||||
);
|
||||
expect(resolveSavedSearches).toHaveBeenCalledWith(
|
||||
mockConflictedSearchDocs,
|
||||
defaultProps.services,
|
||||
defaultProps.indexPatterns,
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
const component = shallow(<Flyout {...defaultProps} />);
|
||||
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
// Ensure the state changes are reflected
|
||||
component.update();
|
||||
|
||||
resolveIndexPatternConflicts.mockImplementation(() => {
|
||||
throw new Error('foobar');
|
||||
});
|
||||
|
||||
component.setState({ file: mockFile });
|
||||
|
||||
// Go through the import flow
|
||||
await component.instance().import();
|
||||
component.update();
|
||||
// Set a resolution
|
||||
component
|
||||
.instance()
|
||||
.onIndexChanged('MyIndexPattern*', { target: { value: '2' } });
|
||||
await component
|
||||
.find('EuiButton[data-test-subj="importSavedObjectsConfirmBtn"]')
|
||||
.simulate('click');
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
|
||||
expect(component.state('error')).toEqual('foobar');
|
||||
expect(component.find('EuiFlyoutBody EuiCallOut')).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,515 @@
|
|||
import React, { Component, Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { groupBy, take } from 'lodash';
|
||||
import {
|
||||
EuiFlyout,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutFooter,
|
||||
EuiFlyoutHeader,
|
||||
EuiButtonEmpty,
|
||||
EuiButton,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
EuiForm,
|
||||
EuiFormRow,
|
||||
EuiSwitch,
|
||||
EuiFilePicker,
|
||||
EuiInMemoryTable,
|
||||
EuiSelect,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiLoadingKibana,
|
||||
EuiCallOut,
|
||||
EuiSpacer,
|
||||
EuiLink,
|
||||
} from '@elastic/eui';
|
||||
import { importFile } from '../../../../lib/import_file';
|
||||
import {
|
||||
resolveSavedObjects,
|
||||
resolveSavedSearches,
|
||||
resolveIndexPatternConflicts,
|
||||
saveObjects,
|
||||
} from '../../../../lib/resolve_saved_objects';
|
||||
import { INCLUDED_TYPES } from '../../objects_table';
|
||||
|
||||
export class Flyout extends Component {
|
||||
static propTypes = {
|
||||
close: PropTypes.func.isRequired,
|
||||
done: PropTypes.func.isRequired,
|
||||
services: PropTypes.array.isRequired,
|
||||
newIndexPatternUrl: PropTypes.string.isRequired,
|
||||
indexPatterns: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
conflictedIndexPatterns: undefined,
|
||||
conflictedSavedObjectsLinkedToSavedSearches: undefined,
|
||||
conflictedSearchDocs: undefined,
|
||||
conflicts: undefined,
|
||||
error: undefined,
|
||||
file: undefined,
|
||||
importCount: 0,
|
||||
indexPatterns: undefined,
|
||||
isOverwriteAllChecked: true,
|
||||
isLoading: false,
|
||||
loadingMessage: undefined,
|
||||
wasImportSuccessful: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchIndexPatterns();
|
||||
}
|
||||
|
||||
fetchIndexPatterns = async () => {
|
||||
const indexPatterns = await this.props.indexPatterns.getFields([
|
||||
'id',
|
||||
'title',
|
||||
]);
|
||||
this.setState({ indexPatterns });
|
||||
};
|
||||
|
||||
changeOverwriteAll = () => {
|
||||
this.setState(state => ({
|
||||
isOverwriteAllChecked: !state.isOverwriteAllChecked,
|
||||
}));
|
||||
};
|
||||
|
||||
setImportFile = ([file]) => {
|
||||
this.setState({ file });
|
||||
};
|
||||
|
||||
import = async () => {
|
||||
const { services, indexPatterns } = this.props;
|
||||
const { file, isOverwriteAllChecked } = this.state;
|
||||
|
||||
this.setState({ isLoading: true, error: undefined });
|
||||
|
||||
let contents;
|
||||
|
||||
try {
|
||||
contents = await importFile(file);
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
isLoading: false,
|
||||
error: 'The file could not be processed.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(contents)) {
|
||||
this.setState({
|
||||
isLoading: false,
|
||||
error: 'Saved objects file format is invalid and cannot be imported.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
contents = contents.filter(content =>
|
||||
INCLUDED_TYPES.includes(content._type)
|
||||
);
|
||||
|
||||
const {
|
||||
conflictedIndexPatterns,
|
||||
conflictedSavedObjectsLinkedToSavedSearches,
|
||||
conflictedSearchDocs,
|
||||
importedObjectCount,
|
||||
} = await resolveSavedObjects(
|
||||
contents,
|
||||
isOverwriteAllChecked,
|
||||
services,
|
||||
indexPatterns
|
||||
);
|
||||
|
||||
const byId = groupBy(conflictedIndexPatterns, ({ obj }) =>
|
||||
obj.searchSource.getOwn('index')
|
||||
);
|
||||
const conflicts = Object.entries(byId).reduce(
|
||||
(accum, [existingIndexPatternId, list]) => {
|
||||
accum.push({
|
||||
existingIndexPatternId,
|
||||
newIndexPatternId: undefined,
|
||||
list: list.map(({ doc }) => ({
|
||||
id: existingIndexPatternId,
|
||||
type: doc._type,
|
||||
name: doc._source.title,
|
||||
})),
|
||||
});
|
||||
return accum;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
this.setState({
|
||||
conflictedIndexPatterns,
|
||||
conflictedSavedObjectsLinkedToSavedSearches,
|
||||
conflictedSearchDocs,
|
||||
conflicts,
|
||||
importCount: importedObjectCount,
|
||||
isLoading: false,
|
||||
wasImportSuccessful: conflicts.length === 0,
|
||||
});
|
||||
};
|
||||
|
||||
get hasConflicts() {
|
||||
return this.state.conflicts && this.state.conflicts.length > 0;
|
||||
}
|
||||
|
||||
get resolutions() {
|
||||
return this.state.conflicts.reduce(
|
||||
(accum, { existingIndexPatternId, newIndexPatternId }) => {
|
||||
if (newIndexPatternId) {
|
||||
accum.push({
|
||||
oldId: existingIndexPatternId,
|
||||
newId: newIndexPatternId,
|
||||
});
|
||||
}
|
||||
return accum;
|
||||
},
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
confirmImport = async () => {
|
||||
const {
|
||||
conflictedIndexPatterns,
|
||||
isOverwriteAllChecked,
|
||||
conflictedSavedObjectsLinkedToSavedSearches,
|
||||
conflictedSearchDocs,
|
||||
} = this.state;
|
||||
|
||||
const { services, indexPatterns } = this.props;
|
||||
|
||||
this.setState({
|
||||
error: undefined,
|
||||
isLoading: true,
|
||||
loadingMessage: undefined,
|
||||
});
|
||||
|
||||
let importCount = this.state.importCount;
|
||||
|
||||
if (this.hasConflicts) {
|
||||
try {
|
||||
const resolutions = this.resolutions;
|
||||
|
||||
// Do not Promise.all these calls as the order matters
|
||||
this.setState({ loadingMessage: 'Resolving conflicts...' });
|
||||
if (resolutions.length) {
|
||||
importCount += await resolveIndexPatternConflicts(
|
||||
resolutions,
|
||||
conflictedIndexPatterns,
|
||||
isOverwriteAllChecked
|
||||
);
|
||||
}
|
||||
this.setState({ loadingMessage: 'Saving conflicts...' });
|
||||
importCount += await saveObjects(
|
||||
conflictedSavedObjectsLinkedToSavedSearches,
|
||||
isOverwriteAllChecked
|
||||
);
|
||||
this.setState({
|
||||
loadingMessage: 'Ensure saved searches are linked properly...',
|
||||
});
|
||||
importCount += await resolveSavedSearches(
|
||||
conflictedSearchDocs,
|
||||
services,
|
||||
indexPatterns,
|
||||
isOverwriteAllChecked
|
||||
);
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
error: e.message,
|
||||
isLoading: false,
|
||||
loadingMessage: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({ isLoading: false, wasImportSuccessful: true, importCount });
|
||||
};
|
||||
|
||||
onIndexChanged = (id, e) => {
|
||||
const value = e.target.value;
|
||||
this.setState(state => {
|
||||
const conflictIndex = state.conflicts.findIndex(
|
||||
conflict => conflict.existingIndexPatternId === id
|
||||
);
|
||||
if (conflictIndex === -1) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
conflicts: [
|
||||
...state.conflicts.slice(0, conflictIndex),
|
||||
{
|
||||
...state.conflicts[conflictIndex],
|
||||
newIndexPatternId: value,
|
||||
},
|
||||
...state.conflicts.slice(conflictIndex + 1),
|
||||
],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
renderConflicts() {
|
||||
const { conflicts } = this.state;
|
||||
|
||||
if (!conflicts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
field: 'existingIndexPatternId',
|
||||
name: 'ID',
|
||||
description: `ID of the index pattern`,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'list',
|
||||
name: 'Count',
|
||||
description: `How many affected objects`,
|
||||
render: list => {
|
||||
return <Fragment>{list.length}</Fragment>;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'list',
|
||||
name: 'Sample of affected objects',
|
||||
description: `Sample of affected objects`,
|
||||
render: list => {
|
||||
return (
|
||||
<ul style={{ listStyle: 'none' }}>
|
||||
{take(list, 3).map((obj, key) => <li key={key}>{obj.name}</li>)}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'existingIndexPatternId',
|
||||
name: 'New index pattern',
|
||||
render: id => {
|
||||
const options = this.state.indexPatterns.map(indexPattern => ({
|
||||
text: indexPattern.get('title'),
|
||||
value: indexPattern.id,
|
||||
}));
|
||||
|
||||
options.unshift({
|
||||
text: '-- Skip Import --',
|
||||
value: '',
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiSelect
|
||||
data-test-subj="managementChangeIndexSelection"
|
||||
onChange={e => this.onIndexChanged(id, e)}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const pagination = {
|
||||
pageSizeOptions: [5, 10, 25],
|
||||
};
|
||||
|
||||
return (
|
||||
<EuiInMemoryTable
|
||||
items={conflicts}
|
||||
columns={columns}
|
||||
pagination={pagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderError() {
|
||||
const { error } = this.state;
|
||||
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiCallOut
|
||||
title="Sorry, there was an error"
|
||||
color="danger"
|
||||
iconType="cross"
|
||||
>
|
||||
<p>{error}</p>
|
||||
</EuiCallOut>
|
||||
<EuiSpacer size="s" />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
renderBody() {
|
||||
const {
|
||||
isLoading,
|
||||
loadingMessage,
|
||||
isOverwriteAllChecked,
|
||||
wasImportSuccessful,
|
||||
importCount,
|
||||
} = this.state;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<EuiFlexGroup justifyContent="spaceAround">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiLoadingKibana size="xl" />
|
||||
<EuiSpacer size="m" />
|
||||
<EuiText>
|
||||
<p>{loadingMessage}</p>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (wasImportSuccessful) {
|
||||
return (
|
||||
<EuiCallOut title="Import successful" color="success" iconType="check">
|
||||
<p>Successfully imported {importCount} objects.</p>
|
||||
</EuiCallOut>
|
||||
);
|
||||
}
|
||||
|
||||
if (this.hasConflicts) {
|
||||
return this.renderConflicts();
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiForm>
|
||||
<EuiFormRow label="Please select a JSON file to import">
|
||||
<EuiFilePicker
|
||||
initialPromptText="Import"
|
||||
onChange={this.setImportFile}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow>
|
||||
<EuiSwitch
|
||||
name="overwriteAll"
|
||||
label="Automatically overwrite all saved objects?"
|
||||
data-test-subj="importSavedObjectsOverwriteToggle"
|
||||
checked={isOverwriteAllChecked}
|
||||
onChange={this.changeOverwriteAll}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiForm>
|
||||
);
|
||||
}
|
||||
|
||||
renderFooter() {
|
||||
const { isLoading, wasImportSuccessful } = this.state;
|
||||
const { done, close } = this.props;
|
||||
|
||||
let confirmButton;
|
||||
|
||||
if (wasImportSuccessful) {
|
||||
confirmButton = (
|
||||
<EuiButton
|
||||
onClick={done}
|
||||
size="s"
|
||||
fill
|
||||
data-test-subj="importSavedObjectsDoneBtn"
|
||||
>
|
||||
Done
|
||||
</EuiButton>
|
||||
);
|
||||
} else if (this.hasConflicts) {
|
||||
confirmButton = (
|
||||
<EuiButton
|
||||
onClick={this.confirmImport}
|
||||
size="s"
|
||||
fill
|
||||
isLoading={isLoading}
|
||||
data-test-subj="importSavedObjectsConfirmBtn"
|
||||
>
|
||||
Confirm all changes
|
||||
</EuiButton>
|
||||
);
|
||||
} else {
|
||||
confirmButton = (
|
||||
<EuiButton
|
||||
onClick={this.import}
|
||||
size="s"
|
||||
fill
|
||||
isLoading={isLoading}
|
||||
data-test-subj="importSavedObjectsImportBtn"
|
||||
>
|
||||
Import
|
||||
</EuiButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty onClick={close} size="s">
|
||||
Cancel
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>{confirmButton}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
|
||||
renderSubheader() {
|
||||
if (
|
||||
!this.hasConflicts ||
|
||||
this.state.isLoading ||
|
||||
this.state.wasImportSuccessful
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiCallOut
|
||||
title="Index Pattern Conflicts"
|
||||
color="warning"
|
||||
iconType="help"
|
||||
>
|
||||
<p>
|
||||
The following saved objects use index patterns that do not exist.
|
||||
Please select the index patterns you'd like re-associated with
|
||||
them. You can{' '}
|
||||
{
|
||||
<EuiLink href={this.props.newIndexPatternUrl}>
|
||||
create a new index pattern
|
||||
</EuiLink>
|
||||
}{' '}
|
||||
if necessary.
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { close } = this.props;
|
||||
|
||||
return (
|
||||
<EuiFlyout onClose={close}>
|
||||
<EuiFlyoutHeader>
|
||||
<EuiTitle>
|
||||
<h2>Import saved objects</h2>
|
||||
</EuiTitle>
|
||||
{this.renderSubheader()}
|
||||
</EuiFlyoutHeader>
|
||||
|
||||
<EuiFlyoutBody>
|
||||
{this.renderError()}
|
||||
{this.renderBody()}
|
||||
</EuiFlyoutBody>
|
||||
|
||||
<EuiFlyoutFooter>{this.renderFooter()}</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { Flyout } from './flyout';
|
|
@ -0,0 +1,115 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Header should render normally 1`] = `
|
||||
<React.Fragment>
|
||||
<EuiSpacer
|
||||
size="m"
|
||||
/>
|
||||
<EuiFlexGroup
|
||||
alignItems="flexEnd"
|
||||
component="div"
|
||||
direction="row"
|
||||
gutterSize="l"
|
||||
justifyContent="spaceBetween"
|
||||
responsive={true}
|
||||
wrap={false}
|
||||
>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiTitle
|
||||
size="m"
|
||||
>
|
||||
<h1>
|
||||
Edit Saved Objects (Found
|
||||
4
|
||||
)
|
||||
</h1>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiFlexGroup
|
||||
alignItems="flexEnd"
|
||||
component="div"
|
||||
direction="row"
|
||||
gutterSize="l"
|
||||
justifyContent="spaceBetween"
|
||||
responsive={true}
|
||||
wrap={false}
|
||||
>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiButton
|
||||
color="primary"
|
||||
data-test-subj="exportAllObjects"
|
||||
fill={false}
|
||||
iconSide="left"
|
||||
iconType="exportAction"
|
||||
onClick={[Function]}
|
||||
size="s"
|
||||
type="button"
|
||||
>
|
||||
Export Everything
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiButton
|
||||
color="primary"
|
||||
data-test-subj="importObjects"
|
||||
fill={false}
|
||||
iconSide="left"
|
||||
iconType="importAction"
|
||||
onClick={[Function]}
|
||||
size="s"
|
||||
type="button"
|
||||
>
|
||||
Import
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiButton
|
||||
color="primary"
|
||||
fill={false}
|
||||
iconSide="left"
|
||||
iconType="refresh"
|
||||
onClick={[Function]}
|
||||
size="s"
|
||||
type="button"
|
||||
>
|
||||
Refresh
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer
|
||||
size="s"
|
||||
/>
|
||||
<EuiText
|
||||
grow={true}
|
||||
>
|
||||
<p>
|
||||
<EuiTextColor
|
||||
color="subdued"
|
||||
>
|
||||
From here you can delete saved objects, such as saved searches. You can also edit the raw data of saved objects. Typically objects are only modified via their associated application, which is probably what you should use instead of this screen.
|
||||
</EuiTextColor>
|
||||
</p>
|
||||
</EuiText>
|
||||
<EuiSpacer
|
||||
size="m"
|
||||
/>
|
||||
</React.Fragment>
|
||||
`;
|
|
@ -0,0 +1,23 @@
|
|||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { Header } from '../header';
|
||||
|
||||
describe('Header', () => {
|
||||
it('should render normally', () => {
|
||||
const props = {
|
||||
onExportAll: () => {},
|
||||
onImport: () => {},
|
||||
onRefresh: () => {},
|
||||
totalCount: 4,
|
||||
};
|
||||
|
||||
const component = shallow(
|
||||
<Header
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,74 @@
|
|||
import React, { Fragment } from 'react';
|
||||
|
||||
import {
|
||||
EuiSpacer,
|
||||
EuiTitle,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiText,
|
||||
EuiTextColor,
|
||||
EuiButton,
|
||||
} from '@elastic/eui';
|
||||
|
||||
export const Header = ({
|
||||
onExportAll,
|
||||
onImport,
|
||||
onRefresh,
|
||||
totalCount,
|
||||
}) => (
|
||||
<Fragment>
|
||||
<EuiSpacer size="m"/>
|
||||
<EuiFlexGroup justifyContent="spaceBetween" alignItems="flexEnd">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle>
|
||||
<h1>Edit Saved Objects (Found {totalCount})</h1>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup justifyContent="spaceBetween" alignItems="flexEnd">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
size="s"
|
||||
iconType="exportAction"
|
||||
data-test-subj="exportAllObjects"
|
||||
onClick={onExportAll}
|
||||
>
|
||||
Export Everything
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
size="s"
|
||||
iconType="importAction"
|
||||
data-test-subj="importObjects"
|
||||
onClick={onImport}
|
||||
>
|
||||
Import
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
size="s"
|
||||
iconType="refresh"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
Refresh
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="s"/>
|
||||
<EuiText>
|
||||
<p>
|
||||
<EuiTextColor color="subdued">
|
||||
From here you can delete saved objects, such as saved searches.
|
||||
You can also edit the raw data of saved objects.
|
||||
Typically objects are only modified via their associated application,
|
||||
which is probably what you should use instead of this screen.
|
||||
</EuiTextColor>
|
||||
</p>
|
||||
</EuiText>
|
||||
<EuiSpacer size="m"/>
|
||||
</Fragment>
|
||||
);
|
|
@ -0,0 +1 @@
|
|||
export { Header } from './header';
|
|
@ -0,0 +1,670 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Relationships should render dashboards normally 1`] = `
|
||||
<EuiFlyout
|
||||
onClose={[MockFunction]}
|
||||
size="m"
|
||||
>
|
||||
<EuiFlyoutHeader>
|
||||
<EuiTitle
|
||||
size="m"
|
||||
>
|
||||
<h2>
|
||||
<EuiToolTip
|
||||
content="dashboard"
|
||||
position="top"
|
||||
>
|
||||
<EuiIcon
|
||||
aria-label="dashboard"
|
||||
size="m"
|
||||
type="dashboardApp"
|
||||
/>
|
||||
</EuiToolTip>
|
||||
|
||||
MyDashboard
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<EuiDescriptionList
|
||||
align="left"
|
||||
compressed={false}
|
||||
type="row"
|
||||
>
|
||||
<React.Fragment
|
||||
key="visualizations"
|
||||
>
|
||||
<EuiDescriptionListTitle
|
||||
style={
|
||||
Object {
|
||||
"marginBottom": "1rem",
|
||||
}
|
||||
}
|
||||
>
|
||||
<EuiCallOut
|
||||
color="success"
|
||||
size="m"
|
||||
title="Dashboard"
|
||||
>
|
||||
<p>
|
||||
Here are some visualizations used on this dashboard. You can
|
||||
safely delete this dashboard and the visualizations will still
|
||||
work properly.
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
</EuiDescriptionListTitle>
|
||||
<EuiInMemoryTable
|
||||
columns={
|
||||
Array [
|
||||
Object {
|
||||
"name": "Type",
|
||||
"render": [Function],
|
||||
},
|
||||
Object {
|
||||
"field": "title",
|
||||
"name": "Title",
|
||||
"render": [Function],
|
||||
},
|
||||
Object {
|
||||
"actions": Array [
|
||||
Object {
|
||||
"description": "View this saved object within Kibana",
|
||||
"icon": "eye",
|
||||
"name": "In app",
|
||||
"onClick": [Function],
|
||||
},
|
||||
],
|
||||
"name": "Actions",
|
||||
},
|
||||
]
|
||||
}
|
||||
items={
|
||||
Array [
|
||||
Object {
|
||||
"id": "1",
|
||||
},
|
||||
Object {
|
||||
"id": "2",
|
||||
},
|
||||
]
|
||||
}
|
||||
pagination={true}
|
||||
responsive={true}
|
||||
sorting={false}
|
||||
/>
|
||||
</React.Fragment>
|
||||
</EuiDescriptionList>
|
||||
</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup
|
||||
alignItems="stretch"
|
||||
component="div"
|
||||
direction="row"
|
||||
gutterSize="l"
|
||||
justifyContent="spaceBetween"
|
||||
responsive={true}
|
||||
wrap={false}
|
||||
>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiButtonEmpty
|
||||
color="primary"
|
||||
iconSide="left"
|
||||
onClick={[MockFunction]}
|
||||
size="s"
|
||||
type="button"
|
||||
>
|
||||
Close
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
`;
|
||||
|
||||
exports[`Relationships should render errors 1`] = `
|
||||
<EuiFlyout
|
||||
onClose={[MockFunction]}
|
||||
size="m"
|
||||
>
|
||||
<EuiFlyoutHeader>
|
||||
<EuiTitle
|
||||
size="m"
|
||||
>
|
||||
<h2>
|
||||
<EuiToolTip
|
||||
content="dashboard"
|
||||
position="top"
|
||||
>
|
||||
<EuiIcon
|
||||
aria-label="dashboard"
|
||||
size="m"
|
||||
type="dashboardApp"
|
||||
/>
|
||||
</EuiToolTip>
|
||||
|
||||
MyDashboard
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
size="m"
|
||||
title="Error"
|
||||
>
|
||||
foo
|
||||
</EuiCallOut>
|
||||
</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup
|
||||
alignItems="stretch"
|
||||
component="div"
|
||||
direction="row"
|
||||
gutterSize="l"
|
||||
justifyContent="spaceBetween"
|
||||
responsive={true}
|
||||
wrap={false}
|
||||
>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiButtonEmpty
|
||||
color="primary"
|
||||
iconSide="left"
|
||||
onClick={[MockFunction]}
|
||||
size="s"
|
||||
type="button"
|
||||
>
|
||||
Close
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
`;
|
||||
|
||||
exports[`Relationships should render index patterns normally 1`] = `
|
||||
<EuiFlyout
|
||||
onClose={[MockFunction]}
|
||||
size="m"
|
||||
>
|
||||
<EuiFlyoutHeader>
|
||||
<EuiTitle
|
||||
size="m"
|
||||
>
|
||||
<h2>
|
||||
<EuiToolTip
|
||||
content="index patterns"
|
||||
position="top"
|
||||
>
|
||||
<EuiIcon
|
||||
aria-label="index patterns"
|
||||
size="m"
|
||||
type="indexPatternApp"
|
||||
/>
|
||||
</EuiToolTip>
|
||||
|
||||
MyIndexPattern*
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<EuiDescriptionList
|
||||
align="left"
|
||||
compressed={false}
|
||||
type="row"
|
||||
>
|
||||
<React.Fragment
|
||||
key="searches"
|
||||
>
|
||||
<EuiDescriptionListTitle
|
||||
style={
|
||||
Object {
|
||||
"marginBottom": "1rem",
|
||||
}
|
||||
}
|
||||
>
|
||||
<EuiCallOut
|
||||
color="warning"
|
||||
size="m"
|
||||
title="Warning"
|
||||
>
|
||||
<p>
|
||||
Here are some saved searches that use this index pattern. If
|
||||
you delete this index pattern, these saved searches will not
|
||||
longer work properly.
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
</EuiDescriptionListTitle>
|
||||
<EuiInMemoryTable
|
||||
columns={
|
||||
Array [
|
||||
Object {
|
||||
"name": "Type",
|
||||
"render": [Function],
|
||||
},
|
||||
Object {
|
||||
"field": "title",
|
||||
"name": "Title",
|
||||
"render": [Function],
|
||||
},
|
||||
Object {
|
||||
"actions": Array [
|
||||
Object {
|
||||
"description": "View this saved object within Kibana",
|
||||
"icon": "eye",
|
||||
"name": "In app",
|
||||
"onClick": [Function],
|
||||
},
|
||||
],
|
||||
"name": "Actions",
|
||||
},
|
||||
]
|
||||
}
|
||||
items={
|
||||
Array [
|
||||
Object {
|
||||
"id": "1",
|
||||
},
|
||||
]
|
||||
}
|
||||
pagination={true}
|
||||
responsive={true}
|
||||
sorting={false}
|
||||
/>
|
||||
</React.Fragment>
|
||||
<React.Fragment
|
||||
key="visualizations"
|
||||
>
|
||||
<EuiDescriptionListTitle
|
||||
style={
|
||||
Object {
|
||||
"marginBottom": "1rem",
|
||||
}
|
||||
}
|
||||
>
|
||||
<EuiCallOut
|
||||
color="warning"
|
||||
size="m"
|
||||
title="Warning"
|
||||
>
|
||||
<p>
|
||||
Here are some visualizations that use this index pattern. If
|
||||
you delete this index pattern, these visualizations will not
|
||||
longer work properly.
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
</EuiDescriptionListTitle>
|
||||
<EuiInMemoryTable
|
||||
columns={
|
||||
Array [
|
||||
Object {
|
||||
"name": "Type",
|
||||
"render": [Function],
|
||||
},
|
||||
Object {
|
||||
"field": "title",
|
||||
"name": "Title",
|
||||
"render": [Function],
|
||||
},
|
||||
Object {
|
||||
"actions": Array [
|
||||
Object {
|
||||
"description": "View this saved object within Kibana",
|
||||
"icon": "eye",
|
||||
"name": "In app",
|
||||
"onClick": [Function],
|
||||
},
|
||||
],
|
||||
"name": "Actions",
|
||||
},
|
||||
]
|
||||
}
|
||||
items={
|
||||
Array [
|
||||
Object {
|
||||
"id": "2",
|
||||
},
|
||||
]
|
||||
}
|
||||
pagination={true}
|
||||
responsive={true}
|
||||
sorting={false}
|
||||
/>
|
||||
</React.Fragment>
|
||||
</EuiDescriptionList>
|
||||
</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup
|
||||
alignItems="stretch"
|
||||
component="div"
|
||||
direction="row"
|
||||
gutterSize="l"
|
||||
justifyContent="spaceBetween"
|
||||
responsive={true}
|
||||
wrap={false}
|
||||
>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiButtonEmpty
|
||||
color="primary"
|
||||
iconSide="left"
|
||||
onClick={[MockFunction]}
|
||||
size="s"
|
||||
type="button"
|
||||
>
|
||||
Close
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
`;
|
||||
|
||||
exports[`Relationships should render searches normally 1`] = `
|
||||
<EuiFlyout
|
||||
onClose={[MockFunction]}
|
||||
size="m"
|
||||
>
|
||||
<EuiFlyoutHeader>
|
||||
<EuiTitle
|
||||
size="m"
|
||||
>
|
||||
<h2>
|
||||
<EuiToolTip
|
||||
content="search"
|
||||
position="top"
|
||||
>
|
||||
<EuiIcon
|
||||
aria-label="search"
|
||||
size="m"
|
||||
type="search"
|
||||
/>
|
||||
</EuiToolTip>
|
||||
|
||||
MySearch
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<EuiDescriptionList
|
||||
align="left"
|
||||
compressed={false}
|
||||
type="row"
|
||||
>
|
||||
<React.Fragment
|
||||
key="indexPatterns"
|
||||
>
|
||||
<EuiDescriptionListTitle
|
||||
style={
|
||||
Object {
|
||||
"marginBottom": "1rem",
|
||||
}
|
||||
}
|
||||
>
|
||||
<EuiCallOut
|
||||
color="success"
|
||||
size="m"
|
||||
title="Saved Search"
|
||||
>
|
||||
<p>
|
||||
Here is the index pattern tied to this saved search.
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
</EuiDescriptionListTitle>
|
||||
<EuiInMemoryTable
|
||||
columns={
|
||||
Array [
|
||||
Object {
|
||||
"name": "Type",
|
||||
"render": [Function],
|
||||
},
|
||||
Object {
|
||||
"field": "title",
|
||||
"name": "Title",
|
||||
"render": [Function],
|
||||
},
|
||||
Object {
|
||||
"actions": Array [
|
||||
Object {
|
||||
"description": "View this saved object within Kibana",
|
||||
"icon": "eye",
|
||||
"name": "In app",
|
||||
"onClick": [Function],
|
||||
},
|
||||
],
|
||||
"name": "Actions",
|
||||
},
|
||||
]
|
||||
}
|
||||
items={
|
||||
Array [
|
||||
Object {
|
||||
"id": "1",
|
||||
},
|
||||
]
|
||||
}
|
||||
pagination={true}
|
||||
responsive={true}
|
||||
sorting={false}
|
||||
/>
|
||||
</React.Fragment>
|
||||
<React.Fragment
|
||||
key="visualizations"
|
||||
>
|
||||
<EuiDescriptionListTitle
|
||||
style={
|
||||
Object {
|
||||
"marginBottom": "1rem",
|
||||
}
|
||||
}
|
||||
>
|
||||
<EuiCallOut
|
||||
color="warning"
|
||||
size="m"
|
||||
title="Warning"
|
||||
>
|
||||
<p>
|
||||
Here are some visualizations that use this saved search. If
|
||||
you delete this saved search, these visualizations will not
|
||||
longer work properly.
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
</EuiDescriptionListTitle>
|
||||
<EuiInMemoryTable
|
||||
columns={
|
||||
Array [
|
||||
Object {
|
||||
"name": "Type",
|
||||
"render": [Function],
|
||||
},
|
||||
Object {
|
||||
"field": "title",
|
||||
"name": "Title",
|
||||
"render": [Function],
|
||||
},
|
||||
Object {
|
||||
"actions": Array [
|
||||
Object {
|
||||
"description": "View this saved object within Kibana",
|
||||
"icon": "eye",
|
||||
"name": "In app",
|
||||
"onClick": [Function],
|
||||
},
|
||||
],
|
||||
"name": "Actions",
|
||||
},
|
||||
]
|
||||
}
|
||||
items={
|
||||
Array [
|
||||
Object {
|
||||
"id": "2",
|
||||
},
|
||||
]
|
||||
}
|
||||
pagination={true}
|
||||
responsive={true}
|
||||
sorting={false}
|
||||
/>
|
||||
</React.Fragment>
|
||||
</EuiDescriptionList>
|
||||
</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup
|
||||
alignItems="stretch"
|
||||
component="div"
|
||||
direction="row"
|
||||
gutterSize="l"
|
||||
justifyContent="spaceBetween"
|
||||
responsive={true}
|
||||
wrap={false}
|
||||
>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiButtonEmpty
|
||||
color="primary"
|
||||
iconSide="left"
|
||||
onClick={[MockFunction]}
|
||||
size="s"
|
||||
type="button"
|
||||
>
|
||||
Close
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
`;
|
||||
|
||||
exports[`Relationships should render visualizations normally 1`] = `
|
||||
<EuiFlyout
|
||||
onClose={[MockFunction]}
|
||||
size="m"
|
||||
>
|
||||
<EuiFlyoutHeader>
|
||||
<EuiTitle
|
||||
size="m"
|
||||
>
|
||||
<h2>
|
||||
<EuiToolTip
|
||||
content="visualization"
|
||||
position="top"
|
||||
>
|
||||
<EuiIcon
|
||||
aria-label="visualization"
|
||||
size="m"
|
||||
type="visualizeApp"
|
||||
/>
|
||||
</EuiToolTip>
|
||||
|
||||
MyViz
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<EuiDescriptionList
|
||||
align="left"
|
||||
compressed={false}
|
||||
type="row"
|
||||
>
|
||||
<React.Fragment
|
||||
key="dashboards"
|
||||
>
|
||||
<EuiDescriptionListTitle
|
||||
style={
|
||||
Object {
|
||||
"marginBottom": "1rem",
|
||||
}
|
||||
}
|
||||
>
|
||||
<EuiCallOut
|
||||
color="warning"
|
||||
size="m"
|
||||
title="Warning"
|
||||
>
|
||||
<p>
|
||||
Here are some dashboards which contain this visualization. If
|
||||
you delete this visualization, these dashboards will no longer
|
||||
show them.
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
</EuiDescriptionListTitle>
|
||||
<EuiInMemoryTable
|
||||
columns={
|
||||
Array [
|
||||
Object {
|
||||
"name": "Type",
|
||||
"render": [Function],
|
||||
},
|
||||
Object {
|
||||
"field": "title",
|
||||
"name": "Title",
|
||||
"render": [Function],
|
||||
},
|
||||
Object {
|
||||
"actions": Array [
|
||||
Object {
|
||||
"description": "View this saved object within Kibana",
|
||||
"icon": "eye",
|
||||
"name": "In app",
|
||||
"onClick": [Function],
|
||||
},
|
||||
],
|
||||
"name": "Actions",
|
||||
},
|
||||
]
|
||||
}
|
||||
items={
|
||||
Array [
|
||||
Object {
|
||||
"id": "1",
|
||||
},
|
||||
Object {
|
||||
"id": "2",
|
||||
},
|
||||
]
|
||||
}
|
||||
pagination={true}
|
||||
responsive={true}
|
||||
sorting={false}
|
||||
/>
|
||||
</React.Fragment>
|
||||
</EuiDescriptionList>
|
||||
</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup
|
||||
alignItems="stretch"
|
||||
component="div"
|
||||
direction="row"
|
||||
gutterSize="l"
|
||||
justifyContent="spaceBetween"
|
||||
responsive={true}
|
||||
wrap={false}
|
||||
>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiButtonEmpty
|
||||
color="primary"
|
||||
iconSide="left"
|
||||
onClick={[MockFunction]}
|
||||
size="s"
|
||||
type="button"
|
||||
>
|
||||
Close
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
`;
|
|
@ -0,0 +1,207 @@
|
|||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
jest.mock('ui/errors', () => ({
|
||||
SavedObjectNotFound: class SavedObjectNotFound extends Error {
|
||||
constructor(options) {
|
||||
super();
|
||||
for (const option in options) {
|
||||
if (options.hasOwnProperty(option)) {
|
||||
this[option] = options[option];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('ui/chrome', () => ({
|
||||
addBasePath: () => ''
|
||||
}));
|
||||
|
||||
import { Relationships } from '../relationships';
|
||||
|
||||
describe('Relationships', () => {
|
||||
it('should render index patterns normally', async () => {
|
||||
const props = {
|
||||
getRelationships: jest.fn().mockImplementation(() => ({
|
||||
searches: [
|
||||
{
|
||||
id: '1',
|
||||
}
|
||||
],
|
||||
visualizations: [
|
||||
{
|
||||
id: '2',
|
||||
}
|
||||
],
|
||||
})),
|
||||
getEditUrl: () => '',
|
||||
goInApp: jest.fn(),
|
||||
id: '1',
|
||||
type: 'index-pattern',
|
||||
title: 'MyIndexPattern*',
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
const component = shallow(
|
||||
<Relationships
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
// Make sure we are showing loading
|
||||
expect(component.find('EuiLoadingKibana').length).toBe(1);
|
||||
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
// Ensure the state changes are reflected
|
||||
component.update();
|
||||
|
||||
expect(props.getRelationships).toHaveBeenCalled();
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render searches normally', async () => {
|
||||
const props = {
|
||||
getRelationships: jest.fn().mockImplementation(() => ({
|
||||
indexPatterns: [
|
||||
{
|
||||
id: '1',
|
||||
}
|
||||
],
|
||||
visualizations: [
|
||||
{
|
||||
id: '2',
|
||||
}
|
||||
],
|
||||
})),
|
||||
getEditUrl: () => '',
|
||||
goInApp: jest.fn(),
|
||||
id: '1',
|
||||
type: 'search',
|
||||
title: 'MySearch',
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
const component = shallow(
|
||||
<Relationships
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
// Make sure we are showing loading
|
||||
expect(component.find('EuiLoadingKibana').length).toBe(1);
|
||||
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
// Ensure the state changes are reflected
|
||||
component.update();
|
||||
|
||||
expect(props.getRelationships).toHaveBeenCalled();
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render visualizations normally', async () => {
|
||||
const props = {
|
||||
getRelationships: jest.fn().mockImplementation(() => ({
|
||||
dashboards: [
|
||||
{
|
||||
id: '1',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
}
|
||||
],
|
||||
})),
|
||||
getEditUrl: () => '',
|
||||
goInApp: jest.fn(),
|
||||
id: '1',
|
||||
type: 'visualization',
|
||||
title: 'MyViz',
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
const component = shallow(
|
||||
<Relationships
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
// Make sure we are showing loading
|
||||
expect(component.find('EuiLoadingKibana').length).toBe(1);
|
||||
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
// Ensure the state changes are reflected
|
||||
component.update();
|
||||
|
||||
expect(props.getRelationships).toHaveBeenCalled();
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render dashboards normally', async () => {
|
||||
const props = {
|
||||
getRelationships: jest.fn().mockImplementation(() => ({
|
||||
visualizations: [
|
||||
{
|
||||
id: '1',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
}
|
||||
],
|
||||
})),
|
||||
getEditUrl: () => '',
|
||||
goInApp: jest.fn(),
|
||||
id: '1',
|
||||
type: 'dashboard',
|
||||
title: 'MyDashboard',
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
const component = shallow(
|
||||
<Relationships
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
// Make sure we are showing loading
|
||||
expect(component.find('EuiLoadingKibana').length).toBe(1);
|
||||
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
// Ensure the state changes are reflected
|
||||
component.update();
|
||||
|
||||
expect(props.getRelationships).toHaveBeenCalled();
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render errors', async () => {
|
||||
const props = {
|
||||
getRelationships: jest.fn().mockImplementation(() => {
|
||||
throw new Error('foo');
|
||||
}),
|
||||
getEditUrl: () => '',
|
||||
goInApp: jest.fn(),
|
||||
id: '1',
|
||||
type: 'dashboard',
|
||||
title: 'MyDashboard',
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
const component = shallow(
|
||||
<Relationships
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
// Ensure all promises resolve
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
// Ensure the state changes are reflected
|
||||
component.update();
|
||||
|
||||
expect(props.getRelationships).toHaveBeenCalled();
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
export { Relationships } from './relationships';
|
|
@ -0,0 +1,237 @@
|
|||
import React, { Component, Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
EuiTitle,
|
||||
EuiFlyout,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutFooter,
|
||||
EuiFlyoutHeader,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiButtonEmpty,
|
||||
EuiDescriptionList,
|
||||
EuiDescriptionListTitle,
|
||||
EuiLink,
|
||||
EuiIcon,
|
||||
EuiCallOut,
|
||||
EuiLoadingKibana,
|
||||
EuiInMemoryTable,
|
||||
EuiToolTip
|
||||
} from '@elastic/eui';
|
||||
import { getSavedObjectIcon, getSavedObjectLabel } from '../../../../lib';
|
||||
|
||||
export class Relationships extends Component {
|
||||
static propTypes = {
|
||||
getRelationships: PropTypes.func.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
close: PropTypes.func.isRequired,
|
||||
getEditUrl: PropTypes.func.isRequired,
|
||||
goInApp: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
relationships: undefined,
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.getRelationshipData();
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.id !== this.props.id) {
|
||||
this.getRelationshipData();
|
||||
}
|
||||
}
|
||||
|
||||
async getRelationshipData() {
|
||||
const { id, type, getRelationships } = this.props;
|
||||
|
||||
this.setState({ isLoading: true });
|
||||
|
||||
try {
|
||||
const relationships = await getRelationships(type, id);
|
||||
this.setState({ relationships, isLoading: false, error: undefined });
|
||||
} catch (err) {
|
||||
this.setState({ error: err.message, isLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
renderError() {
|
||||
const { error } = this.state;
|
||||
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiCallOut title="Error" color="danger">
|
||||
{error}
|
||||
</EuiCallOut>
|
||||
);
|
||||
}
|
||||
|
||||
renderRelationships() {
|
||||
const { getEditUrl, goInApp } = this.props;
|
||||
const { relationships, isLoading, error } = this.state;
|
||||
|
||||
if (error) {
|
||||
return this.renderError();
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <EuiLoadingKibana size="xl" />;
|
||||
}
|
||||
|
||||
const items = [];
|
||||
|
||||
for (const [type, list] of Object.entries(relationships)) {
|
||||
if (list.length === 0) {
|
||||
items.push(
|
||||
<EuiDescriptionListTitle key={`${type}_not_found`}>
|
||||
No {type} found.
|
||||
</EuiDescriptionListTitle>
|
||||
);
|
||||
} else {
|
||||
// let node;
|
||||
let calloutTitle = 'Warning';
|
||||
let calloutColor = 'warning';
|
||||
let calloutText;
|
||||
|
||||
switch (this.props.type) {
|
||||
case 'dashboard':
|
||||
calloutColor = 'success';
|
||||
calloutTitle = 'Dashboard';
|
||||
calloutText = `Here are some visualizations used on this dashboard. You can
|
||||
safely delete this dashboard and the visualizations will still
|
||||
work properly.`;
|
||||
break;
|
||||
case 'search':
|
||||
if (type === 'visualizations') {
|
||||
calloutText = `Here are some visualizations that use this saved search. If
|
||||
you delete this saved search, these visualizations will not
|
||||
longer work properly.`;
|
||||
} else {
|
||||
calloutColor = 'success';
|
||||
calloutTitle = 'Saved Search';
|
||||
calloutText = `Here is the index pattern tied to this saved search.`;
|
||||
}
|
||||
break;
|
||||
case 'visualization':
|
||||
calloutText = `Here are some dashboards which contain this visualization. If
|
||||
you delete this visualization, these dashboards will no longer
|
||||
show them.`;
|
||||
break;
|
||||
case 'index-pattern':
|
||||
if (type === 'visualizations') {
|
||||
calloutText = `Here are some visualizations that use this index pattern. If
|
||||
you delete this index pattern, these visualizations will not
|
||||
longer work properly.`;
|
||||
} else if (type === 'searches') {
|
||||
calloutText = `Here are some saved searches that use this index pattern. If
|
||||
you delete this index pattern, these saved searches will not
|
||||
longer work properly.`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
items.push(
|
||||
<Fragment key={type}>
|
||||
<EuiDescriptionListTitle style={{ marginBottom: '1rem' }}>
|
||||
<EuiCallOut color={calloutColor} title={calloutTitle}>
|
||||
<p>{calloutText}</p>
|
||||
</EuiCallOut>
|
||||
</EuiDescriptionListTitle>
|
||||
<EuiInMemoryTable
|
||||
items={list}
|
||||
columns={[
|
||||
{
|
||||
name: 'Type',
|
||||
render: () => (
|
||||
<EuiToolTip
|
||||
position="top"
|
||||
content={getSavedObjectLabel(type)}
|
||||
>
|
||||
<EuiIcon
|
||||
aria-label={getSavedObjectLabel(type)}
|
||||
size="s"
|
||||
type={getSavedObjectIcon(type)}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Title',
|
||||
field: 'title',
|
||||
render: (title, item) => (
|
||||
<EuiLink href={`#${getEditUrl(item.id, type)}`}>
|
||||
{title}
|
||||
</EuiLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Actions',
|
||||
actions: [
|
||||
{
|
||||
name: 'In app',
|
||||
description: 'View this saved object within Kibana',
|
||||
icon: 'eye',
|
||||
onClick: object => goInApp(object.id, type),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
pagination={true}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <EuiDescriptionList>{items}</EuiDescriptionList>;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { close, title, type } = this.props;
|
||||
|
||||
return (
|
||||
<EuiFlyout onClose={close}>
|
||||
<EuiFlyoutHeader>
|
||||
<EuiTitle>
|
||||
<h2>
|
||||
<EuiToolTip position="top" content={getSavedObjectLabel(type)}>
|
||||
<EuiIcon
|
||||
aria-label={getSavedObjectLabel(type)}
|
||||
size="m"
|
||||
type={getSavedObjectIcon(type)}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
|
||||
{title}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
|
||||
<EuiFlyoutBody>{this.renderRelationships()}</EuiFlyoutBody>
|
||||
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty onClick={close} size="s">
|
||||
Close
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,126 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Table should render normally 1`] = `
|
||||
<React.Fragment>
|
||||
<EuiSearchBar
|
||||
filters={
|
||||
Array [
|
||||
Object {
|
||||
"field": "type",
|
||||
"multiSelect": "or",
|
||||
"name": "Type",
|
||||
"options": Array [
|
||||
2,
|
||||
],
|
||||
"type": "field_value_selection",
|
||||
},
|
||||
]
|
||||
}
|
||||
onChange={[Function]}
|
||||
toolsRight={
|
||||
Array [
|
||||
<EuiButton
|
||||
color="danger"
|
||||
fill={false}
|
||||
iconSide="left"
|
||||
iconType="trash"
|
||||
isDisabled={false}
|
||||
onClick={[Function]}
|
||||
size="s"
|
||||
type="button"
|
||||
>
|
||||
Delete
|
||||
</EuiButton>,
|
||||
<EuiButton
|
||||
color="primary"
|
||||
fill={false}
|
||||
iconSide="left"
|
||||
iconType="exportAction"
|
||||
isDisabled={false}
|
||||
onClick={[Function]}
|
||||
size="s"
|
||||
type="button"
|
||||
>
|
||||
Export
|
||||
</EuiButton>,
|
||||
]
|
||||
}
|
||||
/>
|
||||
<EuiSpacer
|
||||
size="s"
|
||||
/>
|
||||
<div
|
||||
data-test-subj="savedObjectsTable"
|
||||
>
|
||||
<EuiBasicTable
|
||||
columns={
|
||||
Array [
|
||||
Object {
|
||||
"align": "center",
|
||||
"description": "Type of the saved object",
|
||||
"field": "type",
|
||||
"name": "Type",
|
||||
"render": [Function],
|
||||
"sortable": false,
|
||||
"width": "50px",
|
||||
},
|
||||
Object {
|
||||
"dataType": "string",
|
||||
"description": "Title of the saved object",
|
||||
"field": "title",
|
||||
"name": "Title",
|
||||
"render": [Function],
|
||||
"sortable": false,
|
||||
},
|
||||
Object {
|
||||
"actions": Array [
|
||||
Object {
|
||||
"description": "View this saved object within Kibana",
|
||||
"icon": "eye",
|
||||
"name": "In app",
|
||||
"onClick": [Function],
|
||||
},
|
||||
Object {
|
||||
"description": "View the relationships this saved object has to other saved objects",
|
||||
"icon": "kqlSelector",
|
||||
"name": "Relationships",
|
||||
"onClick": [Function],
|
||||
},
|
||||
],
|
||||
"name": "Actions",
|
||||
},
|
||||
]
|
||||
}
|
||||
itemIdToExpandedRowMap={Object {}}
|
||||
items={
|
||||
Array [
|
||||
3,
|
||||
]
|
||||
}
|
||||
loading={false}
|
||||
noItemsMessage="No items found"
|
||||
onChange={[Function]}
|
||||
pagination={
|
||||
Object {
|
||||
"pageIndex": 1,
|
||||
"pageSize": 2,
|
||||
"pageSizeOptions": Array [
|
||||
5,
|
||||
10,
|
||||
20,
|
||||
50,
|
||||
],
|
||||
"totalItemCount": 3,
|
||||
}
|
||||
}
|
||||
responsive={true}
|
||||
selection={
|
||||
Object {
|
||||
"itemId": "id",
|
||||
"onSelectionChange": [Function],
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
`;
|
|
@ -0,0 +1,56 @@
|
|||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
jest.mock('ui/errors', () => ({
|
||||
SavedObjectNotFound: class SavedObjectNotFound extends Error {
|
||||
constructor(options) {
|
||||
super();
|
||||
for (const option in options) {
|
||||
if (options.hasOwnProperty(option)) {
|
||||
this[option] = options[option];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('ui/chrome', () => ({
|
||||
addBasePath: () => ''
|
||||
}));
|
||||
|
||||
import { Table } from '../table';
|
||||
|
||||
describe('Table', () => {
|
||||
it('should render normally', () => {
|
||||
const props = {
|
||||
selectedSavedObjects: [1],
|
||||
selectionConfig: {
|
||||
itemId: 'id',
|
||||
onSelectionChange: () => {},
|
||||
},
|
||||
filterOptions: [2],
|
||||
onDelete: () => {},
|
||||
onExport: () => {},
|
||||
getEditUrl: () => {},
|
||||
goInApp: () => {},
|
||||
|
||||
pageIndex: 1,
|
||||
pageSize: 2,
|
||||
items: [3],
|
||||
totalItemCount: 3,
|
||||
onQueryChange: () => {},
|
||||
onTableChange: () => {},
|
||||
isSearching: false,
|
||||
|
||||
onShowRelationships: () => {},
|
||||
};
|
||||
|
||||
const component = shallow(
|
||||
<Table
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
export { Table } from './table';
|
|
@ -0,0 +1,181 @@
|
|||
import React, { PureComponent, Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
EuiSearchBar,
|
||||
EuiBasicTable,
|
||||
EuiButton,
|
||||
EuiIcon,
|
||||
EuiLink,
|
||||
EuiSpacer,
|
||||
EuiToolTip
|
||||
} from '@elastic/eui';
|
||||
import { getSavedObjectLabel, getSavedObjectIcon } from '../../../../lib';
|
||||
|
||||
export class Table extends PureComponent {
|
||||
static propTypes = {
|
||||
selectedSavedObjects: PropTypes.array.isRequired,
|
||||
selectionConfig: PropTypes.shape({
|
||||
itemId: PropTypes.string.isRequired,
|
||||
selectable: PropTypes.func,
|
||||
selectableMessage: PropTypes.func,
|
||||
onSelectionChange: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
filterOptions: PropTypes.array.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
onExport: PropTypes.func.isRequired,
|
||||
getEditUrl: PropTypes.func.isRequired,
|
||||
goInApp: PropTypes.func.isRequired,
|
||||
|
||||
pageIndex: PropTypes.number.isRequired,
|
||||
pageSize: PropTypes.number.isRequired,
|
||||
items: PropTypes.array.isRequired,
|
||||
totalItemCount: PropTypes.number.isRequired,
|
||||
onQueryChange: PropTypes.func.isRequired,
|
||||
onTableChange: PropTypes.func.isRequired,
|
||||
isSearching: PropTypes.bool.isRequired,
|
||||
|
||||
onShowRelationships: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
pageIndex,
|
||||
pageSize,
|
||||
items,
|
||||
totalItemCount,
|
||||
isSearching,
|
||||
filterOptions,
|
||||
selectionConfig: selection,
|
||||
onDelete,
|
||||
onExport,
|
||||
selectedSavedObjects,
|
||||
onQueryChange,
|
||||
onTableChange,
|
||||
goInApp,
|
||||
getEditUrl,
|
||||
onShowRelationships,
|
||||
} = this.props;
|
||||
|
||||
const pagination = {
|
||||
pageIndex: pageIndex,
|
||||
pageSize: pageSize,
|
||||
totalItemCount: totalItemCount,
|
||||
pageSizeOptions: [5, 10, 20, 50],
|
||||
};
|
||||
|
||||
const filters = [
|
||||
{
|
||||
type: 'field_value_selection',
|
||||
field: 'type',
|
||||
name: 'Type',
|
||||
multiSelect: 'or',
|
||||
options: filterOptions,
|
||||
},
|
||||
// Add this back in once we have tag support
|
||||
// {
|
||||
// type: 'field_value_selection',
|
||||
// field: 'tag',
|
||||
// name: 'Tags',
|
||||
// multiSelect: 'or',
|
||||
// options: [],
|
||||
// },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
field: 'type',
|
||||
name: 'Type',
|
||||
width: '50px',
|
||||
align: 'center',
|
||||
description: `Type of the saved object`,
|
||||
sortable: false,
|
||||
render: type => {
|
||||
return (
|
||||
<EuiToolTip
|
||||
position="top"
|
||||
content={getSavedObjectLabel(type)}
|
||||
>
|
||||
<EuiIcon
|
||||
aria-label={getSavedObjectLabel(type)}
|
||||
type={getSavedObjectIcon(type)}
|
||||
size="s"
|
||||
/>
|
||||
</EuiToolTip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'title',
|
||||
name: 'Title',
|
||||
description: `Title of the saved object`,
|
||||
dataType: 'string',
|
||||
sortable: false,
|
||||
render: (title, object) => (
|
||||
<EuiLink href={getEditUrl(object.id, object.type)}>{title}</EuiLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Actions',
|
||||
actions: [
|
||||
{
|
||||
name: 'In app',
|
||||
description:
|
||||
'View this saved object within Kibana',
|
||||
icon: 'eye',
|
||||
onClick: object => goInApp(object.id, object.type),
|
||||
},
|
||||
{
|
||||
name: 'Relationships',
|
||||
description:
|
||||
'View the relationships this saved object has to other saved objects',
|
||||
icon: 'kqlSelector',
|
||||
onClick: object =>
|
||||
onShowRelationships(object.id, object.type, object.title),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiSearchBar
|
||||
filters={filters}
|
||||
onChange={onQueryChange}
|
||||
toolsRight={[
|
||||
<EuiButton
|
||||
key="deleteSO"
|
||||
iconType="trash"
|
||||
color="danger"
|
||||
size="s"
|
||||
onClick={onDelete}
|
||||
isDisabled={selectedSavedObjects.length === 0}
|
||||
>
|
||||
Delete
|
||||
</EuiButton>,
|
||||
<EuiButton
|
||||
key="exportSO"
|
||||
iconType="exportAction"
|
||||
size="s"
|
||||
onClick={onExport}
|
||||
isDisabled={selectedSavedObjects.length === 0}
|
||||
>
|
||||
Export
|
||||
</EuiButton>,
|
||||
]}
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
<div data-test-subj="savedObjectsTable">
|
||||
<EuiBasicTable
|
||||
loading={isSearching}
|
||||
items={items}
|
||||
columns={columns}
|
||||
pagination={pagination}
|
||||
selection={selection}
|
||||
onChange={onTableChange}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { ObjectsTable } from './objects_table';
|
|
@ -0,0 +1,483 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { flattenDeep } from 'lodash';
|
||||
import { Header } from './components/header';
|
||||
import { Flyout } from './components/flyout';
|
||||
import { Relationships } from './components/relationships';
|
||||
import { Table } from './components/table';
|
||||
|
||||
import {
|
||||
EuiSpacer,
|
||||
Query,
|
||||
EuiInMemoryTable,
|
||||
EuiIcon,
|
||||
EuiConfirmModal,
|
||||
EuiOverlayMask,
|
||||
EUI_MODAL_CONFIRM_BUTTON,
|
||||
EuiCheckboxGroup,
|
||||
EuiToolTip
|
||||
} from '@elastic/eui';
|
||||
import {
|
||||
retrieveAndExportDocs,
|
||||
scanAllTypes,
|
||||
saveToFile,
|
||||
parseQuery,
|
||||
getSavedObjectIcon,
|
||||
getSavedObjectCounts,
|
||||
getRelationships,
|
||||
getSavedObjectLabel,
|
||||
} from '../../lib';
|
||||
import { ensureMinimumTime } from '../../../indices/create_index_pattern_wizard/lib/ensure_minimum_time';
|
||||
import { isSameQuery } from '../../lib/is_same_query';
|
||||
|
||||
export const INCLUDED_TYPES = [
|
||||
'index-pattern',
|
||||
'visualization',
|
||||
'dashboard',
|
||||
'search',
|
||||
];
|
||||
|
||||
export class ObjectsTable extends Component {
|
||||
static propTypes = {
|
||||
savedObjectsClient: PropTypes.object.isRequired,
|
||||
indexPatterns: PropTypes.object.isRequired,
|
||||
$http: PropTypes.func.isRequired,
|
||||
basePath: PropTypes.string.isRequired,
|
||||
perPageConfig: PropTypes.number,
|
||||
newIndexPatternUrl: PropTypes.string.isRequired,
|
||||
services: PropTypes.array.isRequired,
|
||||
getEditUrl: PropTypes.func.isRequired,
|
||||
goInApp: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
totalCount: 0,
|
||||
page: 0,
|
||||
perPage: props.perPageConfig || 10,
|
||||
savedObjects: [],
|
||||
savedObjectCounts: INCLUDED_TYPES.reduce((accum, type) => {
|
||||
accum[type] = 0;
|
||||
return accum;
|
||||
}, {}),
|
||||
activeQuery: Query.parse(''),
|
||||
selectedSavedObjects: [],
|
||||
isShowingImportFlyout: false,
|
||||
isSearching: false,
|
||||
totalItemCount: 0,
|
||||
isShowingRelationships: false,
|
||||
relationshipId: undefined,
|
||||
relationshipType: undefined,
|
||||
relationshipTitle: undefined,
|
||||
isShowingDeleteConfirmModal: false,
|
||||
isShowingExportAllOptionsModal: false,
|
||||
isDeleting: false,
|
||||
exportAllOptions: INCLUDED_TYPES.map(type => ({
|
||||
id: type,
|
||||
label: type,
|
||||
})),
|
||||
exportAllSelectedOptions: INCLUDED_TYPES.reduce((accum, type) => {
|
||||
accum[type] = true;
|
||||
return accum;
|
||||
}, {}),
|
||||
};
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.fetchSavedObjects();
|
||||
this.fetchCounts();
|
||||
}
|
||||
|
||||
fetchCounts = async () => {
|
||||
const { queryText, visibleTypes } = parseQuery(this.state.activeQuery);
|
||||
const includeTypes = INCLUDED_TYPES.filter(
|
||||
type => !visibleTypes || visibleTypes.includes(type)
|
||||
);
|
||||
|
||||
const savedObjectCounts = await getSavedObjectCounts(
|
||||
this.props.$http,
|
||||
includeTypes,
|
||||
queryText
|
||||
);
|
||||
|
||||
this.setState(state => ({
|
||||
...state,
|
||||
savedObjectCounts,
|
||||
exportAllOptions: state.exportAllOptions.map(option => ({
|
||||
...option,
|
||||
label: `${option.id} (${savedObjectCounts[option.id]})`,
|
||||
})),
|
||||
}));
|
||||
};
|
||||
|
||||
fetchSavedObjects = async () => {
|
||||
const { savedObjectsClient } = this.props;
|
||||
const { activeQuery, page, perPage } = this.state;
|
||||
|
||||
if (!activeQuery) {
|
||||
return {
|
||||
pageOfItems: [],
|
||||
totalItemCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
this.setState({ isSearching: true });
|
||||
|
||||
const { queryText, visibleTypes } = parseQuery(activeQuery);
|
||||
|
||||
let savedObjects = [];
|
||||
let totalItemCount = 0;
|
||||
|
||||
const includeTypes = INCLUDED_TYPES.filter(
|
||||
type => !visibleTypes || visibleTypes.includes(type)
|
||||
);
|
||||
|
||||
// TODO: is there a good way to stop existing calls if the input changes?
|
||||
await ensureMinimumTime(
|
||||
(async () => {
|
||||
const data = await savedObjectsClient.find({
|
||||
search: queryText ? `${queryText}*` : undefined,
|
||||
perPage,
|
||||
page: page + 1,
|
||||
sortField: 'type',
|
||||
fields: ['title', 'id'],
|
||||
searchFields: ['title'],
|
||||
includeTypes,
|
||||
});
|
||||
|
||||
savedObjects = data.savedObjects.map(savedObject => ({
|
||||
title: savedObject.attributes.title,
|
||||
type: savedObject.type,
|
||||
id: savedObject.id,
|
||||
icon: getSavedObjectIcon(savedObject.type),
|
||||
}));
|
||||
|
||||
totalItemCount = data.total;
|
||||
})()
|
||||
);
|
||||
|
||||
this.setState({ savedObjects, totalItemCount, isSearching: false });
|
||||
};
|
||||
|
||||
refreshData = async () => {
|
||||
await Promise.all([this.fetchSavedObjects(), this.fetchCounts()]);
|
||||
};
|
||||
|
||||
onSelectionChanged = selection => {
|
||||
const selectedSavedObjects = selection.map(item => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
}));
|
||||
this.setState({ selectedSavedObjects });
|
||||
};
|
||||
|
||||
onQueryChange = query => {
|
||||
// TODO: investigate why this happens at EUI level
|
||||
if (isSameQuery(query, this.state.activeQuery)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState(
|
||||
{
|
||||
activeQuery: query,
|
||||
page: 0, // Reset this on each query change
|
||||
},
|
||||
() => {
|
||||
this.fetchSavedObjects();
|
||||
this.fetchCounts();
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
onTableChange = async table => {
|
||||
const { index: page, size: perPage } = table.page || {};
|
||||
|
||||
this.setState({ page, perPage }, this.fetchSavedObjects);
|
||||
};
|
||||
|
||||
onShowRelationships = (id, type, title) => {
|
||||
this.setState({
|
||||
isShowingRelationships: true,
|
||||
relationshipId: id,
|
||||
relationshipType: type,
|
||||
relationshipTitle: title,
|
||||
});
|
||||
};
|
||||
|
||||
onHideRelationships = () => {
|
||||
this.setState({
|
||||
isShowingRelationships: false,
|
||||
relationshipId: undefined,
|
||||
relationshipType: undefined,
|
||||
relationshipTitle: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
onExport = async () => {
|
||||
const { savedObjectsClient } = this.props;
|
||||
const { selectedSavedObjects } = this.state;
|
||||
const objects = await savedObjectsClient.bulkGet(selectedSavedObjects);
|
||||
await retrieveAndExportDocs(objects.savedObjects, savedObjectsClient);
|
||||
};
|
||||
|
||||
onExportAll = async () => {
|
||||
const { $http } = this.props;
|
||||
const { exportAllSelectedOptions } = this.state;
|
||||
|
||||
const exportTypes = Object.entries(exportAllSelectedOptions).reduce(
|
||||
(accum, [id, selected]) => {
|
||||
if (selected) {
|
||||
accum.push(id);
|
||||
}
|
||||
return accum;
|
||||
},
|
||||
[]
|
||||
);
|
||||
const results = await scanAllTypes($http, exportTypes);
|
||||
saveToFile(JSON.stringify(flattenDeep(results), null, 2));
|
||||
};
|
||||
|
||||
finishImport = () => {
|
||||
this.hideImportFlyout();
|
||||
this.fetchSavedObjects();
|
||||
this.fetchCounts();
|
||||
};
|
||||
|
||||
showImportFlyout = () => {
|
||||
this.setState({ isShowingImportFlyout: true });
|
||||
};
|
||||
|
||||
hideImportFlyout = () => {
|
||||
this.setState({ isShowingImportFlyout: false });
|
||||
};
|
||||
|
||||
onDelete = () => {
|
||||
this.setState({ isShowingDeleteConfirmModal: true });
|
||||
};
|
||||
|
||||
delete = async () => {
|
||||
const { savedObjectsClient } = this.props;
|
||||
const { selectedSavedObjects, isDeleting } = this.state;
|
||||
|
||||
if (isDeleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ isDeleting: true });
|
||||
|
||||
const indexPatterns = selectedSavedObjects.filter(
|
||||
object => object.type === 'index-pattern'
|
||||
);
|
||||
if (indexPatterns.length) {
|
||||
await this.props.indexPatterns.cache.clearAll();
|
||||
}
|
||||
|
||||
const objects = await savedObjectsClient.bulkGet(selectedSavedObjects);
|
||||
const deletes = objects.savedObjects.map(object =>
|
||||
savedObjectsClient.delete(object.type, object.id)
|
||||
);
|
||||
await Promise.all(deletes);
|
||||
|
||||
// Unset this
|
||||
this.setState({
|
||||
selectedSavedObjects: [],
|
||||
isShowingDeleteConfirmModal: false,
|
||||
isDeleting: false,
|
||||
});
|
||||
|
||||
// Fetching all data
|
||||
await this.fetchSavedObjects();
|
||||
await this.fetchCounts();
|
||||
};
|
||||
|
||||
getRelationships = async (type, id) => {
|
||||
return await getRelationships(
|
||||
type,
|
||||
id,
|
||||
this.props.$http,
|
||||
this.props.basePath
|
||||
);
|
||||
};
|
||||
|
||||
renderFlyout() {
|
||||
if (!this.state.isShowingImportFlyout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flyout
|
||||
close={this.hideImportFlyout}
|
||||
done={this.finishImport}
|
||||
services={this.props.services}
|
||||
indexPatterns={this.props.indexPatterns}
|
||||
newIndexPatternUrl={this.props.newIndexPatternUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderRelationships() {
|
||||
if (!this.state.isShowingRelationships) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Relationships
|
||||
id={this.state.relationshipId}
|
||||
type={this.state.relationshipType}
|
||||
title={this.state.relationshipTitle}
|
||||
getRelationships={this.getRelationships}
|
||||
close={this.onHideRelationships}
|
||||
getDashboardUrl={this.props.getDashboardUrl}
|
||||
getEditUrl={this.props.getEditUrl}
|
||||
goInApp={this.props.goInApp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderDeleteConfirmModal() {
|
||||
if (!this.state.isShowingDeleteConfirmModal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiOverlayMask>
|
||||
<EuiConfirmModal
|
||||
title="Delete saved objects"
|
||||
onCancel={() => this.setState({ isShowingDeleteConfirmModal: false })}
|
||||
onConfirm={this.delete}
|
||||
cancelButtonText="Cancel"
|
||||
confirmButtonText={this.state.isDeleting ? 'Deleting...' : 'Delete'}
|
||||
defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON}
|
||||
>
|
||||
<p>This action will delete the following saved objects:</p>
|
||||
<EuiInMemoryTable
|
||||
items={this.state.selectedSavedObjects}
|
||||
columns={[
|
||||
{
|
||||
field: 'type',
|
||||
name: 'Type',
|
||||
width: '50px',
|
||||
render: type => (
|
||||
<EuiToolTip
|
||||
position="top"
|
||||
content={getSavedObjectLabel(type)}
|
||||
>
|
||||
<EuiIcon type={getSavedObjectIcon(type)} />
|
||||
</EuiToolTip>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'id',
|
||||
name: 'Id/Name',
|
||||
},
|
||||
]}
|
||||
pagination={true}
|
||||
sorting={false}
|
||||
/>
|
||||
</EuiConfirmModal>
|
||||
</EuiOverlayMask>
|
||||
);
|
||||
}
|
||||
|
||||
renderExportAllOptionsModal() {
|
||||
if (!this.state.isShowingExportAllOptionsModal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiOverlayMask>
|
||||
<EuiConfirmModal
|
||||
title="Export All"
|
||||
onCancel={() =>
|
||||
this.setState({ isShowingExportAllOptionsModal: false })
|
||||
}
|
||||
onConfirm={this.onExportAll}
|
||||
cancelButtonText="Cancel"
|
||||
confirmButtonText="Export All"
|
||||
defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON}
|
||||
>
|
||||
<p>
|
||||
Select which types to export. The number in parentheses indicates
|
||||
how many of this type are available to export.
|
||||
</p>
|
||||
<EuiCheckboxGroup
|
||||
options={this.state.exportAllOptions}
|
||||
idToSelectedMap={this.state.exportAllSelectedOptions}
|
||||
onChange={optionId => {
|
||||
const exportAllSelectedOptions = {
|
||||
...this.state.exportAllSelectedOptions,
|
||||
...{
|
||||
[optionId]: !this.state.exportAllSelectedOptions[optionId],
|
||||
},
|
||||
};
|
||||
|
||||
this.setState({
|
||||
exportAllSelectedOptions: exportAllSelectedOptions,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</EuiConfirmModal>
|
||||
</EuiOverlayMask>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
selectedSavedObjects,
|
||||
page,
|
||||
perPage,
|
||||
savedObjects,
|
||||
totalItemCount,
|
||||
isSearching,
|
||||
savedObjectCounts,
|
||||
} = this.state;
|
||||
|
||||
const selectionConfig = {
|
||||
itemId: 'id',
|
||||
onSelectionChange: this.onSelectionChanged,
|
||||
};
|
||||
|
||||
const filterOptions = INCLUDED_TYPES.map(type => ({
|
||||
value: type,
|
||||
name: type,
|
||||
view: `${type} (${savedObjectCounts[type]})`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={{ padding: '0 1rem' }}>
|
||||
{this.renderFlyout()}
|
||||
{this.renderRelationships()}
|
||||
{this.renderDeleteConfirmModal()}
|
||||
{this.renderExportAllOptionsModal()}
|
||||
<Header
|
||||
onExportAll={() =>
|
||||
this.setState({ isShowingExportAllOptionsModal: true })
|
||||
}
|
||||
onImport={this.showImportFlyout}
|
||||
onRefresh={this.refreshData}
|
||||
totalCount={totalItemCount}
|
||||
/>
|
||||
<EuiSpacer size="xs" />
|
||||
<Table
|
||||
selectionConfig={selectionConfig}
|
||||
selectedSavedObjects={selectedSavedObjects}
|
||||
onQueryChange={this.onQueryChange}
|
||||
onTableChange={this.onTableChange}
|
||||
filterOptions={filterOptions}
|
||||
onExport={this.onExport}
|
||||
onDelete={this.onDelete}
|
||||
getEditUrl={this.props.getEditUrl}
|
||||
goInApp={this.props.goInApp}
|
||||
pageIndex={page}
|
||||
pageSize={perPage}
|
||||
items={savedObjects}
|
||||
totalItemCount={totalItemCount}
|
||||
isSearching={isSearching}
|
||||
onShowRelationships={this.onShowRelationships}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import { getInAppUrl } from '../get_in_app_url';
|
||||
|
||||
describe('getInAppUrl', () => {
|
||||
it('should handle saved searches', () => {
|
||||
expect(getInAppUrl(1, 'search')).toEqual('/discover/1');
|
||||
expect(getInAppUrl(1, 'searches')).toEqual('/discover/1');
|
||||
});
|
||||
|
||||
it('should handle visualizations', () => {
|
||||
expect(getInAppUrl(1, 'visualization')).toEqual('/visualize/edit/1');
|
||||
expect(getInAppUrl(1, 'visualizations')).toEqual('/visualize/edit/1');
|
||||
});
|
||||
|
||||
it('should handle index patterns', () => {
|
||||
expect(getInAppUrl(1, 'index-pattern')).toEqual(
|
||||
'/management/kibana/indices/1'
|
||||
);
|
||||
expect(getInAppUrl(1, 'index-patterns')).toEqual(
|
||||
'/management/kibana/indices/1'
|
||||
);
|
||||
expect(getInAppUrl(1, 'indexPatterns')).toEqual(
|
||||
'/management/kibana/indices/1'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle dashboards', () => {
|
||||
expect(getInAppUrl(1, 'dashboard')).toEqual('/dashboard/1');
|
||||
expect(getInAppUrl(1, 'dashboards')).toEqual('/dashboard/1');
|
||||
});
|
||||
|
||||
it('should have a default case', () => {
|
||||
expect(getInAppUrl(1, 'foo')).toEqual('/foo/1');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,41 @@
|
|||
import { getRelationships } from '../get_relationships';
|
||||
|
||||
describe('getRelationships', () => {
|
||||
it('should make an http request', async () => {
|
||||
const $http = jest.fn();
|
||||
const basePath = 'test';
|
||||
|
||||
await getRelationships('dashboard', 1, $http, basePath);
|
||||
expect($http.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle succcesful responses', async () => {
|
||||
const $http = jest.fn().mockImplementation(() => ({ data: [1, 2] }));
|
||||
const basePath = 'test';
|
||||
|
||||
const response = await getRelationships('dashboard', 1, $http, basePath);
|
||||
expect(response).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
const $http = jest.fn().mockImplementation(() => {
|
||||
throw {
|
||||
data: {
|
||||
error: 'Test error',
|
||||
statusCode: 500,
|
||||
},
|
||||
};
|
||||
});
|
||||
const basePath = 'test';
|
||||
|
||||
try {
|
||||
await getRelationships('dashboard', 1, $http, basePath);
|
||||
} catch (e) {
|
||||
// There isn't a great way to handle throwing exceptions
|
||||
// with async/await but this seems to work :shrug:
|
||||
expect(() => {
|
||||
throw e;
|
||||
}).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
|
@ -0,0 +1,28 @@
|
|||
import { getSavedObjectIcon } from '../get_saved_object_icon';
|
||||
|
||||
describe('getSavedObjectIcon', () => {
|
||||
it('should handle saved searches', () => {
|
||||
expect(getSavedObjectIcon('search')).toEqual('search');
|
||||
expect(getSavedObjectIcon('searches')).toEqual('search');
|
||||
});
|
||||
|
||||
it('should handle visualizations', () => {
|
||||
expect(getSavedObjectIcon('visualization')).toEqual('visualizeApp');
|
||||
expect(getSavedObjectIcon('visualizations')).toEqual('visualizeApp');
|
||||
});
|
||||
|
||||
it('should handle index patterns', () => {
|
||||
expect(getSavedObjectIcon('index-pattern')).toEqual('indexPatternApp');
|
||||
expect(getSavedObjectIcon('index-patterns')).toEqual('indexPatternApp');
|
||||
expect(getSavedObjectIcon('indexPatterns')).toEqual('indexPatternApp');
|
||||
});
|
||||
|
||||
it('should handle dashboards', () => {
|
||||
expect(getSavedObjectIcon('dashboard')).toEqual('dashboardApp');
|
||||
expect(getSavedObjectIcon('dashboards')).toEqual('dashboardApp');
|
||||
});
|
||||
|
||||
it('should have a default case', () => {
|
||||
expect(getSavedObjectIcon('foo')).toEqual('apps');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,44 @@
|
|||
import { importFile } from '../import_file';
|
||||
|
||||
describe('importFile', () => {
|
||||
it('should import a file', async () => {
|
||||
class FileReader {
|
||||
readAsText(text) {
|
||||
this.onload({
|
||||
target: {
|
||||
result: JSON.stringify({ text }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const file = 'foo';
|
||||
|
||||
const imported = await importFile(file, FileReader);
|
||||
expect(imported).toEqual({ text: file });
|
||||
});
|
||||
|
||||
it('should throw errors', async () => {
|
||||
class FileReader {
|
||||
readAsText() {
|
||||
this.onload({
|
||||
target: {
|
||||
result: 'not_parseable',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const file = 'foo';
|
||||
|
||||
try {
|
||||
await importFile(file, FileReader);
|
||||
} catch (e) {
|
||||
// There isn't a great way to handle throwing exceptions
|
||||
// with async/await but this seems to work :shrug:
|
||||
expect(() => {
|
||||
throw e;
|
||||
}).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
|
@ -0,0 +1,11 @@
|
|||
import { parseQuery } from '../parse_query';
|
||||
|
||||
describe('getQueryText', () => {
|
||||
it('should know how to get the text out of the AST', () => {
|
||||
const ast = {
|
||||
getTermClauses: () => [{ value: 'foo' }, { value: 'bar' }],
|
||||
getFieldClauses: () => [{ value: 'lala' }, { value: 'lolo' }]
|
||||
};
|
||||
expect(parseQuery({ ast })).toEqual({ queryText: 'foo bar', visibleTypes: 'lala' });
|
||||
});
|
||||
});
|
|
@ -0,0 +1,304 @@
|
|||
import {
|
||||
resolveSavedObjects,
|
||||
resolveIndexPatternConflicts,
|
||||
saveObjects,
|
||||
saveObject,
|
||||
} from '../resolve_saved_objects';
|
||||
|
||||
jest.mock('ui/errors', () => ({
|
||||
SavedObjectNotFound: class SavedObjectNotFound extends Error {
|
||||
constructor(options) {
|
||||
super();
|
||||
for (const option in options) {
|
||||
if (options.hasOwnProperty(option)) {
|
||||
this[option] = options[option];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe('resolveSavedObjects', () => {
|
||||
describe('resolveSavedObjects', () => {
|
||||
it('should take in saved objects and spit out conflicts', async () => {
|
||||
const savedObjects = [
|
||||
{
|
||||
_type: 'search',
|
||||
},
|
||||
{
|
||||
_type: 'index-pattern',
|
||||
_id: '1',
|
||||
_source: {
|
||||
title: 'pattern',
|
||||
timeFieldName: '@timestamp',
|
||||
},
|
||||
},
|
||||
{
|
||||
_type: 'dashboard',
|
||||
},
|
||||
{
|
||||
_type: 'visualization',
|
||||
},
|
||||
];
|
||||
|
||||
const indexPatterns = {
|
||||
get: async () => {
|
||||
return {
|
||||
create: () => '2',
|
||||
};
|
||||
},
|
||||
create: async () => {
|
||||
return '2';
|
||||
},
|
||||
cache: {
|
||||
clear: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
const services = [
|
||||
{
|
||||
type: 'search',
|
||||
get: async () => {
|
||||
return {
|
||||
applyESResp: async () => {},
|
||||
save: async () => {
|
||||
const { SavedObjectNotFound } = require('ui/errors');
|
||||
throw new SavedObjectNotFound({
|
||||
savedObjectType: 'index-pattern',
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'dashboard',
|
||||
get: async () => {
|
||||
return {
|
||||
applyESResp: async () => {},
|
||||
save: async () => {
|
||||
const { SavedObjectNotFound } = require('ui/errors');
|
||||
throw new SavedObjectNotFound({
|
||||
savedObjectType: 'index-pattern',
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'visualization',
|
||||
get: async () => {
|
||||
return {
|
||||
applyESResp: async () => {},
|
||||
save: async () => {
|
||||
const { SavedObjectNotFound } = require('ui/errors');
|
||||
throw new SavedObjectNotFound({
|
||||
savedObjectType: 'index-pattern',
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const overwriteAll = false;
|
||||
|
||||
const result = await resolveSavedObjects(
|
||||
savedObjects,
|
||||
overwriteAll,
|
||||
services,
|
||||
indexPatterns
|
||||
);
|
||||
|
||||
expect(result.conflictedIndexPatterns.length).toBe(3);
|
||||
expect(result.conflictedSavedObjectsLinkedToSavedSearches.length).toBe(0);
|
||||
expect(result.conflictedSearchDocs.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should bucket conflicts based on the type', async () => {
|
||||
const savedObjects = [
|
||||
{
|
||||
_type: 'search',
|
||||
},
|
||||
{
|
||||
_type: 'index-pattern',
|
||||
_id: '1',
|
||||
_source: {
|
||||
title: 'pattern',
|
||||
timeFieldName: '@timestamp',
|
||||
},
|
||||
},
|
||||
{
|
||||
_type: 'dashboard',
|
||||
},
|
||||
{
|
||||
_type: 'visualization',
|
||||
},
|
||||
];
|
||||
|
||||
const indexPatterns = {
|
||||
get: async () => {
|
||||
return {
|
||||
create: () => '2',
|
||||
};
|
||||
},
|
||||
create: async () => {
|
||||
return '2';
|
||||
},
|
||||
cache: {
|
||||
clear: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
const services = [
|
||||
{
|
||||
type: 'search',
|
||||
get: async () => {
|
||||
return {
|
||||
applyESResp: async () => {},
|
||||
save: async () => {
|
||||
const { SavedObjectNotFound } = require('ui/errors');
|
||||
throw new SavedObjectNotFound({
|
||||
savedObjectType: 'search',
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'dashboard',
|
||||
get: async () => {
|
||||
return {
|
||||
applyESResp: async () => {},
|
||||
save: async () => {
|
||||
const { SavedObjectNotFound } = require('ui/errors');
|
||||
throw new SavedObjectNotFound({
|
||||
savedObjectType: 'index-pattern',
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'visualization',
|
||||
get: async () => {
|
||||
return {
|
||||
savedSearchId: '1',
|
||||
applyESResp: async () => {},
|
||||
save: async () => {
|
||||
const { SavedObjectNotFound } = require('ui/errors');
|
||||
throw new SavedObjectNotFound({
|
||||
savedObjectType: 'index-pattern',
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const overwriteAll = false;
|
||||
|
||||
const result = await resolveSavedObjects(
|
||||
savedObjects,
|
||||
overwriteAll,
|
||||
services,
|
||||
indexPatterns
|
||||
);
|
||||
|
||||
expect(result.conflictedIndexPatterns.length).toBe(1);
|
||||
expect(result.conflictedSavedObjectsLinkedToSavedSearches.length).toBe(1);
|
||||
expect(result.conflictedSearchDocs.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveIndexPatternConflicts', () => {
|
||||
it('should resave resolutions', async () => {
|
||||
const hydrateIndexPattern = jest.fn();
|
||||
const save = jest.fn();
|
||||
|
||||
const conflictedIndexPatterns = [
|
||||
{
|
||||
obj: {
|
||||
searchSource: {
|
||||
getOwn: () => '1',
|
||||
},
|
||||
hydrateIndexPattern,
|
||||
save,
|
||||
},
|
||||
},
|
||||
{
|
||||
obj: {
|
||||
searchSource: {
|
||||
getOwn: () => '3',
|
||||
},
|
||||
hydrateIndexPattern,
|
||||
save,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const resolutions = [
|
||||
{
|
||||
oldId: '1',
|
||||
newId: '2',
|
||||
},
|
||||
{
|
||||
oldId: '3',
|
||||
newId: '4',
|
||||
},
|
||||
{
|
||||
oldId: '5',
|
||||
newId: '5',
|
||||
},
|
||||
];
|
||||
|
||||
const overwriteAll = false;
|
||||
|
||||
await resolveIndexPatternConflicts(
|
||||
resolutions,
|
||||
conflictedIndexPatterns,
|
||||
overwriteAll
|
||||
);
|
||||
expect(hydrateIndexPattern.mock.calls.length).toBe(2);
|
||||
expect(save.mock.calls.length).toBe(2);
|
||||
expect(save).toHaveBeenCalledWith({ confirmOverwrite: !overwriteAll });
|
||||
expect(hydrateIndexPattern).toHaveBeenCalledWith('2');
|
||||
expect(hydrateIndexPattern).toHaveBeenCalledWith('4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveObjects', () => {
|
||||
it('should save every object', async () => {
|
||||
const save = jest.fn();
|
||||
|
||||
const objs = [
|
||||
{
|
||||
save,
|
||||
},
|
||||
{
|
||||
save,
|
||||
},
|
||||
];
|
||||
|
||||
const overwriteAll = false;
|
||||
|
||||
await saveObjects(objs, overwriteAll);
|
||||
expect(save.mock.calls.length).toBe(2);
|
||||
expect(save).toHaveBeenCalledWith({ confirmOverwrite: !overwriteAll });
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveObject', () => {
|
||||
it('should save the object', async () => {
|
||||
const save = jest.fn();
|
||||
const obj = {
|
||||
save,
|
||||
};
|
||||
|
||||
const overwriteAll = false;
|
||||
|
||||
await saveObject(obj, overwriteAll);
|
||||
expect(save.mock.calls.length).toBe(1);
|
||||
expect(save).toHaveBeenCalledWith({ confirmOverwrite: !overwriteAll });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,89 @@
|
|||
import { retrieveAndExportDocs } from '../retrieve_and_export_docs';
|
||||
|
||||
jest.mock('../save_to_file', () => ({
|
||||
saveToFile: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('ui/errors', () => ({
|
||||
SavedObjectNotFound: class SavedObjectNotFound extends Error {
|
||||
constructor(options) {
|
||||
super();
|
||||
for (const option in options) {
|
||||
if (options.hasOwnProperty(option)) {
|
||||
this[option] = options[option];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('ui/chrome', () => ({
|
||||
addBasePath: () => {},
|
||||
}));
|
||||
|
||||
describe('retrieveAndExportDocs', () => {
|
||||
let saveToFile;
|
||||
|
||||
beforeEach(() => {
|
||||
saveToFile = require('../save_to_file').saveToFile;
|
||||
saveToFile.mockClear();
|
||||
});
|
||||
|
||||
it('should fetch all', async () => {
|
||||
const savedObjectsClient = {
|
||||
bulkGet: jest.fn().mockImplementation(() => ({
|
||||
savedObjects: [],
|
||||
})),
|
||||
};
|
||||
|
||||
const objs = [1, 2, 3];
|
||||
await retrieveAndExportDocs(objs, savedObjectsClient);
|
||||
expect(savedObjectsClient.bulkGet.mock.calls.length).toBe(1);
|
||||
expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith(objs);
|
||||
});
|
||||
|
||||
it('should use the saveToFile utility', async () => {
|
||||
const savedObjectsClient = {
|
||||
bulkGet: jest.fn().mockImplementation(() => ({
|
||||
savedObjects: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'index-pattern',
|
||||
attributes: {
|
||||
title: 'foobar',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'search',
|
||||
attributes: {
|
||||
title: 'just the foo',
|
||||
},
|
||||
},
|
||||
],
|
||||
})),
|
||||
};
|
||||
|
||||
const objs = [1, 2, 3];
|
||||
await retrieveAndExportDocs(objs, savedObjectsClient);
|
||||
expect(saveToFile.mock.calls.length).toBe(1);
|
||||
expect(saveToFile).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
[
|
||||
{
|
||||
_id: 1,
|
||||
_type: 'index-pattern',
|
||||
_source: { title: 'foobar' },
|
||||
},
|
||||
{
|
||||
_id: 2,
|
||||
_type: 'search',
|
||||
_source: { title: 'just the foo' },
|
||||
},
|
||||
],
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,19 @@
|
|||
import { saveToFile } from '../save_to_file';
|
||||
|
||||
jest.mock('@elastic/filesaver', () => ({
|
||||
saveAs: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('saveToFile', () => {
|
||||
let saveAs;
|
||||
|
||||
beforeEach(() => {
|
||||
saveAs = require('@elastic/filesaver').saveAs;
|
||||
saveAs.mockClear();
|
||||
});
|
||||
|
||||
it('should use the file saver utility', async () => {
|
||||
saveToFile(JSON.stringify({ foo: 1 }));
|
||||
expect(saveAs.mock.calls.length).toBe(1);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,17 @@
|
|||
import { scanAllTypes } from '../scan_all_types';
|
||||
|
||||
jest.mock('ui/chrome', () => ({
|
||||
addBasePath: () => 'apiUrl',
|
||||
}));
|
||||
|
||||
describe('scanAllTypes', () => {
|
||||
it('should call the api', async () => {
|
||||
const $http = {
|
||||
post: jest.fn().mockImplementation(() => ([]))
|
||||
};
|
||||
const typesToInclude = ['index-pattern', 'dashboard'];
|
||||
|
||||
await scanAllTypes($http, typesToInclude);
|
||||
expect($http.post).toBeCalledWith('apiUrl/export', { typesToInclude });
|
||||
});
|
||||
});
|
|
@ -0,0 +1,19 @@
|
|||
export function getInAppUrl(id, type) {
|
||||
switch (type) {
|
||||
case 'search':
|
||||
case 'searches':
|
||||
return `/discover/${id}`;
|
||||
case 'visualization':
|
||||
case 'visualizations':
|
||||
return `/visualize/edit/${id}`;
|
||||
case 'index-pattern':
|
||||
case 'index-patterns':
|
||||
case 'indexPatterns':
|
||||
return `/management/kibana/indices/${id}`;
|
||||
case 'dashboard':
|
||||
case 'dashboards':
|
||||
return `/dashboard/${id}`;
|
||||
default:
|
||||
return `/${type.toLowerCase()}/${id}`;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
import { get } from 'lodash';
|
||||
|
||||
export async function getRelationships(type, id, $http, basePath) {
|
||||
const url = `${basePath}/api/kibana/management/saved_objects/relationships/${type}/${id}`;
|
||||
const options = {
|
||||
method: 'GET',
|
||||
url,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await $http(options);
|
||||
return response ? response.data : undefined;
|
||||
}
|
||||
catch (resp) {
|
||||
const respBody = get(resp, 'data', {});
|
||||
const err = new Error(respBody.message || respBody.error || `${resp.status} Response`);
|
||||
|
||||
err.statusCode = respBody.statusCode || resp.status;
|
||||
err.body = respBody;
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import chrome from 'ui/chrome';
|
||||
|
||||
const apiBase = chrome.addBasePath('/api/kibana/management/saved_objects/scroll');
|
||||
export async function getSavedObjectCounts($http, typesToInclude, searchString) {
|
||||
const results = await $http.post(`${apiBase}/counts`, { typesToInclude, searchString });
|
||||
return results.data;
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
export function getSavedObjectIcon(type) {
|
||||
switch (type) {
|
||||
case 'search':
|
||||
case 'searches':
|
||||
return 'search';
|
||||
case 'visualization':
|
||||
case 'visualizations':
|
||||
return 'visualizeApp';
|
||||
case 'dashboard':
|
||||
case 'dashboards':
|
||||
return 'dashboardApp';
|
||||
case 'index-pattern':
|
||||
case 'index-patterns':
|
||||
case 'indexPatterns':
|
||||
return 'indexPatternApp';
|
||||
default:
|
||||
return 'apps';
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
export function getSavedObjectLabel(type) {
|
||||
switch (type) {
|
||||
case 'index-pattern':
|
||||
case 'index-patterns':
|
||||
case 'indexPatterns':
|
||||
return 'index patterns';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
export async function importFile(file, FileReader = window.FileReader) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = ({ target: { result } }) => {
|
||||
try {
|
||||
resolve(JSON.parse(result));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
fr.readAsText(file);
|
||||
});
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
export * from './get_in_app_url';
|
||||
export * from './get_relationships';
|
||||
export * from './get_saved_object_counts';
|
||||
export * from './get_saved_object_icon';
|
||||
export * from './get_saved_object_label';
|
||||
export * from './import_file';
|
||||
export * from './parse_query';
|
||||
export * from './resolve_saved_objects';
|
||||
export * from './retrieve_and_export_docs';
|
||||
export * from './save_to_file';
|
||||
export * from './scan_all_types';
|
|
@ -0,0 +1,13 @@
|
|||
import { parseQuery } from '.';
|
||||
|
||||
export const isSameQuery = (query1, query2) => {
|
||||
const parsedQuery1 = parseQuery(query1);
|
||||
const parsedQuery2 = parseQuery(query2);
|
||||
|
||||
if (parsedQuery1.queryText === parsedQuery2.queryText) {
|
||||
if (parsedQuery1.visibleTypes === parsedQuery2.visibleTypes) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
|
@ -0,0 +1,21 @@
|
|||
export function parseQuery(query) {
|
||||
let queryText = undefined;
|
||||
let visibleTypes = undefined;
|
||||
|
||||
if (query) {
|
||||
if (query.ast.getTermClauses().length) {
|
||||
queryText = query.ast
|
||||
.getTermClauses()
|
||||
.map(clause => clause.value)
|
||||
.join(' ');
|
||||
}
|
||||
if (query.ast.getFieldClauses('type')) {
|
||||
visibleTypes = query.ast.getFieldClauses('type')[0].value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
queryText,
|
||||
visibleTypes,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,200 @@
|
|||
import { SavedObjectNotFound } from 'ui/errors';
|
||||
|
||||
async function getSavedObject(doc, services) {
|
||||
const service = services.find(service => service.type === doc._type);
|
||||
if (!service) {
|
||||
return;
|
||||
}
|
||||
|
||||
const obj = await service.get();
|
||||
obj.id = doc._id;
|
||||
return obj;
|
||||
}
|
||||
|
||||
async function importIndexPattern(doc, indexPatterns, overwriteAll) {
|
||||
// TODO: consolidate this is the code in create_index_pattern_wizard.js
|
||||
const emptyPattern = await indexPatterns.get();
|
||||
Object.assign(emptyPattern, {
|
||||
id: doc._id,
|
||||
title: doc._source.title,
|
||||
timeFieldName: doc._source.timeFieldName,
|
||||
});
|
||||
const newId = await emptyPattern.create(true, !overwriteAll);
|
||||
indexPatterns.cache.clear(newId);
|
||||
return newId;
|
||||
}
|
||||
|
||||
async function importDocument(obj, doc, overwriteAll) {
|
||||
await obj.applyESResp(doc);
|
||||
return await obj.save({ confirmOverwrite: !overwriteAll });
|
||||
}
|
||||
|
||||
function groupByType(docs) {
|
||||
const defaultDocTypes = {
|
||||
searches: [],
|
||||
indexPatterns: [],
|
||||
other: [],
|
||||
};
|
||||
|
||||
return docs.reduce((types, doc) => {
|
||||
switch (doc._type) {
|
||||
case 'search':
|
||||
types.searches.push(doc);
|
||||
break;
|
||||
case 'index-pattern':
|
||||
types.indexPatterns.push(doc);
|
||||
break;
|
||||
default:
|
||||
types.other.push(doc);
|
||||
}
|
||||
return types;
|
||||
}, defaultDocTypes);
|
||||
}
|
||||
|
||||
async function awaitEachItemInParallel(list, op) {
|
||||
return await Promise.all(list.map(item => op(item)));
|
||||
}
|
||||
|
||||
export async function resolveIndexPatternConflicts(
|
||||
resolutions,
|
||||
conflictedIndexPatterns,
|
||||
overwriteAll
|
||||
) {
|
||||
let importCount = 0;
|
||||
await awaitEachItemInParallel(conflictedIndexPatterns, async ({ obj }) => {
|
||||
let oldIndexId = obj.searchSource.getOwn('index');
|
||||
// Depending on the object, this can either be the raw id or the actual index pattern object
|
||||
if (typeof oldIndexId !== 'string') {
|
||||
oldIndexId = oldIndexId.id;
|
||||
}
|
||||
const resolution = resolutions.find(({ oldId }) => oldId === oldIndexId);
|
||||
if (!resolution) {
|
||||
// The user decided to skip this conflict so do nothing
|
||||
return;
|
||||
}
|
||||
const newIndexId = resolution.newId;
|
||||
await obj.hydrateIndexPattern(newIndexId);
|
||||
if (await saveObject(obj, overwriteAll)) {
|
||||
importCount++;
|
||||
}
|
||||
});
|
||||
return importCount;
|
||||
}
|
||||
|
||||
export async function saveObjects(objs, overwriteAll) {
|
||||
let importCount = 0;
|
||||
await awaitEachItemInParallel(
|
||||
objs,
|
||||
async obj => {
|
||||
if (await saveObject(obj, overwriteAll)) {
|
||||
importCount++;
|
||||
}
|
||||
}
|
||||
);
|
||||
return importCount;
|
||||
}
|
||||
|
||||
export async function saveObject(obj, overwriteAll) {
|
||||
return await obj.save({ confirmOverwrite: !overwriteAll });
|
||||
}
|
||||
|
||||
export async function resolveSavedSearches(
|
||||
savedSearches,
|
||||
services,
|
||||
indexPatterns,
|
||||
overwriteAll
|
||||
) {
|
||||
let importCount = 0;
|
||||
await awaitEachItemInParallel(savedSearches, async searchDoc => {
|
||||
const obj = await getSavedObject(searchDoc, services);
|
||||
if (!obj) {
|
||||
// Just ignore?
|
||||
return;
|
||||
}
|
||||
if (await importDocument(obj, searchDoc, overwriteAll)) {
|
||||
importCount++;
|
||||
}
|
||||
});
|
||||
return importCount;
|
||||
}
|
||||
|
||||
export async function resolveSavedObjects(
|
||||
savedObjects,
|
||||
overwriteAll,
|
||||
services,
|
||||
indexPatterns
|
||||
) {
|
||||
const docTypes = groupByType(savedObjects);
|
||||
|
||||
// Keep track of how many we actually import because the user
|
||||
// can cancel an override
|
||||
let importedObjectCount = 0;
|
||||
|
||||
// Start with the index patterns since everything is dependent on them
|
||||
await awaitEachItemInParallel(
|
||||
docTypes.indexPatterns,
|
||||
async indexPatternDoc => {
|
||||
if (await importIndexPattern(indexPatternDoc, indexPatterns, overwriteAll)) {
|
||||
importedObjectCount++;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// We want to do the same for saved searches, but we want to keep them separate because they need
|
||||
// to be applied _first_ because other saved objects can be depedent on those saved searches existing
|
||||
const conflictedSearchDocs = [];
|
||||
// Keep a record of the index patterns assigned to our imported saved objects that do not
|
||||
// exist. We will provide a way for the user to manually select a new index pattern for those
|
||||
// saved objects.
|
||||
const conflictedIndexPatterns = [];
|
||||
// It's possible to have saved objects that link to saved searches which then link to index patterns
|
||||
// and those could error out, but the error comes as an index pattern not found error. We can't resolve
|
||||
// those the same as way as normal index pattern not found errors, but when those are fixed, it's very
|
||||
// likely that these saved objects will work once resaved so keep them around to resave them.
|
||||
const conflictedSavedObjectsLinkedToSavedSearches = [];
|
||||
|
||||
await awaitEachItemInParallel(docTypes.searches, async searchDoc => {
|
||||
const obj = await getSavedObject(searchDoc, services);
|
||||
|
||||
try {
|
||||
if (await importDocument(obj, searchDoc, overwriteAll)) {
|
||||
importedObjectCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof SavedObjectNotFound) {
|
||||
if (err.savedObjectType === 'index-pattern') {
|
||||
conflictedIndexPatterns.push({ obj, doc: searchDoc });
|
||||
} else {
|
||||
conflictedSearchDocs.push(searchDoc);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await awaitEachItemInParallel(docTypes.other, async otherDoc => {
|
||||
const obj = await getSavedObject(otherDoc, services);
|
||||
|
||||
try {
|
||||
if (await importDocument(obj, otherDoc, overwriteAll)) {
|
||||
importedObjectCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof SavedObjectNotFound) {
|
||||
if (err.savedObjectType === 'index-pattern') {
|
||||
if (obj.savedSearchId) {
|
||||
conflictedSavedObjectsLinkedToSavedSearches.push(obj);
|
||||
} else {
|
||||
conflictedIndexPatterns.push({ obj, doc: otherDoc });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
conflictedIndexPatterns,
|
||||
conflictedSavedObjectsLinkedToSavedSearches,
|
||||
conflictedSearchDocs,
|
||||
importedObjectCount,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import { saveToFile } from './';
|
||||
|
||||
export async function retrieveAndExportDocs(objs, savedObjectsClient) {
|
||||
const response = await savedObjectsClient.bulkGet(objs);
|
||||
const objects = response.savedObjects.map(obj => {
|
||||
return {
|
||||
_id: obj.id,
|
||||
_type: obj.type,
|
||||
_source: obj.attributes
|
||||
};
|
||||
});
|
||||
|
||||
saveToFile(JSON.stringify(objects, null, 2));
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import { saveAs } from '@elastic/filesaver';
|
||||
|
||||
export function saveToFile(resultsJson) {
|
||||
const blob = new Blob([resultsJson], { type: 'application/json' });
|
||||
saveAs(blob, 'export.json');
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import chrome from 'ui/chrome';
|
||||
|
||||
const apiBase = chrome.addBasePath('/api/kibana/management/saved_objects/scroll');
|
||||
export async function scanAllTypes($http, typesToInclude) {
|
||||
const results = await $http.post(`${apiBase}/export`, { typesToInclude });
|
||||
return results.data;
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
import { ChangeIndexModal } from './change_index_modal';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
export function showChangeIndexModal(onChange, conflictedObjects, indices = []) {
|
||||
const container = document.createElement('div');
|
||||
const closeModal = () => {
|
||||
ReactDOM.unmountComponentAtNode(container);
|
||||
document.body.removeChild(container);
|
||||
};
|
||||
|
||||
const onIndexChangeConfirmed = (newIndex) => {
|
||||
onChange(newIndex);
|
||||
closeModal();
|
||||
};
|
||||
|
||||
document.body.appendChild(container);
|
||||
|
||||
const element = (
|
||||
<ChangeIndexModal
|
||||
onChange={onIndexChangeConfirmed}
|
||||
onClose={closeModal}
|
||||
conflictedObjects={conflictedObjects}
|
||||
indices={indices}
|
||||
/>
|
||||
);
|
||||
|
||||
ReactDOM.render(element, container);
|
||||
}
|
341
src/core_plugins/kibana/server/lib/__tests__/relationships.js
Normal file
341
src/core_plugins/kibana/server/lib/__tests__/relationships.js
Normal file
|
@ -0,0 +1,341 @@
|
|||
import expect from 'expect.js';
|
||||
import { findRelationships } from '../management/saved_objects/relationships';
|
||||
|
||||
describe('findRelationships', () => {
|
||||
it('should find relationships for dashboards', async () => {
|
||||
const type = 'dashboard';
|
||||
const id = 'foo';
|
||||
const size = 10;
|
||||
const callCluster = () => ({
|
||||
docs: [
|
||||
{
|
||||
_id: 'visualization:1',
|
||||
found: true,
|
||||
_source: {
|
||||
visualization: {
|
||||
title: 'Foo',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: 'visualization:2',
|
||||
found: true,
|
||||
_source: {
|
||||
visualization: {
|
||||
title: 'Bar',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: 'visualization:3',
|
||||
found: true,
|
||||
_source: {
|
||||
visualization: {
|
||||
title: 'FooBar',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const savedObjectsClient = {
|
||||
_index: '.kibana',
|
||||
get: () => ({
|
||||
attributes: {
|
||||
panelsJSON: JSON.stringify([{ id: '1' }, { id: '2' }, { id: '3' }]),
|
||||
},
|
||||
}),
|
||||
};
|
||||
const result = await findRelationships(
|
||||
type,
|
||||
id,
|
||||
size,
|
||||
callCluster,
|
||||
savedObjectsClient
|
||||
);
|
||||
expect(result).to.eql({
|
||||
visualizations: [
|
||||
{ id: '1', title: 'Foo' },
|
||||
{ id: '2', title: 'Bar' },
|
||||
{ id: '3', title: 'FooBar' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should find relationships for visualizations', async () => {
|
||||
const type = 'visualization';
|
||||
const id = 'foo';
|
||||
const size = 10;
|
||||
const callCluster = () => ({
|
||||
hits: {
|
||||
hits: [
|
||||
{
|
||||
_id: 'dashboard:1',
|
||||
found: true,
|
||||
_source: {
|
||||
dashboard: {
|
||||
title: 'My Dashboard',
|
||||
panelsJSON: JSON.stringify([
|
||||
{
|
||||
type: 'visualization',
|
||||
id,
|
||||
},
|
||||
{
|
||||
type: 'visualization',
|
||||
id: 'foobar',
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: 'dashboard:2',
|
||||
found: true,
|
||||
_source: {
|
||||
dashboard: {
|
||||
title: 'Your Dashboard',
|
||||
panelsJSON: JSON.stringify([
|
||||
{
|
||||
type: 'visualization',
|
||||
id,
|
||||
},
|
||||
{
|
||||
type: 'visualization',
|
||||
id: 'foobar',
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const savedObjectsClient = {
|
||||
_index: '.kibana',
|
||||
};
|
||||
|
||||
const result = await findRelationships(
|
||||
type,
|
||||
id,
|
||||
size,
|
||||
callCluster,
|
||||
savedObjectsClient
|
||||
);
|
||||
expect(result).to.eql({
|
||||
dashboards: [
|
||||
{ id: '1', title: 'My Dashboard' },
|
||||
{ id: '2', title: 'Your Dashboard' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should find relationships for saved searches', async () => {
|
||||
const type = 'search';
|
||||
const id = 'foo';
|
||||
const size = 10;
|
||||
const callCluster = () => ({
|
||||
hits: {
|
||||
hits: [
|
||||
{
|
||||
_id: 'visualization:1',
|
||||
found: true,
|
||||
_source: {
|
||||
visualization: {
|
||||
title: 'Foo',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: 'visualization:2',
|
||||
found: true,
|
||||
_source: {
|
||||
visualization: {
|
||||
title: 'Bar',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: 'visualization:3',
|
||||
found: true,
|
||||
_source: {
|
||||
visualization: {
|
||||
title: 'FooBar',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const savedObjectsClient = {
|
||||
_index: '.kibana',
|
||||
get: type => {
|
||||
if (type === 'search') {
|
||||
return {
|
||||
id: 'search:1',
|
||||
attributes: {
|
||||
kibanaSavedObjectMeta: {
|
||||
searchSourceJSON: JSON.stringify({
|
||||
index: 'index-pattern:1',
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'index-pattern:1',
|
||||
attributes: {
|
||||
title: 'My Index Pattern',
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const result = await findRelationships(
|
||||
type,
|
||||
id,
|
||||
size,
|
||||
callCluster,
|
||||
savedObjectsClient
|
||||
);
|
||||
expect(result).to.eql({
|
||||
visualizations: [
|
||||
{ id: '1', title: 'Foo' },
|
||||
{ id: '2', title: 'Bar' },
|
||||
{ id: '3', title: 'FooBar' },
|
||||
],
|
||||
indexPatterns: [{ id: 'index-pattern:1', title: 'My Index Pattern' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should find relationships for index patterns', async () => {
|
||||
const type = 'index-pattern';
|
||||
const id = 'foo';
|
||||
const size = 10;
|
||||
const callCluster = (endpoint, options) => {
|
||||
if (options._source[0] === 'visualization.title') {
|
||||
return {
|
||||
hits: {
|
||||
hits: [
|
||||
{
|
||||
_id: 'visualization:1',
|
||||
found: true,
|
||||
_source: {
|
||||
visualization: {
|
||||
title: 'Foo',
|
||||
kibanaSavedObjectMeta: {
|
||||
searchSourceJSON: JSON.stringify({
|
||||
index: 'foo',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: 'visualization:2',
|
||||
found: true,
|
||||
_source: {
|
||||
visualization: {
|
||||
title: 'Bar',
|
||||
kibanaSavedObjectMeta: {
|
||||
searchSourceJSON: JSON.stringify({
|
||||
index: 'foo',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: 'visualization:3',
|
||||
found: true,
|
||||
_source: {
|
||||
visualization: {
|
||||
title: 'FooBar',
|
||||
kibanaSavedObjectMeta: {
|
||||
searchSourceJSON: JSON.stringify({
|
||||
index: 'foo2',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hits: {
|
||||
hits: [
|
||||
{
|
||||
_id: 'search:1',
|
||||
found: true,
|
||||
_source: {
|
||||
search: {
|
||||
title: 'Foo',
|
||||
kibanaSavedObjectMeta: {
|
||||
searchSourceJSON: JSON.stringify({
|
||||
index: 'foo',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: 'search:2',
|
||||
found: true,
|
||||
_source: {
|
||||
search: {
|
||||
title: 'Bar',
|
||||
kibanaSavedObjectMeta: {
|
||||
searchSourceJSON: JSON.stringify({
|
||||
index: 'foo',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: 'search:3',
|
||||
found: true,
|
||||
_source: {
|
||||
search: {
|
||||
title: 'FooBar',
|
||||
kibanaSavedObjectMeta: {
|
||||
searchSourceJSON: JSON.stringify({
|
||||
index: 'foo2',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const savedObjectsClient = {
|
||||
_index: '.kibana',
|
||||
};
|
||||
|
||||
const result = await findRelationships(
|
||||
type,
|
||||
id,
|
||||
size,
|
||||
callCluster,
|
||||
savedObjectsClient
|
||||
);
|
||||
expect(result).to.eql({
|
||||
visualizations: [{ id: '1', title: 'Foo' }, { id: '2', title: 'Bar' }],
|
||||
searches: [{ id: '1', title: 'Foo' }, { id: '2', title: 'Bar' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an empty object for invalid types', async () => {
|
||||
const type = 'invalid';
|
||||
const result = await findRelationships(type);
|
||||
expect(result).to.eql({});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,216 @@
|
|||
function formatId(id) {
|
||||
return id.split(':')[1];
|
||||
}
|
||||
|
||||
async function findDashboardRelationships(id, size, callCluster, savedObjectsClient) {
|
||||
const kibanaIndex = savedObjectsClient._index;
|
||||
const dashboard = await savedObjectsClient.get('dashboard', id);
|
||||
const visualizations = [];
|
||||
|
||||
// TODO: should we handle exceptions here or at the parent level?
|
||||
const panelsJSON = JSON.parse(dashboard.attributes.panelsJSON);
|
||||
if (panelsJSON) {
|
||||
const visualizationIds = panelsJSON.map(panel => panel.id);
|
||||
const visualizationResponse = await callCluster('mget', {
|
||||
body: {
|
||||
docs: visualizationIds.slice(0, size).map(id => ({
|
||||
_index: kibanaIndex,
|
||||
_type: 'doc',
|
||||
_id: `visualization:${id}`,
|
||||
_source: [`visualization.title`]
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
visualizations.push(...visualizationResponse.docs.reduce((accum, doc) => {
|
||||
if (doc.found) {
|
||||
accum.push({
|
||||
id: formatId(doc._id),
|
||||
title: doc._source.visualization.title,
|
||||
});
|
||||
}
|
||||
return accum;
|
||||
}, []));
|
||||
}
|
||||
|
||||
return { visualizations };
|
||||
}
|
||||
|
||||
async function findVisualizationRelationships(id, size, callCluster, savedObjectsClient) {
|
||||
const kibanaIndex = savedObjectsClient._index;
|
||||
const allDashboardsResponse = await callCluster('search', {
|
||||
index: kibanaIndex,
|
||||
size: 10000,
|
||||
ignore: [404],
|
||||
_source: [`dashboard.title`, `dashboard.panelsJSON`],
|
||||
body: {
|
||||
query: {
|
||||
term: {
|
||||
type: 'dashboard'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const dashboards = [];
|
||||
for (const dashboard of allDashboardsResponse.hits.hits) {
|
||||
const panelsJSON = JSON.parse(dashboard._source.dashboard.panelsJSON);
|
||||
if (panelsJSON) {
|
||||
for (const panel of panelsJSON) {
|
||||
if (panel.type === 'visualization' && panel.id === id) {
|
||||
dashboards.push({
|
||||
id: formatId(dashboard._id),
|
||||
title: dashboard._source.dashboard.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dashboards.length >= size) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { dashboards };
|
||||
}
|
||||
|
||||
async function findSavedSearchRelationships(id, size, callCluster, savedObjectsClient) {
|
||||
const kibanaIndex = savedObjectsClient._index;
|
||||
const search = await savedObjectsClient.get('search', id);
|
||||
|
||||
const searchSourceJSON = JSON.parse(search.attributes.kibanaSavedObjectMeta.searchSourceJSON);
|
||||
|
||||
const indexPatterns = [];
|
||||
try {
|
||||
const indexPattern = await savedObjectsClient.get('index-pattern', searchSourceJSON.index);
|
||||
indexPatterns.push({ id: indexPattern.id, title: indexPattern.attributes.title });
|
||||
} catch (err) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
const allVisualizationsResponse = await callCluster('search', {
|
||||
index: kibanaIndex,
|
||||
size,
|
||||
ignore: [404],
|
||||
_source: [`visualization.title`],
|
||||
body: {
|
||||
query: {
|
||||
term: {
|
||||
'visualization.savedSearchId': id,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const visualizations = allVisualizationsResponse.hits.hits.map(response => ({
|
||||
id: formatId(response._id),
|
||||
title: response._source.visualization.title,
|
||||
}));
|
||||
|
||||
return { visualizations, indexPatterns };
|
||||
}
|
||||
|
||||
async function findIndexPatternRelationships(id, size, callCluster, savedObjectsClient) {
|
||||
const kibanaIndex = savedObjectsClient._index;
|
||||
|
||||
const [allVisualizationsResponse, savedSearchResponse] = await Promise.all([
|
||||
callCluster('search', {
|
||||
index: kibanaIndex,
|
||||
size: 10000,
|
||||
ignore: [404],
|
||||
_source: [`visualization.title`, `visualization.kibanaSavedObjectMeta.searchSourceJSON`],
|
||||
body: {
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
exists: {
|
||||
field: 'visualization.kibanaSavedObjectMeta.searchSourceJSON',
|
||||
}
|
||||
},
|
||||
{
|
||||
term: {
|
||||
type: {
|
||||
value: 'visualization'
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
callCluster('search', {
|
||||
index: kibanaIndex,
|
||||
size: 10000,
|
||||
ignore: [404],
|
||||
_source: [`search.title`, `search.kibanaSavedObjectMeta.searchSourceJSON`],
|
||||
body: {
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
exists: {
|
||||
field: 'search.kibanaSavedObjectMeta.searchSourceJSON',
|
||||
}
|
||||
},
|
||||
{
|
||||
term: {
|
||||
type: {
|
||||
value: 'search'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
const visualizations = [];
|
||||
for (const visualization of allVisualizationsResponse.hits.hits) {
|
||||
const searchSourceJSON = JSON.parse(visualization._source.visualization.kibanaSavedObjectMeta.searchSourceJSON);
|
||||
if (searchSourceJSON && searchSourceJSON.index === id) {
|
||||
visualizations.push({
|
||||
id: formatId(visualization._id),
|
||||
title: visualization._source.visualization.title,
|
||||
});
|
||||
}
|
||||
|
||||
if (visualizations.length >= size) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const searches = [];
|
||||
for (const search of savedSearchResponse.hits.hits) {
|
||||
const searchSourceJSON = JSON.parse(search._source.search.kibanaSavedObjectMeta.searchSourceJSON);
|
||||
if (searchSourceJSON && searchSourceJSON.index === id) {
|
||||
searches.push({
|
||||
id: formatId(search._id),
|
||||
title: search._source.search.title,
|
||||
});
|
||||
}
|
||||
|
||||
if (searches.length >= size) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { visualizations, searches };
|
||||
}
|
||||
|
||||
export async function findRelationships(type, id, size, callCluster, savedObjectsClient) {
|
||||
switch (type) {
|
||||
case 'dashboard':
|
||||
return await findDashboardRelationships(id, size, callCluster, savedObjectsClient);
|
||||
case 'visualization':
|
||||
return await findVisualizationRelationships(id, size, callCluster, savedObjectsClient);
|
||||
case 'search':
|
||||
return await findSavedSearchRelationships(id, size, callCluster, savedObjectsClient);
|
||||
case 'index-pattern':
|
||||
return await findIndexPatternRelationships(id, size, callCluster, savedObjectsClient);
|
||||
}
|
||||
return {};
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import { registerRelationships } from './saved_objects/relationships';
|
||||
import { registerScrollForExportRoute, registerScrollForCountRoute } from './saved_objects/scroll';
|
||||
|
||||
export function managementApi(server) {
|
||||
registerRelationships(server);
|
||||
registerScrollForExportRoute(server);
|
||||
registerScrollForCountRoute(server);
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
import Boom from 'boom';
|
||||
import Joi from 'joi';
|
||||
import _ from 'lodash';
|
||||
import { findRelationships } from '../../../../lib/management/saved_objects/relationships';
|
||||
|
||||
export function registerRelationships(server) {
|
||||
server.route({
|
||||
path: '/api/kibana/management/saved_objects/relationships/{type}/{id}',
|
||||
method: ['GET'],
|
||||
config: {
|
||||
validate: {
|
||||
params: Joi.object().keys({
|
||||
type: Joi.string(),
|
||||
id: Joi.string(),
|
||||
}),
|
||||
query: Joi.object().keys({
|
||||
size: Joi.number(),
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
handler: async (req, reply) => {
|
||||
const { callWithRequest } = server.plugins.elasticsearch.getCluster('data');
|
||||
const boundCallWithRequest = _.partial(callWithRequest, req);
|
||||
|
||||
const type = req.params.type;
|
||||
const id = req.params.id;
|
||||
const size = req.query.size || 10;
|
||||
|
||||
try {
|
||||
const response = await findRelationships(
|
||||
type,
|
||||
id,
|
||||
size,
|
||||
boundCallWithRequest,
|
||||
req.getSavedObjectsClient(),
|
||||
);
|
||||
|
||||
reply(response);
|
||||
}
|
||||
catch (err) {
|
||||
reply(Boom.boomify(err, { statusCode: 500 }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
|
@ -0,0 +1,151 @@
|
|||
import Boom from 'boom';
|
||||
import Joi from 'joi';
|
||||
import _ from 'lodash';
|
||||
// import { findRelationships } from '../../../../lib/management/saved_objects/relationships';
|
||||
|
||||
async function fetchUntilDone(callCluster, response, results) {
|
||||
results.push(...response.hits.hits);
|
||||
if (response.hits.total > results.length) {
|
||||
const nextResponse = await callCluster('scroll', {
|
||||
scrollId: response._scroll_id,
|
||||
scroll: '30s',
|
||||
});
|
||||
await fetchUntilDone(callCluster, nextResponse, results);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerScrollForExportRoute(server) {
|
||||
server.route({
|
||||
path: '/api/kibana/management/saved_objects/scroll/export',
|
||||
method: ['POST'],
|
||||
config: {
|
||||
validate: {
|
||||
payload: Joi.object().keys({
|
||||
typesToInclude: Joi.array().items(Joi.string()).required(),
|
||||
}).required(),
|
||||
},
|
||||
},
|
||||
|
||||
handler: async (req, reply) => {
|
||||
const { callWithRequest } = server.plugins.elasticsearch.getCluster('admin');
|
||||
const callCluster = _.partial(callWithRequest, req);
|
||||
const results = [];
|
||||
const body = {
|
||||
query: {
|
||||
bool: {
|
||||
should: req.payload.typesToInclude.map(type => ({
|
||||
term: {
|
||||
type: {
|
||||
value: type,
|
||||
}
|
||||
}
|
||||
})),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await fetchUntilDone(callCluster, await callCluster('search', {
|
||||
index: server.config().get('kibana.index'),
|
||||
scroll: '30s',
|
||||
body,
|
||||
}), results);
|
||||
|
||||
const response = results.map(hit => {
|
||||
const type = hit._source.type;
|
||||
if (hit._type === 'doc') {
|
||||
return {
|
||||
_id: hit._id.replace(`${type}:`, ''),
|
||||
_type: type,
|
||||
_source: hit._source[type],
|
||||
_meta: {
|
||||
savedObjectVersion: 2
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
_id: hit._id,
|
||||
_type: hit._type,
|
||||
_source: hit._source,
|
||||
};
|
||||
});
|
||||
|
||||
reply(response);
|
||||
}
|
||||
catch (err) {
|
||||
reply(Boom.boomify(err, { statusCode: 500 }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function registerScrollForCountRoute(server) {
|
||||
server.route({
|
||||
path: '/api/kibana/management/saved_objects/scroll/counts',
|
||||
method: ['POST'],
|
||||
config: {
|
||||
validate: {
|
||||
payload: Joi.object().keys({
|
||||
typesToInclude: Joi.array().items(Joi.string()).required(),
|
||||
searchString: Joi.string()
|
||||
}).required(),
|
||||
},
|
||||
},
|
||||
|
||||
handler: async (req, reply) => {
|
||||
const { callWithRequest } = server.plugins.elasticsearch.getCluster('admin');
|
||||
const callCluster = _.partial(callWithRequest, req);
|
||||
const results = [];
|
||||
|
||||
const body = {
|
||||
_source: 'type',
|
||||
query: {
|
||||
bool: {
|
||||
should: req.payload.typesToInclude.map(type => ({
|
||||
term: {
|
||||
type: {
|
||||
value: type,
|
||||
}
|
||||
}
|
||||
})),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (req.payload.searchString) {
|
||||
body.query.bool.must = {
|
||||
simple_query_string: {
|
||||
query: `${req.payload.searchString}*`,
|
||||
fields: req.payload.typesToInclude.map(type => `${type}.title`),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchUntilDone(callCluster, await callCluster('search', {
|
||||
index: server.config().get('kibana.index'),
|
||||
scroll: '30s',
|
||||
body,
|
||||
}), results);
|
||||
|
||||
const counts = results.reduce((accum, result) => {
|
||||
const type = result._source.type;
|
||||
accum[type] = accum[type] || 0;
|
||||
accum[type]++;
|
||||
return accum;
|
||||
}, {});
|
||||
|
||||
for (const type of req.payload.typesToInclude) {
|
||||
if (!counts[type]) {
|
||||
counts[type] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
reply(counts);
|
||||
}
|
||||
catch (err) {
|
||||
reply(Boom.boomify(err, { statusCode: 500 }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
|
@ -29,8 +29,22 @@ function getFieldsForTypes(searchFields, types) {
|
|||
* @param {Array<string>} searchFields
|
||||
* @return {Object}
|
||||
*/
|
||||
export function getQueryParams(mappings, type, search, searchFields) {
|
||||
export function getQueryParams(mappings, type, search, searchFields, includeTypes) {
|
||||
if (!type && !search) {
|
||||
if (includeTypes) {
|
||||
return {
|
||||
query: {
|
||||
bool: {
|
||||
should: includeTypes.map(includeType => ({
|
||||
term: {
|
||||
type: includeType,
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
|
@ -42,14 +56,29 @@ export function getQueryParams(mappings, type, search, searchFields) {
|
|||
];
|
||||
}
|
||||
|
||||
if (includeTypes) {
|
||||
bool.must = [
|
||||
{
|
||||
bool: {
|
||||
should: includeTypes.map(includeType => ({
|
||||
term: {
|
||||
type: includeType,
|
||||
}
|
||||
})),
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
if (search) {
|
||||
bool.must = [
|
||||
...bool.must || [],
|
||||
{
|
||||
simple_query_string: {
|
||||
query: search,
|
||||
...getFieldsForTypes(
|
||||
searchFields,
|
||||
type ? [type] : Object.keys(getRootProperties(mappings))
|
||||
type ? [type] : Object.keys(getRootProperties(mappings)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,13 +6,14 @@ import { getSortingParams } from './sorting_params';
|
|||
export function getSearchDsl(mappings, options = {}) {
|
||||
const {
|
||||
type,
|
||||
includeTypes,
|
||||
search,
|
||||
searchFields,
|
||||
sortField,
|
||||
sortOrder
|
||||
} = options;
|
||||
|
||||
if (!type && sortField) {
|
||||
if (!type && !includeTypes && sortField) {
|
||||
throw Boom.notAcceptable('Cannot sort without filtering by type');
|
||||
}
|
||||
|
||||
|
@ -21,7 +22,7 @@ export function getSearchDsl(mappings, options = {}) {
|
|||
}
|
||||
|
||||
return {
|
||||
...getQueryParams(mappings, type, search, searchFields),
|
||||
...getQueryParams(mappings, type, search, searchFields, includeTypes),
|
||||
...getSortingParams(mappings, type, sortField, sortOrder),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -33,7 +33,8 @@ describe('getSearchDsl', () => {
|
|||
const opts = {
|
||||
type: 'foo',
|
||||
search: 'bar',
|
||||
searchFields: ['baz']
|
||||
searchFields: ['baz'],
|
||||
includeTypes: ['index-pattern', 'dashboard']
|
||||
};
|
||||
|
||||
getSearchDsl(mappings, opts);
|
||||
|
@ -44,6 +45,7 @@ describe('getSearchDsl', () => {
|
|||
opts.type,
|
||||
opts.search,
|
||||
opts.searchFields,
|
||||
opts.includeTypes,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -7,7 +7,9 @@ export function getSortingParams(mappings, type, sortField, sortOrder) {
|
|||
return {};
|
||||
}
|
||||
|
||||
const field = getProperty(mappings, `${type}.${sortField}`);
|
||||
const key = type ? `${type}.${sortField}` : sortField;
|
||||
|
||||
const field = getProperty(mappings, key);
|
||||
if (!field) {
|
||||
throw Boom.badRequest(`Unknown sort field ${sortField}`);
|
||||
}
|
||||
|
@ -15,7 +17,7 @@ export function getSortingParams(mappings, type, sortField, sortOrder) {
|
|||
return {
|
||||
sort: [
|
||||
{
|
||||
[`${type}.${sortField}`]: {
|
||||
[key]: {
|
||||
order: sortOrder,
|
||||
unmapped_type: field.type
|
||||
}
|
||||
|
|
|
@ -272,6 +272,7 @@ export class SavedObjectsClient {
|
|||
sortField,
|
||||
sortOrder,
|
||||
fields,
|
||||
includeTypes,
|
||||
} = options;
|
||||
|
||||
if (searchFields && !Array.isArray(searchFields)) {
|
||||
|
@ -294,6 +295,7 @@ export class SavedObjectsClient {
|
|||
search,
|
||||
searchFields,
|
||||
type,
|
||||
includeTypes,
|
||||
sortField,
|
||||
sortOrder
|
||||
})
|
||||
|
|
|
@ -371,7 +371,8 @@ describe('SavedObjectsClient', () => {
|
|||
searchFields: ['foo'],
|
||||
type: 'bar',
|
||||
sortField: 'name',
|
||||
sortOrder: 'desc'
|
||||
sortOrder: 'desc',
|
||||
includeTypes: ['index-pattern', 'dashboard'],
|
||||
};
|
||||
|
||||
await savedObjectsClient.find(relevantOpts);
|
||||
|
|
|
@ -11,8 +11,10 @@ export const createFindRoute = (prereqs) => ({
|
|||
per_page: Joi.number().min(0).default(20),
|
||||
page: Joi.number().min(0).default(1),
|
||||
type: Joi.string(),
|
||||
include_types: Joi.array().items(Joi.string()).single(),
|
||||
search: Joi.string().allow('').optional(),
|
||||
search_fields: Joi.array().items(Joi.string()).single(),
|
||||
sort_field: Joi.array().items(Joi.string()).single(),
|
||||
fields: Joi.array().items(Joi.string()).single()
|
||||
}).default()
|
||||
},
|
||||
|
|
|
@ -94,14 +94,15 @@ export class SavedObjectLoader {
|
|||
* @param size
|
||||
* @returns {Promise}
|
||||
*/
|
||||
findAll(search = '', size = 100) {
|
||||
findAll(search = '', size = 100, fields) {
|
||||
return this.savedObjectsClient.find(
|
||||
{
|
||||
type: this.lowercaseType,
|
||||
search: search ? `${search}*` : undefined,
|
||||
perPage: size,
|
||||
page: 1,
|
||||
searchFields: ['title^3', 'description']
|
||||
searchFields: ['title^3', 'description'],
|
||||
fields,
|
||||
}).then((resp) => {
|
||||
return {
|
||||
total: resp.total,
|
||||
|
|
|
@ -34,7 +34,7 @@ export function IndexPatternsGetProvider(Private) {
|
|||
});
|
||||
};
|
||||
|
||||
return (field) => {
|
||||
const retFunction = (field) => {
|
||||
const getter = get.bind(get, field);
|
||||
if (field === 'id') {
|
||||
getter.clearCache = function () {
|
||||
|
@ -43,4 +43,10 @@ export function IndexPatternsGetProvider(Private) {
|
|||
}
|
||||
return getter;
|
||||
};
|
||||
|
||||
retFunction.multiple = async fields => {
|
||||
return (await savedObjectsClient.find({ type: 'index-pattern', fields, perPage: 10000 })).savedObjects;
|
||||
};
|
||||
|
||||
return retFunction;
|
||||
}
|
||||
|
|
|
@ -353,57 +353,50 @@ export function IndexPatternProvider(Private, config, Promise, confirmModalPromi
|
|||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves to true if either the title is unique, or if the user confirmed they
|
||||
* wished to save the duplicate title. Promise is rejected if the user rejects the confirmation.
|
||||
*/
|
||||
warnIfDuplicateTitle() {
|
||||
return findObjectByTitle(savedObjectsClient, type, this.title)
|
||||
.then(duplicate => {
|
||||
if (!duplicate) return false;
|
||||
if (duplicate.id === this.id) return false;
|
||||
|
||||
const confirmMessage =
|
||||
`An index pattern with the title '${this.title}' already exists.`;
|
||||
|
||||
return confirmModalPromise(confirmMessage, { confirmButtonText: 'Go to existing pattern' })
|
||||
.then(() => {
|
||||
kbnUrl.redirect('/management/kibana/indices/{{id}}', { id: duplicate.id });
|
||||
return true;
|
||||
}).catch(() => {
|
||||
return true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
create() {
|
||||
return this.warnIfDuplicateTitle().then((isDuplicate) => {
|
||||
if (isDuplicate) return;
|
||||
async create(allowOverride = false, showOverridePrompt = false) {
|
||||
const _create = async (duplicateId) => {
|
||||
if (duplicateId) {
|
||||
const duplicatePattern = new IndexPattern(duplicateId);
|
||||
await duplicatePattern.destroy();
|
||||
}
|
||||
|
||||
const body = this.prepBody();
|
||||
const response = await savedObjectsClient.create(type, body, { id: this.id });
|
||||
return setId(this, response.id);
|
||||
};
|
||||
|
||||
return savedObjectsClient.create(type, body, { id: this.id })
|
||||
.then(response => setId(this, response.id))
|
||||
.catch(err => {
|
||||
if (err.statusCode !== 409) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const confirmMessage = 'Are you sure you want to overwrite this?';
|
||||
const potentialDuplicateByTitle = await findObjectByTitle(savedObjectsClient, type, this.title);
|
||||
// If there is potentialy duplicate title, just create it
|
||||
if (!potentialDuplicateByTitle) {
|
||||
return await _create();
|
||||
}
|
||||
|
||||
return confirmModalPromise(confirmMessage, { confirmButtonText: 'Overwrite' })
|
||||
.then(() => Promise
|
||||
.try(() => {
|
||||
const cached = patternCache.get(this.id);
|
||||
if (cached) {
|
||||
return cached.then(pattern => pattern.destroy());
|
||||
}
|
||||
})
|
||||
.then(() => savedObjectsClient.create(type, body, { id: this.id, overwrite: true }))
|
||||
.then(response => setId(this, response.id)),
|
||||
_.constant(false) // if the user doesn't overwrite, resolve with false
|
||||
);
|
||||
});
|
||||
});
|
||||
// We found a duplicate but we aren't allowing override, show the warn modal
|
||||
if (!allowOverride) {
|
||||
const confirmMessage = `An index pattern with the title '${this.title}' already exists.`;
|
||||
try {
|
||||
await confirmModalPromise(confirmMessage, { confirmButtonText: 'Go to existing pattern' });
|
||||
return kbnUrl.redirect('/management/kibana/indices/{{id}}', { id: potentialDuplicateByTitle.id });
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// We can override, but we do not want to see a prompt, so just do it
|
||||
if (!showOverridePrompt) {
|
||||
return await _create(potentialDuplicateByTitle.id);
|
||||
}
|
||||
|
||||
// We can override and we want to prompt for confirmation
|
||||
try {
|
||||
await confirmModalPromise(`Are you sure you want to overwrite ${this.title}?`, { confirmButtonText: 'Overwrite' });
|
||||
} catch (err) {
|
||||
// They changed their mind
|
||||
return false;
|
||||
}
|
||||
|
||||
// Let's do it!
|
||||
return await _create(potentialDuplicateByTitle.id);
|
||||
}
|
||||
|
||||
save() {
|
||||
|
|
|
@ -18,4 +18,12 @@ export function IndexPatternsPatternCacheProvider() {
|
|||
this.clear = this.delete = function (id) {
|
||||
if (validId(id)) delete vals[id];
|
||||
};
|
||||
|
||||
this.clearAll = function () {
|
||||
for (const id in vals) {
|
||||
if (vals.hasOwnProperty(id)) {
|
||||
delete vals[id];
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -49,6 +49,7 @@ export function IndexPatternsProvider(Notifier, Private, config) {
|
|||
self.cache = patternCache;
|
||||
self.getIds = getProvider('id');
|
||||
self.getTitles = getProvider('attributes.title');
|
||||
self.getFields = getProvider.multiple;
|
||||
self.intervals = Private(IndexPatternsIntervalsProvider);
|
||||
self.fieldsFetcher = Private(FieldsFetcherProvider);
|
||||
self.fieldFormats = fieldFormats;
|
||||
|
|
|
@ -3,6 +3,7 @@ export default function ({ loadTestFile }) {
|
|||
loadTestFile(require.resolve('./elasticsearch'));
|
||||
loadTestFile(require.resolve('./general'));
|
||||
loadTestFile(require.resolve('./index_patterns'));
|
||||
loadTestFile(require.resolve('./management'));
|
||||
loadTestFile(require.resolve('./saved_objects'));
|
||||
loadTestFile(require.resolve('./scripts'));
|
||||
loadTestFile(require.resolve('./search'));
|
||||
|
|
5
test/api_integration/apis/management/index.js
Normal file
5
test/api_integration/apis/management/index.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
export default function ({ loadTestFile }) {
|
||||
describe('management apis', () => {
|
||||
loadTestFile(require.resolve('./saved_objects'));
|
||||
});
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
export default function ({ loadTestFile }) {
|
||||
describe('saved_objects', () => {
|
||||
loadTestFile(require.resolve('./relationships'));
|
||||
});
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
import expect from 'expect.js';
|
||||
|
||||
export default function ({ getService }) {
|
||||
const supertest = getService('supertest');
|
||||
const esArchiver = getService('esArchiver');
|
||||
|
||||
describe('relationships', () => {
|
||||
before(() => esArchiver.load('management/saved_objects'));
|
||||
after(() => esArchiver.unload('management/saved_objects'));
|
||||
|
||||
it('should work for searches', async () => {
|
||||
await supertest
|
||||
.get(
|
||||
`/api/kibana/management/saved_objects/relationships/search/960372e0-3224-11e8-a572-ffca06da1357`
|
||||
)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body).to.eql({
|
||||
visualizations: [
|
||||
{
|
||||
id: 'a42c0580-3224-11e8-a572-ffca06da1357',
|
||||
title: 'VisualizationFromSavedSearch',
|
||||
},
|
||||
],
|
||||
indexPatterns: [
|
||||
{
|
||||
id: '8963ca30-3224-11e8-a572-ffca06da1357',
|
||||
title: 'saved_objects*',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should work for dashboards', async () => {
|
||||
await supertest
|
||||
.get(
|
||||
`/api/kibana/management/saved_objects/relationships/dashboard/b70c7ae0-3224-11e8-a572-ffca06da1357`
|
||||
)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body).to.eql({
|
||||
visualizations: [
|
||||
{
|
||||
id: 'add810b0-3224-11e8-a572-ffca06da1357',
|
||||
title: 'Visualization',
|
||||
},
|
||||
{
|
||||
id: 'a42c0580-3224-11e8-a572-ffca06da1357',
|
||||
title: 'VisualizationFromSavedSearch',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should work for visualizations', async () => {
|
||||
await supertest
|
||||
.get(
|
||||
`/api/kibana/management/saved_objects/relationships/visualization/a42c0580-3224-11e8-a572-ffca06da1357`
|
||||
)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body).to.eql({
|
||||
dashboards: [
|
||||
{
|
||||
id: 'b70c7ae0-3224-11e8-a572-ffca06da1357',
|
||||
title: 'Dashboard',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should work for index patterns', async () => {
|
||||
await supertest
|
||||
.get(
|
||||
`/api/kibana/management/saved_objects/relationships/index-pattern/8963ca30-3224-11e8-a572-ffca06da1357`
|
||||
)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body).to.eql({
|
||||
searches: [
|
||||
{
|
||||
id: '960372e0-3224-11e8-a572-ffca06da1357',
|
||||
title: 'OneRecord',
|
||||
},
|
||||
],
|
||||
visualizations: [
|
||||
{
|
||||
id: 'add810b0-3224-11e8-a572-ffca06da1357',
|
||||
title: 'Visualization',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,285 @@
|
|||
{
|
||||
"type": "index",
|
||||
"value": {
|
||||
"index": ".kibana",
|
||||
"settings": {
|
||||
"index": {
|
||||
"number_of_shards": "1",
|
||||
"auto_expand_replicas": "0-1",
|
||||
"number_of_replicas": "0"
|
||||
}
|
||||
},
|
||||
"mappings": {
|
||||
"doc": {
|
||||
"dynamic": "strict",
|
||||
"properties": {
|
||||
"config": {
|
||||
"dynamic": "true",
|
||||
"properties": {
|
||||
"buildNum": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"defaultIndex": {
|
||||
"type": "text",
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"type": "keyword",
|
||||
"ignore_above": 256
|
||||
}
|
||||
}
|
||||
},
|
||||
"telemetry:optIn": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "text"
|
||||
},
|
||||
"hits": {
|
||||
"type": "integer"
|
||||
},
|
||||
"kibanaSavedObjectMeta": {
|
||||
"properties": {
|
||||
"searchSourceJSON": {
|
||||
"type": "text"
|
||||
}
|
||||
}
|
||||
},
|
||||
"optionsJSON": {
|
||||
"type": "text"
|
||||
},
|
||||
"panelsJSON": {
|
||||
"type": "text"
|
||||
},
|
||||
"refreshInterval": {
|
||||
"properties": {
|
||||
"display": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"pause": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"section": {
|
||||
"type": "integer"
|
||||
},
|
||||
"value": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"timeFrom": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"timeRestore": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"timeTo": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"title": {
|
||||
"type": "text"
|
||||
},
|
||||
"uiStateJSON": {
|
||||
"type": "text"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"graph-workspace": {
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "text"
|
||||
},
|
||||
"kibanaSavedObjectMeta": {
|
||||
"properties": {
|
||||
"searchSourceJSON": {
|
||||
"type": "text"
|
||||
}
|
||||
}
|
||||
},
|
||||
"numLinks": {
|
||||
"type": "integer"
|
||||
},
|
||||
"numVertices": {
|
||||
"type": "integer"
|
||||
},
|
||||
"title": {
|
||||
"type": "text"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer"
|
||||
},
|
||||
"wsState": {
|
||||
"type": "text"
|
||||
}
|
||||
}
|
||||
},
|
||||
"index-pattern": {
|
||||
"properties": {
|
||||
"fieldFormatMap": {
|
||||
"type": "text"
|
||||
},
|
||||
"fields": {
|
||||
"type": "text"
|
||||
},
|
||||
"intervalName": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"notExpandable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sourceFilters": {
|
||||
"type": "text"
|
||||
},
|
||||
"timeFieldName": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"title": {
|
||||
"type": "text"
|
||||
}
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"properties": {
|
||||
"columns": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"description": {
|
||||
"type": "text"
|
||||
},
|
||||
"hits": {
|
||||
"type": "integer"
|
||||
},
|
||||
"kibanaSavedObjectMeta": {
|
||||
"properties": {
|
||||
"searchSourceJSON": {
|
||||
"type": "text"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sort": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"title": {
|
||||
"type": "text"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"properties": {
|
||||
"uuid": {
|
||||
"type": "keyword"
|
||||
}
|
||||
}
|
||||
},
|
||||
"timelion-sheet": {
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "text"
|
||||
},
|
||||
"hits": {
|
||||
"type": "integer"
|
||||
},
|
||||
"kibanaSavedObjectMeta": {
|
||||
"properties": {
|
||||
"searchSourceJSON": {
|
||||
"type": "text"
|
||||
}
|
||||
}
|
||||
},
|
||||
"timelion_chart_height": {
|
||||
"type": "integer"
|
||||
},
|
||||
"timelion_columns": {
|
||||
"type": "integer"
|
||||
},
|
||||
"timelion_interval": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"timelion_other_interval": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"timelion_rows": {
|
||||
"type": "integer"
|
||||
},
|
||||
"timelion_sheet": {
|
||||
"type": "text"
|
||||
},
|
||||
"title": {
|
||||
"type": "text"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "date"
|
||||
},
|
||||
"url": {
|
||||
"properties": {
|
||||
"accessCount": {
|
||||
"type": "long"
|
||||
},
|
||||
"accessDate": {
|
||||
"type": "date"
|
||||
},
|
||||
"createDate": {
|
||||
"type": "date"
|
||||
},
|
||||
"url": {
|
||||
"type": "text",
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"type": "keyword",
|
||||
"ignore_above": 2048
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"visualization": {
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "text"
|
||||
},
|
||||
"kibanaSavedObjectMeta": {
|
||||
"properties": {
|
||||
"searchSourceJSON": {
|
||||
"type": "text"
|
||||
}
|
||||
}
|
||||
},
|
||||
"savedSearchId": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"title": {
|
||||
"type": "text"
|
||||
},
|
||||
"uiStateJSON": {
|
||||
"type": "text"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer"
|
||||
},
|
||||
"visState": {
|
||||
"type": "text"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,7 +2,6 @@ import expect from 'expect.js';
|
|||
import path from 'path';
|
||||
|
||||
export default function ({ getService, getPageObjects }) {
|
||||
const retry = getService('retry');
|
||||
const kibanaServer = getService('kibanaServer');
|
||||
const esArchiver = getService('esArchiver');
|
||||
const PageObjects = getPageObjects(['common', 'settings', 'header']);
|
||||
|
@ -22,79 +21,78 @@ export default function ({ getService, getPageObjects }) {
|
|||
it('should import saved objects normally', async function () {
|
||||
await PageObjects.settings.clickKibanaSavedObjects();
|
||||
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects.json'));
|
||||
await PageObjects.common.clickConfirmOnModal();
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.settings.clickVisualizationsTab();
|
||||
const rowCount = await retry.try(async () => {
|
||||
const rows = await PageObjects.settings.getVisualizationRows();
|
||||
return rows.length;
|
||||
});
|
||||
expect(rowCount).to.be(2);
|
||||
await PageObjects.settings.clickImportDone();
|
||||
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
|
||||
const objects = await PageObjects.settings.getSavedObjectsInTable();
|
||||
expect(objects.length).to.be(3);
|
||||
});
|
||||
|
||||
it('should import conflicts using a confirm modal', async function () {
|
||||
await PageObjects.settings.clickKibanaSavedObjects();
|
||||
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects-conflicts.json'));
|
||||
await PageObjects.common.clickConfirmOnModal();
|
||||
await PageObjects.settings.setImportIndexFieldOption(2);
|
||||
await PageObjects.settings.clickChangeIndexConfirmButton();
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.settings.clickVisualizationsTab();
|
||||
const rowCount = await retry.try(async () => {
|
||||
const rows = await PageObjects.settings.getVisualizationRows();
|
||||
return rows.length;
|
||||
});
|
||||
expect(rowCount).to.be(2);
|
||||
await PageObjects.settings.setImportIndexFieldOption(2);
|
||||
await PageObjects.settings.clickConfirmConflicts();
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.settings.clickImportDone();
|
||||
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
|
||||
const objects = await PageObjects.settings.getSavedObjectsInTable();
|
||||
expect(objects.length).to.be(3);
|
||||
});
|
||||
|
||||
it('should allow for overrides', async function () {
|
||||
await PageObjects.settings.clickKibanaSavedObjects();
|
||||
|
||||
// Put in data which already exists
|
||||
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_exists.json'));
|
||||
// Say we want to be asked
|
||||
await PageObjects.common.clickCancelOnModal();
|
||||
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_exists.json'), false);
|
||||
// Wait for all the saves to happen
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
// Interact with the conflict modal
|
||||
await PageObjects.settings.setImportIndexFieldOption(2);
|
||||
await PageObjects.settings.clickChangeIndexConfirmButton();
|
||||
await PageObjects.settings.clickConfirmConflicts();
|
||||
// Now confirm we want to override
|
||||
await PageObjects.common.clickConfirmOnModal();
|
||||
// Wait for all the saves to happen
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
// Finish the flyout
|
||||
await PageObjects.settings.clickImportDone();
|
||||
// Wait...
|
||||
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
|
||||
|
||||
await PageObjects.settings.clickVisualizationsTab();
|
||||
const rowCount = await retry.try(async () => {
|
||||
const rows = await PageObjects.settings.getVisualizationRows();
|
||||
return rows.length;
|
||||
});
|
||||
expect(rowCount).to.be(1);
|
||||
const objects = await PageObjects.settings.getSavedObjectsInTable();
|
||||
expect(objects.length).to.be(2);
|
||||
});
|
||||
|
||||
it('should allow for cancelling overrides', async function () {
|
||||
await PageObjects.settings.clickKibanaSavedObjects();
|
||||
|
||||
// Put in data which already exists
|
||||
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_exists.json'));
|
||||
// Say we want to be asked
|
||||
await PageObjects.common.clickCancelOnModal();
|
||||
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_exists.json'), false);
|
||||
// Wait for all the saves to happen
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
// Interact with the conflict modal
|
||||
await PageObjects.settings.setImportIndexFieldOption(2);
|
||||
await PageObjects.settings.clickChangeIndexConfirmButton();
|
||||
await PageObjects.settings.clickConfirmConflicts();
|
||||
// Now cancel the override
|
||||
await PageObjects.common.clickCancelOnModal();
|
||||
// Wait for all saves to happen
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
// Finish the flyout
|
||||
await PageObjects.settings.clickImportDone();
|
||||
// Wait for table to refresh
|
||||
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
|
||||
|
||||
await PageObjects.settings.clickVisualizationsTab();
|
||||
const rowCount = await retry.try(async () => {
|
||||
const rows = await PageObjects.settings.getVisualizationRows();
|
||||
return rows.length;
|
||||
});
|
||||
expect(rowCount).to.be(1);
|
||||
const objects = await PageObjects.settings.getSavedObjectsInTable();
|
||||
expect(objects.length).to.be(2);
|
||||
});
|
||||
|
||||
it('should handle saved searches and objects with saved searches properly', async function () {
|
||||
// First, import the saved search
|
||||
await PageObjects.settings.clickKibanaSavedObjects();
|
||||
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_saved_search.json'));
|
||||
await PageObjects.common.clickConfirmOnModal();
|
||||
// Wait for all the saves to happen
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
|
||||
// Second, we need to delete the index pattern
|
||||
await PageObjects.settings.navigateTo();
|
||||
|
@ -103,26 +101,52 @@ export default function ({ getService, getPageObjects }) {
|
|||
await PageObjects.settings.removeIndexPattern();
|
||||
|
||||
// Last, import a saved object connected to the saved search
|
||||
// This should NOT show the modal
|
||||
// This should NOT show the conflicts
|
||||
await PageObjects.settings.navigateTo();
|
||||
await PageObjects.settings.clickKibanaSavedObjects();
|
||||
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.json'));
|
||||
await PageObjects.common.clickConfirmOnModal();
|
||||
// Wait for all the saves to happen
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.settings.clickImportDone();
|
||||
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
|
||||
|
||||
await PageObjects.settings.clickVisualizationsTab();
|
||||
const vizRowCount = await retry.try(async () => {
|
||||
const rows = await PageObjects.settings.getVisualizationRows();
|
||||
return rows.length;
|
||||
});
|
||||
expect(vizRowCount).to.be(1);
|
||||
const objects = await PageObjects.settings.getSavedObjectsInTable();
|
||||
expect(objects.length).to.be(2);
|
||||
});
|
||||
|
||||
await PageObjects.settings.clickSearchesTab();
|
||||
const searchRowCount = await retry.try(async () => {
|
||||
const rows = await PageObjects.settings.getVisualizationRows();
|
||||
return rows.length;
|
||||
});
|
||||
expect(searchRowCount).to.be(1);
|
||||
it('should work with index patterns', async () => {
|
||||
// First, import the objects
|
||||
await PageObjects.settings.clickKibanaSavedObjects();
|
||||
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_with_index_patterns.json'));
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.settings.clickImportDone();
|
||||
// Wait for all the saves to happen
|
||||
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
|
||||
|
||||
const objects = await PageObjects.settings.getSavedObjectsInTable();
|
||||
expect(objects.length).to.be(2);
|
||||
});
|
||||
|
||||
it('should work when the index pattern does not exist', async () => {
|
||||
// First, we need to delete the index pattern
|
||||
await PageObjects.settings.navigateTo();
|
||||
await PageObjects.settings.clickKibanaIndices();
|
||||
await PageObjects.settings.clickOnOnlyIndexPattern();
|
||||
await PageObjects.settings.removeIndexPattern();
|
||||
|
||||
// Second, create it
|
||||
await PageObjects.settings.createIndexPattern('logstash-', '@timestamp');
|
||||
|
||||
// Then, import the objects
|
||||
await PageObjects.settings.clickKibanaSavedObjects();
|
||||
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_with_index_patterns.json'));
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.settings.clickImportDone();
|
||||
// Wait for all the saves to happen
|
||||
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
|
||||
|
||||
const objects = await PageObjects.settings.getSavedObjectsInTable();
|
||||
expect(objects.length).to.be(2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -479,32 +479,73 @@ export function SettingsPageProvider({ getService, getPageObjects }) {
|
|||
await testSubjects.setValue('editorFieldScript', script);
|
||||
}
|
||||
|
||||
async importFile(path) {
|
||||
async importFile(path, overwriteAll = true) {
|
||||
log.debug(`importFile(${path})`);
|
||||
await remote.findById('testfile').type(path);
|
||||
|
||||
log.debug(`Clicking importObjects`);
|
||||
await testSubjects.click('importObjects');
|
||||
log.debug(`Setting the path on the file input`);
|
||||
await find.setValue('.euiFilePicker__input', path);
|
||||
if (!overwriteAll) {
|
||||
log.debug(`Toggling overwriteAll`);
|
||||
await testSubjects.click('importSavedObjectsOverwriteToggle');
|
||||
} else {
|
||||
log.debug(`Leaving overwriteAll alone`);
|
||||
}
|
||||
await testSubjects.click('importSavedObjectsImportBtn');
|
||||
log.debug(`done importing the file`);
|
||||
}
|
||||
|
||||
async clickImportDone() {
|
||||
await testSubjects.click('importSavedObjectsDoneBtn');
|
||||
}
|
||||
|
||||
async clickConfirmConflicts() {
|
||||
await testSubjects.click('importSavedObjectsConfirmBtn');
|
||||
}
|
||||
|
||||
async setImportIndexFieldOption(child) {
|
||||
await remote.setFindTimeout(defaultFindTimeout)
|
||||
.findByCssSelector(`select[data-test-subj="managementChangeIndexSelection"] > option:nth-child(${child})`)
|
||||
.click();
|
||||
await find
|
||||
.clickByCssSelector(`select[data-test-subj="managementChangeIndexSelection"] > option:nth-child(${child})`);
|
||||
}
|
||||
|
||||
async clickChangeIndexConfirmButton() {
|
||||
await (await testSubjects.find('changeIndexConfirmButton')).click();
|
||||
await testSubjects.click('changeIndexConfirmButton');
|
||||
}
|
||||
|
||||
async clickVisualizationsTab() {
|
||||
await (await testSubjects.find('objectsTab-visualizations')).click();
|
||||
await testSubjects.click('objectsTab-visualizations');
|
||||
}
|
||||
|
||||
async clickSearchesTab() {
|
||||
await (await testSubjects.find('objectsTab-searches')).click();
|
||||
await testSubjects.click('objectsTab-searches');
|
||||
}
|
||||
|
||||
async getVisualizationRows() {
|
||||
return await testSubjects.findAll(`objectsTableRow`);
|
||||
}
|
||||
|
||||
async waitUntilSavedObjectsTableIsNotLoading() {
|
||||
return retry.try(async () => {
|
||||
const exists = await find.existsByDisplayedByCssSelector('*[data-test-subj="savedObjectsTable"] .euiBasicTable-loading');
|
||||
if (exists) {
|
||||
throw new Error('Waiting');
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async getSavedObjectsInTable() {
|
||||
const table = await testSubjects.find('savedObjectsTable');
|
||||
const cells = await table.findAll('css selector', 'td:nth-child(3)');
|
||||
|
||||
const objects = [];
|
||||
for (const cell of cells) {
|
||||
objects.push(await cell.getVisibleText());
|
||||
}
|
||||
|
||||
return objects;
|
||||
}
|
||||
}
|
||||
|
||||
return new SettingsPage();
|
||||
|
|
|
@ -53,6 +53,20 @@ export function FindProvider({ getService }) {
|
|||
});
|
||||
}
|
||||
|
||||
async setValue(selector, text) {
|
||||
return await retry.try(async () => {
|
||||
const element = await this.byCssSelector(selector);
|
||||
await element.click();
|
||||
|
||||
// in case the input element is actually a child of the testSubject, we
|
||||
// call clearValue() and type() on the element that is focused after
|
||||
// clicking on the testSubject
|
||||
const input = await remote.getActiveElement();
|
||||
await input.clearValue();
|
||||
await input.type(text);
|
||||
});
|
||||
}
|
||||
|
||||
async allByCustom(findAllFunction, timeout = defaultFindTimeout) {
|
||||
return await this._withTimeout(timeout, async remote => {
|
||||
return await retry.try(async () => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue