[6.x] [Management] Saved objects to React/EUI! (#17426) (#19179)

* [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:
Chris Roberson 2018-05-17 16:18:51 -04:00 committed by GitHub
parent eae18989a3
commit 8455bcdbba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
74 changed files with 6302 additions and 890 deletions

View file

@ -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);

View file

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

View file

@ -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);
}
};
});

View file

@ -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&apos;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
};

View file

@ -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>
`;

View file

@ -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);
});
});
});

View file

@ -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>
`;

View file

@ -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();
});
});
});

View file

@ -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&apos;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>
);
}
}

View file

@ -0,0 +1 @@
export { Flyout } from './flyout';

View file

@ -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>
`;

View file

@ -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();
});
});

View file

@ -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>
);

View file

@ -0,0 +1 @@
export { Header } from './header';

View file

@ -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>
`;

View file

@ -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();
});
});

View file

@ -0,0 +1 @@
export { Relationships } from './relationships';

View file

@ -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>
&nbsp;&nbsp;
{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>
);
}
}

View file

@ -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>
`;

View file

@ -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();
});
});

View file

@ -0,0 +1 @@
export { Table } from './table';

View file

@ -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>
);
}
}

View file

@ -0,0 +1 @@
export { ObjectsTable } from './objects_table';

View file

@ -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>
);
}
}

View file

@ -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');
});
});

View file

@ -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();
}
});
});

View file

@ -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');
});
});

View file

@ -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();
}
});
});

View file

@ -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' });
});
});

View file

@ -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 });
});
});
});

View file

@ -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
)
);
});
});

View file

@ -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);
});
});

View file

@ -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 });
});
});

View file

@ -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}`;
}
}

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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';
}
}

View file

@ -0,0 +1,10 @@
export function getSavedObjectLabel(type) {
switch (type) {
case 'index-pattern':
case 'index-patterns':
case 'indexPatterns':
return 'index patterns';
default:
return type;
}
}

View file

@ -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);
});
}

View 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';

View file

@ -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;
};

View file

@ -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,
};
}

View file

@ -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,
};
}

View file

@ -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));
}

View file

@ -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');
}

View file

@ -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;
}

View file

@ -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);
}

View 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({});
});
});

View file

@ -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 {};
}

View file

@ -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);
}

View file

@ -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 }));
}
}
});
}

View file

@ -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 }));
}
}
});
}

View file

@ -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)),
)
}
}

View file

@ -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),
};
}

View file

@ -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,
);
});

View file

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

View file

@ -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
})

View file

@ -371,7 +371,8 @@ describe('SavedObjectsClient', () => {
searchFields: ['foo'],
type: 'bar',
sortField: 'name',
sortOrder: 'desc'
sortOrder: 'desc',
includeTypes: ['index-pattern', 'dashboard'],
};
await savedObjectsClient.find(relevantOpts);

View file

@ -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()
},

View file

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

View file

@ -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;
}

View file

@ -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() {

View file

@ -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];
}
}
};
}

View file

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

View file

@ -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'));

View file

@ -0,0 +1,5 @@
export default function ({ loadTestFile }) {
describe('management apis', () => {
loadTestFile(require.resolve('./saved_objects'));
});
}

View file

@ -0,0 +1,5 @@
export default function ({ loadTestFile }) {
describe('saved_objects', () => {
loadTestFile(require.resolve('./relationships'));
});
}

View file

@ -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',
},
],
});
});
});
});
}

View file

@ -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"
}
}
}
}
}
}
}
}

View file

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

View file

@ -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();

View file

@ -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 () => {