[CM] graph onboarding (#157698)

This commit is contained in:
Peter Pisljar 2023-05-29 10:36:47 +02:00 committed by GitHub
parent d7570424b2
commit 6abee5af88
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1222 additions and 108 deletions

View file

@ -0,0 +1,20 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type {
ContentManagementServicesDefinition as ServicesDefinition,
Version,
} from '@kbn/object-versioning';
// We export the versioned service definition from this file and not the barrel to avoid adding
// the schemas in the "public" js bundle
import { serviceDefinition as v1 } from './v1/cm_services';
export const cmServicesDefinition: { [version: Version]: ServicesDefinition } = {
1: v1,
};

View file

@ -0,0 +1,10 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const LATEST_VERSION = 1;
export const CONTENT_ID = 'graph';

View file

@ -0,0 +1,31 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { LATEST_VERSION, CONTENT_ID } from './constants';
export type { GraphContentType } from './types';
export type {
GraphSavedObject,
PartialGraphSavedObject,
GraphSavedObjectAttributes,
GraphGetIn,
GraphGetOut,
GraphCreateIn,
GraphCreateOut,
CreateOptions,
GraphUpdateIn,
GraphUpdateOut,
UpdateOptions,
GraphDeleteIn,
GraphDeleteOut,
GraphSearchIn,
GraphSearchOut,
GraphSearchQuery,
} from './latest';
export * as GraphV1 from './v1';

View file

@ -0,0 +1,8 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './v1';

View file

@ -0,0 +1,8 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export type GraphContentType = 'graph';

View file

@ -0,0 +1,142 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema } from '@kbn/config-schema';
import type { ContentManagementServicesDefinition as ServicesDefinition } from '@kbn/object-versioning';
const apiError = schema.object({
error: schema.string(),
message: schema.string(),
statusCode: schema.number(),
metadata: schema.object({}, { unknowns: 'allow' }),
});
const referenceSchema = schema.object(
{
name: schema.maybe(schema.string()),
type: schema.string(),
id: schema.string(),
},
{ unknowns: 'forbid' }
);
const referencesSchema = schema.arrayOf(referenceSchema);
const graphAttributesSchema = schema.object(
{
title: schema.string(),
description: schema.maybe(schema.string()),
version: schema.maybe(schema.number()),
numLinks: schema.number(),
numVertices: schema.number(),
kibanaSavedObjectMeta: schema.maybe(schema.any()),
wsState: schema.maybe(schema.string()),
legacyIndexPatternRef: schema.maybe(schema.string()),
},
{ unknowns: 'forbid' }
);
const graphSavedObjectSchema = schema.object(
{
id: schema.string(),
type: schema.string(),
version: schema.maybe(schema.string()),
createdAt: schema.maybe(schema.string()),
updatedAt: schema.maybe(schema.string()),
error: schema.maybe(apiError),
attributes: graphAttributesSchema,
references: referencesSchema,
namespaces: schema.maybe(schema.arrayOf(schema.string())),
originId: schema.maybe(schema.string()),
},
{ unknowns: 'allow' }
);
const getResultSchema = schema.object(
{
item: graphSavedObjectSchema,
meta: schema.object(
{
outcome: schema.oneOf([
schema.literal('exactMatch'),
schema.literal('aliasMatch'),
schema.literal('conflict'),
]),
aliasTargetId: schema.maybe(schema.string()),
aliasPurpose: schema.maybe(
schema.oneOf([
schema.literal('savedObjectConversion'),
schema.literal('savedObjectImport'),
])
),
},
{ unknowns: 'forbid' }
),
},
{ unknowns: 'forbid' }
);
const createOptionsSchema = schema.object({
overwrite: schema.maybe(schema.boolean()),
references: schema.maybe(referencesSchema),
});
// Content management service definition.
// We need it for BWC support between different versions of the content
export const serviceDefinition: ServicesDefinition = {
get: {
out: {
result: {
schema: getResultSchema,
},
},
},
create: {
in: {
options: {
schema: createOptionsSchema,
},
data: {
schema: graphAttributesSchema,
},
},
out: {
result: {
schema: schema.object(
{
item: graphSavedObjectSchema,
},
{ unknowns: 'forbid' }
),
},
},
},
update: {
in: {
options: {
schema: createOptionsSchema, // same schema as "create"
},
data: {
schema: graphAttributesSchema,
},
},
},
search: {
in: {
options: {
schema: schema.maybe(
schema.object(
{
searchFields: schema.maybe(schema.arrayOf(schema.string())),
types: schema.maybe(schema.arrayOf(schema.string())),
},
{ unknowns: 'forbid' }
)
),
},
},
},
};

View file

@ -0,0 +1,26 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export type {
GraphSavedObject,
PartialGraphSavedObject,
GraphSavedObjectAttributes,
GraphGetIn,
GraphGetOut,
GraphCreateIn,
GraphCreateOut,
CreateOptions,
GraphUpdateIn,
GraphUpdateOut,
UpdateOptions,
GraphDeleteIn,
GraphDeleteOut,
GraphSearchIn,
GraphSearchOut,
GraphSearchQuery,
Reference,
} from './types';

View file

@ -0,0 +1,115 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
GetIn,
CreateIn,
SearchIn,
UpdateIn,
DeleteIn,
DeleteResult,
SearchResult,
GetResult,
CreateResult,
UpdateResult,
} from '@kbn/content-management-plugin/common';
import { GraphContentType } from '../types';
export interface Reference {
type: string;
id: string;
name: string;
}
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type GraphSavedObjectAttributes = {
title: string;
description?: string;
version?: number;
numVertices: number;
numLinks: number;
wsState?: string;
kibanaSavedObjectMeta?: unknown;
legacyIndexPatternRef: string;
};
export interface GraphSavedObject {
id: string;
type: string;
version?: string;
updatedAt?: string;
createdAt?: string;
attributes: GraphSavedObjectAttributes;
references: Reference[];
namespaces?: string[];
originId?: string;
error?: {
error: string;
message: string;
statusCode: number;
metadata?: Record<string, unknown>;
};
}
export type PartialGraphSavedObject = Omit<GraphSavedObject, 'attributes' | 'references'> & {
attributes: Partial<GraphSavedObjectAttributes>;
references: Reference[] | undefined;
};
// ----------- GET --------------
export type GraphGetIn = GetIn<GraphContentType>;
export type GraphGetOut = GetResult<
GraphSavedObject,
{
outcome: 'exactMatch' | 'aliasMatch' | 'conflict';
aliasTargetId?: string;
aliasPurpose?: 'savedObjectConversion' | 'savedObjectImport';
}
>;
// ----------- CREATE --------------
export interface CreateOptions {
/** If a document with the given `id` already exists, overwrite it's contents (default=false). */
overwrite?: boolean;
/** Array of referenced saved objects. */
references?: Reference[];
}
export type GraphCreateIn = CreateIn<GraphContentType, GraphSavedObjectAttributes, CreateOptions>;
export type GraphCreateOut = CreateResult<GraphSavedObject>;
// ----------- UPDATE --------------
export interface UpdateOptions {
/** Array of referenced saved objects. */
references?: Reference[];
}
export type GraphUpdateIn = UpdateIn<GraphContentType, GraphSavedObjectAttributes, UpdateOptions>;
export type GraphUpdateOut = UpdateResult<PartialGraphSavedObject>;
// ----------- DELETE --------------
export type GraphDeleteIn = DeleteIn<GraphContentType>;
export type GraphDeleteOut = DeleteResult;
// ----------- SEARCH --------------
export interface GraphSearchQuery {
types?: string[];
searchFields?: string[];
}
export type GraphSearchIn = SearchIn<GraphContentType, {}>;
export type GraphSearchOut = SearchResult<GraphSavedObject>;

View file

@ -19,6 +19,7 @@
"inspector",
"savedObjectsManagement",
"savedObjectsFinder",
"contentManagement"
],
"optionalPlugins": [
"home",

View file

@ -11,7 +11,6 @@ import {
ChromeStart,
CoreStart,
PluginInitializerContext,
SavedObjectsClientContract,
ToastsStart,
OverlayStart,
AppMountParameters,
@ -32,10 +31,10 @@ import { TableListViewKibanaProvider } from '@kbn/content-management-table-list'
import './index.scss';
import('./font_awesome');
import { SavedObjectsStart } from '@kbn/saved-objects-plugin/public';
import { SpacesApi } from '@kbn/spaces-plugin/public';
import { KibanaThemeProvider, toMountPoint } from '@kbn/kibana-react-plugin/public';
import { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public';
import { ContentClient } from '@kbn/content-management-plugin/public';
import { GraphSavePolicy } from './types';
import { graphRouter } from './router';
import { checkLicense } from '../common/check_license';
@ -60,14 +59,13 @@ export interface GraphDependencies {
indexPatterns: DataViewsContract;
data: ReturnType<DataPlugin['start']>;
unifiedSearch: UnifiedSearchPublicPluginStart;
savedObjectsClient: SavedObjectsClientContract;
contentClient: ContentClient;
addBasePath: (url: string) => string;
getBasePath: () => string;
storage: Storage;
canEditDrillDownUrls: boolean;
graphSavePolicy: GraphSavePolicy;
overlays: OverlayStart;
savedObjects: SavedObjectsStart;
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
uiSettings: IUiSettingsClient;
history: ScopedHistory<unknown>;

View file

@ -41,7 +41,7 @@ export interface ListingRouteProps {
}
export function ListingRoute({
deps: { chrome, savedObjectsClient, coreStart, capabilities, addBasePath, uiSettings },
deps: { chrome, contentClient, coreStart, capabilities, addBasePath, uiSettings },
}: ListingRouteProps) {
const listingLimit = uiSettings.get(SAVED_OBJECTS_LIMIT_SETTING);
const initialPageSize = uiSettings.get(SAVED_OBJECTS_PER_PAGE_SETTING);
@ -60,7 +60,7 @@ export function ListingRoute({
const findItems = useCallback(
(search: string) => {
return findSavedWorkspace(
{ savedObjectsClient, basePath: coreStart.http.basePath },
{ contentClient, basePath: coreStart.http.basePath },
search,
listingLimit
).then(({ total, hits }) => ({
@ -68,7 +68,7 @@ export function ListingRoute({
hits: hits.map(toTableListViewSavedObject),
}));
},
[coreStart.http.basePath, listingLimit, savedObjectsClient]
[coreStart.http.basePath, listingLimit, contentClient]
);
const editItem = useCallback(
@ -81,11 +81,11 @@ export function ListingRoute({
const deleteItems = useCallback(
async (savedWorkspaces: Array<{ id: string }>) => {
await deleteSavedWorkspace(
savedObjectsClient,
contentClient,
savedWorkspaces.map((cur) => cur.id!)
);
},
[savedObjectsClient]
[contentClient]
);
return (

View file

@ -27,7 +27,7 @@ export const WorkspaceRoute = ({
deps: {
toastNotifications,
coreStart,
savedObjectsClient,
contentClient,
graphSavePolicy,
chrome,
canEditDrillDownUrls,
@ -108,8 +108,8 @@ export const WorkspaceRoute = ({
notifications: coreStart.notifications,
http: coreStart.http,
overlays: coreStart.overlays,
savedObjectsClient,
savePolicy: graphSavePolicy,
contentClient,
changeUrl: (newUrl) => history.push(newUrl),
notifyReact: () => setRenderCounter((cur) => cur + 1),
chrome,
@ -120,7 +120,7 @@ export const WorkspaceRoute = ({
const loaded = useWorkspaceLoader({
workspaceRef,
store,
savedObjectsClient,
contentClient,
spaces,
coreStart,
data,

View file

@ -0,0 +1,59 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { OverlayStart } from '@kbn/core/public';
import { ContentClient } from '@kbn/content-management-plugin/public';
import type { GraphWorkspaceSavedObject } from '../../types';
import { SAVE_DUPLICATE_REJECTED } from './constants';
import { findObjectByTitle } from './find_object_by_title';
import { displayDuplicateTitleConfirmModal } from './display_duplicate_title_confirm_modal';
/**
* check for an existing VisSavedObject with the same title in ES
* returns Promise<true> when it's no duplicate, or the modal displaying the warning
* that's there's a duplicate is confirmed, else it returns a rejected Promise<ErrorMsg>
* @param savedObject
* @param isTitleDuplicateConfirmed
* @param onTitleDuplicate
* @param services
*/
export async function checkForDuplicateTitle(
savedObject: Pick<GraphWorkspaceSavedObject, 'id' | 'title' | 'lastSavedTitle' | 'getEsType'>,
isTitleDuplicateConfirmed: boolean,
onTitleDuplicate: (() => void) | undefined,
services: {
overlays: OverlayStart;
contentClient: ContentClient;
}
): Promise<boolean> {
const { overlays, contentClient } = services;
// Don't check for duplicates if user has already confirmed save with duplicate title
if (isTitleDuplicateConfirmed) {
return true;
}
// Don't check if the user isn't updating the title, otherwise that would become very annoying to have
// to confirm the save every time, except when copyOnSave is true, then we do want to check.
if (savedObject.title === savedObject.lastSavedTitle) {
return true;
}
const duplicate = await findObjectByTitle(contentClient, savedObject.title);
if (!duplicate || duplicate.id === savedObject.id) {
return true;
}
if (onTitleDuplicate) {
onTitleDuplicate();
return Promise.reject(new Error(SAVE_DUPLICATE_REJECTED));
}
// TODO: make onTitleDuplicate a required prop and remove UI components from this class
// Need to leave here until all users pass onTitleDuplicate.
return displayDuplicateTitleConfirmModal(savedObject, overlays);
}

View file

@ -0,0 +1,45 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import type { OverlayStart } from '@kbn/core/public';
import { EuiConfirmModal } from '@elastic/eui';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
export function confirmModalPromise(
message = '',
title = '',
confirmBtnText = '',
overlays: OverlayStart
): Promise<boolean> {
return new Promise((resolve, reject) => {
const cancelButtonText = i18n.translate('xpack.graph.confirmModal.cancelButtonLabel', {
defaultMessage: 'Cancel',
});
const modal = overlays.openModal(
toMountPoint(
<EuiConfirmModal
onCancel={() => {
modal.close();
reject();
}}
onConfirm={() => {
modal.close();
resolve(true);
}}
confirmButtonText={confirmBtnText}
cancelButtonText={cancelButtonText}
title={title}
>
{message}
</EuiConfirmModal>
)
);
});
}

View file

@ -0,0 +1,21 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
/** An error message to be used when the user rejects a confirm overwrite. */
export const OVERWRITE_REJECTED = i18n.translate('xpack.graph.overwriteRejectedDescription', {
defaultMessage: 'Overwrite confirmation was rejected',
});
/** An error message to be used when the user rejects a confirm save with duplicate title. */
export const SAVE_DUPLICATE_REJECTED = i18n.translate(
'xpack.graph.saveDuplicateRejectedDescription',
{
defaultMessage: 'Save with duplicate title confirmation was rejected',
}
);

View file

@ -0,0 +1,39 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import type { OverlayStart } from '@kbn/core/public';
import type { GraphWorkspaceSavedObject } from '../../types';
import { SAVE_DUPLICATE_REJECTED } from './constants';
import { confirmModalPromise } from './confirm_modal_promise';
export function displayDuplicateTitleConfirmModal(
savedObject: Pick<GraphWorkspaceSavedObject, 'title'>,
overlays: OverlayStart
): Promise<boolean> {
const confirmTitle = i18n.translate('xpack.graph.confirmModal.saveDuplicateConfirmationTitle', {
defaultMessage: `This visualization already exists`,
});
const confirmMessage = i18n.translate(
'xpack.graph.confirmModal.saveDuplicateConfirmationMessage',
{
defaultMessage: `Saving "{name}" creates a duplicate title. Would you like to save anyway?`,
values: { name: savedObject.title },
}
);
const confirmButtonText = i18n.translate('xpack.graph.confirmModal.saveDuplicateButtonLabel', {
defaultMessage: 'Save',
});
try {
return confirmModalPromise(confirmMessage, confirmTitle, confirmButtonText, overlays);
} catch {
return Promise.reject(new Error(SAVE_DUPLICATE_REJECTED));
}
}

View file

@ -0,0 +1,79 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { findObjectByTitle } from './find_object_by_title';
import { SimpleSavedObject } from '@kbn/core/public';
import { ContentClient } from '@kbn/content-management-plugin/public';
const mockFindContent = jest.fn(async () => ({
pagination: { total: 0 },
hits: [] as unknown[],
}));
const mockGetContent = jest.fn(async () => ({
item: {
id: 'test',
references: [
{
id: 'test',
type: 'index-pattern',
},
],
attributes: {
visState: JSON.stringify({ type: 'area' }),
kibanaSavedObjectMeta: {
searchSourceJSON: '{filter: []}',
},
},
_version: '1',
},
meta: {
outcome: 'exact',
alias_target_id: null,
},
}));
const mockCreateContent = jest.fn(async (input: any) => ({
item: {
id: 'test',
},
}));
const mockUpdateContent = jest.fn(() => ({
item: {
id: 'test',
},
}));
const contentClientMock = {
create: mockCreateContent,
update: mockUpdateContent,
get: mockGetContent,
search: mockFindContent,
} as unknown as ContentClient;
describe('findObjectByTitle', () => {
beforeEach(() => {
mockFindContent.mockClear();
});
it('returns undefined if title is not provided', async () => {
const match = await findObjectByTitle(contentClientMock, '');
expect(match).toBeUndefined();
});
it('matches any case', async () => {
const indexPattern = {
attributes: { title: 'foo' },
} as SimpleSavedObject;
mockFindContent.mockImplementation(async () => ({
hits: [indexPattern],
pagination: { total: 1 },
}));
const match = await findObjectByTitle(contentClientMock, 'FOO');
expect(match).toEqual(indexPattern);
});
});

View file

@ -0,0 +1,28 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ContentClient } from '@kbn/content-management-plugin/public';
import { CONTENT_ID, GraphSearchIn, GraphSearchOut } from '../../../common/content_management';
/** Returns an object matching a given title */
export async function findObjectByTitle(contentClient: ContentClient, title: string) {
if (!title) {
return;
}
// Elastic search will return the most relevant results first, which means exact matches should come
// first, and so we shouldn't need to request everything. Using 10 just to be on the safe side.
const response = await contentClient.search<GraphSearchIn, GraphSearchOut>({
contentTypeId: CONTENT_ID,
query: {
text: `"${title}"`,
},
options: {
searchFields: ['title'],
},
});
return response.hits.find((obj) => obj.attributes.title.toLowerCase() === title.toLowerCase());
}

View file

@ -0,0 +1,9 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { saveWithConfirmation } from './save_with_confirmation';
export { checkForDuplicateTitle } from './check_for_duplicate_title';

View file

@ -0,0 +1,78 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { get } from 'lodash';
import { i18n } from '@kbn/i18n';
import type { SavedObjectsCreateOptions, OverlayStart } from '@kbn/core/public';
import { ContentClient } from '@kbn/content-management-plugin/public';
import { CONTENT_ID, GraphCreateIn, GraphCreateOut } from '../../../common/content_management';
import { OVERWRITE_REJECTED } from './constants';
import { confirmModalPromise } from './confirm_modal_promise';
import { GraphSavedObjectAttributes, GraphSavedObject } from '../../../common/content_management';
import { GraphWorkspaceSavedObject } from '../../types';
/**
* 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 - VisSavedObject
* @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 {SimpleSavedObject}
*/
export async function saveWithConfirmation(
source: GraphSavedObjectAttributes,
savedObject: Pick<GraphWorkspaceSavedObject, 'title' | 'getEsType' | 'displayName'>,
options: SavedObjectsCreateOptions,
services: { overlays: OverlayStart; contentClient: ContentClient }
): Promise<{ item: GraphSavedObject }> {
const { overlays, contentClient } = services;
try {
return await contentClient.create<GraphCreateIn, GraphCreateOut>({
contentTypeId: CONTENT_ID,
data: source,
options,
});
} catch (err) {
// record exists, confirm overwriting
if (get(err, 'res.status') === 409) {
const confirmMessage = i18n.translate(
'xpack.graph.confirmModal.overwriteConfirmationMessage',
{
defaultMessage: 'Are you sure you want to overwrite {title}?',
values: { title: savedObject.title },
}
);
const title = i18n.translate('xpack.graph.confirmModal.overwriteTitle', {
defaultMessage: 'Overwrite {name}?',
values: { name: savedObject.displayName },
});
const confirmButtonText = i18n.translate('xpack.graph.confirmModal.overwriteButtonLabel', {
defaultMessage: 'Overwrite',
});
return confirmModalPromise(confirmMessage, title, confirmButtonText, overlays)
.then(() =>
contentClient.create<GraphCreateIn, GraphCreateOut>({
contentTypeId: CONTENT_ID,
data: source,
options: {
overwrite: true,
...options,
},
})
)
.catch(() => Promise.reject(new Error(OVERWRITE_REJECTED)));
}
return await Promise.reject(err);
}
}

View file

@ -6,6 +6,7 @@
*/
import { coreMock } from '@kbn/core/public/mocks';
import { ContentClient } from '@kbn/content-management-plugin/public';
import { GraphWorkspaceSavedObject } from '../types';
import { saveSavedWorkspace } from './saved_workspace_utils';
@ -28,11 +29,9 @@ describe('saved_workspace_utils', () => {
savedWorkspace,
{},
{
savedObjectsClient: {
...core.savedObjects.client,
find: jest.fn().mockResolvedValue({ savedObjects: [] }),
create: jest.fn().mockResolvedValue({ id: '456' }),
},
contentClient: {
create: jest.fn().mockReturnValue(Promise.resolve({ item: { id: '456' } })),
} as unknown as ContentClient,
overlays: core.overlays,
}
);

View file

@ -7,26 +7,31 @@
import { cloneDeep, assign, defaults, forOwn } from 'lodash';
import { i18n } from '@kbn/i18n';
import {
IBasePath,
OverlayStart,
SavedObjectsClientContract,
SavedObjectAttributes,
} from '@kbn/core/public';
import { IBasePath, OverlayStart, SavedObjectAttributes } from '@kbn/core/public';
import {
SavedObjectSaveOpts,
checkForDuplicateTitle,
saveWithConfirmation,
isErrorNonFatal,
} from '@kbn/saved-objects-plugin/public';
import { SavedObjectSaveOpts, isErrorNonFatal } from '@kbn/saved-objects-plugin/public';
import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public';
import { ContentClient } from '@kbn/content-management-plugin/public';
import {
GraphGetIn,
GraphGetOut,
GraphSearchIn,
GraphSearchOut,
GraphDeleteIn,
GraphDeleteOut,
GraphCreateIn,
GraphCreateOut,
GraphSavedObjectAttributes,
GraphUpdateOut,
GraphUpdateIn,
CONTENT_ID,
} from '../../common/content_management';
import {
injectReferences,
extractReferences,
} from '../services/persistence/saved_workspace_references';
import { GraphWorkspaceSavedObject } from '../types';
import { checkForDuplicateTitle, saveWithConfirmation } from './saved_objects_utils';
const savedWorkspaceType = 'graph-workspace';
const mapping: Record<string, string> = {
title: 'text',
@ -60,25 +65,25 @@ function mapHits(hit: any, url: string): GraphWorkspaceSavedObject {
interface SavedWorkspaceServices {
basePath: IBasePath;
savedObjectsClient: SavedObjectsClientContract;
contentClient: ContentClient;
}
export function findSavedWorkspace(
{ savedObjectsClient, basePath }: SavedWorkspaceServices,
{ contentClient, basePath }: SavedWorkspaceServices,
searchString: string,
size: number = 100
) {
return savedObjectsClient
.find<Record<string, unknown>>({
type: savedWorkspaceType,
search: searchString ? `${searchString}*` : undefined,
perPage: size,
searchFields: ['title^3', 'description'],
return contentClient
.search<GraphSearchIn, GraphSearchOut>({
contentTypeId: CONTENT_ID,
query: {
text: searchString ? `${searchString}*` : '',
},
})
.then((resp) => {
return {
total: resp.total,
hits: resp.savedObjects.map((hit) => mapHits(hit, urlFor(basePath, hit.id))),
total: resp.pagination.total,
hits: resp.hits.map((hit) => mapHits(hit, urlFor(basePath, hit.id))),
};
});
}
@ -93,18 +98,15 @@ export function getEmptyWorkspace() {
};
}
export async function getSavedWorkspace(
savedObjectsClient: SavedObjectsClientContract,
id: string
) {
const resolveResult = await savedObjectsClient.resolve<Record<string, unknown>>(
savedWorkspaceType,
id
);
export async function getSavedWorkspace(contentClient: ContentClient, id: string) {
const resolveResult = await contentClient.get<GraphGetIn, GraphGetOut>({
contentTypeId: CONTENT_ID,
id,
});
const resp = resolveResult.saved_object;
const resp = resolveResult.item;
if (!resp._version) {
if (!resp.attributes) {
throw new SavedObjectNotFound(savedWorkspaceType, id || '');
}
@ -112,8 +114,10 @@ export async function getSavedWorkspace(
id,
displayName: 'graph workspace',
getEsType: () => savedWorkspaceType,
_source: cloneDeep(resp.attributes),
} as GraphWorkspaceSavedObject;
_source: cloneDeep({
...resp.attributes,
}),
} as unknown as GraphWorkspaceSavedObject;
// assign the defaults to the response
defaults(savedObject._source, defaultsProps);
@ -132,9 +136,9 @@ export async function getSavedWorkspace(
}
const sharingSavedObjectProps = {
outcome: resolveResult.outcome,
aliasTargetId: resolveResult.alias_target_id,
aliasPurpose: resolveResult.alias_purpose,
outcome: resolveResult.meta.outcome,
aliasTargetId: resolveResult.meta.aliasTargetId,
aliasPurpose: resolveResult.meta.aliasPurpose,
};
return {
@ -143,11 +147,15 @@ export async function getSavedWorkspace(
};
}
export function deleteSavedWorkspace(
savedObjectsClient: SavedObjectsClientContract,
ids: string[]
) {
return Promise.all(ids.map((id: string) => savedObjectsClient.delete(savedWorkspaceType, id)));
export function deleteSavedWorkspace(contentClient: ContentClient, ids: string[]) {
return Promise.all(
ids.map((id: string) =>
contentClient.delete<GraphDeleteIn, GraphDeleteOut>({
contentTypeId: CONTENT_ID,
id,
})
)
);
}
export async function saveSavedWorkspace(
@ -158,7 +166,7 @@ export async function saveSavedWorkspace(
onTitleDuplicate,
}: SavedObjectSaveOpts = {},
services: {
savedObjectsClient: SavedObjectsClientContract;
contentClient: ContentClient;
overlays: OverlayStart;
}
) {
@ -208,13 +216,33 @@ export async function saveSavedWorkspace(
references,
};
const resp = confirmOverwrite
? await saveWithConfirmation(attributes, savedObject, createOpt, services)
: await services.savedObjectsClient.create(savedObject.getEsType(), attributes, {
...createOpt,
overwrite: true,
? await saveWithConfirmation(
attributes as GraphSavedObjectAttributes,
savedObject,
createOpt,
services
)
: savedObject.id
? await services.contentClient.update<GraphUpdateIn, GraphUpdateOut>({
contentTypeId: CONTENT_ID,
id: savedObject.id,
data: {
...(extractedRefs.attributes as GraphSavedObjectAttributes),
},
options: {
references: extractedRefs.references,
},
})
: await services.contentClient.create<GraphCreateIn, GraphCreateOut>({
contentTypeId: CONTENT_ID,
data: attributes as GraphSavedObjectAttributes,
options: {
references: createOpt.references,
overwrite: true,
},
});
savedObject.id = resp.id;
savedObject.id = resp.item.id;
savedObject.isSaving = false;
savedObject.lastSavedTitle = savedObject.title;
return savedObject.id;

View file

@ -10,9 +10,8 @@ import { spacesPluginMock } from '@kbn/spaces-plugin/public/mocks';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { createMockGraphStore } from '../state_management/mocks';
import { Workspace } from '../types';
import { SavedObjectsClientCommon } from '@kbn/data-views-plugin/public';
import { renderHook, act, RenderHookOptions } from '@testing-library/react-hooks';
import type { SavedObjectsClientContract } from '@kbn/core/public';
import { ContentClient } from '@kbn/content-management-plugin/public';
jest.mock('react-router-dom', () => {
const useLocation = () => ({
@ -33,19 +32,19 @@ jest.mock('react-router-dom', () => {
};
});
const mockSavedObjectsClient = {
resolve: jest.fn().mockResolvedValue({
saved_object: { id: 10, _version: '7.15.0', attributes: { wsState: '{}' } },
outcome: 'exactMatch',
const mockContentClient = {
get: jest.fn().mockResolvedValue({
item: { id: 10, _version: '7.15.0', attributes: { wsState: '{}' } },
meta: { outcome: 'exactMatch' },
}),
find: jest.fn().mockResolvedValue({ title: 'test', perPage: 1, total: 1, page: 1 }),
} as unknown as SavedObjectsClientCommon;
search: jest.fn().mockResolvedValue({ title: 'test', perPage: 1, total: 1, page: 1 }),
} as unknown as ContentClient;
describe('use_workspace_loader', () => {
const defaultProps: UseWorkspaceLoaderProps = {
workspaceRef: { current: {} as Workspace },
store: createMockGraphStore({}).store,
savedObjectsClient: mockSavedObjectsClient as unknown as SavedObjectsClientContract,
contentClient: mockContentClient as unknown as ContentClient,
coreStart: coreMock.createStart(),
spaces: spacesPluginMock.createStartContract(),
data: dataPluginMock.createStartContract(),
@ -65,13 +64,15 @@ describe('use_workspace_loader', () => {
const props = {
...defaultProps,
spaces: spacesPluginMock.createStartContract(),
savedObjectsClient: {
...mockSavedObjectsClient,
resolve: jest.fn().mockResolvedValue({
saved_object: { id: 10, _version: '7.15.0', attributes: { wsState: '{}' } },
outcome: 'aliasMatch',
alias_target_id: 'aliasTargetId',
alias_purpose: 'savedObjectConversion',
contentClient: {
...mockContentClient,
get: jest.fn().mockResolvedValue({
item: { id: 10, _version: '7.15.0', attributes: { wsState: '{}' } },
meta: {
outcome: 'aliasMatch',
aliasTargetId: 'aliasTargetId',
aliasPurpose: 'savedObjectConversion',
},
}),
},
} as unknown as UseWorkspaceLoaderProps;

View file

@ -7,12 +7,13 @@
import { useEffect, useState } from 'react';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import type { SavedObjectsClientContract, ResolvedSimpleSavedObject } from '@kbn/core/public';
import type { ResolvedSimpleSavedObject } from '@kbn/core/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { i18n } from '@kbn/i18n';
import { CoreStart } from '@kbn/core/public';
import { SpacesApi } from '@kbn/spaces-plugin/public';
import type { DataViewListItem } from '@kbn/data-views-plugin/common';
import { ContentClient } from '@kbn/content-management-plugin/public';
import { GraphStore } from '../state_management';
import { GraphWorkspaceSavedObject, Workspace } from '../types';
import { getEmptyWorkspace, getSavedWorkspace } from './saved_workspace_utils';
@ -21,7 +22,7 @@ import { getEditUrl } from '../services/url';
export interface UseWorkspaceLoaderProps {
store: GraphStore;
workspaceRef: React.MutableRefObject<Workspace | undefined>;
savedObjectsClient: SavedObjectsClientContract;
contentClient: ContentClient;
coreStart: CoreStart;
spaces?: SpacesApi;
data: DataPublicPluginStart;
@ -47,7 +48,7 @@ export const useWorkspaceLoader = ({
spaces,
workspaceRef,
store,
savedObjectsClient,
contentClient,
data,
}: UseWorkspaceLoaderProps) => {
const [state, setState] = useState<WorkspaceLoadedState>();
@ -85,7 +86,7 @@ export const useWorkspaceLoader = ({
sharingSavedObjectProps?: SharingSavedObjectProps;
}> {
return id
? await getSavedWorkspace(savedObjectsClient, id).catch(function (e) {
? await getSavedWorkspace(contentClient, id).catch(function (e) {
coreStart.notifications.toasts.addError(e, {
title: i18n.translate('xpack.graph.missingWorkspaceErrorMessage', {
defaultMessage: "Couldn't load graph with ID",
@ -141,7 +142,7 @@ export const useWorkspaceLoader = ({
search,
store,
historyReplace,
savedObjectsClient,
contentClient,
setState,
coreStart,
workspaceRef,

View file

@ -23,17 +23,21 @@ import { Start as InspectorPublicPluginStart } from '@kbn/inspector-plugin/publi
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { NavigationPublicPluginStart as NavigationStart } from '@kbn/navigation-plugin/public';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import {
ContentManagementPublicSetup,
ContentManagementPublicStart,
} from '@kbn/content-management-plugin/public';
import { LicensingPluginStart } from '@kbn/licensing-plugin/public';
import type { HomePublicPluginSetup, HomePublicPluginStart } from '@kbn/home-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import { SavedObjectsStart } from '@kbn/saved-objects-plugin/public';
import { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public';
import { checkLicense } from '../common/check_license';
import { ConfigSchema } from '../config';
import { CONTENT_ID, LATEST_VERSION } from '../common/content_management';
export interface GraphPluginSetupDependencies {
home?: HomePublicPluginSetup;
contentManagement: ContentManagementPublicSetup;
}
export interface GraphPluginStartDependencies {
@ -41,11 +45,11 @@ export interface GraphPluginStartDependencies {
licensing: LicensingPluginStart;
data: DataPublicPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
savedObjects: SavedObjectsStart;
inspector: InspectorPublicPluginStart;
home?: HomePublicPluginStart;
spaces?: SpacesApi;
savedObjectsManagement: SavedObjectsManagementPluginStart;
contentManagement: ContentManagementPublicStart;
}
export class GraphPlugin
@ -55,7 +59,10 @@ export class GraphPlugin
constructor(private initializerContext: PluginInitializerContext<ConfigSchema>) {}
setup(core: CoreSetup<GraphPluginStartDependencies>, { home }: GraphPluginSetupDependencies) {
setup(
core: CoreSetup<GraphPluginStartDependencies>,
{ home, contentManagement }: GraphPluginSetupDependencies
) {
if (home) {
home.featureCatalogue.register({
id: 'graph',
@ -77,6 +84,16 @@ export class GraphPlugin
const config = this.initializerContext.config.get();
contentManagement.registry.register({
id: CONTENT_ID,
version: {
latest: LATEST_VERSION,
},
name: i18n.translate('xpack.graph.content.name', {
defaultMessage: 'Graph Visualization',
}),
});
core.application.register({
id: 'graph',
title: 'Graph',
@ -100,7 +117,7 @@ export class GraphPlugin
navigation: pluginsStart.navigation,
data: pluginsStart.data,
unifiedSearch: pluginsStart.unifiedSearch,
savedObjectsClient: coreStart.savedObjects.client,
contentClient: pluginsStart.contentManagement.client,
addBasePath: core.http.basePath.prepend,
getBasePath: core.http.basePath.get,
canEditDrillDownUrls: config.canEditDrillDownUrls,
@ -111,7 +128,6 @@ export class GraphPlugin
toastNotifications: coreStart.notifications.toasts,
indexPatterns: pluginsStart.data!.indexPatterns,
overlays: coreStart.overlays,
savedObjects: pluginsStart.savedObjects,
uiSettings: core.uiSettings,
spaces: pluginsStart.spaces,
inspect: pluginsStart.inspector,

View file

@ -6,15 +6,16 @@
*/
import React from 'react';
import { OverlayStart, SavedObjectsClientContract } from '@kbn/core/public';
import { OverlayStart } from '@kbn/core/public';
import { SaveResult } from '@kbn/saved-objects-plugin/public';
import { showSaveModal } from '@kbn/saved-objects-plugin/public';
import { ContentClient } from '@kbn/content-management-plugin/public';
import { GraphWorkspaceSavedObject, GraphSavePolicy } from '../types';
import { SaveModal, OnSaveGraphProps } from '../components/save_modal';
export interface SaveWorkspaceServices {
overlays: OverlayStart;
savedObjectsClient: SavedObjectsClientContract;
contentClient: ContentClient;
}
export type SaveWorkspaceHandler = (

View file

@ -5,16 +5,12 @@
* 2.0.
*/
import {
NotificationsStart,
HttpStart,
OverlayStart,
SavedObjectsClientContract,
} from '@kbn/core/public';
import { NotificationsStart, HttpStart, OverlayStart } from '@kbn/core/public';
import createSagaMiddleware from 'redux-saga';
import { createStore, applyMiddleware, AnyAction } from 'redux';
import { ChromeStart } from '@kbn/core/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import { ContentClient } from '@kbn/content-management-plugin/public';
import { GraphStoreDependencies, createRootReducer, GraphStore, GraphState } from './store';
import { Workspace } from '../types';
@ -58,6 +54,12 @@ export function createMockGraphStore({
} as unknown as ChromeStart,
createWorkspace: jest.fn((index, advancedSettings) => workspaceMock),
getWorkspace: jest.fn(() => workspaceMock),
contentClient: {
get: jest.fn(),
search: jest.fn(),
create: jest.fn(),
update: jest.fn(),
} as unknown as ContentClient,
indexPatternProvider: {
get: jest.fn(async (id: string) => {
if (id === 'missing-dataview') {
@ -78,10 +80,6 @@ export function createMockGraphStore({
overlays: {
openModal: jest.fn(),
} as unknown as OverlayStart,
savedObjectsClient: {
find: jest.fn(),
get: jest.fn(),
} as unknown as SavedObjectsClientContract,
handleSearchQueryError: jest.fn(),
...mockedDepsOverwrites,
};

View file

@ -231,7 +231,7 @@ function showModal(
workspace: savedWorkspace,
saveWorkspace: saveWorkspaceHandler,
services: {
savedObjectsClient: deps.savedObjectsClient,
contentClient: deps.contentClient,
overlays: deps.overlays,
},
});

View file

@ -7,8 +7,9 @@
import createSagaMiddleware, { SagaMiddleware } from 'redux-saga';
import { combineReducers, createStore, Store, AnyAction, Dispatch, applyMiddleware } from 'redux';
import { ChromeStart, OverlayStart, SavedObjectsClientContract } from '@kbn/core/public';
import { ChromeStart, OverlayStart } from '@kbn/core/public';
import { CoreStart } from '@kbn/core/public';
import { ContentClient } from '@kbn/content-management-plugin/public';
import {
fieldsReducer,
FieldsState,
@ -46,7 +47,7 @@ export interface GraphStoreDependencies {
notifications: CoreStart['notifications'];
http: CoreStart['http'];
overlays: OverlayStart;
savedObjectsClient: SavedObjectsClientContract;
contentClient: ContentClient;
savePolicy: GraphSavePolicy;
changeUrl: (newUrl: string) => void;
notifyReact: () => void;

View file

@ -0,0 +1,328 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import Boom from '@hapi/boom';
import type { SearchQuery } from '@kbn/content-management-plugin/common';
import type { ContentStorage, StorageContext } from '@kbn/content-management-plugin/server';
import type {
SavedObject,
SavedObjectReference,
SavedObjectsFindOptions,
} from '@kbn/core-saved-objects-api-server';
import { cmServicesDefinition } from '../../common/content_management/cm_services';
import type {
GraphSavedObjectAttributes,
GraphSavedObject,
PartialGraphSavedObject,
GraphGetOut,
GraphCreateIn,
GraphCreateOut,
CreateOptions,
GraphUpdateIn,
GraphUpdateOut,
UpdateOptions,
GraphDeleteOut,
GraphSearchQuery,
GraphSearchOut,
} from '../../common/content_management';
const savedObjectClientFromRequest = async (ctx: StorageContext) => {
if (!ctx.requestHandlerContext) {
throw new Error('Storage context.requestHandlerContext missing.');
}
const { savedObjects } = await ctx.requestHandlerContext.core;
return savedObjects.client;
};
type PartialSavedObject<T> = Omit<SavedObject<Partial<T>>, 'references'> & {
references: SavedObjectReference[] | undefined;
};
function savedObjectToGraphSavedObject(
savedObject: SavedObject<GraphSavedObjectAttributes>,
partial: false
): GraphSavedObject;
function savedObjectToGraphSavedObject(
savedObject: PartialSavedObject<GraphSavedObjectAttributes>,
partial: true
): PartialGraphSavedObject;
function savedObjectToGraphSavedObject(
savedObject:
| SavedObject<GraphSavedObjectAttributes>
| PartialSavedObject<GraphSavedObjectAttributes>
): GraphSavedObject | PartialGraphSavedObject {
const {
id,
type,
updated_at: updatedAt,
created_at: createdAt,
attributes: {
title,
description,
version,
kibanaSavedObjectMeta,
wsState,
numVertices,
numLinks,
legacyIndexPatternRef,
},
references,
error,
namespaces,
} = savedObject;
return {
id,
type,
updatedAt,
createdAt,
attributes: {
title,
description,
kibanaSavedObjectMeta,
wsState,
version,
numLinks,
numVertices,
legacyIndexPatternRef,
},
references,
error,
namespaces,
};
}
const SO_TYPE = 'graph-workspace';
export class GraphStorage implements ContentStorage<GraphSavedObject, PartialGraphSavedObject> {
constructor() {}
async get(ctx: StorageContext, id: string): Promise<GraphGetOut> {
const {
utils: { getTransforms },
version: { request: requestVersion },
} = ctx;
const transforms = getTransforms(cmServicesDefinition, requestVersion);
const soClient = await savedObjectClientFromRequest(ctx);
// Save data in DB
const {
saved_object: savedObject,
alias_purpose: aliasPurpose,
alias_target_id: aliasTargetId,
outcome,
} = await soClient.resolve<GraphSavedObjectAttributes>(SO_TYPE, id);
const response: GraphGetOut = {
item: savedObjectToGraphSavedObject(savedObject, false),
meta: {
aliasPurpose,
aliasTargetId,
outcome,
},
};
// Validate DB response and DOWN transform to the request version
const { value, error: resultError } = transforms.get.out.result.down<GraphGetOut, GraphGetOut>(
response
);
if (resultError) {
throw Boom.badRequest(`Invalid response. ${resultError.message}`);
}
return value;
}
async bulkGet(): Promise<never> {
// Not implemented. Graph does not use bulkGet
throw new Error(`[bulkGet] has not been implemented. See GraphStorage class.`);
}
async create(
ctx: StorageContext,
data: GraphCreateIn['data'],
options: CreateOptions
): Promise<GraphCreateOut> {
const {
utils: { getTransforms },
version: { request: requestVersion },
} = ctx;
const transforms = getTransforms(cmServicesDefinition, requestVersion);
// Validate input (data & options) & UP transform them to the latest version
const { value: dataToLatest, error: dataError } = transforms.create.in.data.up<
GraphSavedObjectAttributes,
GraphSavedObjectAttributes
>(data);
if (dataError) {
throw Boom.badRequest(`Invalid data. ${dataError.message}`);
}
const { value: optionsToLatest, error: optionsError } = transforms.create.in.options.up<
CreateOptions,
CreateOptions
>(options);
if (optionsError) {
throw Boom.badRequest(`Invalid options. ${optionsError.message}`);
}
// Save data in DB
const soClient = await savedObjectClientFromRequest(ctx);
const savedObject = await soClient.create<GraphSavedObjectAttributes>(
SO_TYPE,
dataToLatest,
optionsToLatest
);
// Validate DB response and DOWN transform to the request version
const { value, error: resultError } = transforms.create.out.result.down<
GraphCreateOut,
GraphCreateOut
>({
item: savedObjectToGraphSavedObject(savedObject, false),
});
if (resultError) {
throw Boom.badRequest(`Invalid response. ${resultError.message}`);
}
return value;
}
async update(
ctx: StorageContext,
id: string,
data: GraphUpdateIn['data'],
options: UpdateOptions
): Promise<GraphUpdateOut> {
const {
utils: { getTransforms },
version: { request: requestVersion },
} = ctx;
const transforms = getTransforms(cmServicesDefinition, requestVersion);
// Validate input (data & options) & UP transform them to the latest version
const { value: dataToLatest, error: dataError } = transforms.update.in.data.up<
GraphSavedObjectAttributes,
GraphSavedObjectAttributes
>(data);
if (dataError) {
throw Boom.badRequest(`Invalid data. ${dataError.message}`);
}
const { value: optionsToLatest, error: optionsError } = transforms.update.in.options.up<
CreateOptions,
CreateOptions
>(options);
if (optionsError) {
throw Boom.badRequest(`Invalid options. ${optionsError.message}`);
}
// Save data in DB
const soClient = await savedObjectClientFromRequest(ctx);
const partialSavedObject = await soClient.update<GraphSavedObjectAttributes>(
SO_TYPE,
id,
dataToLatest,
optionsToLatest
);
// Validate DB response and DOWN transform to the request version
const { value, error: resultError } = transforms.update.out.result.down<
GraphUpdateOut,
GraphUpdateOut
>({
item: savedObjectToGraphSavedObject(partialSavedObject, true),
});
if (resultError) {
throw Boom.badRequest(`Invalid response. ${resultError.message}`);
}
return value;
}
async delete(ctx: StorageContext, id: string): Promise<GraphDeleteOut> {
const soClient = await savedObjectClientFromRequest(ctx);
await soClient.delete(SO_TYPE, id);
return { success: true };
}
async search(
ctx: StorageContext,
query: SearchQuery,
options: GraphSearchQuery = {}
): Promise<GraphSearchOut> {
const {
utils: { getTransforms },
version: { request: requestVersion },
} = ctx;
const transforms = getTransforms(cmServicesDefinition, requestVersion);
const soClient = await savedObjectClientFromRequest(ctx);
// Validate and UP transform the options
const { value: optionsToLatest, error: optionsError } = transforms.search.in.options.up<
GraphSearchQuery,
GraphSearchQuery
>(options);
if (optionsError) {
throw Boom.badRequest(`Invalid payload. ${optionsError.message}`);
}
const { searchFields = ['title^3', 'description'], types = ['graph-workspace'] } =
optionsToLatest;
const { included, excluded } = query.tags ?? {};
const hasReference: SavedObjectsFindOptions['hasReference'] = included
? included.map((id) => ({
id,
type: 'tag',
}))
: undefined;
const hasNoReference: SavedObjectsFindOptions['hasNoReference'] = excluded
? excluded.map((id) => ({
id,
type: 'tag',
}))
: undefined;
const soQuery: SavedObjectsFindOptions = {
type: types,
search: query.text,
perPage: query.limit,
page: query.cursor ? Number(query.cursor) : undefined,
defaultSearchOperator: 'AND',
searchFields,
hasReference,
hasNoReference,
};
// Execute the query in the DB
const response = await soClient.find<GraphSavedObjectAttributes>(soQuery);
// Validate the response and DOWN transform to the request version
const { value, error: resultError } = transforms.search.out.result.down<
GraphSearchOut,
GraphSearchOut
>({
hits: response.saved_objects.map((so) => savedObjectToGraphSavedObject(so, false)),
pagination: {
total: response.total,
},
});
if (resultError) {
throw Boom.badRequest(`Invalid response. ${resultError.message}`);
}
return value;
}
}

View file

@ -0,0 +1,8 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { GraphStorage } from './graph_storage';

View file

@ -11,11 +11,14 @@ import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import { LicensingPluginSetup, LicensingPluginStart } from '@kbn/licensing-plugin/server';
import { HomeServerPluginSetup } from '@kbn/home-plugin/server';
import { PluginSetupContract as FeaturesPluginSetup } from '@kbn/features-plugin/server';
import { ContentManagementServerSetup } from '@kbn/content-management-plugin/server';
import { LicenseState } from './lib/license_state';
import { registerSearchRoute } from './routes/search';
import { registerExploreRoute } from './routes/explore';
import { registerSampleData } from './sample_data';
import { graphWorkspace } from './saved_objects';
import { CONTENT_ID, LATEST_VERSION } from '../common/content_management';
import { GraphStorage } from './content_management/graph_storage';
export class GraphPlugin implements Plugin {
private licenseState: LicenseState | null = null;
@ -26,10 +29,12 @@ export class GraphPlugin implements Plugin {
licensing,
home,
features,
contentManagement,
}: {
licensing: LicensingPluginSetup;
home?: HomeServerPluginSetup;
features?: FeaturesPluginSetup;
contentManagement: ContentManagementServerSetup;
}
) {
const licenseState = new LicenseState();
@ -38,6 +43,14 @@ export class GraphPlugin implements Plugin {
core.savedObjects.registerType(graphWorkspace);
licensing.featureUsage.register('Graph', 'platinum');
contentManagement.register({
id: CONTENT_ID,
storage: new GraphStorage(),
version: {
latest: LATEST_VERSION,
},
});
if (home) {
registerSampleData(home.sampleData, licenseState);
}

View file

@ -41,6 +41,9 @@
"@kbn/saved-objects-management-plugin",
"@kbn/saved-objects-finder-plugin",
"@kbn/core-saved-objects-server",
"@kbn/content-management-plugin",
"@kbn/core-saved-objects-api-server",
"@kbn/object-versioning",
],
"exclude": [
"target/**/*",