[Visualization] Get rid of saved object loader and use savedObjectClient resolve (#113121)

* First step: create saved_visualize_utils, starting use new get/save methods

* Use new util methods in embeddable

* move findListItem in utils

* some clean up

* clean up

* Some fixes

* Fix saved object tags

* Some types fixes

* Fix unit tests

* Clean up code

* Add unit tests for new utils

* Fix lint

* Fix tagging

* Add unit tests

* Some fixes

* Clean up code

* Fix lint

* Fix types

* put new methods in start contract

* Fix imports

* Fix lint

* Fix comments

* Fix lint

* Fix CI

* use local url instead of full path

* Fix unit test

* Some clean up

* Fix nits

* fix types

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Uladzislau Lasitsa 2021-10-11 11:56:36 +03:00 committed by GitHub
parent 2dece3d446
commit 41c813bac4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1291 additions and 124 deletions

View file

@ -11,7 +11,7 @@
"inspector",
"savedObjects"
],
"optionalPlugins": ["usageCollection"],
"optionalPlugins": ["usageCollection", "spaces", "savedObjectsTaggingOss"],
"requiredBundles": ["kibanaUtils", "discover"],
"extraPublicDirs": ["common/constants", "common/prepare_log_table", "common/expression_functions"],
"owner": {

View file

@ -20,16 +20,10 @@ import {
AttributeService,
} from '../../../../plugins/embeddable/public';
import { DisabledLabEmbeddable } from './disabled_lab_embeddable';
import {
getSavedVisualizationsLoader,
getUISettings,
getHttp,
getTimeFilter,
getCapabilities,
} from '../services';
import { getUISettings, getHttp, getTimeFilter, getCapabilities } from '../services';
import { urlFor } from '../utils/saved_visualize_utils';
import { VisualizeEmbeddableFactoryDeps } from './visualize_embeddable_factory';
import { VISUALIZE_ENABLE_LABS_SETTING } from '../../common/constants';
import { SavedVisualizationsLoader } from '../saved_visualizations';
import { IndexPattern } from '../../../data/public';
import { createVisualizeEmbeddableAsync } from './visualize_embeddable_async';
@ -38,7 +32,6 @@ export const createVisEmbeddableFromObject =
async (
vis: Vis,
input: Partial<VisualizeInput> & { id: string },
savedVisualizationsLoader?: SavedVisualizationsLoader,
attributeService?: AttributeService<
VisualizeSavedObjectAttributes,
VisualizeByValueInput,
@ -46,16 +39,12 @@ export const createVisEmbeddableFromObject =
>,
parent?: IContainer
): Promise<VisualizeEmbeddable | ErrorEmbeddable | DisabledLabEmbeddable> => {
const savedVisualizations = getSavedVisualizationsLoader();
try {
const visId = vis.id as string;
const editPath = visId ? savedVisualizations.urlFor(visId) : '#/edit_by_value';
const editPath = visId ? urlFor(visId) : '#/edit_by_value';
const editUrl = visId
? getHttp().basePath.prepend(`/app/visualize${savedVisualizations.urlFor(visId)}`)
: '';
const editUrl = visId ? getHttp().basePath.prepend(`/app/visualize${urlFor(visId)}`) : '';
const isLabsEnabled = getUISettings().get<boolean>(VISUALIZE_ENABLE_LABS_SETTING);
if (!isLabsEnabled && vis.type.stage === 'experimental') {
@ -87,7 +76,6 @@ export const createVisEmbeddableFromObject =
},
input,
attributeService,
savedVisualizationsLoader,
parent
);
} catch (e) {

View file

@ -39,7 +39,7 @@ import { getExpressions, getUiActions } from '../services';
import { VIS_EVENT_TO_TRIGGER } from './events';
import { VisualizeEmbeddableFactoryDeps } from './visualize_embeddable_factory';
import { SavedObjectAttributes } from '../../../../core/types';
import { SavedVisualizationsLoader } from '../saved_visualizations';
import { getSavedVisualization } from '../utils/saved_visualize_utils';
import { VisSavedObject } from '../types';
import { toExpressionAst } from './to_ast';
@ -108,7 +108,6 @@ export class VisualizeEmbeddable
VisualizeByValueInput,
VisualizeByReferenceInput
>;
private savedVisualizationsLoader?: SavedVisualizationsLoader;
constructor(
timefilter: TimefilterContract,
@ -119,7 +118,6 @@ export class VisualizeEmbeddable
VisualizeByValueInput,
VisualizeByReferenceInput
>,
savedVisualizationsLoader?: SavedVisualizationsLoader,
parent?: IContainer
) {
super(
@ -144,7 +142,6 @@ export class VisualizeEmbeddable
this.vis.uiState.on('change', this.uiStateChangeHandler);
this.vis.uiState.on('reload', this.reload);
this.attributeService = attributeService;
this.savedVisualizationsLoader = savedVisualizationsLoader;
if (this.attributeService) {
const isByValue = !this.inputIsRefType(initialInput);
@ -455,7 +452,15 @@ export class VisualizeEmbeddable
};
getInputAsRefType = async (): Promise<VisualizeByReferenceInput> => {
const savedVis = await this.savedVisualizationsLoader?.get({});
const { savedObjectsClient, data, spaces, savedObjectsTaggingOss } = await this.deps.start()
.plugins;
const savedVis = await getSavedVisualization({
savedObjectsClient,
search: data.search,
dataViews: data.dataViews,
spaces,
savedObjectsTagging: savedObjectsTaggingOss?.getTaggingApi(),
});
if (!savedVis) {
throw new Error('Error creating a saved vis object');
}

View file

@ -33,20 +33,20 @@ import type {
import { VISUALIZE_EMBEDDABLE_TYPE } from './constants';
import type { SerializedVis, Vis } from '../vis';
import { createVisAsync } from '../vis_async';
import {
getCapabilities,
getTypes,
getUISettings,
getSavedVisualizationsLoader,
} from '../services';
import { getCapabilities, getTypes, getUISettings } from '../services';
import { showNewVisModal } from '../wizard';
import { convertToSerializedVis } from '../saved_visualizations/_saved_vis';
import {
convertToSerializedVis,
getSavedVisualization,
saveVisualization,
getFullPath,
} from '../utils/saved_visualize_utils';
import {
extractControlsReferences,
extractTimeSeriesReferences,
injectTimeSeriesReferences,
injectControlsReferences,
} from '../saved_visualizations/saved_visualization_references';
} from '../utils/saved_visualization_references';
import { createVisEmbeddableFromObject } from './create_vis_embeddable_from_object';
import { VISUALIZE_ENABLE_LABS_SETTING } from '../../common/constants';
import { checkForDuplicateTitle } from '../../../saved_objects/public';
@ -59,7 +59,15 @@ interface VisualizationAttributes extends SavedObjectAttributes {
export interface VisualizeEmbeddableFactoryDeps {
start: StartServicesGetter<
Pick<VisualizationsStartDeps, 'inspector' | 'embeddable' | 'savedObjectsClient'>
Pick<
VisualizationsStartDeps,
| 'inspector'
| 'embeddable'
| 'savedObjectsClient'
| 'data'
| 'savedObjectsTaggingOss'
| 'spaces'
>
>;
}
@ -147,17 +155,36 @@ export class VisualizeEmbeddableFactory
input: Partial<VisualizeInput> & { id: string },
parent?: IContainer
): Promise<VisualizeEmbeddable | ErrorEmbeddable | DisabledLabEmbeddable> {
const savedVisualizations = getSavedVisualizationsLoader();
const startDeps = await this.deps.start();
try {
const savedObject = await savedVisualizations.get(savedObjectId);
const savedObject = await getSavedVisualization(
{
savedObjectsClient: startDeps.core.savedObjects.client,
search: startDeps.plugins.data.search,
dataViews: startDeps.plugins.data.dataViews,
spaces: startDeps.plugins.spaces,
savedObjectsTagging: startDeps.plugins.savedObjectsTaggingOss?.getTaggingApi(),
},
savedObjectId
);
if (savedObject.sharingSavedObjectProps?.outcome === 'conflict') {
return new ErrorEmbeddable(
i18n.translate('visualizations.embeddable.legacyURLConflict.errorMessage', {
defaultMessage: `This visualization has the same URL as a legacy alias. Disable the alias to resolve this error : {json}`,
values: { json: savedObject.sharingSavedObjectProps?.errorJSON },
}),
input,
parent
);
}
const visState = convertToSerializedVis(savedObject);
const vis = await createVisAsync(savedObject.visState.type, visState);
return createVisEmbeddableFromObject(this.deps)(
vis,
input,
savedVisualizations,
await this.getAttributeService(),
parent
);
@ -173,11 +200,9 @@ export class VisualizeEmbeddableFactory
if (input.savedVis) {
const visState = input.savedVis;
const vis = await createVisAsync(visState.type, visState);
const savedVisualizations = getSavedVisualizationsLoader();
return createVisEmbeddableFromObject(this.deps)(
vis,
input,
savedVisualizations,
await this.getAttributeService(),
parent
);
@ -201,9 +226,9 @@ export class VisualizeEmbeddableFactory
confirmOverwrite: false,
returnToOrigin: true,
isTitleDuplicateConfirmed: true,
copyOnSave: false,
};
savedVis.title = title;
savedVis.copyOnSave = false;
savedVis.description = '';
savedVis.searchSourceFields = visObj?.data.searchSource?.getSerializedFields();
const serializedVis = (visObj as unknown as Vis).serialize();
@ -217,7 +242,12 @@ export class VisualizeEmbeddableFactory
if (visObj) {
savedVis.uiStateJSON = visObj?.uiState.toString();
}
const id = await savedVis.save(saveOptions);
const { core, plugins } = await this.deps.start();
const id = await saveVisualization(savedVis, saveOptions, {
savedObjectsClient: core.savedObjects.client,
overlays: core.overlays,
savedObjectsTagging: plugins.savedObjectsTaggingOss?.getTaggingApi(),
});
if (!id || id === '') {
throw new Error(
i18n.translate('visualizations.savingVisualizationFailed.errorMsg', {
@ -225,6 +255,7 @@ export class VisualizeEmbeddableFactory
})
);
}
core.chrome.recentlyAccessed.add(getFullPath(id), savedVis.title, String(id));
return { id };
} catch (error) {
throw error;

View file

@ -38,6 +38,7 @@ export {
VisToExpressionAst,
VisToExpressionAstParams,
VisEditorOptionsProps,
GetVisOptions,
} from './types';
export { VisualizationListItem, VisualizationStage } from './vis_types/vis_type_alias_registry';
export { VISUALIZE_ENABLE_LABS_SETTING } from '../common/constants';
@ -49,3 +50,4 @@ export {
FakeParams,
HistogramParams,
} from '../common/expression_functions/xy_dimension';
export { urlFor, getFullPath } from './utils/saved_visualize_utils';

View file

@ -10,6 +10,7 @@ import { PluginInitializerContext } from '../../../core/public';
import { Schema, VisualizationsSetup, VisualizationsStart } from './';
import { Schemas } from './vis_types';
import { VisualizationsPlugin } from './plugin';
import { spacesPluginMock } from '../../../../x-pack/plugins/spaces/public/mocks';
import { coreMock, applicationServiceMock } from '../../../core/public/mocks';
import { embeddablePluginMock } from '../../../plugins/embeddable/public/mocks';
import { expressionsPluginMock } from '../../../plugins/expressions/public/mocks';
@ -18,6 +19,7 @@ import { usageCollectionPluginMock } from '../../../plugins/usage_collection/pub
import { uiActionsPluginMock } from '../../../plugins/ui_actions/public/mocks';
import { inspectorPluginMock } from '../../../plugins/inspector/public/mocks';
import { savedObjectsPluginMock } from '../../../plugins/saved_objects/public/mocks';
import { savedObjectTaggingOssPluginMock } from '../../saved_objects_tagging_oss/public/mocks';
const createSetupContract = (): VisualizationsSetup => ({
createBaseVisualization: jest.fn(),
@ -34,6 +36,9 @@ const createStartContract = (): VisualizationsStart => ({
savedVisualizationsLoader: {
get: jest.fn(),
} as any,
getSavedVisualization: jest.fn(),
saveVisualization: jest.fn(),
findListItems: jest.fn(),
showNewVisModal: jest.fn(),
createVis: jest.fn(),
convertFromSerializedVis: jest.fn(),
@ -61,9 +66,11 @@ const createInstance = async () => {
uiActions: uiActionsPluginMock.createStartContract(),
application: applicationServiceMock.createStartContract(),
embeddable: embeddablePluginMock.createStartContract(),
spaces: spacesPluginMock.createStartContract(),
getAttributeService: jest.fn(),
savedObjectsClient: coreMock.createStart().savedObjects.client,
savedObjects: savedObjectsPluginMock.createStartContract(),
savedObjectsTaggingOss: savedObjectTaggingOssPluginMock.createStart(),
});
return {

View file

@ -5,6 +5,8 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { SavedObjectsFindOptionsReference } from 'kibana/public';
import {
setUISettings,
setTypes,
@ -30,6 +32,7 @@ import {
VisualizeEmbeddableFactory,
createVisEmbeddableFromObject,
} from './embeddable';
import type { SpacesPluginStart } from '../../../../x-pack/plugins/spaces/public';
import { TypesService } from './vis_types/types_service';
import { range as rangeExpressionFunction } from '../common/expression_functions/range';
import { visDimension as visDimensionExpressionFunction } from '../common/expression_functions/vis_dimension';
@ -43,7 +46,10 @@ import { showNewVisModal } from './wizard';
import {
convertFromSerializedVis,
convertToSerializedVis,
} from './saved_visualizations/_saved_vis';
getSavedVisualization,
saveVisualization,
findListItems,
} from './utils/saved_visualize_utils';
import { createSavedSearchesLoader } from '../../discover/public';
@ -66,7 +72,9 @@ import type {
import type { DataPublicPluginSetup, DataPublicPluginStart } from '../../../plugins/data/public';
import type { ExpressionsSetup, ExpressionsStart } from '../../expressions/public';
import type { EmbeddableSetup, EmbeddableStart } from '../../embeddable/public';
import type { SavedObjectTaggingOssPluginStart } from '../../saved_objects_tagging_oss/public';
import { createVisAsync } from './vis_async';
import type { VisSavedObject, SaveVisOptions, GetVisOptions } from './types';
/**
* Interface for this plugin's returned setup/start contracts.
@ -82,6 +90,13 @@ export interface VisualizationsStart extends TypesStart {
convertToSerializedVis: typeof convertToSerializedVis;
convertFromSerializedVis: typeof convertFromSerializedVis;
showNewVisModal: typeof showNewVisModal;
getSavedVisualization: (opts?: GetVisOptions | string) => Promise<VisSavedObject>;
saveVisualization: (savedVis: VisSavedObject, saveOptions: SaveVisOptions) => Promise<string>;
findListItems: (
searchTerm: string,
listingLimit: number,
references?: SavedObjectsFindOptionsReference[]
) => Promise<{ hits: Array<Record<string, unknown>>; total: number }>;
__LEGACY: { createVisEmbeddableFromObject: ReturnType<typeof createVisEmbeddableFromObject> };
}
@ -103,6 +118,8 @@ export interface VisualizationsStartDeps {
getAttributeService: EmbeddableStart['getAttributeService'];
savedObjects: SavedObjectsStart;
savedObjectsClient: SavedObjectsClientContract;
spaces?: SpacesPluginStart;
savedObjectsTaggingOss?: SavedObjectTaggingOssPluginStart;
}
/**
@ -149,7 +166,15 @@ export class VisualizationsPlugin
public start(
core: CoreStart,
{ data, expressions, uiActions, embeddable, savedObjects }: VisualizationsStartDeps
{
data,
expressions,
uiActions,
embeddable,
savedObjects,
spaces,
savedObjectsTaggingOss,
}: VisualizationsStartDeps
): VisualizationsStart {
const types = this.types.start();
setTypes(types);
@ -181,6 +206,28 @@ export class VisualizationsPlugin
return {
...types,
showNewVisModal,
getSavedVisualization: async (opts) => {
return getSavedVisualization(
{
search: data.search,
savedObjectsClient: core.savedObjects.client,
dataViews: data.dataViews,
spaces,
savedObjectsTagging: savedObjectsTaggingOss?.getTaggingApi(),
},
opts
);
},
saveVisualization: async (savedVis, saveOptions) => {
return saveVisualization(savedVis, saveOptions, {
savedObjectsClient: core.savedObjects.client,
overlays: core.overlays,
savedObjectsTagging: savedObjectsTaggingOss?.getTaggingApi(),
});
},
findListItems: async (searchTerm, listingLimit, references) => {
return findListItems(core.savedObjects.client, types, searchTerm, listingLimit, references);
},
/**
* creates new instance of Vis
* @param {IndexPattern} indexPattern - index pattern to use

View file

@ -16,11 +16,11 @@
import type { SavedObjectsStart, SavedObject } from '../../../../plugins/saved_objects/public';
// @ts-ignore
import { updateOldState } from '../legacy/vis_update_state';
import { extractReferences, injectReferences } from './saved_visualization_references';
import { extractReferences, injectReferences } from '../utils/saved_visualization_references';
import { createSavedSearchesLoader } from '../../../discover/public';
import type { SavedObjectsClientContract } from '../../../../core/public';
import type { IndexPatternsContract } from '../../../../plugins/data/public';
import type { ISavedVis, SerializedVis } from '../types';
import type { ISavedVis } from '../types';
export interface SavedVisServices {
savedObjectsClient: SavedObjectsClientContract;
@ -28,43 +28,7 @@ export interface SavedVisServices {
indexPatterns: IndexPatternsContract;
}
export const convertToSerializedVis = (savedVis: ISavedVis): SerializedVis => {
const { id, title, description, visState, uiStateJSON, searchSourceFields } = savedVis;
const aggs = searchSourceFields && searchSourceFields.index ? visState.aggs || [] : visState.aggs;
return {
id,
title,
type: visState.type,
description,
params: visState.params,
uiState: JSON.parse(uiStateJSON || '{}'),
data: {
aggs,
searchSource: searchSourceFields!,
savedSearchId: savedVis.savedSearchId,
},
};
};
export const convertFromSerializedVis = (vis: SerializedVis): ISavedVis => {
return {
id: vis.id,
title: vis.title,
description: vis.description,
visState: {
title: vis.title,
type: vis.type,
aggs: vis.data.aggs,
params: vis.params,
},
uiStateJSON: JSON.stringify(vis.uiState),
searchSourceFields: vis.data.searchSource,
savedSearchId: vis.data.savedSearchId,
};
};
/** @deprecated **/
export function createSavedVisClass(services: SavedVisServices) {
const savedSearch = createSavedSearchesLoader(services);

View file

@ -22,6 +22,7 @@ export interface FindListItemsOptions {
references?: SavedObjectsFindOptionsReference[];
}
/** @deprecated **/
export function createSavedVisLoader(services: SavedVisServicesWithVisualizations) {
const { savedObjectsClient, visualizationTypes } = services;

View file

@ -6,13 +6,14 @@
* Side Public License, v 1.
*/
import { SavedObject } from '../../../plugins/saved_objects/public';
import type { SavedObjectsMigrationVersion } from 'kibana/public';
import {
IAggConfigs,
SearchSourceFields,
TimefilterContract,
AggConfigSerialized,
} from '../../../plugins/data/public';
import type { ISearchSource } from '../../data/common';
import { ExpressionAstExpression } from '../../expressions/public';
import type { SerializedVis, Vis } from './vis';
@ -36,9 +37,39 @@ export interface ISavedVis {
uiStateJSON?: string;
savedSearchRefName?: string;
savedSearchId?: string;
sharingSavedObjectProps?: {
outcome?: 'aliasMatch' | 'exactMatch' | 'conflict';
aliasTargetId?: string;
errorJSON?: string;
};
}
export interface VisSavedObject extends SavedObject, ISavedVis {}
export interface VisSavedObject extends ISavedVis {
lastSavedTitle: string;
getEsType: () => string;
getDisplayName?: () => string;
displayName: string;
migrationVersion?: SavedObjectsMigrationVersion;
searchSource?: ISearchSource;
version?: string;
tags?: string[];
}
export interface SaveVisOptions {
confirmOverwrite?: boolean;
isTitleDuplicateConfirmed?: boolean;
onTitleDuplicate?: () => void;
copyOnSave?: boolean;
}
export interface GetVisOptions {
id?: string;
searchSource?: boolean;
migrationVersion?: SavedObjectsMigrationVersion;
savedSearchId?: string;
type?: string;
indexPattern?: string;
}
export interface VisToExpressionAstParams {
timefilter: TimefilterContract;

View file

@ -0,0 +1,507 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { ISearchSource } from '../../../data/common';
import type { SpacesPluginStart } from '../../../../../x-pack/plugins/spaces/public';
import type { SavedObjectsTaggingApi } from '../../../saved_objects_tagging_oss/public';
import { coreMock } from '../../../../core/public/mocks';
import { dataPluginMock } from '../../../data/public/mocks';
import { SavedObjectsClientContract } from '../../../../core/public';
import {
findListItems,
getSavedVisualization,
saveVisualization,
SAVED_VIS_TYPE,
} from './saved_visualize_utils';
import { VisTypeAlias, TypesStart } from '../vis_types';
import type { VisSavedObject } from '../types';
let visTypes = [] as VisTypeAlias[];
const mockGetAliases = jest.fn(() => visTypes);
const mockGetTypes = jest.fn((type: string) => type) as unknown as TypesStart['get'];
jest.mock('../services', () => ({
getSpaces: jest.fn(() => ({
getActiveSpace: () => ({
id: 'test',
}),
})),
}));
const mockParseSearchSourceJSON = jest.fn();
const mockInjectSearchSourceReferences = jest.fn();
const mockExtractSearchSourceReferences = jest.fn((...args) => [{}, []]);
jest.mock('../../../../plugins/data/public', () => ({
extractSearchSourceReferences: jest.fn((...args) => mockExtractSearchSourceReferences(...args)),
injectSearchSourceReferences: jest.fn((...args) => mockInjectSearchSourceReferences(...args)),
parseSearchSourceJSON: jest.fn((...args) => mockParseSearchSourceJSON(...args)),
}));
const mockInjectReferences = jest.fn();
const mockExtractReferences = jest.fn(() => ({ references: [], attributes: {} }));
jest.mock('./saved_visualization_references', () => ({
injectReferences: jest.fn((...args) => mockInjectReferences(...args)),
extractReferences: jest.fn(() => mockExtractReferences()),
}));
let isTitleDuplicateConfirmed = true;
const mockCheckForDuplicateTitle = jest.fn(() => {
if (!isTitleDuplicateConfirmed) {
throw new Error();
}
});
const mockSaveWithConfirmation = jest.fn(() => ({ id: 'test-after-confirm' }));
jest.mock('../../../../plugins/saved_objects/public', () => ({
checkForDuplicateTitle: jest.fn(() => mockCheckForDuplicateTitle()),
saveWithConfirmation: jest.fn(() => mockSaveWithConfirmation()),
isErrorNonFatal: jest.fn(() => true),
}));
describe('saved_visualize_utils', () => {
const { overlays, savedObjects } = coreMock.createStart();
const savedObjectsClient = savedObjects.client as jest.Mocked<SavedObjectsClientContract>;
(savedObjectsClient.resolve as jest.Mock).mockImplementation(() => ({
saved_object: {
references: [
{
id: 'test',
type: 'index-pattern',
},
],
attributes: {
visState: JSON.stringify({ type: 'area' }),
kibanaSavedObjectMeta: {
searchSourceJSON: '{filter: []}',
},
},
_version: '1',
},
outcome: 'exact',
alias_target_id: null,
}));
(savedObjectsClient.create as jest.Mock).mockImplementation(() => ({ id: 'test' }));
const { dataViews, search } = dataPluginMock.createStartContract();
describe('getSavedVisualization', () => {
beforeEach(() => {
mockParseSearchSourceJSON.mockClear();
mockInjectSearchSourceReferences.mockClear();
mockInjectReferences.mockClear();
});
it('should return object with defaults if was not provided id', async () => {
const savedVis = await getSavedVisualization({
savedObjectsClient,
search,
dataViews,
spaces: Promise.resolve({
getActiveSpace: () => ({
id: 'test',
}),
}) as unknown as SpacesPluginStart,
});
expect(savedVis).toBeDefined();
expect(savedVis.title).toBe('');
expect(savedVis.displayName).toBe(SAVED_VIS_TYPE);
});
it('should create search source if saved object has searchSourceJSON', async () => {
await getSavedVisualization(
{
savedObjectsClient,
search,
dataViews,
spaces: Promise.resolve({
getActiveSpace: () => ({
id: 'test',
}),
}) as unknown as SpacesPluginStart,
},
{ id: 'test', searchSource: true }
);
expect(mockParseSearchSourceJSON).toHaveBeenCalledWith('{filter: []}');
expect(mockInjectSearchSourceReferences).toHaveBeenCalled();
expect(search.searchSource.create).toHaveBeenCalled();
});
it('should inject references if saved object has references', async () => {
await getSavedVisualization(
{
savedObjectsClient,
search,
dataViews,
spaces: Promise.resolve({
getActiveSpace: () => ({
id: 'test',
}),
}) as unknown as SpacesPluginStart,
},
{ id: 'test', searchSource: true }
);
expect(mockInjectReferences.mock.calls[0][1]).toEqual([
{
id: 'test',
type: 'index-pattern',
},
]);
});
it('should call getTagIdsFromReferences if we provide savedObjectsTagging service', async () => {
const mockGetTagIdsFromReferences = jest.fn(() => ['test']);
await getSavedVisualization(
{
savedObjectsClient,
search,
dataViews,
spaces: Promise.resolve({
getActiveSpace: () => ({
id: 'test',
}),
}) as unknown as SpacesPluginStart,
savedObjectsTagging: {
ui: {
getTagIdsFromReferences: mockGetTagIdsFromReferences,
},
} as unknown as SavedObjectsTaggingApi,
},
{ id: 'test', searchSource: true }
);
expect(mockGetTagIdsFromReferences).toHaveBeenCalled();
});
});
describe('saveVisualization', () => {
let vis: VisSavedObject;
beforeEach(() => {
mockExtractSearchSourceReferences.mockClear();
mockExtractReferences.mockClear();
mockSaveWithConfirmation.mockClear();
savedObjectsClient.create.mockClear();
vis = {
visState: {
type: 'area',
},
title: 'test',
uiStateJSON: '{}',
version: '1',
__tags: [],
lastSavedTitle: 'test',
displayName: 'test',
getEsType: () => 'vis',
} as unknown as VisSavedObject;
});
it('should return id after save', async () => {
const savedVisId = await saveVisualization(vis, {}, { savedObjectsClient, overlays });
expect(savedObjectsClient.create).toHaveBeenCalled();
expect(mockExtractReferences).toHaveBeenCalled();
expect(savedVisId).toBe('test');
});
it('should call extractSearchSourceReferences if we new vis has searchSourceFields', async () => {
vis.searchSourceFields = { fields: [] };
await saveVisualization(vis, {}, { savedObjectsClient, overlays });
expect(mockExtractSearchSourceReferences).toHaveBeenCalledWith(vis.searchSourceFields);
});
it('should serialize searchSource', async () => {
vis.searchSource = {
serialize: jest.fn(() => ({ searchSourceJSON: '{}', references: [] })),
} as unknown as ISearchSource;
await saveVisualization(vis, {}, { savedObjectsClient, overlays });
expect(vis.searchSource?.serialize).toHaveBeenCalled();
});
it('should call updateTagsReferences if we provide savedObjectsTagging service', async () => {
const mockUpdateTagsReferences = jest.fn(() => []);
await saveVisualization(
vis,
{},
{
savedObjectsClient,
overlays,
savedObjectsTagging: {
ui: {
updateTagsReferences: mockUpdateTagsReferences,
},
} as unknown as SavedObjectsTaggingApi,
}
);
expect(mockUpdateTagsReferences).toHaveBeenCalled();
});
describe('confirmOverwrite', () => {
it('as false we should not call saveWithConfirmation and just do create', async () => {
const savedVisId = await saveVisualization(
vis,
{ confirmOverwrite: false },
{ savedObjectsClient, overlays }
);
expect(savedObjectsClient.create).toHaveBeenCalled();
expect(mockExtractReferences).toHaveBeenCalled();
expect(mockSaveWithConfirmation).not.toHaveBeenCalled();
expect(savedVisId).toBe('test');
});
it('as true we should call saveWithConfirmation', async () => {
const savedVisId = await saveVisualization(
vis,
{ confirmOverwrite: true },
{ savedObjectsClient, overlays }
);
expect(savedObjectsClient.create).not.toHaveBeenCalled();
expect(mockSaveWithConfirmation).toHaveBeenCalled();
expect(savedVisId).toBe('test-after-confirm');
});
});
describe('isTitleDuplicateConfirmed', () => {
it('as false we should not save vis with duplicated title', async () => {
isTitleDuplicateConfirmed = false;
const savedVisId = await saveVisualization(
vis,
{ isTitleDuplicateConfirmed },
{ savedObjectsClient, overlays }
);
expect(savedObjectsClient.create).not.toHaveBeenCalled();
expect(mockSaveWithConfirmation).not.toHaveBeenCalled();
expect(mockCheckForDuplicateTitle).toHaveBeenCalled();
expect(savedVisId).toBe('');
expect(vis.id).toBeUndefined();
});
it('as true we should save vis with duplicated title', async () => {
isTitleDuplicateConfirmed = true;
const savedVisId = await saveVisualization(
vis,
{ isTitleDuplicateConfirmed },
{ savedObjectsClient, overlays }
);
expect(mockCheckForDuplicateTitle).toHaveBeenCalled();
expect(savedObjectsClient.create).toHaveBeenCalled();
expect(savedVisId).toBe('test');
expect(vis.id).toBe('test');
});
});
});
describe('findListItems', () => {
function testProps() {
(savedObjectsClient.find as jest.Mock).mockImplementation(() => ({
total: 0,
savedObjects: [],
}));
return {
savedObjectsClient,
search: '',
size: 10,
};
}
beforeEach(() => {
savedObjectsClient.find.mockClear();
});
it('searches visualization title and description', async () => {
const props = testProps();
const { find } = props.savedObjectsClient;
await findListItems(
props.savedObjectsClient,
{ get: mockGetTypes, getAliases: mockGetAliases },
props.search,
props.size
);
expect(find.mock.calls).toMatchObject([
[
{
type: ['visualization'],
searchFields: ['title^3', 'description'],
},
],
]);
});
it('searches searchFields and types specified by app extensions', async () => {
const props = testProps();
visTypes = [
{
appExtensions: {
visualizations: {
docTypes: ['bazdoc', 'etc'],
searchFields: ['baz', 'bing'],
},
},
} as VisTypeAlias,
];
const { find } = props.savedObjectsClient;
await findListItems(
props.savedObjectsClient,
{ get: mockGetTypes, getAliases: mockGetAliases },
props.search,
props.size
);
expect(find.mock.calls).toMatchObject([
[
{
type: ['bazdoc', 'etc', 'visualization'],
searchFields: ['baz', 'bing', 'title^3', 'description'],
},
],
]);
});
it('deduplicates types and search fields', async () => {
const props = testProps();
visTypes = [
{
appExtensions: {
visualizations: {
docTypes: ['bazdoc', 'bar'],
searchFields: ['baz', 'bing', 'barfield'],
},
},
} as VisTypeAlias,
{
appExtensions: {
visualizations: {
docTypes: ['visualization', 'foo', 'bazdoc'],
searchFields: ['baz', 'bing', 'foofield'],
},
},
} as VisTypeAlias,
];
const { find } = props.savedObjectsClient;
await findListItems(
props.savedObjectsClient,
{ get: mockGetTypes, getAliases: mockGetAliases },
props.search,
props.size
);
expect(find.mock.calls).toMatchObject([
[
{
type: ['bazdoc', 'bar', 'visualization', 'foo'],
searchFields: ['baz', 'bing', 'barfield', 'foofield', 'title^3', 'description'],
},
],
]);
});
it('searches the search term prefix', async () => {
const props = {
...testProps(),
search: 'ahoythere',
};
const { find } = props.savedObjectsClient;
await findListItems(
props.savedObjectsClient,
{ get: mockGetTypes, getAliases: mockGetAliases },
props.search,
props.size
);
expect(find.mock.calls).toMatchObject([
[
{
search: 'ahoythere*',
},
],
]);
});
it('searches with references', async () => {
const props = {
...testProps(),
references: [
{ type: 'foo', id: 'hello' },
{ type: 'bar', id: 'dolly' },
],
};
const { find } = props.savedObjectsClient;
await findListItems(
props.savedObjectsClient,
{ get: mockGetTypes, getAliases: mockGetAliases },
props.search,
props.size,
props.references
);
expect(find.mock.calls).toMatchObject([
[
{
hasReference: [
{ type: 'foo', id: 'hello' },
{ type: 'bar', id: 'dolly' },
],
},
],
]);
});
it('uses type-specific toListItem function, if available', async () => {
const props = testProps();
visTypes = [
{
appExtensions: {
visualizations: {
docTypes: ['wizard'],
toListItem(savedObject) {
return {
id: savedObject.id,
title: `${(savedObject.attributes as { label: string }).label} THE GRAY`,
};
},
},
},
} as VisTypeAlias,
];
(props.savedObjectsClient.find as jest.Mock).mockImplementationOnce(async () => ({
total: 2,
savedObjects: [
{
id: 'lotr',
type: 'wizard',
attributes: { label: 'Gandalf' },
},
{
id: 'wat',
type: 'visualization',
attributes: { title: 'WATEVER', typeName: 'test' },
},
],
}));
const items = await findListItems(
props.savedObjectsClient,
{ get: mockGetTypes, getAliases: mockGetAliases },
props.search,
props.size
);
expect(items).toEqual({
total: 2,
hits: [
{
id: 'lotr',
references: undefined,
title: 'Gandalf THE GRAY',
},
{
id: 'wat',
references: undefined,
icon: undefined,
savedObjectType: 'visualization',
editUrl: '/edit/wat',
type: 'test',
typeName: 'test',
typeTitle: undefined,
title: 'WATEVER',
url: '#/edit/wat',
},
],
});
});
});
});

View file

@ -0,0 +1,403 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import _ from 'lodash';
import type {
SavedObjectsFindOptionsReference,
SavedObjectsFindOptions,
SavedObjectsClientContract,
SavedObjectAttributes,
SavedObjectReference,
} from 'kibana/public';
import type { OverlayStart } from '../../../../core/public';
import { SavedObjectNotFound } from '../../../kibana_utils/public';
import {
extractSearchSourceReferences,
injectSearchSourceReferences,
parseSearchSourceJSON,
DataPublicPluginStart,
} from '../../../../plugins/data/public';
import {
checkForDuplicateTitle,
saveWithConfirmation,
isErrorNonFatal,
} from '../../../../plugins/saved_objects/public';
import type { SavedObjectsTaggingApi } from '../../../saved_objects_tagging_oss/public';
import type { SpacesPluginStart } from '../../../../../x-pack/plugins/spaces/public';
import { VisualizationsAppExtension } from '../vis_types/vis_type_alias_registry';
import type {
VisSavedObject,
SerializedVis,
ISavedVis,
SaveVisOptions,
GetVisOptions,
} from '../types';
import type { TypesStart, BaseVisType } from '../vis_types';
// @ts-ignore
import { updateOldState } from '../legacy/vis_update_state';
import { injectReferences, extractReferences } from './saved_visualization_references';
export const SAVED_VIS_TYPE = 'visualization';
const getDefaults = (opts: GetVisOptions) => ({
title: '',
visState: !opts.type ? null : { type: opts.type },
uiStateJSON: '{}',
description: '',
savedSearchId: opts.savedSearchId,
version: 1,
});
export function getFullPath(id: string) {
return `/app/visualize#/edit/${id}`;
}
export function urlFor(id: string) {
return `#/edit/${encodeURIComponent(id)}`;
}
export function mapHitSource(
visTypes: Pick<TypesStart, 'get'>,
{
attributes,
id,
references,
}: {
attributes: SavedObjectAttributes;
id: string;
references: SavedObjectReference[];
}
) {
const newAttributes: {
id: string;
references: SavedObjectReference[];
url: string;
savedObjectType?: string;
editUrl?: string;
type?: BaseVisType;
icon?: BaseVisType['icon'];
image?: BaseVisType['image'];
typeTitle?: BaseVisType['title'];
error?: string;
} = {
id,
references,
url: urlFor(id),
...attributes,
};
let typeName = attributes.typeName;
if (attributes.visState) {
try {
typeName = JSON.parse(String(attributes.visState)).type;
} catch (e) {
/* missing typename handled below */
}
}
if (!typeName || !visTypes.get(typeName as string)) {
newAttributes.error = 'Unknown visualization type';
return newAttributes;
}
newAttributes.type = visTypes.get(typeName as string);
newAttributes.savedObjectType = 'visualization';
newAttributes.icon = newAttributes.type?.icon;
newAttributes.image = newAttributes.type?.image;
newAttributes.typeTitle = newAttributes.type?.title;
newAttributes.editUrl = `/edit/${id}`;
return newAttributes;
}
export const convertToSerializedVis = (savedVis: VisSavedObject): SerializedVis => {
const { id, title, description, visState, uiStateJSON, searchSourceFields } = savedVis;
const aggs = searchSourceFields && searchSourceFields.index ? visState.aggs || [] : visState.aggs;
return {
id,
title,
type: visState.type,
description,
params: visState.params,
uiState: JSON.parse(uiStateJSON || '{}'),
data: {
aggs,
searchSource: searchSourceFields!,
savedSearchId: savedVis.savedSearchId,
},
};
};
export const convertFromSerializedVis = (vis: SerializedVis): ISavedVis => {
return {
id: vis.id,
title: vis.title,
description: vis.description,
visState: {
title: vis.title,
type: vis.type,
aggs: vis.data.aggs,
params: vis.params,
},
uiStateJSON: JSON.stringify(vis.uiState),
searchSourceFields: vis.data.searchSource,
savedSearchId: vis.data.savedSearchId,
};
};
export async function findListItems(
savedObjectsClient: SavedObjectsClientContract,
visTypes: Pick<TypesStart, 'get' | 'getAliases'>,
search: string,
size: number,
references?: SavedObjectsFindOptionsReference[]
) {
const visAliases = visTypes.getAliases();
const extensions = visAliases
.map((v) => v.appExtensions?.visualizations)
.filter(Boolean) as VisualizationsAppExtension[];
const extensionByType = extensions.reduce((acc, m) => {
return m!.docTypes.reduce((_acc, type) => {
acc[type] = m;
return acc;
}, acc);
}, {} as { [visType: string]: VisualizationsAppExtension });
const searchOption = (field: string, ...defaults: string[]) =>
_(extensions).map(field).concat(defaults).compact().flatten().uniq().value() as string[];
const searchOptions: SavedObjectsFindOptions = {
type: searchOption('docTypes', 'visualization'),
searchFields: searchOption('searchFields', 'title^3', 'description'),
search: search ? `${search}*` : undefined,
perPage: size,
page: 1,
defaultSearchOperator: 'AND' as 'AND',
hasReference: references,
};
const { total, savedObjects } = await savedObjectsClient.find<SavedObjectAttributes>(
searchOptions
);
return {
total,
hits: savedObjects.map((savedObject) => {
const config = extensionByType[savedObject.type];
if (config) {
return {
...config.toListItem(savedObject),
references: savedObject.references,
};
} else {
return mapHitSource(visTypes, savedObject);
}
}),
};
}
export async function getSavedVisualization(
services: {
savedObjectsClient: SavedObjectsClientContract;
search: DataPublicPluginStart['search'];
dataViews: DataPublicPluginStart['dataViews'];
spaces?: SpacesPluginStart;
savedObjectsTagging?: SavedObjectsTaggingApi;
},
opts?: GetVisOptions | string
): Promise<VisSavedObject> {
if (typeof opts !== 'object') {
opts = { id: opts } as GetVisOptions;
}
const id = (opts.id as string) || '';
const savedObject = {
id,
migrationVersion: opts.migrationVersion,
displayName: SAVED_VIS_TYPE,
getEsType: () => SAVED_VIS_TYPE,
getDisplayName: () => SAVED_VIS_TYPE,
searchSource: opts.searchSource ? services.search.searchSource.createEmpty() : undefined,
} as VisSavedObject;
const defaultsProps = getDefaults(opts);
if (!id) {
Object.assign(savedObject, defaultsProps);
return savedObject;
}
const {
saved_object: resp,
outcome,
alias_target_id: aliasTargetId,
} = await services.savedObjectsClient.resolve<SavedObjectAttributes>(SAVED_VIS_TYPE, id);
if (!resp._version) {
throw new SavedObjectNotFound(SAVED_VIS_TYPE, id || '');
}
const attributes = _.cloneDeep(resp.attributes);
if (attributes.visState && typeof attributes.visState === 'string') {
attributes.visState = JSON.parse(attributes.visState);
}
// assign the defaults to the response
_.defaults(attributes, defaultsProps);
Object.assign(savedObject, attributes);
savedObject.lastSavedTitle = savedObject.title;
savedObject.sharingSavedObjectProps = {
aliasTargetId,
outcome,
errorJSON:
outcome === 'conflict' && services.spaces
? JSON.stringify({
targetType: SAVED_VIS_TYPE,
sourceId: id,
targetSpace: (await services.spaces.getActiveSpace()).id,
})
: undefined,
};
const meta = (attributes.kibanaSavedObjectMeta || {}) as SavedObjectAttributes;
if (meta.searchSourceJSON) {
try {
let searchSourceValues = parseSearchSourceJSON(meta.searchSourceJSON as string);
if (opts.searchSource) {
searchSourceValues = injectSearchSourceReferences(
searchSourceValues as any,
resp.references
);
savedObject.searchSource = await services.search.searchSource.create(searchSourceValues);
} else {
savedObject.searchSourceFields = searchSourceValues;
}
} catch (error: any) {
throw error;
}
}
if (resp.references && resp.references.length > 0) {
injectReferences(savedObject, resp.references);
}
if (services.savedObjectsTagging) {
savedObject.tags = services.savedObjectsTagging.ui.getTagIdsFromReferences(resp.references);
}
savedObject.visState = await updateOldState(savedObject.visState);
if (savedObject.searchSourceFields?.index) {
await services.dataViews.get(savedObject.searchSourceFields.index as any);
}
return savedObject;
}
export async function saveVisualization(
savedObject: VisSavedObject,
{
confirmOverwrite = false,
isTitleDuplicateConfirmed = false,
onTitleDuplicate,
copyOnSave = false,
}: SaveVisOptions,
services: {
savedObjectsClient: SavedObjectsClientContract;
overlays: OverlayStart;
savedObjectsTagging?: SavedObjectsTaggingApi;
}
) {
// 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 (copyOnSave) {
delete savedObject.id;
}
const attributes: SavedObjectAttributes = {
visState: JSON.stringify(savedObject.visState),
title: savedObject.title,
uiStateJSON: savedObject.uiStateJSON,
description: savedObject.description,
savedSearchId: savedObject.savedSearchId,
version: savedObject.version,
};
let references: SavedObjectReference[] = [];
if (savedObject.searchSource) {
const { searchSourceJSON, references: searchSourceReferences } =
savedObject.searchSource.serialize();
attributes.kibanaSavedObjectMeta = { searchSourceJSON };
references.push(...searchSourceReferences);
}
if (savedObject.searchSourceFields) {
const [searchSourceFields, searchSourceReferences] = extractSearchSourceReferences(
savedObject.searchSourceFields
);
const searchSourceJSON = JSON.stringify(searchSourceFields);
attributes.kibanaSavedObjectMeta = { searchSourceJSON };
references.push(...searchSourceReferences);
}
if (services.savedObjectsTagging) {
references = services.savedObjectsTagging.ui.updateTagsReferences(
references,
savedObject.tags || []
);
}
const extractedRefs = extractReferences({ attributes, references });
if (!extractedRefs.references) {
throw new Error('References not returned from extractReferences');
}
try {
await checkForDuplicateTitle(
{
...savedObject,
copyOnSave,
} as any,
isTitleDuplicateConfirmed,
onTitleDuplicate,
services as any
);
const createOpt = {
id: savedObject.id,
migrationVersion: savedObject.migrationVersion,
references: extractedRefs.references,
};
const resp = confirmOverwrite
? await saveWithConfirmation(attributes, savedObject, createOpt, services)
: await services.savedObjectsClient.create(SAVED_VIS_TYPE, extractedRefs.attributes, {
...createOpt,
overwrite: true,
});
savedObject.id = resp.id;
savedObject.lastSavedTitle = savedObject.title;
return savedObject.id;
} catch (err: any) {
savedObject.id = originalId;
if (isErrorNonFatal(err)) {
return '';
}
return Promise.reject(err);
}
}

View file

@ -23,5 +23,6 @@
{ "path": "../usage_collection/tsconfig.json" },
{ "path": "../kibana_utils/tsconfig.json" },
{ "path": "../discover/tsconfig.json" },
{ "path": "../../../x-pack/plugins/spaces/tsconfig.json" },
]
}

View file

@ -17,7 +17,8 @@
"home",
"share",
"savedObjectsTaggingOss",
"usageCollection"
"usageCollection",
"spaces"
],
"requiredBundles": [
"kibanaUtils",

View file

@ -0,0 +1,112 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { shallow, mount } from 'enzyme';
import { VisualizeEditorCommon } from './visualize_editor_common';
import { VisualizeEditorVisInstance } from '../types';
const mockGetLegacyUrlConflict = jest.fn();
const mockRedirectLegacyUrl = jest.fn(() => Promise.resolve());
jest.mock('../../../../kibana_react/public', () => ({
useKibana: jest.fn(() => ({
services: {
spaces: {
ui: {
redirectLegacyUrl: mockRedirectLegacyUrl,
components: {
getLegacyUrlConflict: mockGetLegacyUrlConflict,
},
},
},
history: {
location: {
search: '?_g=test',
},
},
http: {
basePath: {
prepend: (url: string) => url,
},
},
},
})),
withKibana: jest.fn((comp) => comp),
}));
describe('VisualizeEditorCommon', () => {
it('should display a conflict callout if saved object conflicts', async () => {
shallow(
<VisualizeEditorCommon
appState={null}
hasUnsavedChanges={false}
setHasUnsavedChanges={() => {}}
hasUnappliedChanges={false}
isEmbeddableRendered={false}
onAppLeave={() => {}}
visEditorRef={React.createRef()}
visInstance={
{
savedVis: {
id: 'test',
sharingSavedObjectProps: {
outcome: 'conflict',
aliasTargetId: 'alias_id',
},
},
vis: {
type: {
title: 'TSVB',
},
},
} as VisualizeEditorVisInstance
}
/>
);
expect(mockGetLegacyUrlConflict).toHaveBeenCalledWith({
currentObjectId: 'test',
objectNoun: 'TSVB visualization',
otherObjectId: 'alias_id',
otherObjectPath: '#/edit/alias_id?_g=test',
});
});
it('should redirect to new id if saved object aliasMatch', async () => {
mount(
<VisualizeEditorCommon
appState={null}
hasUnsavedChanges={false}
setHasUnsavedChanges={() => {}}
hasUnappliedChanges={false}
isEmbeddableRendered={false}
onAppLeave={() => {}}
visEditorRef={React.createRef()}
visInstance={
{
savedVis: {
id: 'test',
sharingSavedObjectProps: {
outcome: 'aliasMatch',
aliasTargetId: 'alias_id',
},
},
vis: {
type: {
title: 'TSVB',
},
},
} as VisualizeEditorVisInstance
}
/>
);
expect(mockRedirectLegacyUrl).toHaveBeenCalledWith(
'#/edit/alias_id?_g=test',
'TSVB visualization'
);
});
});

View file

@ -7,15 +7,19 @@
*/
import './visualize_editor.scss';
import React, { RefObject } from 'react';
import React, { RefObject, useCallback, useEffect } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { EuiScreenReaderOnly } from '@elastic/eui';
import { AppMountParameters } from 'kibana/public';
import { VisualizeTopNav } from './visualize_top_nav';
import { ExperimentalVisInfo } from './experimental_vis_info';
import { useKibana } from '../../../../kibana_react/public';
import { urlFor } from '../../../../visualizations/public';
import {
SavedVisInstance,
VisualizeAppState,
VisualizeServices,
VisualizeAppStateContainer,
VisualizeEditorVisInstance,
} from '../types';
@ -53,6 +57,55 @@ export const VisualizeEditorCommon = ({
embeddableId,
visEditorRef,
}: VisualizeEditorCommonProps) => {
const { services } = useKibana<VisualizeServices>();
useEffect(() => {
async function aliasMatchRedirect() {
const sharingSavedObjectProps = visInstance?.savedVis.sharingSavedObjectProps;
if (services.spaces && sharingSavedObjectProps?.outcome === 'aliasMatch') {
// We found this object by a legacy URL alias from its old ID; redirect the user to the page with its new ID, preserving any URL hash
const newObjectId = sharingSavedObjectProps?.aliasTargetId; // This is always defined if outcome === 'aliasMatch'
const newPath = `${urlFor(newObjectId!)}${services.history.location.search}`;
await services.spaces.ui.redirectLegacyUrl(
newPath,
i18n.translate('visualize.legacyUrlConflict.objectNoun', {
defaultMessage: '{visName} visualization',
values: {
visName: visInstance?.vis?.type.title,
},
})
);
return;
}
}
aliasMatchRedirect();
}, [visInstance?.savedVis.sharingSavedObjectProps, visInstance?.vis?.type.title, services]);
const getLegacyUrlConflictCallout = useCallback(() => {
// This function returns a callout component *if* we have encountered a "legacy URL conflict" scenario
const currentObjectId = visInstance?.savedVis.id;
const sharingSavedObjectProps = visInstance?.savedVis.sharingSavedObjectProps;
if (services.spaces && sharingSavedObjectProps?.outcome === 'conflict' && currentObjectId) {
// We have resolved to one object, but another object has a legacy URL alias associated with this ID/page. We should display a
// callout with a warning for the user, and provide a way for them to navigate to the other object.
const otherObjectId = sharingSavedObjectProps?.aliasTargetId!; // This is always defined if outcome === 'conflict'
const otherObjectPath = `${urlFor(otherObjectId)}${services.history.location.search}`;
return services.spaces.ui.components.getLegacyUrlConflict({
objectNoun: i18n.translate('visualize.legacyUrlConflict.objectNoun', {
defaultMessage: '{visName} visualization',
values: {
visName: visInstance?.vis?.type.title,
},
}),
currentObjectId,
otherObjectId,
otherObjectPath,
});
}
return null;
}, [visInstance?.savedVis, services, visInstance?.vis?.type.title]);
return (
<div className={`app-container visEditor visEditor--${visInstance?.vis.type.name}`}>
{visInstance && appState && currentAppState && (
@ -74,6 +127,7 @@ export const VisualizeEditorCommon = ({
)}
{visInstance?.vis?.type?.stage === 'experimental' && <ExperimentalVisInfo />}
{visInstance?.vis?.type?.getInfoMessage?.(visInstance.vis)}
{getLegacyUrlConflictCallout()}
{visInstance && (
<EuiScreenReaderOnly>
<h1>

View file

@ -31,7 +31,6 @@ export const VisualizeListing = () => {
chrome,
dashboard,
history,
savedVisualizations,
toastNotifications,
visualizations,
stateTransferService,
@ -113,16 +112,16 @@ export const VisualizeListing = () => {
}
const isLabsEnabled = uiSettings.get(VISUALIZE_ENABLE_LABS_SETTING);
return savedVisualizations
.findListItems(searchTerm, { size: listingLimit, references })
.then(({ total, hits }: { total: number; hits: object[] }) => ({
return visualizations
.findListItems(searchTerm, listingLimit, references)
.then(({ total, hits }: { total: number; hits: Array<Record<string, unknown>> }) => ({
total,
hits: hits.filter(
(result: any) => isLabsEnabled || result.type?.stage !== 'experimental'
),
}));
},
[listingLimit, savedVisualizations, uiSettings, savedObjectsTagging]
[listingLimit, uiSettings, savedObjectsTagging, visualizations]
);
const deleteItems = useCallback(

View file

@ -42,6 +42,7 @@ import type { SavedObjectsStart, SavedObject } from 'src/plugins/saved_objects/p
import type { EmbeddableStart, EmbeddableStateTransfer } from 'src/plugins/embeddable/public';
import type { UrlForwardingStart } from 'src/plugins/url_forwarding/public';
import type { PresentationUtilPluginStart } from 'src/plugins/presentation_util/public';
import type { SpacesPluginStart } from '../../../../../x-pack/plugins/spaces/public';
import type { DashboardStart } from '../../../dashboard/public';
import type { SavedObjectsTaggingApi } from '../../../saved_objects_tagging_oss/public';
import type { UsageCollectionStart } from '../../../usage_collection/public';
@ -94,7 +95,6 @@ export interface VisualizeServices extends CoreStart {
dashboardCapabilities: Record<string, boolean | Record<string, boolean>>;
visualizations: VisualizationsStart;
savedObjectsPublic: SavedObjectsStart;
savedVisualizations: VisualizationsStart['savedVisualizationsLoader'];
setActiveUrl: (newUrl: string) => void;
createVisEmbeddableFromObject: VisualizationsStart['__LEGACY']['createVisEmbeddableFromObject'];
restorePreviousUrl: () => void;
@ -105,6 +105,7 @@ export interface VisualizeServices extends CoreStart {
presentationUtil: PresentationUtilPluginStart;
usageCollection?: UsageCollectionStart;
getKibanaVersion: () => string;
spaces?: SpacesPluginStart;
}
export interface SavedVisInstance {

View file

@ -14,7 +14,11 @@ import { parse } from 'query-string';
import { Capabilities } from 'src/core/public';
import { TopNavMenuData } from 'src/plugins/navigation/public';
import { VISUALIZE_EMBEDDABLE_TYPE, VisualizeInput } from '../../../../visualizations/public';
import {
VISUALIZE_EMBEDDABLE_TYPE,
VisualizeInput,
getFullPath,
} from '../../../../visualizations/public';
import {
showSaveModal,
SavedObjectSaveModalOrigin,
@ -87,6 +91,7 @@ export const getTopNavConfig = (
data,
application,
chrome,
overlays,
history,
share,
setActiveUrl,
@ -99,6 +104,8 @@ export const getTopNavConfig = (
presentationUtil,
usageCollection,
getKibanaVersion,
savedObjects,
visualizations,
}: VisualizeServices
) => {
const { vis, embeddableHandler } = visInstance;
@ -117,8 +124,10 @@ export const getTopNavConfig = (
/**
* Called when the user clicks "Save" button.
*/
async function doSave(saveOptions: SavedObjectSaveOpts & { dashboardId?: string }) {
const newlyCreated = !Boolean(savedVis.id) || savedVis.copyOnSave;
async function doSave(
saveOptions: SavedObjectSaveOpts & { dashboardId?: string; copyOnSave?: boolean }
) {
const newlyCreated = !Boolean(savedVis.id) || saveOptions.copyOnSave;
// vis.title was not bound and it's needed to reflect title into visState
stateContainer.transitions.setVis({
title: savedVis.title,
@ -129,7 +138,7 @@ export const getTopNavConfig = (
setHasUnsavedChanges(false);
try {
const id = await savedVis.save(saveOptions);
const id = await visualizations.saveVisualization(savedVis, saveOptions);
if (id) {
toastNotifications.addSuccess({
@ -142,6 +151,8 @@ export const getTopNavConfig = (
'data-test-subj': 'saveVisualizationSuccess',
});
chrome.recentlyAccessed.add(getFullPath(id), savedVis.title, String(id));
if ((originatingApp && saveOptions.returnToOrigin) || saveOptions.dashboardId) {
if (!embeddableId) {
const appPath = `${VisualizeConstants.EDIT_PATH}/${encodeURIComponent(id)}`;
@ -164,7 +175,7 @@ export const getTopNavConfig = (
state: {
type: VISUALIZE_EMBEDDABLE_TYPE,
input: { savedObjectId: id },
embeddableId: savedVis.copyOnSave ? undefined : embeddableId,
embeddableId: saveOptions.copyOnSave ? undefined : embeddableId,
searchSessionId: data.search.session.getSessionId(),
},
path,
@ -392,11 +403,10 @@ export const getTopNavConfig = (
const currentTitle = savedVis.title;
savedVis.title = newTitle;
embeddableHandler.updateInput({ title: newTitle });
savedVis.copyOnSave = newCopyOnSave;
savedVis.description = newDescription;
if (savedObjectsTagging && savedObjectsTagging.ui.hasTagDecoration(savedVis)) {
savedVis.setTags(selectedTags);
if (savedObjectsTagging) {
savedVis.tags = selectedTags;
}
const saveOptions = {
@ -405,6 +415,7 @@ export const getTopNavConfig = (
onTitleDuplicate,
returnToOrigin,
dashboardId: !!dashboardId ? dashboardId : undefined,
copyOnSave: newCopyOnSave,
};
// If we're adding to a dashboard and not saving to library,
@ -457,9 +468,7 @@ export const getTopNavConfig = (
let tagOptions: React.ReactNode | undefined;
if (savedObjectsTagging) {
if (savedVis && savedObjectsTagging.ui.hasTagDecoration(savedVis)) {
selectedTags = savedVis.getTags();
}
selectedTags = savedVis.tags || [];
tagOptions = (
<savedObjectsTagging.ui.components.SavedObjectSaveModalTagSelector
initialSelection={selectedTags}

View file

@ -30,11 +30,12 @@ jest.mock('../../../../discover/public', () => ({
})),
}));
let savedVisMock: VisSavedObject;
describe('getVisualizationInstance', () => {
const serializedVisMock = {
type: 'area',
};
let savedVisMock: VisSavedObject;
let visMock: Vis<VisParams>;
let mockServices: jest.Mocked<VisualizeServices>;
let subj: BehaviorSubject<any>;
@ -47,13 +48,16 @@ describe('getVisualizationInstance', () => {
data: {},
} as Vis<VisParams>;
savedVisMock = {} as VisSavedObject;
// @ts-expect-error
mockServices.data.search.showError.mockImplementation(() => {});
// @ts-expect-error
mockServices.savedVisualizations.get.mockImplementation(() => savedVisMock);
// @ts-expect-error
mockServices.visualizations.convertToSerializedVis.mockImplementation(() => serializedVisMock);
// @ts-expect-error
mockServices.visualizations.getSavedVisualization.mockImplementation(
(opts: unknown) => savedVisMock
);
// @ts-expect-error
mockServices.visualizations.createVis.mockImplementation(() => visMock);
// @ts-expect-error
mockServices.createVisEmbeddableFromObject.mockImplementation(() => ({
@ -71,7 +75,9 @@ describe('getVisualizationInstance', () => {
opts
);
expect(mockServices.savedVisualizations.get).toHaveBeenCalledWith(opts);
expect((mockServices.visualizations.getSavedVisualization as jest.Mock).mock.calls[0][0]).toBe(
opts
);
expect(savedVisMock.searchSourceFields).toEqual({
index: opts.indexPattern,
});
@ -98,7 +104,9 @@ describe('getVisualizationInstance', () => {
visMock.type.setup = jest.fn(() => newVisObj);
const { vis } = await getVisualizationInstance(mockServices, 'saved_vis_id');
expect(mockServices.savedVisualizations.get).toHaveBeenCalledWith('saved_vis_id');
expect((mockServices.visualizations.getSavedVisualization as jest.Mock).mock.calls[0][0]).toBe(
'saved_vis_id'
);
expect(savedVisMock.searchSourceFields).toBeUndefined();
expect(visMock.type.setup).toHaveBeenCalledWith(visMock);
expect(vis).toBe(newVisObj);
@ -128,7 +136,6 @@ describe('getVisualizationInstanceInput', () => {
const serializedVisMock = {
type: 'pie',
};
let savedVisMock: VisSavedObject;
let visMock: Vis<VisParams>;
let mockServices: jest.Mocked<VisualizeServices>;
let subj: BehaviorSubject<any>;
@ -142,10 +149,12 @@ describe('getVisualizationInstanceInput', () => {
} as Vis<VisParams>;
savedVisMock = {} as VisSavedObject;
// @ts-expect-error
mockServices.savedVisualizations.get.mockImplementation(() => savedVisMock);
// @ts-expect-error
mockServices.visualizations.createVis.mockImplementation(() => visMock);
// @ts-expect-error
mockServices.visualizations.getSavedVisualization.mockImplementation(
(opts: unknown) => savedVisMock
);
// @ts-expect-error
mockServices.createVisEmbeddableFromObject.mockImplementation(() => ({
getOutput$: jest.fn(() => subj.asObservable()),
}));
@ -183,7 +192,7 @@ describe('getVisualizationInstanceInput', () => {
const { savedVis, savedSearch, vis, embeddableHandler } =
await getVisualizationInstanceFromInput(mockServices, input);
expect(mockServices.savedVisualizations.get).toHaveBeenCalled();
expect(mockServices.visualizations.getSavedVisualization).toHaveBeenCalled();
expect(mockServices.visualizations.createVis).toHaveBeenCalledWith(
serializedVisMock.type,
input.savedVis

View file

@ -66,14 +66,15 @@ export const getVisualizationInstanceFromInput = async (
visualizeServices: VisualizeServices,
input: VisualizeInput
) => {
const { visualizations, savedVisualizations } = visualizeServices;
const { visualizations } = visualizeServices;
const visState = input.savedVis as SerializedVis;
/**
* A saved vis is needed even in by value mode to support 'save to library' which converts the 'by value'
* state of the visualization, into a new saved object.
*/
const savedVis: VisSavedObject = await savedVisualizations.get();
const savedVis: VisSavedObject = await visualizations.getSavedVisualization();
if (visState.uiState && Object.keys(visState.uiState).length !== 0) {
savedVis.uiStateJSON = JSON.stringify(visState.uiState);
}
@ -107,8 +108,8 @@ export const getVisualizationInstance = async (
*/
opts?: Record<string, unknown> | string
) => {
const { visualizations, savedVisualizations } = visualizeServices;
const savedVis: VisSavedObject = await savedVisualizations.get(opts);
const { visualizations } = visualizeServices;
const savedVis: VisSavedObject = await visualizations.getSavedVisualization(opts);
if (typeof opts !== 'string') {
savedVis.searchSourceFields = { index: opts?.indexPattern } as SearchSourceFields;

View file

@ -26,7 +26,6 @@ export const createVisualizeServicesMock = () => {
location: { pathname: '' },
},
visualizations,
savedVisualizations: visualizations.savedVisualizationsLoader,
createVisEmbeddableFromObject: visualizations.__LEGACY.createVisEmbeddableFromObject,
} as unknown as jest.Mocked<VisualizeServices>;
};

View file

@ -22,7 +22,6 @@ import { createEmbeddableStateTransferMock } from '../../../../../embeddable/pub
const mockDefaultEditorControllerDestroy = jest.fn();
const mockEmbeddableHandlerDestroy = jest.fn();
const mockEmbeddableHandlerRender = jest.fn();
const mockSavedVisDestroy = jest.fn();
const savedVisId = '9ca7aa90-b892-11e8-a6d9-e546fe2bba5f';
const mockSavedVisInstance = {
embeddableHandler: {
@ -32,7 +31,6 @@ const mockSavedVisInstance = {
savedVis: {
id: savedVisId,
title: 'Test Vis',
destroy: mockSavedVisDestroy,
},
vis: {
type: {},
@ -103,7 +101,6 @@ describe('useSavedVisInstance', () => {
mockDefaultEditorControllerDestroy.mockClear();
mockEmbeddableHandlerDestroy.mockClear();
mockEmbeddableHandlerRender.mockClear();
mockSavedVisDestroy.mockClear();
toastNotifications.addWarning.mockClear();
mockGetVisualizationInstance.mockClear();
});
@ -153,7 +150,6 @@ describe('useSavedVisInstance', () => {
expect(mockDefaultEditorControllerDestroy.mock.calls.length).toBe(1);
expect(mockEmbeddableHandlerDestroy).not.toHaveBeenCalled();
expect(mockSavedVisDestroy.mock.calls.length).toBe(1);
});
});
@ -236,7 +232,6 @@ describe('useSavedVisInstance', () => {
unmount();
expect(mockDefaultEditorControllerDestroy).not.toHaveBeenCalled();
expect(mockEmbeddableHandlerDestroy.mock.calls.length).toBe(1);
expect(mockSavedVisDestroy.mock.calls.length).toBe(1);
});
});
});

View file

@ -176,9 +176,6 @@ export const useSavedVisInstance = (
} else if (state.savedVisInstance?.embeddableHandler) {
state.savedVisInstance.embeddableHandler.destroy();
}
if (state.savedVisInstance?.savedVis) {
state.savedVisInstance.savedVis.destroy();
}
};
}, [state]);

View file

@ -28,6 +28,7 @@ import {
createKbnUrlStateStorage,
withNotifyOnErrors,
} from '../../kibana_utils/public';
import type { SpacesPluginStart } from '../../../../x-pack/plugins/spaces/public';
import { VisualizeConstants } from './application/visualize_constants';
import { DataPublicPluginStart, DataPublicPluginSetup, esFilters } from '../../data/public';
@ -61,6 +62,7 @@ export interface VisualizePluginStartDependencies {
savedObjectsTaggingOss?: SavedObjectTaggingOssPluginStart;
presentationUtil: PresentationUtilPluginStart;
usageCollection?: UsageCollectionStart;
spaces: SpacesPluginStart;
}
export interface VisualizePluginSetupDependencies {
@ -192,7 +194,6 @@ export class VisualizePlugin
data: pluginsStart.data,
localStorage: new Storage(localStorage),
navigation: pluginsStart.navigation,
savedVisualizations: pluginsStart.visualizations.savedVisualizationsLoader,
share: pluginsStart.share,
toastNotifications: coreStart.notifications.toasts,
visualizeCapabilities: coreStart.application.capabilities.visualize,
@ -212,6 +213,7 @@ export class VisualizePlugin
presentationUtil: pluginsStart.presentationUtil,
usageCollection: pluginsStart.usageCollection,
getKibanaVersion: () => this.initializerContext.env.packageInfo.version,
spaces: pluginsStart.spaces,
};
params.element.classList.add('visAppWrapper');

View file

@ -24,6 +24,7 @@
{ "path": "../kibana_react/tsconfig.json" },
{ "path": "../home/tsconfig.json" },
{ "path": "../presentation_util/tsconfig.json" },
{ "path": "../discover/tsconfig.json" }
{ "path": "../discover/tsconfig.json" },
{ "path": "../../../x-pack/plugins/spaces/tsconfig.json" },
]
}