Deangularize and typescriptify ui/saved_object (#51562)

This commit is contained in:
Matthias Wilhelm 2019-12-12 17:35:22 +01:00 committed by GitHub
parent 06eeb59da3
commit 85aea35c94
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 1297 additions and 838 deletions

View file

@ -41,5 +41,5 @@ export function getSavedDashboardMock(
getQuery: () => ({ query: '', language: 'kuery' }),
getFilters: () => [],
...config,
};
} as SavedObjectDashboard;
}

View file

@ -35,7 +35,7 @@ import {
State,
AppStateClass as TAppStateClass,
KbnUrl,
SaveOptions,
SavedObjectSaveOpts,
unhashUrl,
} from './legacy_imports';
import { FilterStateManager, IndexPattern } from '../../../data/public';
@ -608,7 +608,7 @@ export class DashboardAppController {
* @return {Promise}
* @resolved {String} - The id of the doc
*/
function save(saveOptions: SaveOptions): Promise<SaveResult> {
function save(saveOptions: SavedObjectSaveOpts): Promise<SaveResult> {
return saveDashboard(angular.toJson, timefilter, dashboardStateManager, saveOptions)
.then(function(id) {
if (id) {

View file

@ -30,7 +30,7 @@ export const legacyChrome = chrome;
export { State } from 'ui/state_management/state';
export { AppState } from 'ui/state_management/app_state';
export { AppStateClass } from 'ui/state_management/app_state';
export { SaveOptions } from 'ui/saved_objects/saved_object';
export { SavedObjectSaveOpts } from 'ui/saved_objects/types';
export { npSetup, npStart } from 'ui/new_platform';
export { SavedObjectRegistryProvider } from 'ui/saved_objects';
export { IPrivate } from 'ui/private';

View file

@ -18,7 +18,7 @@
*/
import { TimefilterContract } from 'src/plugins/data/public';
import { SaveOptions } from '../legacy_imports';
import { SavedObjectSaveOpts } from '../legacy_imports';
import { updateSavedDashboard } from './update_saved_dashboard';
import { DashboardStateManager } from '../dashboard_state_manager';
@ -34,7 +34,7 @@ export function saveDashboard(
toJson: (obj: any) => string,
timeFilter: TimefilterContract,
dashboardStateManager: DashboardStateManager,
saveOptions: SaveOptions
saveOptions: SavedObjectSaveOpts
): Promise<string> {
dashboardStateManager.saveState();

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { SavedObject } from 'ui/saved_objects/saved_object';
import { SavedObject } from 'ui/saved_objects/types';
import { SearchSourceContract } from '../../../../../ui/public/courier';
import { esFilters, Query, RefreshInterval } from '../../../../../../plugins/data/public';

View file

@ -22,6 +22,7 @@ import './saved_dashboard';
import { uiModules } from 'ui/modules';
import { SavedObjectLoader, SavedObjectsClientProvider } from 'ui/saved_objects';
import { savedObjectManagementRegistry } from '../../management/saved_object_registry';
import { npStart } from '../../../../../ui/public/new_platform';
const module = uiModules.get('app/dashboard');
@ -35,7 +36,7 @@ savedObjectManagementRegistry.register({
});
// This is the only thing that gets injected into controllers
module.service('savedDashboards', function (Private, SavedDashboard, kbnUrl, chrome) {
module.service('savedDashboards', function (Private, SavedDashboard) {
const savedObjectClient = Private(SavedObjectsClientProvider);
return new SavedObjectLoader(SavedDashboard, kbnUrl, chrome, savedObjectClient);
return new SavedObjectLoader(SavedDashboard, savedObjectClient, npStart.core.chrome);
});

View file

@ -32,7 +32,7 @@ describe('discoverField', function () {
let $scope;
let indexPattern;
let $elem;
beforeEach(() => pluginInstance.initializeServices(true));
beforeEach(() => pluginInstance.initializeServices());
beforeEach(() => pluginInstance.initializeInnerAngular());
beforeEach(ngMock.module('app/discover'));
beforeEach(ngMock.inject(function (Private, $rootScope, $compile) {

View file

@ -70,7 +70,7 @@ describe('discover field chooser directives', function () {
on-remove-field="removeField"
></disc-field-chooser>
`);
beforeEach(() => pluginInstance.initializeServices(true));
beforeEach(() => pluginInstance.initializeServices());
beforeEach(() => pluginInstance.initializeInnerAngular());
beforeEach(ngMock.module('app/discover', ($provide) => {

View file

@ -27,7 +27,7 @@ import { npStart } from 'ui/new_platform';
describe('context app', function () {
beforeEach(() => pluginInstance.initializeInnerAngular());
beforeEach(() => pluginInstance.initializeServices(true));
beforeEach(() => pluginInstance.initializeServices());
beforeEach(ngMock.module('app/discover'));
beforeEach(ngMock.module(function createServiceStubs($provide) {
$provide.value('indexPatterns', createIndexPatternsStub());

View file

@ -26,7 +26,7 @@ import { getQueryParameterActions } from '../actions';
describe('context app', function () {
beforeEach(() => pluginInstance.initializeInnerAngular());
beforeEach(() => pluginInstance.initializeServices(true));
beforeEach(() => pluginInstance.initializeServices());
beforeEach(ngMock.module('app/discover'));
describe('action setPredecessorCount', function () {

View file

@ -27,7 +27,7 @@ import { getQueryParameterActions } from '../actions';
describe('context app', function () {
beforeEach(() => pluginInstance.initializeInnerAngular());
beforeEach(() => pluginInstance.initializeServices(true));
beforeEach(() => pluginInstance.initializeServices());
beforeEach(ngMock.module('app/discover'));
describe('action setQueryParameters', function () {

View file

@ -27,7 +27,7 @@ import { getQueryParameterActions } from '../actions';
describe('context app', function () {
beforeEach(() => pluginInstance.initializeInnerAngular());
beforeEach(() => pluginInstance.initializeServices(true));
beforeEach(() => pluginInstance.initializeServices());
beforeEach(ngMock.module('app/discover'));
describe('action setSuccessorCount', function () {

View file

@ -37,7 +37,7 @@ describe('Doc Table', function () {
let fakeRowVals;
let stubFieldFormatConverter;
beforeEach(() => pluginInstance.initializeServices(true));
beforeEach(() => pluginInstance.initializeServices());
beforeEach(() => pluginInstance.initializeInnerAngular());
beforeEach(ngMock.module('app/discover'));
beforeEach(

View file

@ -25,14 +25,12 @@ import {
IUiSettingsClient,
} from 'kibana/public';
import * as docViewsRegistry from 'ui/registry/doc_views';
import chromeLegacy from 'ui/chrome';
import { IPrivate } from 'ui/private';
import { FilterManager, TimefilterContract, IndexPatternsContract } from 'src/plugins/data/public';
// @ts-ignore
import { createSavedSearchesService } from '../saved_searches/saved_searches';
// @ts-ignore
import { createSavedSearchFactory } from '../saved_searches/_saved_search';
import { DiscoverStartPlugins } from '../plugin';
import { DataStart } from '../../../../data/public';
import { EuiUtilsStart } from '../../../../../../plugins/eui_utils/public';
import { SavedSearch } from '../types';
import { SharePluginStart } from '../../../../../../plugins/share/public';
@ -42,6 +40,7 @@ export interface DiscoverServices {
capabilities: Capabilities;
chrome: ChromeStart;
core: CoreStart;
data: DataStart;
docLinks: DocLinksStart;
docViewsRegistry: docViewsRegistry.DocViewsRegistry;
eui_utils: EuiUtilsStart;
@ -52,35 +51,19 @@ export interface DiscoverServices {
share: SharePluginStart;
timefilter: TimefilterContract;
toastNotifications: ToastsStart;
// legacy
getSavedSearchById: (id: string) => Promise<SavedSearch>;
getSavedSearchUrlById: (id: string) => Promise<string>;
uiSettings: IUiSettingsClient;
}
export async function buildGlobalAngularServices() {
const injector = await chromeLegacy.dangerouslyGetActiveInjector();
const Private = injector.get<IPrivate>('Private');
const kbnUrl = injector.get<IPrivate>('kbnUrl');
const SavedSearchFactory = createSavedSearchFactory(Private);
const service = createSavedSearchesService(Private, SavedSearchFactory, kbnUrl, chromeLegacy);
return {
getSavedSearchById: async (id: string) => service.get(id),
getSavedSearchUrlById: async (id: string) => service.urlFor(id),
export async function buildServices(core: CoreStart, plugins: DiscoverStartPlugins) {
const services = {
savedObjectsClient: core.savedObjects.client,
indexPatterns: plugins.data.indexPatterns,
chrome: core.chrome,
overlays: core.overlays,
};
}
export async function buildServices(core: CoreStart, plugins: DiscoverStartPlugins, test: false) {
const globalAngularServices = !test
? await buildGlobalAngularServices()
: {
getSavedSearchById: async (id: string) => void id,
getSavedSearchUrlById: async (id: string) => void id,
State: null,
};
const savedObjectService = createSavedSearchesService(services);
return {
...globalAngularServices,
addBasePath: core.http.basePath.prepend,
capabilities: core.application.capabilities,
chrome: core.chrome,
@ -90,6 +73,8 @@ export async function buildServices(core: CoreStart, plugins: DiscoverStartPlugi
docViewsRegistry,
eui_utils: plugins.eui_utils,
filterManager: plugins.data.query.filterManager,
getSavedSearchById: async (id: string) => savedObjectService.get(id),
getSavedSearchUrlById: async (id: string) => savedObjectService.urlFor(id),
indexPatterns: plugins.data.indexPatterns,
inspector: plugins.inspector,
// @ts-ignore

View file

@ -106,11 +106,11 @@ export class DiscoverPlugin implements Plugin<DiscoverSetup, DiscoverStart> {
this.innerAngularInitialized = true;
};
this.initializeServices = async (test = false) => {
this.initializeServices = async () => {
if (this.servicesInitialized) {
return;
}
const services = await buildServices(core, plugins, test);
const services = await buildServices(core, plugins);
setServices(services);
this.servicesInitialized = true;
};

View file

@ -1,72 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createLegacyClass } from 'ui/utils/legacy_class';
import { SavedObjectProvider } from 'ui/saved_objects/saved_object';
import { uiModules } from 'ui/modules';
const module = uiModules.get('discover/saved_searches', []);
export function createSavedSearchFactory(Private) {
const SavedObject = Private(SavedObjectProvider);
createLegacyClass(SavedSearch).inherits(SavedObject);
function SavedSearch(id) {
SavedObject.call(this, {
type: SavedSearch.type,
mapping: SavedSearch.mapping,
searchSource: SavedSearch.searchSource,
id: id,
defaults: {
title: '',
description: '',
columns: [],
hits: 0,
sort: [],
version: 1,
},
});
this.showInRecentlyAccessed = true;
}
SavedSearch.type = 'search';
SavedSearch.mapping = {
title: 'text',
description: 'text',
hits: 'integer',
columns: 'keyword',
sort: 'keyword',
version: 'integer',
};
// Order these fields to the top, the rest are alphabetical
SavedSearch.fieldOrder = ['title', 'description'];
SavedSearch.searchSource = true;
SavedSearch.prototype.getFullPath = function () {
return `/app/kibana#/discover/${this.id}`;
};
return SavedSearch;
}
module.factory('SavedSearch', createSavedSearchFactory);

View file

@ -0,0 +1,71 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SavedObjectKibanaServices } from 'ui/saved_objects/types';
import { createSavedObjectClass } from 'ui/saved_objects/saved_object';
export function createSavedSearchClass(services: SavedObjectKibanaServices) {
const SavedObjectClass = createSavedObjectClass(services);
class SavedSearch extends SavedObjectClass {
public static type: string = 'search';
public static mapping = {
title: 'text',
description: 'text',
hits: 'integer',
columns: 'keyword',
sort: 'keyword',
version: 'integer',
};
// Order these fields to the top, the rest are alphabetical
public static fieldOrder = ['title', 'description'];
public static searchSource = true;
public id: string;
public showInRecentlyAccessed: boolean;
constructor(id: string) {
super({
id,
type: 'search',
mapping: {
title: 'text',
description: 'text',
hits: 'integer',
columns: 'keyword',
sort: 'keyword',
version: 'integer',
},
searchSource: true,
defaults: {
title: '',
description: '',
columns: [],
hits: 0,
sort: [],
version: 1,
},
});
this.showInRecentlyAccessed = true;
this.id = id;
this.getFullPath = () => `/app/kibana#/discover/${String(id)}`;
}
}
return SavedSearch;
}

View file

@ -16,12 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
import './_saved_search';
import { npStart } from 'ui/new_platform';
// @ts-ignore
import { uiModules } from 'ui/modules';
import { SavedObjectLoader, SavedObjectsClientProvider } from 'ui/saved_objects';
import { SavedObjectLoader } from 'ui/saved_objects';
import { SavedObjectKibanaServices } from 'ui/saved_objects/types';
// @ts-ignore
import { savedObjectManagementRegistry } from '../../management/saved_object_registry';
import { createSavedSearchClass } from './_saved_search';
// Register this service with the saved object registry so it can be
// edited by the object editor.
@ -30,9 +32,13 @@ savedObjectManagementRegistry.register({
title: 'searches',
});
export function createSavedSearchesService(Private, SavedSearch, kbnUrl, chrome) {
const savedObjectClient = Private(SavedObjectsClientProvider);
const savedSearchLoader = new SavedObjectLoader(SavedSearch, kbnUrl, chrome, savedObjectClient);
export function createSavedSearchesService(services: SavedObjectKibanaServices) {
const SavedSearchClass = createSavedSearchClass(services);
const savedSearchLoader = new SavedObjectLoader(
SavedSearchClass,
services.savedObjectsClient,
services.chrome
);
// Customize loader properties since adding an 's' on type doesn't work for type 'search' .
savedSearchLoader.loaderProperties = {
name: 'searches',
@ -40,11 +46,18 @@ export function createSavedSearchesService(Private, SavedSearch, kbnUrl, chrome)
nouns: 'saved searches',
};
savedSearchLoader.urlFor = (id) => {
return kbnUrl.eval('#/discover/{{id}}', { id: id });
};
savedSearchLoader.urlFor = (id: string) => `#/discover/${encodeURIComponent(id)}`;
return savedSearchLoader;
}
// this is needed for saved object management
const module = uiModules.get('discover/saved_searches');
module.service('savedSearches', createSavedSearchesService);
module.service('savedSearches', () => {
const services = {
savedObjectsClient: npStart.core.savedObjects.client,
indexPatterns: npStart.plugins.data.indexPatterns,
chrome: npStart.core.chrome,
overlays: npStart.core.overlays,
};
return createSavedSearchesService(services);
});

View file

@ -22,7 +22,7 @@ import { PersistedState } from 'ui/persisted_state';
import { Subscription } from 'rxjs';
import * as Rx from 'rxjs';
import { buildPipeline } from 'ui/visualize/loader/pipeline_helpers';
import { SavedObject } from 'ui/saved_objects/saved_object';
import { SavedObject } from 'ui/saved_objects/types';
import { Vis } from 'ui/vis';
import { queryGeohashBounds } from 'ui/visualize/loader/utils';
import { getTableAggs } from 'ui/visualize/loader/pipeline_helpers/utilities';

View file

@ -38,7 +38,7 @@ import {
uiModules
.get('app/visualize')
.factory('SavedVis', function (Promise, savedSearches, Private) {
.factory('SavedVis', function (savedSearches, Private) {
const SavedObject = Private(SavedObjectProvider);
createLegacyClass(SavedVis).inherits(SavedObject);
function SavedVis(opts) {
@ -95,18 +95,15 @@ uiModules
return `/app/kibana#${VisualizeConstants.EDIT_PATH}/${this.id}`;
};
SavedVis.prototype._afterEsResp = function () {
SavedVis.prototype._afterEsResp = async function () {
const self = this;
return self._getLinkedSavedSearch()
.then(function () {
self.searchSource.setField('size', 0);
return self.vis ? self._updateVis() : self._createVis();
});
await self._getLinkedSavedSearch();
self.searchSource.setField('size', 0);
return self.vis ? self._updateVis() : self._createVis();
};
SavedVis.prototype._getLinkedSavedSearch = Promise.method(function () {
SavedVis.prototype._getLinkedSavedSearch = async function () {
const self = this;
const linkedSearch = !!self.savedSearchId;
const current = self.savedSearch;
@ -122,13 +119,10 @@ uiModules
}
if (linkedSearch) {
return savedSearches.get(self.savedSearchId)
.then(function (savedSearch) {
self.savedSearch = savedSearch;
self.searchSource.setParent(self.savedSearch.searchSource);
});
self.savedSearch = await savedSearches.get(self.savedSearchId);
self.searchSource.setParent(self.savedSearch.searchSource);
}
});
};
SavedVis.prototype._createVis = function () {
const self = this;

View file

@ -24,6 +24,7 @@ import { savedObjectManagementRegistry } from '../../management/saved_object_reg
import { start as visualizations } from '../../../../visualizations/public/np_ready/public/legacy';
import { createVisualizeEditUrl } from '../visualize_constants';
import { findListItems } from './find_list_items';
import { npStart } from '../../../../../ui/public/new_platform';
const app = uiModules.get('app/visualize');
@ -34,14 +35,13 @@ savedObjectManagementRegistry.register({
title: 'visualizations',
});
app.service('savedVisualizations', function (SavedVis, Private, kbnUrl, chrome) {
app.service('savedVisualizations', function (SavedVis, Private) {
const visTypes = visualizations.types;
const savedObjectClient = Private(SavedObjectsClientProvider);
const saveVisualizationLoader = new SavedObjectLoader(
SavedVis,
kbnUrl,
chrome,
savedObjectClient
savedObjectClient,
npStart.core.chrome
);
saveVisualizationLoader.mapHitSource = function (source, id) {
@ -73,7 +73,7 @@ app.service('savedVisualizations', function (SavedVis, Private, kbnUrl, chrome)
};
saveVisualizationLoader.urlFor = function (id) {
return kbnUrl.eval('#/visualize/edit/{{id}}', { id: id });
return `#/visualize/edit/${encodeURIComponent(id)}`;
};
// This behaves similarly to find, except it returns visualizations that are

View file

@ -21,6 +21,7 @@ import { SavedObjectLoader, SavedObjectsClientProvider } from 'ui/saved_objects'
import { savedObjectManagementRegistry } from 'plugins/kibana/management/saved_object_registry';
import { uiModules } from 'ui/modules';
import './_saved_sheet.js';
import { npStart } from '../../../../ui/public/new_platform';
const module = uiModules.get('app/sheet');
@ -32,9 +33,9 @@ savedObjectManagementRegistry.register({
});
// This is the only thing that gets injected into controllers
module.service('savedSheets', function (Private, SavedSheet, kbnUrl, chrome) {
module.service('savedSheets', function (Private, SavedSheet, kbnUrl) {
const savedObjectClient = Private(SavedObjectsClientProvider);
const savedSheetLoader = new SavedObjectLoader(SavedSheet, kbnUrl, chrome, savedObjectClient);
const savedSheetLoader = new SavedObjectLoader(SavedSheet, savedObjectClient, npStart.core.chrome);
savedSheetLoader.urlFor = function (id) {
return kbnUrl.eval('#/{{id}}', { id: id });
};

View file

@ -19,7 +19,7 @@
import sinon from 'sinon';
import expect from '@kbn/expect';
import { findObjectByTitle } from '../find_object_by_title';
import { findObjectByTitle } from '../helpers/find_object_by_title';
import { SimpleSavedObject } from '../../../../../core/public';
describe('findObjectByTitle', () => {

View file

@ -22,7 +22,7 @@ import expect from '@kbn/expect';
import sinon from 'sinon';
import Bluebird from 'bluebird';
import { SavedObjectProvider } from '../saved_object';
import { createSavedObjectClass } from '../saved_object';
import StubIndexPattern from 'test_utils/stub_index_pattern';
import { SavedObjectsClientProvider } from '../saved_objects_client_provider';
import { InvalidJSONProperty } from '../../../../../plugins/kibana_utils/public';
@ -97,9 +97,9 @@ describe('Saved Object', function () {
);
beforeEach(ngMock.inject(function (es, Private, $window) {
SavedObject = Private(SavedObjectProvider);
esDataStub = es;
savedObjectsClientStub = Private(SavedObjectsClientProvider);
SavedObject = createSavedObjectClass({ savedObjectsClient: savedObjectsClientStub });
esDataStub = es;
window = $window;
}));
@ -110,66 +110,6 @@ describe('Saved Object', function () {
sinon.stub(esDataStub, 'create').returns(Bluebird.reject(mock409FetchError));
}
describe('when true', function () {
it('requests confirmation and updates on yes response', function () {
stubESResponse(getMockedDocResponse('myId'));
return createInitializedSavedObject({ type: 'dashboard', id: 'myId' }).then(savedObject => {
const createStub = sinon.stub(savedObjectsClientStub, 'create');
createStub.onFirstCall().returns(Bluebird.reject(mock409FetchError));
createStub.onSecondCall().returns(Bluebird.resolve({ id: 'myId' }));
stubConfirmOverwrite();
savedObject.lastSavedTitle = 'original title';
savedObject.title = 'new title';
return savedObject.save({ confirmOverwrite: true })
.then(() => {
expect(window.confirm.called).to.be(true);
expect(savedObject.id).to.be('myId');
expect(savedObject.isSaving).to.be(false);
expect(savedObject.lastSavedTitle).to.be('new title');
expect(savedObject.title).to.be('new title');
});
});
});
it('does not update on no response', function () {
stubESResponse(getMockedDocResponse('HI'));
return createInitializedSavedObject({ type: 'dashboard', id: 'HI' }).then(savedObject => {
window.confirm = sinon.stub().returns(false);
sinon.stub(savedObjectsClientStub, 'create').returns(Bluebird.reject(mock409FetchError));
savedObject.lastSavedTitle = 'original title';
savedObject.title = 'new title';
return savedObject.save({ confirmOverwrite: true })
.then(() => {
expect(savedObject.id).to.be('HI');
expect(savedObject.isSaving).to.be(false);
expect(savedObject.lastSavedTitle).to.be('original title');
expect(savedObject.title).to.be('new title');
});
});
});
it('handles create failures', function () {
stubESResponse(getMockedDocResponse('myId'));
return createInitializedSavedObject({ type: 'dashboard', id: 'myId' }).then(savedObject => {
stubConfirmOverwrite();
sinon.stub(savedObjectsClientStub, 'create').returns(Bluebird.reject(mock409FetchError));
return savedObject.save({ confirmOverwrite: true })
.then(() => {
expect(true).to.be(false); // Force failure, the save should not succeed.
})
.catch(() => {
expect(window.confirm.called).to.be(true);
});
});
});
});
it('when false does not request overwrite', function () {
const mockDocResponse = getMockedDocResponse('myId');
stubESResponse(mockDocResponse);
@ -691,18 +631,6 @@ describe('Saved Object', function () {
});
});
it('init is called', function () {
const initCallback = sinon.spy();
const config = {
type: 'dashboard',
init: initCallback
};
return createInitializedSavedObject(config).then(() => {
expect(initCallback.called).to.be(true);
});
});
describe('searchSource', function () {
it('when true, creates index', function () {
const indexPatternId = 'testIndexPattern';

View file

@ -16,15 +16,25 @@
* specific language governing permissions and limitations
* under the License.
*/
import { i18n } from '@kbn/i18n';
export interface SaveOptions {
confirmOverwrite: boolean;
isTitleDuplicateConfirmed: boolean;
onTitleDuplicate: () => void;
}
export interface SavedObject {
save: (saveOptions: SaveOptions) => Promise<string>;
copyOnSave: boolean;
id?: string;
}
/**
* An error message to be used when the user rejects a confirm overwrite.
* @type {string}
*/
export const OVERWRITE_REJECTED = i18n.translate(
'common.ui.savedObjects.overwriteRejectedDescription',
{
defaultMessage: 'Overwrite confirmation was rejected',
}
);
/**
* An error message to be used when the user rejects a confirm save with duplicate title.
* @type {string}
*/
export const SAVE_DUPLICATE_REJECTED = i18n.translate(
'common.ui.savedObjects.saveDuplicateRejectedDescription',
{
defaultMessage: 'Save with duplicate title confirmation was rejected',
}
);

View file

@ -0,0 +1,82 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import _ from 'lodash';
import { EsResponse, SavedObject, SavedObjectConfig } from 'ui/saved_objects/types';
import { parseSearchSource } from 'ui/saved_objects/helpers/parse_search_source';
import { expandShorthand, SavedObjectNotFound } from '../../../../../plugins/kibana_utils/public';
import { IndexPattern } from '../../../../core_plugins/data/public';
/**
* A given response of and ElasticSearch containing a plain saved object is applied to the given
* savedObject
*/
export async function applyESResp(
resp: EsResponse,
savedObject: SavedObject,
config: SavedObjectConfig
) {
const mapping = expandShorthand(config.mapping);
const esType = config.type || '';
savedObject._source = _.cloneDeep(resp._source);
const injectReferences = config.injectReferences;
const hydrateIndexPattern = savedObject.hydrateIndexPattern!;
if (typeof resp.found === 'boolean' && !resp.found) {
throw new SavedObjectNotFound(esType, savedObject.id || '');
}
const meta = resp._source.kibanaSavedObjectMeta || {};
delete resp._source.kibanaSavedObjectMeta;
if (!config.indexPattern && savedObject._source.indexPattern) {
config.indexPattern = savedObject._source.indexPattern as IndexPattern;
delete savedObject._source.indexPattern;
}
// assign the defaults to the response
_.defaults(savedObject._source, savedObject.defaults);
// transform the source using _deserializers
_.forOwn(mapping, (fieldMapping, fieldName) => {
if (fieldMapping._deserialize && typeof fieldName === 'string') {
savedObject._source[fieldName] = fieldMapping._deserialize(
savedObject._source[fieldName] as string
);
}
});
// Give obj all of the values in _source.fields
_.assign(savedObject, savedObject._source);
savedObject.lastSavedTitle = savedObject.title;
try {
await parseSearchSource(savedObject, esType, meta.searchSourceJSON, resp.references);
await hydrateIndexPattern();
if (injectReferences && resp.references && resp.references.length > 0) {
injectReferences(savedObject, resp.references);
}
if (typeof config.afterESResp === 'function') {
await config.afterESResp.call(savedObject);
}
return savedObject;
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
throw e;
}
}

View file

@ -0,0 +1,126 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import _ from 'lodash';
import { SearchSource } from 'ui/courier';
import { hydrateIndexPattern } from './hydrate_index_pattern';
import { intializeSavedObject } from './initialize_saved_object';
import { serializeSavedObject } from './serialize_saved_object';
import {
EsResponse,
SavedObject,
SavedObjectConfig,
SavedObjectKibanaServices,
SavedObjectSaveOpts,
} from '../types';
import { applyESResp } from './apply_es_resp';
import { saveSavedObject } from './save_saved_object';
export function buildSavedObject(
savedObject: SavedObject,
config: SavedObjectConfig = {},
services: SavedObjectKibanaServices
) {
const { indexPatterns, savedObjectsClient } = services;
// type name for this object, used as the ES-type
const esType = config.type || '';
savedObject.getDisplayName = () => esType;
// NOTE: this.type (not set in this file, but somewhere else) is the sub type, e.g. 'area' or
// 'data table', while esType is the more generic type - e.g. 'visualization' or 'saved search'.
savedObject.getEsType = () => esType;
/**
* Flips to true during a save operation, and back to false once the save operation
* completes.
* @type {boolean}
*/
savedObject.isSaving = false;
savedObject.defaults = config.defaults || {};
// optional search source which this object configures
savedObject.searchSource = config.searchSource ? new SearchSource() : undefined;
// the id of the document
savedObject.id = config.id || void 0;
// the migration version of the document, should only be set on imports
savedObject.migrationVersion = config.migrationVersion;
// Whether to create a copy when the object is saved. This should eventually go away
// in favor of a better rename/save flow.
savedObject.copyOnSave = false;
/**
* After creation or fetching from ES, ensure that the searchSources index indexPattern
* is an bonafide IndexPattern object.
*
* @return {Promise<IndexPattern | null>}
*/
savedObject.hydrateIndexPattern = (id?: string) =>
hydrateIndexPattern(id || '', savedObject, indexPatterns, config);
/**
* Asynchronously initialize this object - will only run
* once even if called multiple times.
*
* @return {Promise}
* @resolved {SavedObject}
*/
savedObject.init = _.once(() => intializeSavedObject(savedObject, savedObjectsClient, config));
savedObject.applyESResp = (resp: EsResponse) => applyESResp(resp, savedObject, config);
/**
* Serialize this object
* @return {Object}
*/
savedObject._serialize = () => serializeSavedObject(savedObject, config);
/**
* Returns true if the object's original title has been changed. New objects return false.
* @return {boolean}
*/
savedObject.isTitleChanged = () =>
savedObject._source && savedObject._source.title !== savedObject.title;
savedObject.creationOpts = (opts: Record<string, any> = {}) => ({
id: savedObject.id,
migrationVersion: savedObject.migrationVersion,
...opts,
});
savedObject.save = async (opts: SavedObjectSaveOpts) => {
try {
const result = await saveSavedObject(savedObject, config, opts, services);
return Promise.resolve(result);
} catch (e) {
return Promise.reject(e);
}
};
savedObject.destroy = () => {};
/**
* Delete this object from Elasticsearch
* @return {promise}
*/
savedObject.delete = () => {
if (!savedObject.id) {
return Promise.reject(new Error('Deleting a saved Object requires type and id'));
}
return savedObjectsClient.delete(esType, savedObject.id);
};
}

View file

@ -0,0 +1,69 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SavedObject, SavedObjectKibanaServices } from '../types';
import { findObjectByTitle } from './find_object_by_title';
import { SAVE_DUPLICATE_REJECTED } from '../constants';
import { displayDuplicateTitleConfirmModal } from './display_duplicate_title_confirm_modal';
/**
* check for an existing SavedObject with the same title in ES
* returns Promise<true> when it's no duplicate, or the modal displaying the warning
* that's there's a duplicate is confirmed, else it returns a rejected Promise<ErrorMsg>
* @param savedObject
* @param isTitleDuplicateConfirmed
* @param onTitleDuplicate
* @param services
*/
export async function checkForDuplicateTitle(
savedObject: SavedObject,
isTitleDuplicateConfirmed: boolean,
onTitleDuplicate: (() => void) | undefined,
services: SavedObjectKibanaServices
): Promise<true> {
const { savedObjectsClient, overlays } = services;
// Don't check for duplicates if user has already confirmed save with duplicate title
if (isTitleDuplicateConfirmed) {
return true;
}
// Don't check if the user isn't updating the title, otherwise that would become very annoying to have
// to confirm the save every time, except when copyOnSave is true, then we do want to check.
if (savedObject.title === savedObject.lastSavedTitle && !savedObject.copyOnSave) {
return true;
}
const duplicate = await findObjectByTitle(
savedObjectsClient,
savedObject.getEsType(),
savedObject.title
);
if (!duplicate || duplicate.id === savedObject.id) {
return true;
}
if (onTitleDuplicate) {
onTitleDuplicate();
return Promise.reject(new Error(SAVE_DUPLICATE_REJECTED));
}
// TODO: make onTitleDuplicate a required prop and remove UI components from this class
// Need to leave here until all users pass onTitleDuplicate.
return displayDuplicateTitleConfirmModal(savedObject, overlays);
}

View file

@ -0,0 +1,59 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { OverlayStart } from 'kibana/public';
import { i18n } from '@kbn/i18n';
import { EuiConfirmModal } from '@elastic/eui';
import { toMountPoint } from '../../../../../plugins/kibana_react/public';
export function confirmModalPromise(
message = '',
title = '',
confirmBtnText = '',
overlays: OverlayStart
): Promise<true> {
return new Promise((resolve, reject) => {
const cancelButtonText = i18n.translate(
'common.ui.savedObjects.confirmModal.cancelButtonLabel',
{
defaultMessage: 'Cancel',
}
);
const modal = overlays.openModal(
toMountPoint(
<EuiConfirmModal
onCancel={() => {
modal.close();
reject();
}}
onConfirm={() => {
modal.close();
resolve(true);
}}
confirmButtonText={confirmBtnText}
cancelButtonText={cancelButtonText}
title={title}
>
{message}
</EuiConfirmModal>
)
);
});
}

View file

@ -0,0 +1,85 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import _ from 'lodash';
import { i18n } from '@kbn/i18n';
import { SavedObject, SavedObjectKibanaServices } from 'ui/saved_objects/types';
import { SavedObjectAttributes } from 'kibana/public';
import { OVERWRITE_REJECTED } from 'ui/saved_objects/constants';
import { confirmModalPromise } from './confirm_modal_promise';
/**
* Attempts to create the current object using the serialized source. If an object already
* exists, a warning message requests an overwrite confirmation.
* @param source - serialized version of this object (return value from this._serialize())
* What will be indexed into elasticsearch.
* @param savedObject - savedObject
* @param esType - type of the saved object
* @param options - options to pass to the saved object create method
* @param services - provides Kibana services savedObjectsClient and overlays
* @returns {Promise} - A promise that is resolved with the objects id if the object is
* successfully indexed. If the overwrite confirmation was rejected, an error is thrown with
* a confirmRejected = true parameter so that case can be handled differently than
* a create or index error.
* @resolved {SavedObject}
*/
export async function createSource(
source: SavedObjectAttributes,
savedObject: SavedObject,
esType: string,
options = {},
services: SavedObjectKibanaServices
) {
const { savedObjectsClient, overlays } = services;
try {
return await savedObjectsClient.create(esType, source, options);
} catch (err) {
// record exists, confirm overwriting
if (_.get(err, 'res.status') === 409) {
const confirmMessage = i18n.translate(
'common.ui.savedObjects.confirmModal.overwriteConfirmationMessage',
{
defaultMessage: 'Are you sure you want to overwrite {title}?',
values: { title: savedObject.title },
}
);
const title = i18n.translate('common.ui.savedObjects.confirmModal.overwriteTitle', {
defaultMessage: 'Overwrite {name}?',
values: { name: savedObject.getDisplayName() },
});
const confirmButtonText = i18n.translate(
'common.ui.savedObjects.confirmModal.overwriteButtonLabel',
{
defaultMessage: 'Overwrite',
}
);
return confirmModalPromise(confirmMessage, title, confirmButtonText, overlays)
.then(() =>
savedObjectsClient.create(
esType,
source,
savedObject.creationOpts({ overwrite: true, ...options })
)
)
.catch(() => Promise.reject(new Error(OVERWRITE_REJECTED)));
}
return await Promise.reject(err);
}
}

View file

@ -0,0 +1,49 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { i18n } from '@kbn/i18n';
import { OverlayStart } from 'kibana/public';
import { SAVE_DUPLICATE_REJECTED } from '../constants';
import { confirmModalPromise } from './confirm_modal_promise';
import { SavedObject } from '../types';
export function displayDuplicateTitleConfirmModal(
savedObject: SavedObject,
overlays: OverlayStart
): Promise<true> {
const confirmMessage = i18n.translate(
'common.ui.savedObjects.confirmModal.saveDuplicateConfirmationMessage',
{
defaultMessage: `A {name} with the title '{title}' already exists. Would you like to save anyway?`,
values: { title: savedObject.title, name: savedObject.getDisplayName() },
}
);
const confirmButtonText = i18n.translate(
'common.ui.savedObjects.confirmModal.saveDuplicateButtonLabel',
{
defaultMessage: 'Save {name}',
values: { name: savedObject.getDisplayName() },
}
);
try {
return confirmModalPromise(confirmMessage, '', confirmButtonText, overlays);
} catch (_) {
return Promise.reject(new Error(SAVE_DUPLICATE_REJECTED));
}
}

View file

@ -17,7 +17,6 @@
* under the License.
*/
import { find } from 'lodash';
import { SavedObjectAttributes } from 'src/core/server';
import { SavedObjectsClientContract } from 'src/core/public';
import { SimpleSavedObject } from 'src/core/public';
@ -30,30 +29,23 @@ import { SimpleSavedObject } from 'src/core/public';
* @param title {string}
* @returns {Promise<SimpleSavedObject|undefined>}
*/
export function findObjectByTitle<T extends SavedObjectAttributes>(
export async function findObjectByTitle<T extends SavedObjectAttributes>(
savedObjectsClient: SavedObjectsClientContract,
type: string,
title: string
): Promise<SimpleSavedObject<T> | void> {
if (!title) {
return Promise.resolve();
return;
}
// Elastic search will return the most relevant results first, which means exact matches should come
// first, and so we shouldn't need to request everything. Using 10 just to be on the safe side.
return savedObjectsClient
.find<T>({
type,
perPage: 10,
search: `"${title}"`,
searchFields: ['title'],
fields: ['title'],
})
.then(response => {
const match = find(response.savedObjects, obj => {
return obj.get('title').toLowerCase() === title.toLowerCase();
});
return match;
});
const response = await savedObjectsClient.find<T>({
type,
perPage: 10,
search: `"${title}"`,
searchFields: ['title'],
fields: ['title'],
});
return response.savedObjects.find(obj => obj.get('title').toLowerCase() === title.toLowerCase());
}

View file

@ -0,0 +1,55 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SavedObject, SavedObjectConfig } from '../types';
import { IndexPatternsContract } from '../../../../../plugins/data/public';
/**
* After creation or fetching from ES, ensure that the searchSources index indexPattern
* is an bonafide IndexPattern object.
*
* @return {Promise<IndexPattern | null>}
*/
export async function hydrateIndexPattern(
id: string,
savedObject: SavedObject,
indexPatterns: IndexPatternsContract,
config: SavedObjectConfig
) {
const clearSavedIndexPattern = !!config.clearSavedIndexPattern;
const indexPattern = config.indexPattern;
if (!savedObject.searchSource) {
return null;
}
if (clearSavedIndexPattern) {
savedObject.searchSource!.setField('index', undefined);
return null;
}
const index = id || indexPattern || savedObject.searchSource!.getOwnField('index');
if (typeof index !== 'string' || !index) {
return null;
}
const indexObj = await indexPatterns.get(index);
savedObject.searchSource!.setField('index', indexObj);
return indexObj;
}

View file

@ -0,0 +1,59 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import _ from 'lodash';
import { SavedObjectsClientContract } from 'kibana/public';
import { SavedObject, SavedObjectConfig } from '../types';
/**
* Initialize saved object
*/
export async function intializeSavedObject(
savedObject: SavedObject,
savedObjectsClient: SavedObjectsClientContract,
config: SavedObjectConfig
) {
const esType = config.type;
// ensure that the esType is defined
if (!esType) throw new Error('You must define a type name to use SavedObject objects.');
if (!savedObject.id) {
// just assign the defaults and be done
_.assign(savedObject, savedObject.defaults);
await savedObject.hydrateIndexPattern!();
if (typeof config.afterESResp === 'function') {
await config.afterESResp.call(savedObject);
}
return savedObject;
}
const resp = await savedObjectsClient.get(esType, savedObject.id);
const respMapped = {
_id: resp.id,
_type: resp.type,
_source: _.cloneDeep(resp.attributes),
references: resp.references,
found: !!resp._version,
};
await savedObject.applyESResp(respMapped);
if (typeof config.init === 'function') {
await config.init.call(savedObject);
}
return savedObject;
}

View file

@ -0,0 +1,97 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import _ from 'lodash';
import { migrateLegacyQuery } from 'ui/utils/migrate_legacy_query';
import { SavedObject } from '../types';
import { InvalidJSONProperty } from '../../../../../plugins/kibana_utils/public';
export function parseSearchSource(
savedObject: SavedObject,
esType: string,
searchSourceJson: string,
references: any[]
) {
if (!savedObject.searchSource) return;
// if we have a searchSource, set its values based on the searchSourceJson field
let searchSourceValues: Record<string, any>;
try {
searchSourceValues = JSON.parse(searchSourceJson);
} catch (e) {
throw new InvalidJSONProperty(
`Invalid JSON in ${esType} "${savedObject.id}". ${e.message} JSON: ${searchSourceJson}`
);
}
// This detects a scenario where documents with invalid JSON properties have been imported into the saved object index.
// (This happened in issue #20308)
if (!searchSourceValues || typeof searchSourceValues !== 'object') {
throw new InvalidJSONProperty(`Invalid searchSourceJSON in ${esType} "${savedObject.id}".`);
}
// Inject index id if a reference is saved
if (searchSourceValues.indexRefName) {
const reference = references.find(
(ref: Record<string, any>) => ref.name === searchSourceValues.indexRefName
);
if (!reference) {
throw new Error(
`Could not find reference for ${
searchSourceValues.indexRefName
} on ${savedObject.getEsType()} ${savedObject.id}`
);
}
searchSourceValues.index = reference.id;
delete searchSourceValues.indexRefName;
}
if (searchSourceValues.filter) {
searchSourceValues.filter.forEach((filterRow: any) => {
if (!filterRow.meta || !filterRow.meta.indexRefName) {
return;
}
const reference = references.find((ref: any) => ref.name === filterRow.meta.indexRefName);
if (!reference) {
throw new Error(
`Could not find reference for ${
filterRow.meta.indexRefName
} on ${savedObject.getEsType()}`
);
}
filterRow.meta.index = reference.id;
delete filterRow.meta.indexRefName;
});
}
const searchSourceFields = savedObject.searchSource.getFields();
const fnProps = _.transform(
searchSourceFields,
function(dynamic: Record<string, any>, val: any, name: string | undefined) {
if (_.isFunction(val) && name) dynamic[name] = val;
},
{}
);
savedObject.searchSource.setFields(_.defaults(searchSourceValues, fnProps));
const query = savedObject.searchSource.getOwnField('query');
if (typeof query !== 'undefined') {
savedObject.searchSource.setField('query', migrateLegacyQuery(query));
}
}

View file

@ -0,0 +1,128 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
SavedObject,
SavedObjectConfig,
SavedObjectKibanaServices,
SavedObjectSaveOpts,
} from '../types';
import { OVERWRITE_REJECTED, SAVE_DUPLICATE_REJECTED } from '../constants';
import { createSource } from './create_source';
import { checkForDuplicateTitle } from './check_for_duplicate_title';
/**
* @param error {Error} the error
* @return {boolean}
*/
function isErrorNonFatal(error: { message: string }) {
if (!error) return false;
return error.message === OVERWRITE_REJECTED || error.message === SAVE_DUPLICATE_REJECTED;
}
/**
* Saves this object.
*
* @param {string} [esType]
* @param {SavedObject} [savedObject]
* @param {SavedObjectConfig} [config]
* @param {object} [options={}]
* @property {boolean} [options.confirmOverwrite=false] - If true, attempts to create the source so it
* can confirm an overwrite if a document with the id already exists.
* @property {boolean} [options.isTitleDuplicateConfirmed=false] - If true, save allowed with duplicate title
* @property {func} [options.onTitleDuplicate] - function called if duplicate title exists.
* When not provided, confirm modal will be displayed asking user to confirm or cancel save.
* @param {SavedObjectKibanaServices} [services]
* @return {Promise}
* @resolved {String} - The id of the doc
*/
export async function saveSavedObject(
savedObject: SavedObject,
config: SavedObjectConfig,
{
confirmOverwrite = false,
isTitleDuplicateConfirmed = false,
onTitleDuplicate,
}: SavedObjectSaveOpts = {},
services: SavedObjectKibanaServices
): Promise<string> {
const { savedObjectsClient, chrome } = services;
const esType = config.type || '';
const extractReferences = config.extractReferences;
// Save the original id in case the save fails.
const originalId = savedObject.id;
// Read https://github.com/elastic/kibana/issues/9056 and
// https://github.com/elastic/kibana/issues/9012 for some background into why this copyOnSave variable
// exists.
// The goal is to move towards a better rename flow, but since our users have been conditioned
// to expect a 'save as' flow during a rename, we are keeping the logic the same until a better
// UI/UX can be worked out.
if (savedObject.copyOnSave) {
delete savedObject.id;
}
// Here we want to extract references and set them within "references" attribute
let { attributes, references } = savedObject._serialize();
if (extractReferences) {
({ attributes, references } = extractReferences({ attributes, references }));
}
if (!references) throw new Error('References not returned from extractReferences');
try {
await checkForDuplicateTitle(
savedObject,
isTitleDuplicateConfirmed,
onTitleDuplicate,
services
);
savedObject.isSaving = true;
const resp = confirmOverwrite
? await createSource(
attributes,
savedObject,
esType,
savedObject.creationOpts({ references }),
services
)
: await savedObjectsClient.create(
esType,
attributes,
savedObject.creationOpts({ references, overwrite: true })
);
savedObject.id = resp.id;
if (savedObject.showInRecentlyAccessed && savedObject.getFullPath) {
chrome.recentlyAccessed.add(
savedObject.getFullPath(),
savedObject.title,
String(savedObject.id)
);
}
savedObject.isSaving = false;
savedObject.lastSavedTitle = savedObject.title;
return savedObject.id;
} catch (err) {
savedObject.isSaving = false;
savedObject.id = originalId;
if (isErrorNonFatal(err)) {
return '';
}
return Promise.reject(err);
}
}

View file

@ -0,0 +1,98 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import _ from 'lodash';
import angular from 'angular';
import { SavedObject, SavedObjectConfig } from '../types';
import { expandShorthand } from '../../../../../plugins/kibana_utils/public';
export function serializeSavedObject(savedObject: SavedObject, config: SavedObjectConfig) {
// mapping definition for the fields that this object will expose
const mapping = expandShorthand(config.mapping);
const attributes = {} as Record<string, any>;
const references = [];
_.forOwn(mapping, (fieldMapping, fieldName) => {
if (typeof fieldName !== 'string') {
return;
}
// @ts-ignore
const savedObjectFieldVal = savedObject[fieldName];
if (savedObjectFieldVal != null) {
attributes[fieldName] = fieldMapping._serialize
? fieldMapping._serialize(savedObjectFieldVal)
: savedObjectFieldVal;
}
});
if (savedObject.searchSource) {
let searchSourceFields: Record<string, any> = _.omit(savedObject.searchSource.getFields(), [
'sort',
'size',
]);
if (searchSourceFields.index) {
// searchSourceFields.index will normally be an IndexPattern, but can be a string in two scenarios:
// (1) `init()` (and by extension `hydrateIndexPattern()`) hasn't been called on Saved Object
// (2) The IndexPattern doesn't exist, so we fail to resolve it in `hydrateIndexPattern()`
const indexId =
typeof searchSourceFields.index === 'string'
? searchSourceFields.index
: searchSourceFields.index.id;
const refName = 'kibanaSavedObjectMeta.searchSourceJSON.index';
references.push({
name: refName,
type: 'index-pattern',
id: indexId,
});
searchSourceFields = {
...searchSourceFields,
indexRefName: refName,
index: undefined,
};
}
if (searchSourceFields.filter) {
searchSourceFields = {
...searchSourceFields,
filter: searchSourceFields.filter.map((filterRow: any, i: number) => {
if (!filterRow.meta || !filterRow.meta.index) {
return filterRow;
}
const refName = `kibanaSavedObjectMeta.searchSourceJSON.filter[${i}].meta.index`;
references.push({
name: refName,
type: 'index-pattern',
id: filterRow.meta.index,
});
return {
...filterRow,
meta: {
...filterRow.meta,
indexRefName: refName,
index: undefined,
},
};
}),
};
}
attributes.kibanaSavedObjectMeta = {
searchSourceJSON: angular.toJson(searchSourceFields),
};
}
return { attributes, references };
}

View file

@ -19,6 +19,5 @@
export { SavedObjectRegistryProvider } from './saved_object_registry';
export { SavedObjectsClientProvider } from './saved_objects_client_provider';
// @ts-ignore
export { SavedObjectLoader } from './saved_object_loader';
export { findObjectByTitle } from './find_object_by_title';
export { findObjectByTitle } from './helpers/find_object_by_title';

View file

@ -1,541 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @name SavedObject
*
* NOTE: SavedObject seems to track a reference to an object in ES,
* and surface methods for CRUD functionality (save and delete). This seems
* similar to how Backbone Models work.
*
* This class seems to interface with ES primarily through the es Angular
* service and the saved object api.
*/
import angular from 'angular';
import _ from 'lodash';
import { InvalidJSONProperty, SavedObjectNotFound, expandShorthand } from '../../../../plugins/kibana_utils/public';
import { SearchSource } from '../courier';
import { findObjectByTitle } from './find_object_by_title';
import { SavedObjectsClientProvider } from './saved_objects_client_provider';
import { migrateLegacyQuery } from '../utils/migrate_legacy_query';
import { npStart } from 'ui/new_platform';
import { i18n } from '@kbn/i18n';
/**
* An error message to be used when the user rejects a confirm overwrite.
* @type {string}
*/
const OVERWRITE_REJECTED = i18n.translate('common.ui.savedObjects.overwriteRejectedDescription', {
defaultMessage: 'Overwrite confirmation was rejected'
});
/**
* An error message to be used when the user rejects a confirm save with duplicate title.
* @type {string}
*/
const SAVE_DUPLICATE_REJECTED = i18n.translate('common.ui.savedObjects.saveDuplicateRejectedDescription', {
defaultMessage: 'Save with duplicate title confirmation was rejected'
});
/**
* @param error {Error} the error
* @return {boolean}
*/
function isErrorNonFatal(error) {
if (!error) return false;
return error.message === OVERWRITE_REJECTED || error.message === SAVE_DUPLICATE_REJECTED;
}
export function SavedObjectProvider(Promise, Private, confirmModalPromise, indexPatterns) {
const savedObjectsClient = Private(SavedObjectsClientProvider);
/**
* The SavedObject class is a base class for saved objects loaded from the server and
* provides additional functionality besides loading/saving/deleting/etc.
*
* It is overloaded and configured to provide type-aware functionality.
* To just retrieve the attributes of saved objects, it is recommended to use SavedObjectLoader
* which returns instances of SimpleSavedObject which don't introduce additional type-specific complexity.
* @param {*} config
*/
function SavedObject(config) {
if (!_.isObject(config)) config = {};
/************
* Initialize config vars
************/
// type name for this object, used as the ES-type
const esType = config.type;
this.getDisplayName = function () {
return esType;
};
// NOTE: this.type (not set in this file, but somewhere else) is the sub type, e.g. 'area' or
// 'data table', while esType is the more generic type - e.g. 'visualization' or 'saved search'.
this.getEsType = function () {
return esType;
};
/**
* Flips to true during a save operation, and back to false once the save operation
* completes.
* @type {boolean}
*/
this.isSaving = false;
this.defaults = config.defaults || {};
// mapping definition for the fields that this object will expose
const mapping = expandShorthand(config.mapping);
const afterESResp = config.afterESResp || _.noop;
const customInit = config.init || _.noop;
const extractReferences = config.extractReferences;
const injectReferences = config.injectReferences;
// optional search source which this object configures
this.searchSource = config.searchSource ? new SearchSource() : undefined;
// the id of the document
this.id = config.id || void 0;
// the migration version of the document, should only be set on imports
this.migrationVersion = config.migrationVersion;
// Whether to create a copy when the object is saved. This should eventually go away
// in favor of a better rename/save flow.
this.copyOnSave = false;
const parseSearchSource = (searchSourceJson, references) => {
if (!this.searchSource) return;
// if we have a searchSource, set its values based on the searchSourceJson field
let searchSourceValues;
try {
searchSourceValues = JSON.parse(searchSourceJson);
} catch (e) {
throw new InvalidJSONProperty(
`Invalid JSON in ${esType} "${this.id}". ${e.message} JSON: ${searchSourceJson}`
);
}
// This detects a scenario where documents with invalid JSON properties have been imported into the saved object index.
// (This happened in issue #20308)
if (!searchSourceValues || typeof searchSourceValues !== 'object') {
throw new InvalidJSONProperty(`Invalid searchSourceJSON in ${esType} "${this.id}".`);
}
// Inject index id if a reference is saved
if (searchSourceValues.indexRefName) {
const reference = references.find(reference => reference.name === searchSourceValues.indexRefName);
if (!reference) {
throw new Error(`Could not find reference for ${searchSourceValues.indexRefName} on ${this.getEsType()} ${this.id}`);
}
searchSourceValues.index = reference.id;
delete searchSourceValues.indexRefName;
}
if (searchSourceValues.filter) {
searchSourceValues.filter.forEach((filterRow) => {
if (!filterRow.meta || !filterRow.meta.indexRefName) {
return;
}
const reference = references.find(reference => reference.name === filterRow.meta.indexRefName);
if (!reference) {
throw new Error(`Could not find reference for ${filterRow.meta.indexRefName} on ${this.getEsType()}`);
}
filterRow.meta.index = reference.id;
delete filterRow.meta.indexRefName;
});
}
const searchSourceFields = this.searchSource.getFields();
const fnProps = _.transform(searchSourceFields, function (dynamic, val, name) {
if (_.isFunction(val)) dynamic[name] = val;
}, {});
this.searchSource.setFields(_.defaults(searchSourceValues, fnProps));
if (!_.isUndefined(this.searchSource.getOwnField('query'))) {
this.searchSource.setField('query', migrateLegacyQuery(this.searchSource.getOwnField('query')));
}
};
/**
* After creation or fetching from ES, ensure that the searchSources index indexPattern
* is an bonafide IndexPattern object.
*
* @return {Promise<IndexPattern | null>}
*/
this.hydrateIndexPattern = (id) => {
if (!this.searchSource) {
return Promise.resolve(null);
}
if (config.clearSavedIndexPattern) {
this.searchSource.setField('index', null);
return Promise.resolve(null);
}
let index = id || config.indexPattern || this.searchSource.getOwnField('index');
if (!index) {
return Promise.resolve(null);
}
// If index is not an IndexPattern object at this point, then it's a string id of an index.
if (typeof index === 'string') {
index = indexPatterns.get(index);
}
// At this point index will either be an IndexPattern, if cached, or a promise that
// will return an IndexPattern, if not cached.
return Promise.resolve(index).then(indexPattern => {
this.searchSource.setField('index', indexPattern);
});
};
/**
* Asynchronously initialize this object - will only run
* once even if called multiple times.
*
* @return {Promise}
* @resolved {SavedObject}
*/
this.init = _.once(() => {
// ensure that the esType is defined
if (!esType) throw new Error('You must define a type name to use SavedObject objects.');
return Promise.resolve()
.then(() => {
// If there is not id, then there is no document to fetch from elasticsearch
if (!this.id) {
// just assign the defaults and be done
_.assign(this, this.defaults);
return this.hydrateIndexPattern().then(() => {
return afterESResp.call(this);
});
}
// fetch the object from ES
return savedObjectsClient.get(esType, this.id)
.then(resp => {
// temporary compatability for savedObjectsClient
return {
_id: resp.id,
_type: resp.type,
_source: _.cloneDeep(resp.attributes),
references: resp.references,
found: resp._version ? true : false
};
})
.then(this.applyESResp)
.catch(this.applyEsResp);
})
.then(() => customInit.call(this))
.then(() => this);
});
this.applyESResp = (resp) => {
this._source = _.cloneDeep(resp._source);
if (resp.found != null && !resp.found) {
throw new SavedObjectNotFound(esType, this.id);
}
const meta = resp._source.kibanaSavedObjectMeta || {};
delete resp._source.kibanaSavedObjectMeta;
if (!config.indexPattern && this._source.indexPattern) {
config.indexPattern = this._source.indexPattern;
delete this._source.indexPattern;
}
// assign the defaults to the response
_.defaults(this._source, this.defaults);
// transform the source using _deserializers
_.forOwn(mapping, (fieldMapping, fieldName) => {
if (fieldMapping._deserialize) {
this._source[fieldName] = fieldMapping._deserialize(this._source[fieldName], resp, fieldName, fieldMapping);
}
});
// Give obj all of the values in _source.fields
_.assign(this, this._source);
this.lastSavedTitle = this.title;
return Promise.try(() => {
parseSearchSource(meta.searchSourceJSON, resp.references);
return this.hydrateIndexPattern();
}).then(() => {
if (injectReferences && resp.references && resp.references.length > 0) {
injectReferences(this, resp.references);
}
return this;
}).then(() => {
return Promise.cast(afterESResp.call(this, resp));
});
};
/**
* Serialize this object
*
* @return {Object}
*/
this._serialize = () => {
const attributes = {};
const references = [];
_.forOwn(mapping, (fieldMapping, fieldName) => {
if (this[fieldName] != null) {
attributes[fieldName] = (fieldMapping._serialize)
? fieldMapping._serialize(this[fieldName])
: this[fieldName];
}
});
if (this.searchSource) {
let searchSourceFields = _.omit(this.searchSource.getFields(), ['sort', 'size']);
if (searchSourceFields.index) {
// searchSourceFields.index will normally be an IndexPattern, but can be a string in two scenarios:
// (1) `init()` (and by extension `hydrateIndexPattern()`) hasn't been called on this Saved Object
// (2) The IndexPattern doesn't exist, so we fail to resolve it in `hydrateIndexPattern()`
const indexId = typeof (searchSourceFields.index) === 'string' ? searchSourceFields.index : searchSourceFields.index.id;
const refName = 'kibanaSavedObjectMeta.searchSourceJSON.index';
references.push({
name: refName,
type: 'index-pattern',
id: indexId,
});
searchSourceFields = {
...searchSourceFields,
indexRefName: refName,
index: undefined,
};
}
if (searchSourceFields.filter) {
searchSourceFields = {
...searchSourceFields,
filter: searchSourceFields.filter.map((filterRow, i) => {
if (!filterRow.meta || !filterRow.meta.index) {
return filterRow;
}
const refName = `kibanaSavedObjectMeta.searchSourceJSON.filter[${i}].meta.index`;
references.push({
name: refName,
type: 'index-pattern',
id: filterRow.meta.index,
});
return {
...filterRow,
meta: {
...filterRow.meta,
indexRefName: refName,
index: undefined,
}
};
}),
};
}
attributes.kibanaSavedObjectMeta = {
searchSourceJSON: angular.toJson(searchSourceFields)
};
}
return { attributes, references };
};
/**
* Returns true if the object's original title has been changed. New objects return false.
* @return {boolean}
*/
this.isTitleChanged = () => {
return this._source && this._source.title !== this.title;
};
this.creationOpts = (opts = {}) => ({
id: this.id,
migrationVersion: this.migrationVersion,
...opts,
});
/**
* Attempts to create the current object using the serialized source. If an object already
* exists, a warning message requests an overwrite confirmation.
* @param source - serialized version of this object (return value from this._serialize())
* What will be indexed into elasticsearch.
* @param options - options to pass to the saved object create method
* @returns {Promise} - A promise that is resolved with the objects id if the object is
* successfully indexed. If the overwrite confirmation was rejected, an error is thrown with
* a confirmRejected = true parameter so that case can be handled differently than
* a create or index error.
* @resolved {SavedObject}
*/
const createSource = (source, options = {}) => {
return savedObjectsClient.create(esType, source, options)
.catch(err => {
// record exists, confirm overwriting
if (_.get(err, 'res.status') === 409) {
const confirmMessage = i18n.translate('common.ui.savedObjects.confirmModal.overwriteConfirmationMessage', {
defaultMessage: 'Are you sure you want to overwrite {title}?',
values: { title: this.title }
});
return confirmModalPromise(confirmMessage, {
confirmButtonText: i18n.translate('common.ui.savedObjects.confirmModal.overwriteButtonLabel', {
defaultMessage: 'Overwrite',
}),
title: i18n.translate('common.ui.savedObjects.confirmModal.overwriteTitle', {
defaultMessage: 'Overwrite {name}?',
values: { name: this.getDisplayName() }
}),
})
.then(() => savedObjectsClient.create(esType, source, this.creationOpts({ overwrite: true, ...options })))
.catch(() => Promise.reject(new Error(OVERWRITE_REJECTED)));
}
return Promise.reject(err);
});
};
const displayDuplicateTitleConfirmModal = () => {
const confirmMessage = i18n.translate('common.ui.savedObjects.confirmModal.saveDuplicateConfirmationMessage', {
defaultMessage: `A {name} with the title '{title}' already exists. Would you like to save anyway?`,
values: { title: this.title, name: this.getDisplayName() }
});
return confirmModalPromise(confirmMessage, {
confirmButtonText: i18n.translate('common.ui.savedObjects.confirmModal.saveDuplicateButtonLabel', {
defaultMessage: 'Save {name}',
values: { name: this.getDisplayName() }
})
})
.catch(() => Promise.reject(new Error(SAVE_DUPLICATE_REJECTED)));
};
const checkForDuplicateTitle = (isTitleDuplicateConfirmed, onTitleDuplicate) => {
// Don't check for duplicates if user has already confirmed save with duplicate title
if (isTitleDuplicateConfirmed) {
return Promise.resolve();
}
// Don't check if the user isn't updating the title, otherwise that would become very annoying to have
// to confirm the save every time, except when copyOnSave is true, then we do want to check.
if (this.title === this.lastSavedTitle && !this.copyOnSave) {
return Promise.resolve();
}
return findObjectByTitle(savedObjectsClient, this.getEsType(), this.title)
.then(duplicate => {
if (!duplicate) return true;
if (duplicate.id === this.id) return true;
if (onTitleDuplicate) {
onTitleDuplicate();
return Promise.reject(new Error(SAVE_DUPLICATE_REJECTED));
}
// TODO: make onTitleDuplicate a required prop and remove UI components from this class
// Need to leave here until all users pass onTitleDuplicate.
return displayDuplicateTitleConfirmModal();
});
};
/**
* Saves this object.
*
* @param {object} [options={}]
* @property {boolean} [options.confirmOverwrite=false] - If true, attempts to create the source so it
* can confirm an overwrite if a document with the id already exists.
* @property {boolean} [options.isTitleDuplicateConfirmed=false] - If true, save allowed with duplicate title
* @property {func} [options.onTitleDuplicate] - function called if duplicate title exists.
* When not provided, confirm modal will be displayed asking user to confirm or cancel save.
* @return {Promise}
* @resolved {String} - The id of the doc
*/
this.save = ({ confirmOverwrite = false, isTitleDuplicateConfirmed = false, onTitleDuplicate } = {}) => {
// Save the original id in case the save fails.
const originalId = this.id;
// Read https://github.com/elastic/kibana/issues/9056 and
// https://github.com/elastic/kibana/issues/9012 for some background into why this copyOnSave variable
// exists.
// The goal is to move towards a better rename flow, but since our users have been conditioned
// to expect a 'save as' flow during a rename, we are keeping the logic the same until a better
// UI/UX can be worked out.
if (this.copyOnSave) {
this.id = null;
}
// Here we want to extract references and set them within "references" attribute
let { attributes, references } = this._serialize();
if (extractReferences) {
({ attributes, references } = extractReferences({ attributes, references }));
}
if (!references) throw new Error('References not returned from extractReferences');
this.isSaving = true;
return checkForDuplicateTitle(isTitleDuplicateConfirmed, onTitleDuplicate)
.then(() => {
if (confirmOverwrite) {
return createSource(attributes, this.creationOpts({ references }));
} else {
return savedObjectsClient.create(esType, attributes, this.creationOpts({ references, overwrite: true }));
}
})
.then((resp) => {
this.id = resp.id;
})
.then(() => {
if (this.showInRecentlyAccessed && this.getFullPath) {
npStart.core.chrome.recentlyAccessed.add(this.getFullPath(), this.title, this.id);
}
this.isSaving = false;
this.lastSavedTitle = this.title;
return this.id;
})
.catch((err) => {
this.isSaving = false;
this.id = originalId;
if (isErrorNonFatal(err)) {
return;
}
return Promise.reject(err);
});
};
this.destroy = () => {};
/**
* Delete this object from Elasticsearch
* @return {promise}
*/
this.delete = () => {
return savedObjectsClient.delete(esType, this.id);
};
}
return SavedObject;
}

View file

@ -0,0 +1,63 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @name SavedObject
*
* NOTE: SavedObject seems to track a reference to an object in ES,
* and surface methods for CRUD functionality (save and delete). This seems
* similar to how Backbone Models work.
*
* This class seems to interface with ES primarily through the es Angular
* service and the saved object api.
*/
import { npStart } from 'ui/new_platform';
import { SavedObject, SavedObjectConfig, SavedObjectKibanaServices } from './types';
import { buildSavedObject } from './helpers/build_saved_object';
export function createSavedObjectClass(services: SavedObjectKibanaServices) {
/**
* The SavedObject class is a base class for saved objects loaded from the server and
* provides additional functionality besides loading/saving/deleting/etc.
*
* It is overloaded and configured to provide type-aware functionality.
* To just retrieve the attributes of saved objects, it is recommended to use SavedObjectLoader
* which returns instances of SimpleSavedObject which don't introduce additional type-specific complexity.
* @param {*} config
*/
class SavedObjectClass {
constructor(config: SavedObjectConfig = {}) {
// @ts-ignore
const self: SavedObject = this;
buildSavedObject(self, config, services);
}
}
return SavedObjectClass as new (config: SavedObjectConfig) => SavedObject;
}
// the old angular way, should be removed once no longer used
export function SavedObjectProvider() {
const services = {
savedObjectsClient: npStart.core.savedObjects.client,
indexPatterns: npStart.plugins.data.indexPatterns,
chrome: npStart.core.chrome,
overlays: npStart.core.overlays,
};
return createSavedObjectClass(services);
}

View file

@ -16,7 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { SavedObject } from 'ui/saved_objects/types';
import { ChromeStart, SavedObjectsClientContract, SavedObjectsFindOptions } from 'kibana/public';
import { StringUtils } from '../utils/string_utils';
/**
@ -28,20 +29,25 @@ import { StringUtils } from '../utils/string_utils';
* to avoid pulling in extra functionality which isn't used.
*/
export class SavedObjectLoader {
constructor(SavedObjectClass, kbnUrl, chrome, savedObjectClient) {
private readonly Class: (id: string) => SavedObject;
public type: string;
public lowercaseType: string;
public loaderProperties: Record<string, string>;
constructor(
SavedObjectClass: any,
private readonly savedObjectsClient: SavedObjectsClientContract,
private readonly chrome: ChromeStart
) {
this.type = SavedObjectClass.type;
this.Class = SavedObjectClass;
this.lowercaseType = this.type.toLowerCase();
this.kbnUrl = kbnUrl;
this.chrome = chrome;
this.loaderProperties = {
name: `${ this.lowercaseType }s`,
name: `${this.lowercaseType}s`,
noun: StringUtils.upperFirst(this.type),
nouns: `${ this.lowercaseType }s`,
nouns: `${this.lowercaseType}s`,
};
this.savedObjectsClient = savedObjectClient;
}
/**
@ -50,27 +56,38 @@ export class SavedObjectLoader {
* @param id
* @returns {Promise<SavedObject>}
*/
get(id) {
return (new this.Class(id)).init();
async get(id: string) {
// @ts-ignore
const obj = new this.Class(id);
return obj.init();
}
urlFor(id) {
return this.kbnUrl.eval(`#/${ this.lowercaseType }/{{id}}`, { id: id });
urlFor(id: string) {
return `#/${this.lowercaseType}/${encodeURIComponent(id)}`;
}
delete(ids) {
ids = !Array.isArray(ids) ? [ids] : ids;
async delete(ids: string | string[]) {
const idsUsed = !Array.isArray(ids) ? [ids] : ids;
const deletions = ids.map(id => {
const deletions = idsUsed.map(id => {
// @ts-ignore
const savedObject = new this.Class(id);
return savedObject.delete();
});
await Promise.all(deletions);
return Promise.all(deletions).then(() => {
if (this.chrome) {
this.chrome.untrackNavLinksForDeletedSavedObjects(ids);
}
});
const coreNavLinks = this.chrome.navLinks;
/**
* Modify last url for deleted saved objects to avoid loading pages with "Could not locate..."
*/
coreNavLinks
.getAll()
.filter(
link =>
link.linkToLastSubUrl &&
idsUsed.find(deletedId => link.url && link.url.includes(deletedId)) !== undefined
)
.forEach(link => coreNavLinks.update(link.id, { url: link.baseUrl }));
}
/**
@ -80,7 +97,7 @@ export class SavedObjectLoader {
* @param id
* @returns {source} The modified source object, with an id and url field.
*/
mapHitSource(source, id) {
mapHitSource(source: Record<string, unknown>, id: string) {
source.id = id;
source.url = this.urlFor(id);
return source;
@ -92,7 +109,7 @@ export class SavedObjectLoader {
* @param hit
* @returns {hit.attributes} The modified hit.attributes object, with an id and url field.
*/
mapSavedObjectApiHits(hit) {
mapSavedObjectApiHits(hit: { attributes: Record<string, unknown>; id: string }) {
return this.mapHitSource(hit.attributes, hit.id);
}
@ -100,13 +117,14 @@ export class SavedObjectLoader {
* TODO: Rather than use a hardcoded limit, implement pagination. See
* https://github.com/elastic/kibana/issues/8044 for reference.
*
* @param searchString
* @param search
* @param size
* @param fields
* @returns {Promise}
*/
findAll(search = '', size = 100, fields) {
return this.savedObjectsClient.find(
{
findAll(search: string = '', size: number = 100, fields?: string[]) {
return this.savedObjectsClient
.find({
type: this.lowercaseType,
search: search ? `${search}*` : undefined,
perPage: size,
@ -114,20 +132,20 @@ export class SavedObjectLoader {
searchFields: ['title^3', 'description'],
defaultSearchOperator: 'AND',
fields,
}).then((resp) => {
return {
total: resp.total,
hits: resp.savedObjects
.map((savedObject) => this.mapSavedObjectApiHits(savedObject))
};
});
} as SavedObjectsFindOptions)
.then(resp => {
return {
total: resp.total,
hits: resp.savedObjects.map(savedObject => this.mapSavedObjectApiHits(savedObject)),
};
});
}
find(search = '', size = 100) {
find(search: string = '', size: number = 100) {
return this.findAll(search, size).then(resp => {
return {
total: resp.total,
hits: resp.hits.filter(savedObject => !savedObject.error)
hits: resp.hits.filter(savedObject => !savedObject.error),
};
});
}

View file

@ -0,0 +1,90 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ChromeStart, OverlayStart, SavedObjectsClientContract } from 'kibana/public';
import { SearchSource, SearchSourceContract } from 'ui/courier';
import { SavedObjectAttributes, SavedObjectReference } from 'kibana/server';
import { IndexPatternsContract } from '../../../../plugins/data/public';
import { IndexPattern } from '../../../core_plugins/data/public';
export interface SavedObject {
_serialize: () => { attributes: SavedObjectAttributes; references: SavedObjectReference[] };
_source: Record<string, unknown>;
applyESResp: (resp: EsResponse) => Promise<SavedObject>;
copyOnSave: boolean;
creationOpts: (opts: SavedObjectCreationOpts) => Record<string, unknown>;
defaults: any;
delete?: () => Promise<{}>;
destroy?: () => void;
getDisplayName: () => string;
getEsType: () => string;
getFullPath: () => string;
hydrateIndexPattern?: (id?: string) => Promise<null | IndexPattern>;
id?: string;
init?: () => Promise<SavedObject>;
isSaving: boolean;
isTitleChanged: () => boolean;
lastSavedTitle: string;
migrationVersion?: Record<string, any>;
save: (saveOptions: SavedObjectSaveOpts) => Promise<string>;
searchSource?: SearchSourceContract;
showInRecentlyAccessed: boolean;
title: string;
}
export interface SavedObjectSaveOpts {
confirmOverwrite?: boolean;
isTitleDuplicateConfirmed?: boolean;
onTitleDuplicate?: () => void;
}
export interface SavedObjectCreationOpts {
references?: SavedObjectReference[];
overwrite?: boolean;
}
export interface SavedObjectKibanaServices {
savedObjectsClient: SavedObjectsClientContract;
indexPatterns: IndexPatternsContract;
chrome: ChromeStart;
overlays: OverlayStart;
}
export interface SavedObjectConfig {
afterESResp?: () => any;
clearSavedIndexPattern?: boolean;
defaults?: any;
extractReferences?: (opts: {
attributes: SavedObjectAttributes;
references: SavedObjectReference[];
}) => {
attributes: SavedObjectAttributes;
references: SavedObjectReference[];
};
id?: string;
init?: () => void;
indexPattern?: IndexPattern;
injectReferences?: any;
mapping?: any;
migrationVersion?: Record<string, any>;
path?: string;
searchSource?: SearchSource | boolean;
type?: string;
}
export type EsResponse = Record<string, any>;

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { SavedObject } from 'ui/saved_objects/saved_object';
import { SavedObject } from 'ui/saved_objects/types';
import { AdvancedSettings, UrlTemplate, WorkspaceField } from './app_state';
import { WorkspaceNode, WorkspaceEdge } from './workspace_state';

View file

@ -6,12 +6,12 @@
import './saved_gis_map';
import { uiModules } from 'ui/modules';
import { SavedObjectLoader, SavedObjectsClientProvider } from 'ui/saved_objects';
import { SavedObjectLoader } from 'ui/saved_objects';
import { npStart } from '../../../../../../../src/legacy/ui/public/new_platform';
const module = uiModules.get('app/maps');
// This is the only thing that gets injected into controllers
module.service('gisMapSavedObjectLoader', function (Private, SavedGisMap, kbnUrl, chrome) {
const savedObjectClient = Private(SavedObjectsClientProvider);
return new SavedObjectLoader(SavedGisMap, kbnUrl, chrome, savedObjectClient);
module.service('gisMapSavedObjectLoader', function (SavedGisMap) {
return new SavedObjectLoader(SavedGisMap, npStart.core.savedObjects.client, npStart.core.chrome);
});