From 66318e84238eb24935e5255bc9edba26ed37640c Mon Sep 17 00:00:00 2001 From: Chris Roberson Date: Thu, 17 May 2018 13:46:47 -0400 Subject: [PATCH] [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 ef3339bd7a6496d0a0eb46b0b0dcd753a5fe164f. * 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 --- src/core_plugins/kibana/index.js | 2 + .../management/sections/objects/_objects.html | 209 +----- .../management/sections/objects/_objects.js | 389 ++-------- .../sections/objects/change_index_modal.js | 214 ------ .../__snapshots__/objects_table.test.js.snap | 215 ++++++ .../__tests__/objects_table.test.js | 412 +++++++++++ .../__snapshots__/flyout.test.js.snap | 240 +++++++ .../flyout/__tests__/flyout.test.js | 284 ++++++++ .../objects_table/components/flyout/flyout.js | 515 ++++++++++++++ .../objects_table/components/flyout/index.js | 1 + .../__snapshots__/header.test.js.snap | 113 +++ .../header/__tests__/header.test.js | 23 + .../objects_table/components/header/header.js | 74 ++ .../objects_table/components/header/index.js | 1 + .../__snapshots__/relationships.test.js.snap | 665 ++++++++++++++++++ .../__tests__/relationships.test.js | 207 ++++++ .../components/relationships/index.js | 1 + .../components/relationships/relationships.js | 237 +++++++ .../__snapshots__/table.test.js.snap | 126 ++++ .../components/table/__tests__/table.test.js | 56 ++ .../objects_table/components/table/index.js | 1 + .../objects_table/components/table/table.js | 181 +++++ .../objects/components/objects_table/index.js | 1 + .../components/objects_table/objects_table.js | 483 +++++++++++++ .../lib/__tests__/get_in_app_url.test.js | 34 + .../lib/__tests__/get_relationships.test.js | 41 ++ .../__tests__/get_saved_object_icon.test.js | 28 + .../objects/lib/__tests__/import_file.test.js | 44 ++ .../objects/lib/__tests__/parse_query.test.js | 11 + .../__tests__/resolve_saved_objects.test.js | 304 ++++++++ .../retrieve_and_export_docs.test.js | 89 +++ .../lib/__tests__/save_to_file.test.js | 19 + .../lib/__tests__/scan_all_types.test.js | 17 + .../sections/objects/lib/get_in_app_url.js | 19 + .../sections/objects/lib/get_relationships.js | 23 + .../objects/lib/get_saved_object_counts.js | 7 + .../objects/lib/get_saved_object_icon.js | 19 + .../objects/lib/get_saved_object_label.js | 10 + .../sections/objects/lib/import_file.js | 13 + .../management/sections/objects/lib/index.js | 11 + .../sections/objects/lib/is_same_query.js | 13 + .../sections/objects/lib/parse_query.js | 21 + .../objects/lib/resolve_saved_objects.js | 200 ++++++ .../objects/lib/retrieve_and_export_docs.js | 14 + .../sections/objects/lib/save_to_file.js | 6 + .../sections/objects/lib/scan_all_types.js | 7 + .../objects/show_change_index_modal.js | 29 - .../server/lib/__tests__/relationships.js | 341 +++++++++ .../management/saved_objects/relationships.js | 216 ++++++ .../server/routes/api/management/index.js | 8 + .../management/saved_objects/relationships.js | 46 ++ .../api/management/saved_objects/scroll.js | 151 ++++ .../client/lib/search_dsl/query_params.js | 33 +- .../client/lib/search_dsl/search_dsl.js | 5 +- .../client/lib/search_dsl/search_dsl.test.js | 4 +- .../client/lib/search_dsl/sorting_params.js | 6 +- .../client/saved_objects_client.js | 2 + .../client/saved_objects_client.test.js | 3 +- src/server/saved_objects/routes/find.js | 2 + .../saved_object/saved_object_loader.js | 5 +- src/ui/public/index_patterns/_get.js | 8 +- .../public/index_patterns/_index_pattern.js | 89 ++- .../public/index_patterns/_pattern_cache.js | 8 + .../public/index_patterns/index_patterns.js | 1 + test/api_integration/apis/index.js | 1 + test/api_integration/apis/management/index.js | 5 + .../apis/management/saved_objects/index.js | 5 + .../management/saved_objects/relationships.js | 99 +++ .../management/saved_objects/data.json.gz | Bin 0 -> 1389 bytes .../management/saved_objects/mappings.json | 285 ++++++++ .../apps/management/_import_objects.js | 128 ++-- .../_import_objects_with_index_patterns.json | 31 + test/functional/page_objects/settings_page.js | 57 +- test/functional/services/find.js | 14 + 74 files changed, 6291 insertions(+), 891 deletions(-) delete mode 100644 src/core_plugins/kibana/public/management/sections/objects/change_index_modal.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/components/objects_table/__tests__/__snapshots__/objects_table.test.js.snap create mode 100644 src/core_plugins/kibana/public/management/sections/objects/components/objects_table/__tests__/objects_table.test.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/__tests__/__snapshots__/flyout.test.js.snap create mode 100644 src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/__tests__/flyout.test.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/flyout.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/index.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/__tests__/__snapshots__/header.test.js.snap create mode 100644 src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/__tests__/header.test.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/header.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/index.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__tests__/__snapshots__/relationships.test.js.snap create mode 100644 src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__tests__/relationships.test.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/index.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/relationships.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/__tests__/__snapshots__/table.test.js.snap create mode 100644 src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/__tests__/table.test.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/index.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/table.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/components/objects_table/index.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/components/objects_table/objects_table.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/get_in_app_url.test.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/get_relationships.test.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/get_saved_object_icon.test.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/import_file.test.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/parse_query.test.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/resolve_saved_objects.test.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/retrieve_and_export_docs.test.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/save_to_file.test.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/scan_all_types.test.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/lib/get_in_app_url.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/lib/get_relationships.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/lib/get_saved_object_counts.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/lib/get_saved_object_icon.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/lib/get_saved_object_label.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/lib/import_file.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/lib/index.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/lib/is_same_query.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/lib/parse_query.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/lib/resolve_saved_objects.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/lib/retrieve_and_export_docs.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/lib/save_to_file.js create mode 100644 src/core_plugins/kibana/public/management/sections/objects/lib/scan_all_types.js delete mode 100644 src/core_plugins/kibana/public/management/sections/objects/show_change_index_modal.js create mode 100644 src/core_plugins/kibana/server/lib/__tests__/relationships.js create mode 100644 src/core_plugins/kibana/server/lib/management/saved_objects/relationships.js create mode 100644 src/core_plugins/kibana/server/routes/api/management/index.js create mode 100644 src/core_plugins/kibana/server/routes/api/management/saved_objects/relationships.js create mode 100644 src/core_plugins/kibana/server/routes/api/management/saved_objects/scroll.js create mode 100644 test/api_integration/apis/management/index.js create mode 100644 test/api_integration/apis/management/saved_objects/index.js create mode 100644 test/api_integration/apis/management/saved_objects/relationships.js create mode 100644 test/api_integration/fixtures/es_archiver/management/saved_objects/data.json.gz create mode 100644 test/api_integration/fixtures/es_archiver/management/saved_objects/mappings.json create mode 100644 test/functional/apps/management/exports/_import_objects_with_index_patterns.json diff --git a/src/core_plugins/kibana/index.js b/src/core_plugins/kibana/index.js index 9d4d1588598c3..dac1f99e1273d 100644 --- a/src/core_plugins/kibana/index.js +++ b/src/core_plugins/kibana/index.js @@ -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); diff --git a/src/core_plugins/kibana/public/management/sections/objects/_objects.html b/src/core_plugins/kibana/public/management/sections/objects/_objects.html index 2270e5c83f151..b8c4e99b53a11 100644 --- a/src/core_plugins/kibana/public/management/sections/objects/_objects.html +++ b/src/core_plugins/kibana/public/management/sections/objects/_objects.html @@ -1,212 +1,5 @@ - -
-
-

- Edit Saved Objects -

-
- -
- - - - - -
-
- - -
-

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

-
- -
- - -
-
- -
-
- - -
- -
-
- -
- -
- - - - - -
- -
- -
-
- - -
-
- No {{service.title}} matched your search. -
-
- - - - - - - - - - - - - - - - - - -
-
- -
-
-
- Title -
-
-
- -
-
- -
- - -
-
-
- {{ selectedItems.length }} selected -
-
-
- -
-
-
+
diff --git a/src/core_plugins/kibana/public/management/sections/objects/_objects.js b/src/core_plugins/kibana/public/management/sections/objects/_objects.js index b8c511db9245f..c203e0beebf17 100644 --- a/src/core_plugins/kibana/public/management/sections/objects/_objects.js +++ b/src/core_plugins/kibana/public/management/sections/objects/_objects.js @@ -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'; - -const indexPatternsResolutions = { - indexPatterns: function (Private) { - const savedObjectsClient = Private(SavedObjectsClientProvider); - - return savedObjectsClient.find({ - type: 'index-pattern', - fields: ['title'], - perPage: 10000 - }).then(response => response.savedObjects); - } -}; +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 REACT_OBJECTS_TABLE_DOM_ELEMENT_ID = 'reactSavedObjectsTable'; + +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( + { + 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; + } -uiRoutes - .when('/management/kibana/objects', { - template: objectIndexHTML, - resolve: indexPatternsResolutions + 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/: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); } }; }); diff --git a/src/core_plugins/kibana/public/management/sections/objects/change_index_modal.js b/src/core_plugins/kibana/public/management/sections/objects/change_index_modal.js deleted file mode 100644 index 638ffac9a3f46..0000000000000 --- a/src/core_plugins/kibana/public/management/sections/objects/change_index_modal.js +++ /dev/null @@ -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) => {obj.name}
); - - return ( - - - {indexPatternId} - - - {objects.length} - - - {sample} - - - - - - ); - }); - - const TableComponent = () => ( - - - - ID - - - Count - - - Sample of affected objects - - - New index pattern - - - - {rows} - - - ); - - return ( - - - - - Index Pattern Conflicts - - - -

- The following saved objects use index patterns that do not exist. - Please select the index patterns you'd like re-associated them with. -

- { totalIndexPatterns > perPage - ? ( - - - - = 1} - hasNextPage={page < totalIndexPatterns} - endNumber={Math.min(totalIndexPatterns, page + perPage)} - totalItems={totalIndexPatterns} - onNextPage={() => this.setState({ page: page + 1 })} - onPreviousPage={() => this.setState({ page: page - 1 })} - /> - - - - - ) : ( - - ) - } -
- - - - Cancel - - - Confirm all changes - - -
-
- ); - } -} - -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 -}; diff --git a/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/__tests__/__snapshots__/objects_table.test.js.snap b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/__tests__/__snapshots__/objects_table.test.js.snap new file mode 100644 index 0000000000000..40ff5fcd855c3 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/__tests__/__snapshots__/objects_table.test.js.snap @@ -0,0 +1,215 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ObjectsTable delete should show a confirm modal 1`] = ` + +

+ This action will delete the following saved objects: +

+ +
+`; + +exports[`ObjectsTable export should allow the user to choose when exporting all 1`] = ` + +

+ Select which types to export. The number in parentheses indicates how many of this type are available to export. +

+ +
+`; + +exports[`ObjectsTable import should show the flyout 1`] = ` + +`; + +exports[`ObjectsTable relationships should show the flyout 1`] = ` + +`; + +exports[`ObjectsTable should render normally 1`] = ` +
+
+ + + +`; diff --git a/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/__tests__/objects_table.test.js b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/__tests__/objects_table.test.js new file mode 100644 index 0000000000000..5603f812135c4 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/__tests__/objects_table.test.js @@ -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( + + ); + + // 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( + + ); + + // 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( + + ); + + // 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( + + ); + + // 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( + + ); + + // 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( + + ); + + // 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( + + ); + + // 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( + + ); + + // 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( + + ); + + // 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( + + ); + + 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( + + ); + + // 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); + }); + }); +}); diff --git a/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/__tests__/__snapshots__/flyout.test.js.snap b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/__tests__/__snapshots__/flyout.test.js.snap new file mode 100644 index 0000000000000..c9cf66b939fa9 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/__tests__/__snapshots__/flyout.test.js.snap @@ -0,0 +1,240 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Flyout conflicts should allow conflict resolution 1`] = ` + + + +

+ Import saved objects +

+
+ + + +

+ 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 + + + create a new index pattern + + + if necessary. +

+
+
+
+ + + + + + + + Cancel + + + + + Confirm all changes + + + + +
+`; + +exports[`Flyout conflicts should handle errors 1`] = ` + +

+ foobar +

+
+`; + +exports[`Flyout should render import step 1`] = ` + + + +

+ Import saved objects +

+
+
+ + + + + + + + + + + + + + + Cancel + + + + + Import + + + + +
+`; diff --git a/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/__tests__/flyout.test.js b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/__tests__/flyout.test.js new file mode 100644 index 0000000000000..31bcea153613a --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/__tests__/flyout.test.js @@ -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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + }); + }); +}); diff --git a/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/flyout.js b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/flyout.js new file mode 100644 index 0000000000000..541ca5170c8e3 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/flyout.js @@ -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 {list.length}; + }, + }, + { + field: 'list', + name: 'Sample of affected objects', + description: `Sample of affected objects`, + render: list => { + return ( +
    + {take(list, 3).map((obj, key) =>
  • {obj.name}
  • )} +
+ ); + }, + }, + { + 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 ( + this.onIndexChanged(id, e)} + options={options} + /> + ); + }, + }, + ]; + + const pagination = { + pageSizeOptions: [5, 10, 25], + }; + + return ( + + ); + } + + renderError() { + const { error } = this.state; + + if (!error) { + return null; + } + + return ( + + +

{error}

+
+ +
+ ); + } + + renderBody() { + const { + isLoading, + loadingMessage, + isOverwriteAllChecked, + wasImportSuccessful, + importCount, + } = this.state; + + if (isLoading) { + return ( + + + + + +

{loadingMessage}

+
+
+
+ ); + } + + if (wasImportSuccessful) { + return ( + +

Successfully imported {importCount} objects.

+
+ ); + } + + if (this.hasConflicts) { + return this.renderConflicts(); + } + + return ( + + + + + + + + + ); + } + + renderFooter() { + const { isLoading, wasImportSuccessful } = this.state; + const { done, close } = this.props; + + let confirmButton; + + if (wasImportSuccessful) { + confirmButton = ( + + Done + + ); + } else if (this.hasConflicts) { + confirmButton = ( + + Confirm all changes + + ); + } else { + confirmButton = ( + + Import + + ); + } + + return ( + + + + Cancel + + + {confirmButton} + + ); + } + + renderSubheader() { + if ( + !this.hasConflicts || + this.state.isLoading || + this.state.wasImportSuccessful + ) { + return null; + } + + return ( + + + +

+ 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{' '} + { + + create a new index pattern + + }{' '} + if necessary. +

+
+
+ ); + } + + render() { + const { close } = this.props; + + return ( + + + +

Import saved objects

+
+ {this.renderSubheader()} +
+ + + {this.renderError()} + {this.renderBody()} + + + {this.renderFooter()} +
+ ); + } +} diff --git a/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/index.js b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/index.js new file mode 100644 index 0000000000000..629a85e9a6e68 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/index.js @@ -0,0 +1 @@ +export { Flyout } from './flyout'; diff --git a/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/__tests__/__snapshots__/header.test.js.snap b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/__tests__/__snapshots__/header.test.js.snap new file mode 100644 index 0000000000000..7564cd1fbce99 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/__tests__/__snapshots__/header.test.js.snap @@ -0,0 +1,113 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Header should render normally 1`] = ` + + + + + +

+ Edit Saved Objects (Found + 4 + ) +

+
+
+ + + + + Export Everything + + + + + Import + + + + + Refresh + + + + +
+ + +

+ + 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. + +

+
+ +
+`; diff --git a/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/__tests__/header.test.js b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/__tests__/header.test.js new file mode 100644 index 0000000000000..16814430eaa77 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/__tests__/header.test.js @@ -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( +
+ ); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/header.js b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/header.js new file mode 100644 index 0000000000000..a9145216b7d46 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/header.js @@ -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, +}) => ( + + + + + +

Edit Saved Objects (Found {totalCount})

+
+
+ + + + + Export Everything + + + + + Import + + + + + Refresh + + + + +
+ + +

+ + 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. + +

+
+ +
+); diff --git a/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/index.js b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/index.js new file mode 100644 index 0000000000000..ddd9723152366 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/index.js @@ -0,0 +1 @@ +export { Header } from './header'; diff --git a/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__tests__/__snapshots__/relationships.test.js.snap b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__tests__/__snapshots__/relationships.test.js.snap new file mode 100644 index 0000000000000..fa6abf9e0baeb --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__tests__/__snapshots__/relationships.test.js.snap @@ -0,0 +1,665 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Relationships should render dashboards normally 1`] = ` + + + +

+ + + +    + MyDashboard +

+
+
+ + + + + +

+ Here are some visualizations used on this dashboard. You can + safely delete this dashboard and the visualizations will still + work properly. +

+
+
+ +
+
+
+ + + + + Close + + + + +
+`; + +exports[`Relationships should render errors 1`] = ` + + + +

+ + + +    + MyDashboard +

+
+
+ + + foo + + + + + + + Close + + + + +
+`; + +exports[`Relationships should render index patterns normally 1`] = ` + + + +

+ + + +    + MyIndexPattern* +

+
+
+ + + + + +

+ Here are some saved searches that use this index pattern. If + you delete this index pattern, these saved searches will not + longer work properly. +

+
+
+ +
+ + + +

+ Here are some visualizations that use this index pattern. If + you delete this index pattern, these visualizations will not + longer work properly. +

+
+
+ +
+
+
+ + + + + Close + + + + +
+`; + +exports[`Relationships should render searches normally 1`] = ` + + + +

+ + + +    + MySearch +

+
+
+ + + + + +

+ Here is the index pattern tied to this saved search. +

+
+
+ +
+ + + +

+ Here are some visualizations that use this saved search. If + you delete this saved search, these visualizations will not + longer work properly. +

+
+
+ +
+
+
+ + + + + Close + + + + +
+`; + +exports[`Relationships should render visualizations normally 1`] = ` + + + +

+ + + +    + MyViz +

+
+
+ + + + + +

+ Here are some dashboards which contain this visualization. If + you delete this visualization, these dashboards will no longer + show them. +

+
+
+ +
+
+
+ + + + + Close + + + + +
+`; diff --git a/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__tests__/relationships.test.js b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__tests__/relationships.test.js new file mode 100644 index 0000000000000..b1f5b8271382e --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__tests__/relationships.test.js @@ -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( + + ); + + // 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( + + ); + + // 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( + + ); + + // 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( + + ); + + // 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( + + ); + + // 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(); + }); +}); diff --git a/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/index.js b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/index.js new file mode 100644 index 0000000000000..6e96d820c7b28 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/index.js @@ -0,0 +1 @@ +export { Relationships } from './relationships'; diff --git a/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/relationships.js b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/relationships.js new file mode 100644 index 0000000000000..a33b8a008113d --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/relationships.js @@ -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 ( + + {error} + + ); + } + + renderRelationships() { + const { getEditUrl, goInApp } = this.props; + const { relationships, isLoading, error } = this.state; + + if (error) { + return this.renderError(); + } + + if (isLoading) { + return ; + } + + const items = []; + + for (const [type, list] of Object.entries(relationships)) { + if (list.length === 0) { + items.push( + + No {type} found. + + ); + } 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( + + + +

{calloutText}

+
+
+ ( + + + + ), + }, + { + name: 'Title', + field: 'title', + render: (title, item) => ( + + {title} + + ), + }, + { + name: 'Actions', + actions: [ + { + name: 'In app', + description: 'View this saved object within Kibana', + icon: 'eye', + onClick: object => goInApp(object.id, type), + }, + ], + }, + ]} + pagination={true} + /> +
+ ); + } + } + + return {items}; + } + + render() { + const { close, title, type } = this.props; + + return ( + + + +

+ + + +    + {title} +

+
+
+ + {this.renderRelationships()} + + + + + + Close + + + + +
+ ); + } +} diff --git a/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/__tests__/__snapshots__/table.test.js.snap b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/__tests__/__snapshots__/table.test.js.snap new file mode 100644 index 0000000000000..749dd8f651605 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/__tests__/__snapshots__/table.test.js.snap @@ -0,0 +1,126 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Table should render normally 1`] = ` + + + Delete + , + + Export + , + ] + } + /> + +
+ +
+
+`; diff --git a/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/__tests__/table.test.js b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/__tests__/table.test.js new file mode 100644 index 0000000000000..f71f193105f91 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/__tests__/table.test.js @@ -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( +
+ ); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/index.js b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/index.js new file mode 100644 index 0000000000000..48232283cba67 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/index.js @@ -0,0 +1 @@ +export { Table } from './table'; diff --git a/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/table.js b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/table.js new file mode 100644 index 0000000000000..862bf4a56c400 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/table.js @@ -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 ( + + + + ); + }, + }, + { + field: 'title', + name: 'Title', + description: `Title of the saved object`, + dataType: 'string', + sortable: false, + render: (title, object) => ( + {title} + ), + }, + { + 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 ( + + + Delete + , + + Export + , + ]} + /> + +
+ +
+
+ ); + } +} diff --git a/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/index.js b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/index.js new file mode 100644 index 0000000000000..5efa883bc563e --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/index.js @@ -0,0 +1 @@ +export { ObjectsTable } from './objects_table'; diff --git a/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/objects_table.js b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/objects_table.js new file mode 100644 index 0000000000000..090ced0b83a37 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/objects_table.js @@ -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 ( + + ); + } + + renderRelationships() { + if (!this.state.isShowingRelationships) { + return null; + } + + return ( + + ); + } + + renderDeleteConfirmModal() { + if (!this.state.isShowingDeleteConfirmModal) { + return null; + } + + return ( + + this.setState({ isShowingDeleteConfirmModal: false })} + onConfirm={this.delete} + cancelButtonText="Cancel" + confirmButtonText={this.state.isDeleting ? 'Deleting...' : 'Delete'} + defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + > +

This action will delete the following saved objects:

+ ( + + + + ), + }, + { + field: 'id', + name: 'Id/Name', + }, + ]} + pagination={true} + sorting={false} + /> +
+
+ ); + } + + renderExportAllOptionsModal() { + if (!this.state.isShowingExportAllOptionsModal) { + return null; + } + + return ( + + + this.setState({ isShowingExportAllOptionsModal: false }) + } + onConfirm={this.onExportAll} + cancelButtonText="Cancel" + confirmButtonText="Export All" + defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + > +

+ Select which types to export. The number in parentheses indicates + how many of this type are available to export. +

+ { + const exportAllSelectedOptions = { + ...this.state.exportAllSelectedOptions, + ...{ + [optionId]: !this.state.exportAllSelectedOptions[optionId], + }, + }; + + this.setState({ + exportAllSelectedOptions: exportAllSelectedOptions, + }); + }} + /> +
+
+ ); + } + + 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 ( +
+ {this.renderFlyout()} + {this.renderRelationships()} + {this.renderDeleteConfirmModal()} + {this.renderExportAllOptionsModal()} +
+ this.setState({ isShowingExportAllOptionsModal: true }) + } + onImport={this.showImportFlyout} + onRefresh={this.refreshData} + totalCount={totalItemCount} + /> + +
+ + ); + } +} diff --git a/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/get_in_app_url.test.js b/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/get_in_app_url.test.js new file mode 100644 index 0000000000000..3741bbe01df47 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/get_in_app_url.test.js @@ -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'); + }); +}); diff --git a/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/get_relationships.test.js b/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/get_relationships.test.js new file mode 100644 index 0000000000000..9d8dad7914546 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/get_relationships.test.js @@ -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(); + } + }); +}); diff --git a/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/get_saved_object_icon.test.js b/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/get_saved_object_icon.test.js new file mode 100644 index 0000000000000..2c0785ce4b426 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/get_saved_object_icon.test.js @@ -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'); + }); +}); diff --git a/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/import_file.test.js b/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/import_file.test.js new file mode 100644 index 0000000000000..a003ed292b9fb --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/import_file.test.js @@ -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(); + } + }); +}); diff --git a/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/parse_query.test.js b/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/parse_query.test.js new file mode 100644 index 0000000000000..061a22660ac98 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/parse_query.test.js @@ -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' }); + }); +}); diff --git a/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/resolve_saved_objects.test.js b/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/resolve_saved_objects.test.js new file mode 100644 index 0000000000000..6ff09f8787e86 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/resolve_saved_objects.test.js @@ -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 }); + }); + }); +}); diff --git a/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/retrieve_and_export_docs.test.js b/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/retrieve_and_export_docs.test.js new file mode 100644 index 0000000000000..f6131cdd7aa2f --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/retrieve_and_export_docs.test.js @@ -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 + ) + ); + }); +}); diff --git a/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/save_to_file.test.js b/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/save_to_file.test.js new file mode 100644 index 0000000000000..18cd5c3f21fba --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/save_to_file.test.js @@ -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); + }); +}); diff --git a/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/scan_all_types.test.js b/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/scan_all_types.test.js new file mode 100644 index 0000000000000..885cb35ff785e --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/lib/__tests__/scan_all_types.test.js @@ -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 }); + }); +}); diff --git a/src/core_plugins/kibana/public/management/sections/objects/lib/get_in_app_url.js b/src/core_plugins/kibana/public/management/sections/objects/lib/get_in_app_url.js new file mode 100644 index 0000000000000..e41992cdd7a1d --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/lib/get_in_app_url.js @@ -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}`; + } +} diff --git a/src/core_plugins/kibana/public/management/sections/objects/lib/get_relationships.js b/src/core_plugins/kibana/public/management/sections/objects/lib/get_relationships.js new file mode 100644 index 0000000000000..aee7e36f641a5 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/lib/get_relationships.js @@ -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; + } +} diff --git a/src/core_plugins/kibana/public/management/sections/objects/lib/get_saved_object_counts.js b/src/core_plugins/kibana/public/management/sections/objects/lib/get_saved_object_counts.js new file mode 100644 index 0000000000000..a4fb0df7c8721 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/lib/get_saved_object_counts.js @@ -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; +} diff --git a/src/core_plugins/kibana/public/management/sections/objects/lib/get_saved_object_icon.js b/src/core_plugins/kibana/public/management/sections/objects/lib/get_saved_object_icon.js new file mode 100644 index 0000000000000..304868a73dd4f --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/lib/get_saved_object_icon.js @@ -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'; + } +} diff --git a/src/core_plugins/kibana/public/management/sections/objects/lib/get_saved_object_label.js b/src/core_plugins/kibana/public/management/sections/objects/lib/get_saved_object_label.js new file mode 100644 index 0000000000000..6d1aa6a3f952c --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/lib/get_saved_object_label.js @@ -0,0 +1,10 @@ +export function getSavedObjectLabel(type) { + switch (type) { + case 'index-pattern': + case 'index-patterns': + case 'indexPatterns': + return 'index patterns'; + default: + return type; + } +} diff --git a/src/core_plugins/kibana/public/management/sections/objects/lib/import_file.js b/src/core_plugins/kibana/public/management/sections/objects/lib/import_file.js new file mode 100644 index 0000000000000..3427ced91005a --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/lib/import_file.js @@ -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); + }); +} diff --git a/src/core_plugins/kibana/public/management/sections/objects/lib/index.js b/src/core_plugins/kibana/public/management/sections/objects/lib/index.js new file mode 100644 index 0000000000000..9f2a08795f2d9 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/lib/index.js @@ -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'; diff --git a/src/core_plugins/kibana/public/management/sections/objects/lib/is_same_query.js b/src/core_plugins/kibana/public/management/sections/objects/lib/is_same_query.js new file mode 100644 index 0000000000000..1bdf0d2f26697 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/lib/is_same_query.js @@ -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; +}; diff --git a/src/core_plugins/kibana/public/management/sections/objects/lib/parse_query.js b/src/core_plugins/kibana/public/management/sections/objects/lib/parse_query.js new file mode 100644 index 0000000000000..d89f3383455b2 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/lib/parse_query.js @@ -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, + }; +} diff --git a/src/core_plugins/kibana/public/management/sections/objects/lib/resolve_saved_objects.js b/src/core_plugins/kibana/public/management/sections/objects/lib/resolve_saved_objects.js new file mode 100644 index 0000000000000..b04b6fba802c2 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/lib/resolve_saved_objects.js @@ -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, + }; +} diff --git a/src/core_plugins/kibana/public/management/sections/objects/lib/retrieve_and_export_docs.js b/src/core_plugins/kibana/public/management/sections/objects/lib/retrieve_and_export_docs.js new file mode 100644 index 0000000000000..e29fe5457bf62 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/lib/retrieve_and_export_docs.js @@ -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)); +} diff --git a/src/core_plugins/kibana/public/management/sections/objects/lib/save_to_file.js b/src/core_plugins/kibana/public/management/sections/objects/lib/save_to_file.js new file mode 100644 index 0000000000000..03524e7940c98 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/lib/save_to_file.js @@ -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'); +} diff --git a/src/core_plugins/kibana/public/management/sections/objects/lib/scan_all_types.js b/src/core_plugins/kibana/public/management/sections/objects/lib/scan_all_types.js new file mode 100644 index 0000000000000..cc5e55c8cbf46 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/objects/lib/scan_all_types.js @@ -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; +} diff --git a/src/core_plugins/kibana/public/management/sections/objects/show_change_index_modal.js b/src/core_plugins/kibana/public/management/sections/objects/show_change_index_modal.js deleted file mode 100644 index 4f6f08347f7fa..0000000000000 --- a/src/core_plugins/kibana/public/management/sections/objects/show_change_index_modal.js +++ /dev/null @@ -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 = ( - - ); - - ReactDOM.render(element, container); -} diff --git a/src/core_plugins/kibana/server/lib/__tests__/relationships.js b/src/core_plugins/kibana/server/lib/__tests__/relationships.js new file mode 100644 index 0000000000000..a1988112a33ac --- /dev/null +++ b/src/core_plugins/kibana/server/lib/__tests__/relationships.js @@ -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({}); + }); +}); diff --git a/src/core_plugins/kibana/server/lib/management/saved_objects/relationships.js b/src/core_plugins/kibana/server/lib/management/saved_objects/relationships.js new file mode 100644 index 0000000000000..0391055db34b2 --- /dev/null +++ b/src/core_plugins/kibana/server/lib/management/saved_objects/relationships.js @@ -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 {}; +} diff --git a/src/core_plugins/kibana/server/routes/api/management/index.js b/src/core_plugins/kibana/server/routes/api/management/index.js new file mode 100644 index 0000000000000..f292b0090c006 --- /dev/null +++ b/src/core_plugins/kibana/server/routes/api/management/index.js @@ -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); +} diff --git a/src/core_plugins/kibana/server/routes/api/management/saved_objects/relationships.js b/src/core_plugins/kibana/server/routes/api/management/saved_objects/relationships.js new file mode 100644 index 0000000000000..8fc23b79a8109 --- /dev/null +++ b/src/core_plugins/kibana/server/routes/api/management/saved_objects/relationships.js @@ -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 })); + } + } + }); +} diff --git a/src/core_plugins/kibana/server/routes/api/management/saved_objects/scroll.js b/src/core_plugins/kibana/server/routes/api/management/saved_objects/scroll.js new file mode 100644 index 0000000000000..f10bd5be4d290 --- /dev/null +++ b/src/core_plugins/kibana/server/routes/api/management/saved_objects/scroll.js @@ -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 })); + } + } + }); +} diff --git a/src/server/saved_objects/client/lib/search_dsl/query_params.js b/src/server/saved_objects/client/lib/search_dsl/query_params.js index 3b29aa41b6d22..c77843233ea54 100644 --- a/src/server/saved_objects/client/lib/search_dsl/query_params.js +++ b/src/server/saved_objects/client/lib/search_dsl/query_params.js @@ -29,8 +29,22 @@ function getFieldsForTypes(searchFields, types) { * @param {Array} 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)), ) } } diff --git a/src/server/saved_objects/client/lib/search_dsl/search_dsl.js b/src/server/saved_objects/client/lib/search_dsl/search_dsl.js index a76f91eb11b0b..69c127336d5a9 100644 --- a/src/server/saved_objects/client/lib/search_dsl/search_dsl.js +++ b/src/server/saved_objects/client/lib/search_dsl/search_dsl.js @@ -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), }; } diff --git a/src/server/saved_objects/client/lib/search_dsl/search_dsl.test.js b/src/server/saved_objects/client/lib/search_dsl/search_dsl.test.js index 4a147aa7873a9..35078f43ba502 100644 --- a/src/server/saved_objects/client/lib/search_dsl/search_dsl.test.js +++ b/src/server/saved_objects/client/lib/search_dsl/search_dsl.test.js @@ -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, ); }); diff --git a/src/server/saved_objects/client/lib/search_dsl/sorting_params.js b/src/server/saved_objects/client/lib/search_dsl/sorting_params.js index 0ccda5d230fa8..a76ddd769a23e 100644 --- a/src/server/saved_objects/client/lib/search_dsl/sorting_params.js +++ b/src/server/saved_objects/client/lib/search_dsl/sorting_params.js @@ -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 } diff --git a/src/server/saved_objects/client/saved_objects_client.js b/src/server/saved_objects/client/saved_objects_client.js index 79dc55540e039..31569f17a8fca 100644 --- a/src/server/saved_objects/client/saved_objects_client.js +++ b/src/server/saved_objects/client/saved_objects_client.js @@ -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 }) diff --git a/src/server/saved_objects/client/saved_objects_client.test.js b/src/server/saved_objects/client/saved_objects_client.test.js index 741acc2e9f284..6162b1ee8e521 100644 --- a/src/server/saved_objects/client/saved_objects_client.test.js +++ b/src/server/saved_objects/client/saved_objects_client.test.js @@ -371,7 +371,8 @@ describe('SavedObjectsClient', () => { searchFields: ['foo'], type: 'bar', sortField: 'name', - sortOrder: 'desc' + sortOrder: 'desc', + includeTypes: ['index-pattern', 'dashboard'], }; await savedObjectsClient.find(relevantOpts); diff --git a/src/server/saved_objects/routes/find.js b/src/server/saved_objects/routes/find.js index 0e81d621d017b..4c76a938ad347 100644 --- a/src/server/saved_objects/routes/find.js +++ b/src/server/saved_objects/routes/find.js @@ -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() }, diff --git a/src/ui/public/courier/saved_object/saved_object_loader.js b/src/ui/public/courier/saved_object/saved_object_loader.js index cd66269523899..f2436b89ca81a 100644 --- a/src/ui/public/courier/saved_object/saved_object_loader.js +++ b/src/ui/public/courier/saved_object/saved_object_loader.js @@ -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, diff --git a/src/ui/public/index_patterns/_get.js b/src/ui/public/index_patterns/_get.js index 1d362d2638242..b842127ca9906 100644 --- a/src/ui/public/index_patterns/_get.js +++ b/src/ui/public/index_patterns/_get.js @@ -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; } diff --git a/src/ui/public/index_patterns/_index_pattern.js b/src/ui/public/index_patterns/_index_pattern.js index c09a8c0496c20..ff38d14c15246 100644 --- a/src/ui/public/index_patterns/_index_pattern.js +++ b/src/ui/public/index_patterns/_index_pattern.js @@ -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?'; - - 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 - ); - }); - }); + const potentialDuplicateByTitle = await findObjectByTitle(savedObjectsClient, type, this.title); + // If there is potentialy duplicate title, just create it + if (!potentialDuplicateByTitle) { + return await _create(); + } + + // 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() { diff --git a/src/ui/public/index_patterns/_pattern_cache.js b/src/ui/public/index_patterns/_pattern_cache.js index 89dda350c2f49..f358e9e10e4ca 100644 --- a/src/ui/public/index_patterns/_pattern_cache.js +++ b/src/ui/public/index_patterns/_pattern_cache.js @@ -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]; + } + } + }; } diff --git a/src/ui/public/index_patterns/index_patterns.js b/src/ui/public/index_patterns/index_patterns.js index e982ca39a662e..1e2e3ca3ad926 100644 --- a/src/ui/public/index_patterns/index_patterns.js +++ b/src/ui/public/index_patterns/index_patterns.js @@ -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; diff --git a/test/api_integration/apis/index.js b/test/api_integration/apis/index.js index f4ded568b9d80..33ac45c337eb4 100644 --- a/test/api_integration/apis/index.js +++ b/test/api_integration/apis/index.js @@ -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')); diff --git a/test/api_integration/apis/management/index.js b/test/api_integration/apis/management/index.js new file mode 100644 index 0000000000000..9cbc580207372 --- /dev/null +++ b/test/api_integration/apis/management/index.js @@ -0,0 +1,5 @@ +export default function ({ loadTestFile }) { + describe('management apis', () => { + loadTestFile(require.resolve('./saved_objects')); + }); +} diff --git a/test/api_integration/apis/management/saved_objects/index.js b/test/api_integration/apis/management/saved_objects/index.js new file mode 100644 index 0000000000000..c07126d96493d --- /dev/null +++ b/test/api_integration/apis/management/saved_objects/index.js @@ -0,0 +1,5 @@ +export default function ({ loadTestFile }) { + describe('saved_objects', () => { + loadTestFile(require.resolve('./relationships')); + }); +} diff --git a/test/api_integration/apis/management/saved_objects/relationships.js b/test/api_integration/apis/management/saved_objects/relationships.js new file mode 100644 index 0000000000000..dec6a0dedff8f --- /dev/null +++ b/test/api_integration/apis/management/saved_objects/relationships.js @@ -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', + }, + ], + }); + }); + }); + }); +} diff --git a/test/api_integration/fixtures/es_archiver/management/saved_objects/data.json.gz b/test/api_integration/fixtures/es_archiver/management/saved_objects/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..3835470826ab65367692a9cfaa4e733fd4b212ab GIT binary patch literal 1389 zcmV-z1(Nz7iwFP!000026V+JVZ`(Eyf6rfGXfIn48~X5vthb^?hYW2R#6}+$2LUCX zWv;U5QB=~@(Eq+8C0mrECGvu%nI97Qc)a8Be)qeRwm8BHEDTTpT7Lt@`rIvk4l`4T`Ye*PR``HRW2F}!8dN8&7E<|46alr3~Wa#(D z(`7V-p##oxyfcvTTtu~R!%Nd>rH||Rh7t+%hDfF5+K%71y}s+sok=kA0?!`!6Zg+r z|G-Y|Nw3L7J3EKF-W(M_qGE5>aENLvCNtEs4H1Pb5^N(;U+p)wcMflM4zKoWxH^FB z*Ehded(m~W>pW9Ag`++wcxKrDJP}591f--W87J2v=L^6A0CTz(dq3V*YtL?%{$r=_`u&{0z zU0A0JeO4{Vq+n^RWf9L)i9#EnOPm2J2TPJLOVGUGSvYVTIF6q9&NPNm7@3b+lUW zcMjqnzi#VCMLL0}jU*(Ea}t--ljISk_*me=k|k2nP;hl!)R$1aB#>`emXm~iCkhJz z8M=`@@}G(4hRu$!o(7ITn0jLa)=e|*=>B5rDE8IJm^BNkv^A6Hy+VTCuha3Efo9LB z=pIEIBFH)`)4c(4QKr_>9c0DtE^zK;B)P=V)odiVf>>oy_Ev%EBiusLSjU1XO_t?$ zR)ICmlLVJ36A1PabV^iICg)s|#4sb(_YZj*l`S`4mq^2wrJSh=PxV4va&#z=s;Fp< z*`AS&99ethc@#HiK@Ad%~R#n!e50r(7FWHo;Vtl9H z56i+j?HH4mG=p-F56bNtRENp;k)W>49CDr6%qUt)#Me18&H=H1A|hsKRx{O#QMgb6 v%oU{SA2Xh&6!nYgC1g^)U_E@Feh%Rr%NGv_r^`FkQzY{bxwgd}85jTnLl&@P literal 0 HcmV?d00001 diff --git a/test/api_integration/fixtures/es_archiver/management/saved_objects/mappings.json b/test/api_integration/fixtures/es_archiver/management/saved_objects/mappings.json new file mode 100644 index 0000000000000..582e976c3cca0 --- /dev/null +++ b/test/api_integration/fixtures/es_archiver/management/saved_objects/mappings.json @@ -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" + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/test/functional/apps/management/_import_objects.js b/test/functional/apps/management/_import_objects.js index 9cb5cc6d0f52b..bfc9b8d4e66f6 100644 --- a/test/functional/apps/management/_import_objects.js +++ b/test/functional/apps/management/_import_objects.js @@ -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.header.waitUntilLoadingHasFinished(); await PageObjects.settings.setImportIndexFieldOption(2); - await PageObjects.settings.clickChangeIndexConfirmButton(); + await PageObjects.settings.clickConfirmConflicts(); 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 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(); + + const objects = await PageObjects.settings.getSavedObjectsInTable(); + expect(objects.length).to.be(2); + }); + + 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(); - await PageObjects.settings.clickVisualizationsTab(); - const vizRowCount = await retry.try(async () => { - const rows = await PageObjects.settings.getVisualizationRows(); - return rows.length; - }); - expect(vizRowCount).to.be(1); - - await PageObjects.settings.clickSearchesTab(); - const searchRowCount = await retry.try(async () => { - const rows = await PageObjects.settings.getVisualizationRows(); - return rows.length; - }); - expect(searchRowCount).to.be(1); + const objects = await PageObjects.settings.getSavedObjectsInTable(); + expect(objects.length).to.be(2); }); }); } diff --git a/test/functional/apps/management/exports/_import_objects_with_index_patterns.json b/test/functional/apps/management/exports/_import_objects_with_index_patterns.json new file mode 100644 index 0000000000000..9b90f16ad8993 --- /dev/null +++ b/test/functional/apps/management/exports/_import_objects_with_index_patterns.json @@ -0,0 +1,31 @@ +[ + { + "_id": "f1e4c910-a2e6-11e7-bb30-233be9be6a15", + "_type": "index-pattern", + "_source": { + "title": "logstash-*", + "timeFieldName": "@timestamp", + "fields": "[{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"agent\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"xss.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"headings.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:tag.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:section.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@tags.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:card.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:site_name.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"request.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"agent.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:site.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"relatedContent.og:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"index.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"response.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@message.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"links.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"extension.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"machine.os.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"host.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:type.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"spaces.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image:height.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:image:width.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"expression script\",\"type\":\"number\",\"count\":0,\"scripted\":true,\"script\":\"doc['bytes'].value\",\"lang\":\"expression\",\"indexed\":true,\"analyzed\":false,\"doc_values\":false}]" + }, + "_meta": { + "savedObjectVersion": 2 + } + }, + { + "_id": "Shared-Item-Visualization-AreaChart", + "_type": "visualization", + "_source": { + "title": "Shared-Item Visualization AreaChart", + "visState": "{\"title\":\"New Visualization\",\"type\":\"area\",\"params\":{\"shareYAxis\":true,\"addTooltip\":true,\"addLegend\":true,\"smoothLines\":false,\"scale\":\"linear\",\"interpolate\":\"linear\",\"mode\":\"stacked\",\"times\":[],\"addTimeMarker\":false,\"defaultYExtents\":false,\"setYExtents\":false,\"yAxis\":{}},\"aggs\":[{\"id\":\"1\",\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}}],\"listeners\":{}}", + "uiStateJSON": "{}", + "description": "AreaChart", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"f1e4c910-a2e6-11e7-bb30-233be9be6a15\",\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[]}" + } + }, + "_meta": { + "savedObjectVersion": 2 + } + } +] diff --git a/test/functional/page_objects/settings_page.js b/test/functional/page_objects/settings_page.js index 64b1b851993ce..4bd958de6e145 100644 --- a/test/functional/page_objects/settings_page.js +++ b/test/functional/page_objects/settings_page.js @@ -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(); diff --git a/test/functional/services/find.js b/test/functional/services/find.js index 8922faa222f91..19199621be0aa 100644 --- a/test/functional/services/find.js +++ b/test/functional/services/find.js @@ -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 () => {