From 78e0bdf3144e11d40092fbd3a70e546115771d86 Mon Sep 17 00:00:00 2001 From: Maryia Lapata Date: Tue, 24 Mar 2020 15:15:28 +0300 Subject: [PATCH] [NP] Graph: get rid of saved objects class wrapper (#59917) * Implement find saved workspace * Implement get saved workspace * Create helper function as applyESResp * Fix eslint * Implement savedWorkspaceLoader.get() * Implement deleteWS * Implement saveWS * Remove applyESRespUtil * Refactoring * Refactoring * Remove savedWorkspaceLoader * Update unit test * Fix merge conflicts * Add unit tests for saveWithConfirmation * Fix TS * Move saveWithConfirmation to a separate file Co-authored-by: Elastic Machine --- src/plugins/saved_objects/public/index.ts | 8 +- .../saved_object/helpers/save_saved_object.ts | 2 +- .../helpers/save_with_confirmation.test.ts | 93 ++++++++ .../helpers/save_with_confirmation.ts | 87 ++++++++ .../public/saved_object/index.ts | 3 + x-pack/plugins/graph/public/app.js | 26 ++- x-pack/plugins/graph/public/application.ts | 11 +- .../public/helpers/saved_workspace_utils.ts | 207 ++++++++++++++++++ .../services/persistence/saved_workspace.ts | 68 ------ .../persistence/saved_workspace_loader.ts | 69 ------ .../saved_workspace_references.test.ts | 9 +- .../persistence/saved_workspace_references.ts | 6 +- .../graph/public/services/save_modal.tsx | 14 +- .../graph/public/state_management/mocks.ts | 14 +- .../state_management/persistence.test.ts | 5 +- .../public/state_management/persistence.ts | 11 +- .../graph/public/state_management/store.ts | 4 +- .../plugins/graph/public/types/persistence.ts | 17 +- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 20 files changed, 478 insertions(+), 180 deletions(-) create mode 100644 src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.test.ts create mode 100644 src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.ts create mode 100644 x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts delete mode 100644 x-pack/plugins/graph/public/services/persistence/saved_workspace.ts delete mode 100644 x-pack/plugins/graph/public/services/persistence/saved_workspace_loader.ts diff --git a/src/plugins/saved_objects/public/index.ts b/src/plugins/saved_objects/public/index.ts index ea92c921efad0..9e0a7c40c043f 100644 --- a/src/plugins/saved_objects/public/index.ts +++ b/src/plugins/saved_objects/public/index.ts @@ -21,7 +21,13 @@ import { SavedObjectsPublicPlugin } from './plugin'; export { OnSaveProps, SavedObjectSaveModal, SaveResult, showSaveModal } from './save_modal'; export { getSavedObjectFinder, SavedObjectFinderUi, SavedObjectMetaData } from './finder'; -export { SavedObjectLoader, createSavedObjectClass } from './saved_object'; +export { + SavedObjectLoader, + createSavedObjectClass, + checkForDuplicateTitle, + saveWithConfirmation, + isErrorNonFatal, +} from './saved_object'; export { SavedObjectSaveOpts, SavedObjectKibanaServices, SavedObject } from './types'; export const plugin = () => new SavedObjectsPublicPlugin(); diff --git a/src/plugins/saved_objects/public/saved_object/helpers/save_saved_object.ts b/src/plugins/saved_objects/public/saved_object/helpers/save_saved_object.ts index e43619c2692a5..1fe3e551b09bc 100644 --- a/src/plugins/saved_objects/public/saved_object/helpers/save_saved_object.ts +++ b/src/plugins/saved_objects/public/saved_object/helpers/save_saved_object.ts @@ -30,7 +30,7 @@ import { checkForDuplicateTitle } from './check_for_duplicate_title'; * @param error {Error} the error * @return {boolean} */ -function isErrorNonFatal(error: { message: string }) { +export function isErrorNonFatal(error: { message: string }) { if (!error) return false; return error.message === OVERWRITE_REJECTED || error.message === SAVE_DUPLICATE_REJECTED; } diff --git a/src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.test.ts b/src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.test.ts new file mode 100644 index 0000000000000..b05747a10ecb7 --- /dev/null +++ b/src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.test.ts @@ -0,0 +1,93 @@ +/* + * 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 { SavedObjectAttributes, SavedObjectsCreateOptions, OverlayStart } from 'kibana/public'; +import { SavedObjectsClientContract } from '../../../../../core/public'; +import { saveWithConfirmation } from './save_with_confirmation'; +import * as deps from './confirm_modal_promise'; +import { OVERWRITE_REJECTED } from '../../constants'; + +describe('saveWithConfirmation', () => { + const savedObjectsClient: SavedObjectsClientContract = {} as SavedObjectsClientContract; + const overlays: OverlayStart = {} as OverlayStart; + const source: SavedObjectAttributes = {} as SavedObjectAttributes; + const options: SavedObjectsCreateOptions = {} as SavedObjectsCreateOptions; + const savedObject = { + getEsType: () => 'test type', + title: 'test title', + displayName: 'test display name', + }; + + beforeEach(() => { + savedObjectsClient.create = jest.fn(); + jest.spyOn(deps, 'confirmModalPromise').mockReturnValue(Promise.resolve({} as any)); + }); + + test('should call create of savedObjectsClient', async () => { + await saveWithConfirmation(source, savedObject, options, { savedObjectsClient, overlays }); + expect(savedObjectsClient.create).toHaveBeenCalledWith( + savedObject.getEsType(), + source, + options + ); + }); + + test('should call confirmModalPromise when such record exists', async () => { + savedObjectsClient.create = jest + .fn() + .mockImplementation((type, src, opt) => + opt && opt.overwrite ? Promise.resolve({} as any) : Promise.reject({ res: { status: 409 } }) + ); + + await saveWithConfirmation(source, savedObject, options, { savedObjectsClient, overlays }); + expect(deps.confirmModalPromise).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(String), + overlays + ); + }); + + test('should call create of savedObjectsClient when overwriting confirmed', async () => { + savedObjectsClient.create = jest + .fn() + .mockImplementation((type, src, opt) => + opt && opt.overwrite ? Promise.resolve({} as any) : Promise.reject({ res: { status: 409 } }) + ); + + await saveWithConfirmation(source, savedObject, options, { savedObjectsClient, overlays }); + expect(savedObjectsClient.create).toHaveBeenLastCalledWith(savedObject.getEsType(), source, { + overwrite: true, + ...options, + }); + }); + + test('should reject when overwriting denied', async () => { + savedObjectsClient.create = jest.fn().mockReturnValue(Promise.reject({ res: { status: 409 } })); + jest.spyOn(deps, 'confirmModalPromise').mockReturnValue(Promise.reject()); + + expect.assertions(1); + await expect( + saveWithConfirmation(source, savedObject, options, { + savedObjectsClient, + overlays, + }) + ).rejects.toThrow(OVERWRITE_REJECTED); + }); +}); diff --git a/src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.ts b/src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.ts new file mode 100644 index 0000000000000..b413ea19a932d --- /dev/null +++ b/src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.ts @@ -0,0 +1,87 @@ +/* + * 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 { get } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { + SavedObjectAttributes, + SavedObjectsCreateOptions, + OverlayStart, + SavedObjectsClientContract, +} from 'kibana/public'; +import { OVERWRITE_REJECTED } from '../../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 what will be indexed into elasticsearch. + * @param savedObject - a simple object that contains properties title and displayName, and getEsType method + * @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 saveWithConfirmation( + source: SavedObjectAttributes, + savedObject: { + getEsType(): string; + title: string; + displayName: string; + }, + options: SavedObjectsCreateOptions, + services: { savedObjectsClient: SavedObjectsClientContract; overlays: OverlayStart } +) { + const { savedObjectsClient, overlays } = services; + try { + return await savedObjectsClient.create(savedObject.getEsType(), source, options); + } catch (err) { + // record exists, confirm overwriting + if (get(err, 'res.status') === 409) { + const confirmMessage = i18n.translate( + 'savedObjects.confirmModal.overwriteConfirmationMessage', + { + defaultMessage: 'Are you sure you want to overwrite {title}?', + values: { title: savedObject.title }, + } + ); + + const title = i18n.translate('savedObjects.confirmModal.overwriteTitle', { + defaultMessage: 'Overwrite {name}?', + values: { name: savedObject.displayName }, + }); + const confirmButtonText = i18n.translate('savedObjects.confirmModal.overwriteButtonLabel', { + defaultMessage: 'Overwrite', + }); + + return confirmModalPromise(confirmMessage, title, confirmButtonText, overlays) + .then(() => + savedObjectsClient.create(savedObject.getEsType(), source, { + overwrite: true, + ...options, + }) + ) + .catch(() => Promise.reject(new Error(OVERWRITE_REJECTED))); + } + return await Promise.reject(err); + } +} diff --git a/src/plugins/saved_objects/public/saved_object/index.ts b/src/plugins/saved_objects/public/saved_object/index.ts index d3be5ea6df617..178ffaf88f4be 100644 --- a/src/plugins/saved_objects/public/saved_object/index.ts +++ b/src/plugins/saved_objects/public/saved_object/index.ts @@ -19,3 +19,6 @@ export { createSavedObjectClass } from './saved_object'; export { SavedObjectLoader } from './saved_object_loader'; +export { checkForDuplicateTitle } from './helpers/check_for_duplicate_title'; +export { saveWithConfirmation } from './helpers/save_with_confirmation'; +export { isErrorNonFatal } from './helpers/save_saved_object'; diff --git a/x-pack/plugins/graph/public/app.js b/x-pack/plugins/graph/public/app.js index 72dddc2b9f813..53175d18e629f 100644 --- a/x-pack/plugins/graph/public/app.js +++ b/x-pack/plugins/graph/public/app.js @@ -31,6 +31,11 @@ import { asAngularSyncedObservable } from './helpers/as_observable'; import { colorChoices } from './helpers/style_choices'; import { createGraphStore, datasourceSelector, hasFieldsSelector } from './state_management'; import { formatHttpError } from './helpers/format_http_error'; +import { + findSavedWorkspace, + getSavedWorkspace, + deleteSavedWorkspace, +} from './helpers/saved_workspace_utils'; export function initGraphApp(angularModule, deps) { const { @@ -42,7 +47,6 @@ export function initGraphApp(angularModule, deps) { getBasePath, data, config, - savedWorkspaceLoader, capabilities, coreStart, storage, @@ -112,15 +116,21 @@ export function initGraphApp(angularModule, deps) { $location.url(getNewPath()); }; $scope.find = search => { - return savedWorkspaceLoader.find(search, $scope.listingLimit); + return findSavedWorkspace( + { savedObjectsClient, basePath: coreStart.http.basePath }, + search, + $scope.listingLimit + ); }; $scope.editItem = workspace => { $location.url(getEditPath(workspace)); }; $scope.getViewUrl = workspace => getEditUrl(addBasePath, workspace); - $scope.delete = workspaces => { - return savedWorkspaceLoader.delete(workspaces.map(({ id }) => id)); - }; + $scope.delete = workspaces => + deleteSavedWorkspace( + savedObjectsClient, + workspaces.map(({ id }) => id) + ); $scope.capabilities = capabilities; $scope.initialFilter = $location.search().filter || ''; $scope.coreStart = coreStart; @@ -133,7 +143,7 @@ export function initGraphApp(angularModule, deps) { resolve: { savedWorkspace: function($rootScope, $route, $location) { return $route.current.params.id - ? savedWorkspaceLoader.get($route.current.params.id).catch(function(e) { + ? getSavedWorkspace(savedObjectsClient, $route.current.params.id).catch(function(e) { toastNotifications.addError(e, { title: i18n.translate('xpack.graph.missingWorkspaceErrorMessage', { defaultMessage: "Couldn't load graph with ID", @@ -146,7 +156,7 @@ export function initGraphApp(angularModule, deps) { // return promise that never returns to prevent the controller from loading return new Promise(); }) - : savedWorkspaceLoader.get(); + : getSavedWorkspace(savedObjectsClient); }, indexPatterns: function() { return savedObjectsClient @@ -283,6 +293,8 @@ export function initGraphApp(angularModule, deps) { }, notifications: coreStart.notifications, http: coreStart.http, + overlays: coreStart.overlays, + savedObjectsClient, showSaveModal, setWorkspaceInitialized: () => { $scope.workspaceInitialized = true; diff --git a/x-pack/plugins/graph/public/application.ts b/x-pack/plugins/graph/public/application.ts index 4f7bdd69db356..f804265f1f5ab 100644 --- a/x-pack/plugins/graph/public/application.ts +++ b/x-pack/plugins/graph/public/application.ts @@ -29,7 +29,6 @@ import { Plugin as DataPlugin, IndexPatternsContract } from '../../../../src/plu import { LicensingPluginSetup } from '../../licensing/public'; import { checkLicense } from '../common/check_license'; import { NavigationPublicPluginStart as NavigationStart } from '../../../../src/plugins/navigation/public'; -import { createSavedWorkspacesLoader } from './services/persistence/saved_workspace_loader'; import { Storage } from '../../../../src/plugins/kibana_utils/public'; import { addAppRedirectMessageToUrl, @@ -87,15 +86,7 @@ export const renderApp = ({ appBasePath, element, ...deps }: GraphDependencies) } }); - const savedWorkspaceLoader = createSavedWorkspacesLoader({ - chrome: deps.coreStart.chrome, - indexPatterns: deps.data.indexPatterns, - overlays: deps.coreStart.overlays, - savedObjectsClient: deps.coreStart.savedObjects.client, - basePath: deps.coreStart.http.basePath, - }); - - initGraphApp(graphAngularModule, { ...deps, savedWorkspaceLoader }); + initGraphApp(graphAngularModule, deps); const $injector = mountGraphApp(appBasePath, element); return () => { licenseSubscription.unsubscribe(); diff --git a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts new file mode 100644 index 0000000000000..2933e94b86e86 --- /dev/null +++ b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts @@ -0,0 +1,207 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { cloneDeep, assign, defaults, forOwn } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { + IBasePath, + OverlayStart, + SavedObjectsClientContract, + SavedObjectAttributes, +} from 'kibana/public'; + +import { + SavedObjectSaveOpts, + checkForDuplicateTitle, + saveWithConfirmation, + isErrorNonFatal, + SavedObjectKibanaServices, +} from '../../../../../src/plugins/saved_objects/public'; +import { + injectReferences, + extractReferences, +} from '../services/persistence/saved_workspace_references'; +import { SavedObjectNotFound } from '../../../../../src/plugins/kibana_utils/public'; +import { GraphWorkspaceSavedObject } from '../types'; + +const savedWorkspaceType = 'graph-workspace'; +const mapping: Record = { + title: 'text', + description: 'text', + numLinks: 'integer', + numVertices: 'integer', + version: 'integer', + wsState: 'json', +}; +const defaultsProps = { + title: i18n.translate('xpack.graph.savedWorkspace.workspaceNameTitle', { + defaultMessage: 'New Graph Workspace', + }), + numLinks: 0, + numVertices: 0, + wsState: '{}', + version: 1, +}; + +const urlFor = (basePath: IBasePath, id: string) => + basePath.prepend(`/app/graph#/workspace/${encodeURIComponent(id)}`); + +function mapHits(hit: { id: string; attributes: Record }, url: string) { + const source = hit.attributes; + source.id = hit.id; + source.url = url; + source.icon = 'fa-share-alt'; // looks like a graph + return source; +} + +interface SavedWorkspaceServices { + basePath: IBasePath; + savedObjectsClient: SavedObjectsClientContract; +} + +export function findSavedWorkspace( + { savedObjectsClient, basePath }: SavedWorkspaceServices, + searchString: string, + size: number = 100 +) { + return savedObjectsClient + .find>({ + type: savedWorkspaceType, + search: searchString ? `${searchString}*` : undefined, + perPage: size, + searchFields: ['title^3', 'description'], + }) + .then(resp => { + return { + total: resp.total, + hits: resp.savedObjects.map(hit => mapHits(hit, urlFor(basePath, hit.id))), + }; + }); +} + +export async function getSavedWorkspace( + savedObjectsClient: SavedObjectsClientContract, + id?: string +) { + const savedObject = { + id, + displayName: 'graph workspace', + getEsType: () => savedWorkspaceType, + } as { [key: string]: any }; + + if (!id) { + assign(savedObject, defaultsProps); + return Promise.resolve(savedObject); + } + + const resp = await savedObjectsClient.get>(savedWorkspaceType, id); + savedObject._source = cloneDeep(resp.attributes); + + if (!resp._version) { + throw new SavedObjectNotFound(savedWorkspaceType, id || ''); + } + + // assign the defaults to the response + defaults(savedObject._source, defaultsProps); + + // transform the source using JSON.parse + if (savedObject._source.wsState) { + savedObject._source.wsState = JSON.parse(savedObject._source.wsState as string); + } + + // Give obj all of the values in _source.fields + assign(savedObject, savedObject._source); + savedObject.lastSavedTitle = savedObject.title; + + if (resp.references && resp.references.length > 0) { + injectReferences(savedObject, resp.references); + } + + return savedObject as GraphWorkspaceSavedObject; +} + +export function deleteSavedWorkspace( + savedObjectsClient: SavedObjectsClientContract, + ids: string[] +) { + return Promise.all(ids.map((id: string) => savedObjectsClient.delete(savedWorkspaceType, id))); +} + +export async function saveSavedWorkspace( + savedObject: GraphWorkspaceSavedObject, + { + confirmOverwrite = false, + isTitleDuplicateConfirmed = false, + onTitleDuplicate, + }: SavedObjectSaveOpts = {}, + services: { + savedObjectsClient: SavedObjectsClientContract; + overlays: OverlayStart; + } +) { + // 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; + } + + let attributes: SavedObjectAttributes = {}; + + forOwn(mapping, (fieldType, fieldName) => { + const savedObjectFieldVal = savedObject[fieldName as keyof GraphWorkspaceSavedObject] as string; + if (savedObjectFieldVal != null) { + attributes[fieldName as keyof GraphWorkspaceSavedObject] = + fieldName === 'wsState' ? JSON.stringify(savedObjectFieldVal) : savedObjectFieldVal; + } + }); + const extractedRefs = extractReferences({ attributes, references: [] }); + const references = extractedRefs.references; + attributes = extractedRefs.attributes; + + if (!references) { + throw new Error('References not returned from extractReferences'); + } + + try { + await checkForDuplicateTitle( + savedObject as any, + isTitleDuplicateConfirmed, + onTitleDuplicate, + services as SavedObjectKibanaServices + ); + savedObject.isSaving = true; + + const createOpt = { + id: savedObject.id, + migrationVersion: savedObject.migrationVersion, + references, + }; + const resp = confirmOverwrite + ? await saveWithConfirmation(attributes, savedObject, createOpt, services) + : await services.savedObjectsClient.create(savedObject.getEsType(), attributes, { + ...createOpt, + overwrite: true, + }); + + savedObject.id = resp.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); + } +} diff --git a/x-pack/plugins/graph/public/services/persistence/saved_workspace.ts b/x-pack/plugins/graph/public/services/persistence/saved_workspace.ts deleted file mode 100644 index e2bd885dc7209..0000000000000 --- a/x-pack/plugins/graph/public/services/persistence/saved_workspace.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { i18n } from '@kbn/i18n'; -import { extractReferences, injectReferences } from './saved_workspace_references'; -import { - SavedObject, - createSavedObjectClass, - SavedObjectKibanaServices, -} from '../../../../../../src/plugins/saved_objects/public'; - -export interface SavedWorkspace extends SavedObject { - wsState?: string; -} - -export function createSavedWorkspaceClass(services: SavedObjectKibanaServices) { - // SavedWorkspace constructor. Usually you'd interact with an instance of this. - // ID is option, without it one will be generated on save. - const SavedObjectClass = createSavedObjectClass(services); - class SavedWorkspaceClass extends SavedObjectClass { - public static type: string = 'graph-workspace'; - // if type:workspace has no mapping, we push this mapping into ES - public static mapping: Record = { - title: 'text', - description: 'text', - numLinks: 'integer', - numVertices: 'integer', - version: 'integer', - wsState: 'json', - }; - // Order these fields to the top, the rest are alphabetical - public static fieldOrder = ['title', 'description']; - public static searchSource = false; - - public wsState?: string; - - constructor(id: string) { - // Gives our SavedWorkspace the properties of a SavedObject - super({ - type: SavedWorkspaceClass.type, - mapping: SavedWorkspaceClass.mapping, - searchSource: SavedWorkspaceClass.searchSource, - extractReferences, - injectReferences, - // if this is null/undefined then the SavedObject will be assigned the defaults - id, - // default values that will get assigned if the doc is new - defaults: { - title: i18n.translate('xpack.graph.savedWorkspace.workspaceNameTitle', { - defaultMessage: 'New Graph Workspace', - }), - numLinks: 0, - numVertices: 0, - wsState: '{}', - version: 1, - }, - }); - } - // Overwrite the default getDisplayName function which uses type and which is not very - // user friendly for this object. - getDisplayName = () => { - return 'graph workspace'; - }; - } - return SavedWorkspaceClass; -} diff --git a/x-pack/plugins/graph/public/services/persistence/saved_workspace_loader.ts b/x-pack/plugins/graph/public/services/persistence/saved_workspace_loader.ts deleted file mode 100644 index fb64fbadfbf7c..0000000000000 --- a/x-pack/plugins/graph/public/services/persistence/saved_workspace_loader.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { IBasePath } from 'kibana/public'; -import { i18n } from '@kbn/i18n'; - -import { SavedObjectKibanaServices } from '../../../../../../src/plugins/saved_objects/public'; -import { createSavedWorkspaceClass } from './saved_workspace'; - -export function createSavedWorkspacesLoader( - services: SavedObjectKibanaServices & { basePath: IBasePath } -) { - const { savedObjectsClient, basePath } = services; - const SavedWorkspace = createSavedWorkspaceClass(services); - const urlFor = (id: string) => - basePath.prepend(`/app/graph#/workspace/${encodeURIComponent(id)}`); - const mapHits = (hit: { id: string; attributes: Record }) => { - const source = hit.attributes; - source.id = hit.id; - source.url = urlFor(hit.id); - source.icon = 'fa-share-alt'; // looks like a graph - return source; - }; - - return { - type: SavedWorkspace.type, - Class: SavedWorkspace, - loaderProperties: { - name: 'Graph workspace', - noun: i18n.translate('xpack.graph.savedWorkspaces.graphWorkspaceLabel', { - defaultMessage: 'Graph workspace', - }), - nouns: i18n.translate('xpack.graph.savedWorkspaces.graphWorkspacesLabel', { - defaultMessage: 'Graph workspaces', - }), - }, - // Returns a single dashboard by ID, should be the name of the workspace - get: (id: string) => { - // Returns a promise that contains a workspace which is a subclass of docSource - // @ts-ignore - return new SavedWorkspace(id).init(); - }, - urlFor, - delete: (ids: string | string[]) => { - const idArr = Array.isArray(ids) ? ids : [ids]; - return Promise.all( - idArr.map((id: string) => savedObjectsClient.delete(SavedWorkspace.type, id)) - ); - }, - find: (searchString: string, size: number = 100) => { - return savedObjectsClient - .find>({ - type: SavedWorkspace.type, - search: searchString ? `${searchString}*` : undefined, - perPage: size, - searchFields: ['title^3', 'description'], - }) - .then(resp => { - return { - total: resp.total, - hits: resp.savedObjects.map(hit => mapHits(hit)), - }; - }); - }, - }; -} diff --git a/x-pack/plugins/graph/public/services/persistence/saved_workspace_references.test.ts b/x-pack/plugins/graph/public/services/persistence/saved_workspace_references.test.ts index 716520cb83aa1..c973a54a650a6 100644 --- a/x-pack/plugins/graph/public/services/persistence/saved_workspace_references.test.ts +++ b/x-pack/plugins/graph/public/services/persistence/saved_workspace_references.test.ts @@ -5,7 +5,6 @@ */ import { extractReferences, injectReferences } from './saved_workspace_references'; -import { SavedWorkspace } from './saved_workspace'; describe('extractReferences', () => { test('extracts references from wsState', () => { @@ -67,7 +66,7 @@ describe('injectReferences', () => { indexPatternRefName: 'indexPattern_0', bar: true, }), - } as SavedWorkspace; + }; const references = [ { name: 'indexPattern_0', @@ -89,7 +88,7 @@ Object { const context = { id: '1', title: 'test', - } as SavedWorkspace; + } as any; injectReferences(context, []); expect(context).toMatchInlineSnapshot(` Object { @@ -103,7 +102,7 @@ Object { const context = { id: '1', wsState: JSON.stringify({ bar: true }), - } as SavedWorkspace; + }; injectReferences(context, []); expect(context).toMatchInlineSnapshot(` Object { @@ -119,7 +118,7 @@ Object { wsState: JSON.stringify({ indexPatternRefName: 'indexPattern_0', }), - } as SavedWorkspace; + }; expect(() => injectReferences(context, [])).toThrowErrorMatchingInlineSnapshot( `"Could not find reference \\"indexPattern_0\\""` ); diff --git a/x-pack/plugins/graph/public/services/persistence/saved_workspace_references.ts b/x-pack/plugins/graph/public/services/persistence/saved_workspace_references.ts index 3a596b8068655..0948d7a88fce8 100644 --- a/x-pack/plugins/graph/public/services/persistence/saved_workspace_references.ts +++ b/x-pack/plugins/graph/public/services/persistence/saved_workspace_references.ts @@ -5,7 +5,6 @@ */ import { SavedObjectAttributes, SavedObjectReference } from 'kibana/public'; -import { SavedWorkspace } from './saved_workspace'; export function extractReferences({ attributes, @@ -38,7 +37,10 @@ export function extractReferences({ }; } -export function injectReferences(savedObject: SavedWorkspace, references: SavedObjectReference[]) { +export function injectReferences( + savedObject: { wsState?: string }, + references: SavedObjectReference[] +) { // Skip if wsState is missing, at the time of development of this, there is no guarantee each // saved object has wsState. if (typeof savedObject.wsState !== 'string') { diff --git a/x-pack/plugins/graph/public/services/save_modal.tsx b/x-pack/plugins/graph/public/services/save_modal.tsx index 127ff6a2b4c37..94b5de3be13ac 100644 --- a/x-pack/plugins/graph/public/services/save_modal.tsx +++ b/x-pack/plugins/graph/public/services/save_modal.tsx @@ -5,18 +5,24 @@ */ import React from 'react'; -import { I18nStart } from 'src/core/public'; +import { I18nStart, OverlayStart, SavedObjectsClientContract } from 'src/core/public'; import { SaveResult } from 'src/plugins/saved_objects/public'; import { GraphWorkspaceSavedObject, GraphSavePolicy } from '../types'; import { SaveModal, OnSaveGraphProps } from '../components/save_modal'; +export interface SaveWorkspaceServices { + overlays: OverlayStart; + savedObjectsClient: SavedObjectsClientContract; +} + export type SaveWorkspaceHandler = ( saveOptions: { confirmOverwrite: boolean; isTitleDuplicateConfirmed: boolean; onTitleDuplicate: () => void; }, - dataConsent: boolean + dataConsent: boolean, + services: SaveWorkspaceServices ) => Promise; export function openSaveModal({ @@ -26,6 +32,7 @@ export function openSaveModal({ saveWorkspace, showSaveModal, I18nContext, + services, }: { savePolicy: GraphSavePolicy; hasData: boolean; @@ -33,6 +40,7 @@ export function openSaveModal({ saveWorkspace: SaveWorkspaceHandler; showSaveModal: (el: React.ReactNode, I18nContext: I18nStart['Context']) => void; I18nContext: I18nStart['Context']; + services: SaveWorkspaceServices; }) { const currentTitle = workspace.title; const currentDescription = workspace.description; @@ -52,7 +60,7 @@ export function openSaveModal({ isTitleDuplicateConfirmed, onTitleDuplicate, }; - return saveWorkspace(saveOptions, dataConsent).then(response => { + return saveWorkspace(saveOptions, dataConsent, services).then(response => { // If the save wasn't successful, put the original values back. if (!('id' in response) || !Boolean(response.id)) { workspace.title = currentTitle; diff --git a/x-pack/plugins/graph/public/state_management/mocks.ts b/x-pack/plugins/graph/public/state_management/mocks.ts index d06f8a7b3ef0b..02a5830ffd6be 100644 --- a/x-pack/plugins/graph/public/state_management/mocks.ts +++ b/x-pack/plugins/graph/public/state_management/mocks.ts @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { NotificationsStart, HttpStart } from 'kibana/public'; +import { + NotificationsStart, + HttpStart, + OverlayStart, + SavedObjectsClientContract, +} from 'kibana/public'; import createSagaMiddleware from 'redux-saga'; import { createStore, applyMiddleware, AnyAction } from 'redux'; import { ChromeStart } from 'kibana/public'; @@ -79,6 +84,13 @@ export function createMockGraphStore({ setLiveResponseFields: jest.fn(), setUrlTemplates: jest.fn(), setWorkspaceInitialized: jest.fn(), + overlays: ({ + openModal: jest.fn(), + } as unknown) as OverlayStart, + savedObjectsClient: ({ + find: jest.fn(), + get: jest.fn(), + } as unknown) as SavedObjectsClientContract, ...mockedDepsOverwrites, }; const sagaMiddleware = createSagaMiddleware(); diff --git a/x-pack/plugins/graph/public/state_management/persistence.test.ts b/x-pack/plugins/graph/public/state_management/persistence.test.ts index 2dac92fceb6b4..285bf2d6a0ea9 100644 --- a/x-pack/plugins/graph/public/state_management/persistence.test.ts +++ b/x-pack/plugins/graph/public/state_management/persistence.test.ts @@ -40,6 +40,10 @@ jest.mock('../services/save_modal', () => ({ openSaveModal: jest.fn(), })); +jest.mock('../helpers/saved_workspace_utils', () => ({ + saveSavedWorkspace: jest.fn().mockResolvedValueOnce('123'), +})); + describe('persistence sagas', () => { let env: MockedGraphEnvironment; @@ -90,7 +94,6 @@ describe('persistence sagas', () => { savePolicy: 'configAndDataWithConsent', }, }); - (env.mockedDeps.getSavedWorkspace().save as jest.Mock).mockResolvedValueOnce('123'); env.mockedDeps.getSavedWorkspace().id = '123'; }); diff --git a/x-pack/plugins/graph/public/state_management/persistence.ts b/x-pack/plugins/graph/public/state_management/persistence.ts index 0f72186af031f..8dd1386f70e6e 100644 --- a/x-pack/plugins/graph/public/state_management/persistence.ts +++ b/x-pack/plugins/graph/public/state_management/persistence.ts @@ -22,6 +22,8 @@ import { import { updateMetaData, metaDataSelector } from './meta_data'; import { openSaveModal, SaveWorkspaceHandler } from '../services/save_modal'; import { getEditPath } from '../services/url'; +import { saveSavedWorkspace } from '../helpers/saved_workspace_utils'; + const actionCreator = actionCreatorFactory('x-pack/graph'); export const loadSavedWorkspace = actionCreator('LOAD_WORKSPACE'); @@ -140,7 +142,8 @@ function showModal( ) { const saveWorkspaceHandler: SaveWorkspaceHandler = async ( saveOptions, - userHasConfirmedSaveWorkspaceData + userHasConfirmedSaveWorkspaceData, + services ) => { const canSaveData = deps.savePolicy === 'configAndData' || @@ -157,7 +160,7 @@ function showModal( canSaveData ); try { - const id = await savedWorkspace.save(saveOptions); + const id = await saveSavedWorkspace(savedWorkspace, saveOptions, services); if (id) { const title = i18n.translate('xpack.graph.saveWorkspace.successNotificationTitle', { defaultMessage: 'Saved "{workspaceTitle}"', @@ -200,5 +203,9 @@ function showModal( showSaveModal: deps.showSaveModal, saveWorkspace: saveWorkspaceHandler, I18nContext: deps.I18nContext, + services: { + savedObjectsClient: deps.savedObjectsClient, + overlays: deps.overlays, + }, }); } diff --git a/x-pack/plugins/graph/public/state_management/store.ts b/x-pack/plugins/graph/public/state_management/store.ts index 4aeef0338923b..e639662225cb3 100644 --- a/x-pack/plugins/graph/public/state_management/store.ts +++ b/x-pack/plugins/graph/public/state_management/store.ts @@ -6,7 +6,7 @@ import createSagaMiddleware, { SagaMiddleware } from 'redux-saga'; import { combineReducers, createStore, Store, AnyAction, Dispatch, applyMiddleware } from 'redux'; -import { ChromeStart, I18nStart } from 'kibana/public'; +import { ChromeStart, I18nStart, OverlayStart, SavedObjectsClientContract } from 'kibana/public'; import { CoreStart } from 'src/core/public'; import { fieldsReducer, @@ -54,6 +54,8 @@ export interface GraphStoreDependencies { getSavedWorkspace: () => GraphWorkspaceSavedObject; notifications: CoreStart['notifications']; http: CoreStart['http']; + overlays: OverlayStart; + savedObjectsClient: SavedObjectsClientContract; showSaveModal: (el: React.ReactNode, I18nContext: I18nStart['Context']) => void; savePolicy: GraphSavePolicy; changeUrl: (newUrl: string) => void; diff --git a/x-pack/plugins/graph/public/types/persistence.ts b/x-pack/plugins/graph/public/types/persistence.ts index b0209153c82e3..6847199d5878c 100644 --- a/x-pack/plugins/graph/public/types/persistence.ts +++ b/x-pack/plugins/graph/public/types/persistence.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SavedObject } from '../../../../../src/plugins/saved_objects/public'; import { AdvancedSettings, UrlTemplate, WorkspaceField } from './app_state'; import { WorkspaceNode, WorkspaceEdge } from './workspace_state'; @@ -12,15 +11,23 @@ type Omit = Pick>; /** * Workspace fetched from server. - * This type is returned by `SavedWorkspacesProvider#get`. */ -export interface GraphWorkspaceSavedObject extends SavedObject { - title: string; +export interface GraphWorkspaceSavedObject { + copyOnSave?: boolean; description: string; + displayName: string; + getEsType(): string; + id?: string; + isSaving?: boolean; + lastSavedTitle?: string; + migrationVersion?: Record; numLinks: number; numVertices: number; - version: number; + title: string; + type: string; + version?: number; wsState: string; + _source: Record; } export interface SerializedWorkspaceState { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index b746b659704ff..f02b52e5922d1 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5679,8 +5679,6 @@ "xpack.graph.outlinkEncoders.textPlainTitle": "プレインテキスト", "xpack.graph.pluginDescription": "Elasticsearch データの関連性のある関係を浮上させ分析します。", "xpack.graph.savedWorkspace.workspaceNameTitle": "新規グラフワークスペース", - "xpack.graph.savedWorkspaces.graphWorkspaceLabel": "グラフワークスペース", - "xpack.graph.savedWorkspaces.graphWorkspacesLabel": "グラフワークスペース", "xpack.graph.saveWorkspace.savingErrorMessage": "ワークスペースの保存に失敗しました: {message}", "xpack.graph.saveWorkspace.successNotification.noDataSavedText": "構成が保存されましたが、データは保存されませんでした", "xpack.graph.saveWorkspace.successNotificationTitle": "「{workspaceTitle}」が保存されました", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 77c077e653b84..be6a3df6b6c18 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5679,8 +5679,6 @@ "xpack.graph.outlinkEncoders.textPlainTitle": "纯文本", "xpack.graph.pluginDescription": "显示并分析 Elasticsearch 数据中的相关关系。", "xpack.graph.savedWorkspace.workspaceNameTitle": "新建 Graph 工作空间", - "xpack.graph.savedWorkspaces.graphWorkspaceLabel": "Graph 工作空间", - "xpack.graph.savedWorkspaces.graphWorkspacesLabel": "Graph 工作空间", "xpack.graph.saveWorkspace.savingErrorMessage": "无法保存工作空间:{message}", "xpack.graph.saveWorkspace.successNotification.noDataSavedText": "配置会被保存,但不保存数据", "xpack.graph.saveWorkspace.successNotificationTitle": "已保存“{workspaceTitle}”",