[ContentManagement] Fix Visualize List search and CRUD operations via CM (#165485)

## Summary

Fix #163246

This PR fixes the CM problems within the Visualize List page leveraging
the services already in place.
The approach here is lighter than #165292 as it passes each client via
the TypesService already used to register extensions in the
Visualization scope. Also the `search` method now transparently uses the
`mSearch` if more content types are detected without leaking any
implementation detail outside the `VisualizationClient` interface.

More fixes/features:
* fixed Maps update operation definition which was missing the
`overwrite` flag
* Allow `mSearch` to accept an options object argument
* Added new helper functions to interact with the metadata flyout in
Listing page


### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
This commit is contained in:
Marco Liberati 2023-09-13 18:18:03 +02:00 committed by GitHub
parent 691311ce7c
commit 01ad4c2b44
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 491 additions and 417 deletions

View file

@ -66,6 +66,13 @@ const deleteVisualization = async (id: string) => {
};
const search = async (query: SearchQuery = {}, options?: VisualizationSearchQuery) => {
if (options && options.types && options.types.length > 1) {
const { types } = options;
return getContentManagement().client.mSearch<VisualizationSearchOut['hits'][number]>({
contentTypes: types.map((type) => ({ contentTypeId: type })),
query,
});
}
return getContentManagement().client.search<VisualizationSearchIn, VisualizationSearchOut>({
contentTypeId: 'visualization',
query,

View file

@ -24,7 +24,15 @@ export { getVisSchemas } from './vis_schemas';
/** @public types */
export type { VisualizationsSetup, VisualizationsStart };
export { VisGroups } from './vis_types/vis_groups_enum';
export type { BaseVisType, VisTypeAlias, VisTypeDefinition, Schema, ISchemas } from './vis_types';
export type {
BaseVisType,
VisTypeAlias,
VisTypeDefinition,
Schema,
ISchemas,
VisualizationClient,
SerializableAttributes,
} from './vis_types';
export type { Vis, SerializedVis, SerializedVisData, VisData } from './vis';
export type VisualizeEmbeddableFactoryContract = PublicContract<VisualizeEmbeddableFactory>;
export type VisualizeEmbeddableContract = PublicContract<VisualizeEmbeddable>;

View file

@ -333,6 +333,7 @@ export class VisualizationsPlugin
unifiedSearch: pluginsStart.unifiedSearch,
serverless: pluginsStart.serverless,
noDataPage: pluginsStart.noDataPage,
contentManagement: pluginsStart.contentManagement,
};
params.element.classList.add('visAppWrapper');

View file

@ -9,12 +9,42 @@
import type { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public';
import type { OverlayStart } from '@kbn/core-overlays-browser';
import { ContentManagementPublicStart } from '@kbn/content-management-plugin/public';
import { extractReferences } from '../saved_visualization_references';
import { visualizationsClient } from '../../content_management';
import { TypesStart } from '../../vis_types';
interface UpdateBasicSoAttributesDependencies {
savedObjectsTagging?: SavedObjectsTaggingApi;
overlays: OverlayStart;
typesService: TypesStart;
contentManagement: ContentManagementPublicStart;
}
function getClientForType(
type: string,
typesService: TypesStart,
contentManagement: ContentManagementPublicStart
) {
const visAliases = typesService.getAliases();
return (
visAliases
.find((v) => v.appExtensions?.visualizations.docTypes.includes(type))
?.appExtensions?.visualizations.client(contentManagement) || visualizationsClient
);
}
function getAdditionalOptionsForUpdate(
type: string,
typesService: TypesStart,
method: 'update' | 'create'
) {
const visAliases = typesService.getAliases();
const aliasType = visAliases.find((v) => v.appExtensions?.visualizations.docTypes.includes(type));
if (!aliasType) {
return { overwrite: true };
}
return aliasType?.appExtensions?.visualizations?.clientOptions?.[method];
}
export const updateBasicSoAttributes = async (
@ -27,7 +57,9 @@ export const updateBasicSoAttributes = async (
},
dependencies: UpdateBasicSoAttributesDependencies
) => {
const so = await visualizationsClient.get(soId);
const client = getClientForType(type, dependencies.typesService, dependencies.contentManagement);
const so = await client.get(soId);
const extractedReferences = extractReferences({
attributes: so.item.attributes,
references: so.item.references,
@ -48,14 +80,14 @@ export const updateBasicSoAttributes = async (
);
}
return await visualizationsClient.update({
return await client.update({
id: soId,
data: {
...attributes,
},
options: {
overwrite: true,
references,
...getAdditionalOptionsForUpdate(type, dependencies.typesService, 'update'),
},
});
};

View file

@ -12,17 +12,17 @@ import {
injectSearchSourceReferences,
SerializedSearchSourceFields,
} from '@kbn/data-plugin/public';
import { SerializableRecord } from '@kbn/utility-types';
import { SavedVisState, VisSavedObject } from '../../types';
import { extractTimeSeriesReferences, injectTimeSeriesReferences } from './timeseries_references';
import { extractControlsReferences, injectControlsReferences } from './controls_references';
import type { SerializableAttributes } from '../../vis_types/vis_type_alias_registry';
export function extractReferences({
attributes,
references = [],
}: {
attributes: SerializableRecord;
attributes: SerializableAttributes;
references: SavedObjectReference[];
}) {
const updatedAttributes = { ...attributes };

View file

@ -73,6 +73,7 @@ jest.mock('../services', () => ({
update: mockUpdateContent,
get: mockGetContent,
search: mockFindContent,
mSearch: mockFindContent,
},
})),
}));
@ -358,10 +359,11 @@ describe('saved_visualize_utils', () => {
expect(mockFindContent.mock.calls).toMatchObject([
[
{
options: {
types: ['bazdoc', 'etc', 'visualization'],
searchFields: ['baz', 'bing', 'title^3', 'description'],
},
contentTypes: [
{ contentTypeId: 'bazdoc' },
{ contentTypeId: 'etc' },
{ contentTypeId: 'visualization' },
],
},
],
]);
@ -395,10 +397,12 @@ describe('saved_visualize_utils', () => {
expect(mockFindContent.mock.calls).toMatchObject([
[
{
options: {
types: ['bazdoc', 'bar', 'visualization', 'foo'],
searchFields: ['baz', 'bing', 'barfield', 'foofield', 'title^3', 'description'],
},
contentTypes: [
{ contentTypeId: 'bazdoc' },
{ contentTypeId: 'bar' },
{ contentTypeId: 'visualization' },
{ contentTypeId: 'foo' },
],
},
],
]);

View file

@ -11,3 +11,4 @@ export { Schemas } from './schemas';
export { VisGroups } from './vis_groups_enum';
export { BaseVisType } from './base_vis_type';
export type { VisTypeDefinition, ISchemas, Schema } from './types';
export type { VisualizationClient, SerializableAttributes } from './vis_type_alias_registry';

View file

@ -6,6 +6,13 @@
* Side Public License, v 1.
*/
import { SearchQuery } from '@kbn/content-management-plugin/common';
import { ContentManagementPublicStart } from '@kbn/content-management-plugin/public';
import {
ContentManagementCrudTypes,
SavedObjectCreateOptions,
SavedObjectUpdateOptions,
} from '@kbn/content-management-utils';
import type { SimpleSavedObject } from '@kbn/core/public';
import { BaseVisType } from './base_vis_type';
@ -27,9 +34,54 @@ export interface VisualizationListItem {
type?: BaseVisType | string;
}
export interface SerializableAttributes {
[key: string]: unknown;
}
export type GenericVisualizationCrudTypes<
ContentType extends string,
Attr extends SerializableAttributes
> = ContentManagementCrudTypes<
ContentType,
Attr,
Pick<SavedObjectCreateOptions, 'overwrite' | 'references'>,
Pick<SavedObjectUpdateOptions, 'references'>,
object
>;
export interface VisualizationClient<
ContentType extends string = string,
Attr extends SerializableAttributes = SerializableAttributes
> {
get: (id: string) => Promise<GenericVisualizationCrudTypes<ContentType, Attr>['GetOut']>;
create: (
visualization: Omit<
GenericVisualizationCrudTypes<ContentType, Attr>['CreateIn'],
'contentTypeId'
>
) => Promise<GenericVisualizationCrudTypes<ContentType, Attr>['CreateOut']>;
update: (
visualization: Omit<
GenericVisualizationCrudTypes<ContentType, Attr>['UpdateIn'],
'contentTypeId'
>
) => Promise<GenericVisualizationCrudTypes<ContentType, Attr>['UpdateOut']>;
delete: (id: string) => Promise<GenericVisualizationCrudTypes<ContentType, Attr>['DeleteOut']>;
search: (
query: SearchQuery,
options?: object
) => Promise<GenericVisualizationCrudTypes<ContentType, Attr>['SearchOut']>;
}
export interface VisualizationsAppExtension {
docTypes: string[];
searchFields?: string[];
/** let each visualization client pass its own custom options if required */
clientOptions?: {
update?: { overwrite?: boolean; [otherOption: string]: unknown };
create?: { [otherOption: string]: unknown };
};
client: (contentManagement: ContentManagementPublicStart) => VisualizationClient;
toListItem: (savedObject: SimpleSavedObject<any>) => VisualizationListItem;
}

View file

@ -109,6 +109,7 @@ const useTableListViewProps = (
overlays,
toastNotifications,
visualizeCapabilities,
contentManagement,
},
} = useKibana<VisualizeServices>();
@ -176,11 +177,16 @@ const useTableListViewProps = (
description: args.description ?? '',
tags: args.tags,
},
{ overlays, savedObjectsTagging }
{
overlays,
savedObjectsTagging,
typesService: getTypes(),
contentManagement,
}
);
}
},
[overlays, savedObjectsTagging]
[overlays, savedObjectsTagging, contentManagement]
);
const contentEditorValidators: OpenContentEditorParams['customValidators'] = useMemo(

View file

@ -42,6 +42,7 @@ import type { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plug
import type { SavedSearch, SavedSearchPublicPluginStart } from '@kbn/saved-search-plugin/public';
import type { ServerlessPluginStart } from '@kbn/serverless/public';
import type { NoDataPagePluginStart } from '@kbn/no-data-page-plugin/public';
import { ContentManagementPublicStart } from '@kbn/content-management-plugin/public';
import type {
Vis,
VisualizeEmbeddableContract,
@ -119,6 +120,7 @@ export interface VisualizeServices extends CoreStart {
unifiedSearch: UnifiedSearchPublicPluginStart;
serverless?: ServerlessPluginStart;
noDataPage?: NoDataPagePluginStart;
contentManagement: ContentManagementPublicStart;
}
export interface VisInstance {

View file

@ -14,8 +14,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const PageObjects = getPageObjects(['dashboard', 'header', 'common']);
const browser = getService('browser');
const listingTable = getService('listingTable');
const testSubjects = getService('testSubjects');
const retry = getService('retry');
const dashboardAddPanel = getService('dashboardAddPanel');
describe('dashboard listing page', function describeIndexTests() {
@ -217,12 +215,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.dashboard.gotoDashboardLandingPage();
await listingTable.searchForItemWithName(`${dashboardName}-editMetaData`);
await testSubjects.click('inspect-action');
await testSubjects.setValue('nameInput', 'new title');
await testSubjects.setValue('descriptionInput', 'new description');
await retry.try(async () => {
await testSubjects.click('saveButton');
await testSubjects.missingOrFail('flyoutTitle');
await listingTable.inspectVisualization();
await listingTable.editVisualizationDetails({
title: 'new title',
description: 'new description',
});
await listingTable.searchAndExpectItemsCount('dashboard', 'new title', 1);

View file

@ -84,5 +84,22 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await listingTable.expectItemsCount('visualize', 0);
});
});
describe('Edit', () => {
before(async () => {
await PageObjects.visualize.gotoVisualizationLandingPage();
});
it('should edit the title and description of a visualization', async () => {
await listingTable.searchForItemWithName('Hello');
await listingTable.inspectVisualization();
await listingTable.editVisualizationDetails({
title: 'new title',
description: 'new description',
});
await listingTable.searchForItemWithName('new title');
await listingTable.expectItemsCount('visualize', 1);
});
});
});
}

View file

@ -154,6 +154,35 @@ export class ListingTableService extends FtrService {
return visualizationNames;
}
/**
* Open the inspect flyout
*/
public async inspectVisualization(index: number = 0) {
const inspectButtons = await this.testSubjects.findAll('inspect-action');
await inspectButtons[index].click();
}
/**
* Edit Visualization title and description in the flyout
*/
public async editVisualizationDetails(
{ title, description }: { title?: string; description?: string } = {},
shouldSave: boolean = true
) {
if (title) {
await this.testSubjects.setValue('nameInput', title);
}
if (description) {
await this.testSubjects.setValue('descriptionInput', description);
}
if (shouldSave) {
await this.retry.try(async () => {
await this.testSubjects.click('saveButton');
await this.testSubjects.missingOrFail('flyoutTitle');
});
}
}
/**
* Returns items count on landing page
*/

View file

@ -26,6 +26,7 @@ export type {
LensSearchIn,
LensSearchOut,
LensSearchQuery,
LensCrudTypes,
} from './latest';
export * as LensV1 from './v1';

View file

@ -22,5 +22,5 @@ export type {
LensSearchIn,
LensSearchOut,
LensSearchQuery,
Reference,
LensCrudTypes,
} from './types';

View file

@ -5,20 +5,10 @@
* 2.0.
*/
import {
GetIn,
CreateIn,
SearchIn,
UpdateIn,
DeleteIn,
DeleteResult,
SearchResult,
GetResult,
CreateResult,
UpdateResult,
} from '@kbn/content-management-plugin/common';
import type { UpdateIn } from '@kbn/content-management-plugin/common';
import type { ContentManagementCrudTypes } from '@kbn/content-management-utils';
import { LensContentType } from '../types';
import type { LensContentType } from '../types';
export interface Reference {
type: string;
@ -26,6 +16,22 @@ export interface Reference {
name: string;
}
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 interface UpdateOptions {
/** Array of referenced saved objects. */
references?: Reference[];
}
export interface LensSearchQuery {
searchFields?: string[];
}
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type LensSavedObjectAttributes = {
title: string;
@ -34,78 +40,41 @@ export type LensSavedObjectAttributes = {
state?: unknown;
};
export interface LensSavedObject {
id: string;
type: string;
version?: string;
updatedAt?: string;
createdAt?: string;
attributes: LensSavedObjectAttributes;
references: Reference[];
namespaces?: string[];
originId?: string;
error?: {
error: string;
message: string;
statusCode: number;
metadata?: Record<string, unknown>;
};
}
// Need to handle update in Lens in a bit different way
export type LensCrudTypes = Omit<
ContentManagementCrudTypes<
LensContentType,
LensSavedObjectAttributes,
CreateOptions,
UpdateOptions,
LensSearchQuery
>,
'UpdateIn'
> & { UpdateIn: UpdateIn<LensContentType, LensSavedObjectAttributes, UpdateOptions> };
export type LensSavedObject = LensCrudTypes['Item'];
export type PartialLensSavedObject = LensCrudTypes['PartialItem'];
export type PartialLensSavedObject = Omit<LensSavedObject, 'attributes' | 'references'> & {
attributes: Partial<LensSavedObjectAttributes>;
references: Reference[] | undefined;
};
// ----------- GET --------------
export type LensGetIn = GetIn<LensContentType>;
export type LensGetIn = LensCrudTypes['GetIn'];
export type LensGetOut = GetResult<
LensSavedObject,
{
outcome: 'exactMatch' | 'aliasMatch' | 'conflict';
aliasTargetId?: string;
aliasPurpose?: 'savedObjectConversion' | 'savedObjectImport';
}
>;
export type LensGetOut = LensCrudTypes['GetOut'];
// ----------- 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 LensCreateIn = CreateIn<LensContentType, LensSavedObjectAttributes, CreateOptions>;
export type LensCreateOut = CreateResult<LensSavedObject>;
export type LensCreateIn = LensCrudTypes['CreateIn'];
export type LensCreateOut = LensCrudTypes['CreateOut'];
// ----------- UPDATE --------------
export interface UpdateOptions {
/** Array of referenced saved objects. */
references?: Reference[];
}
export type LensUpdateIn = UpdateIn<LensContentType, LensSavedObjectAttributes, UpdateOptions>;
export type LensUpdateOut = UpdateResult<PartialLensSavedObject>;
export type LensUpdateIn = LensCrudTypes['UpdateIn'];
export type LensUpdateOut = LensCrudTypes['UpdateOut'];
// ----------- DELETE --------------
export type LensDeleteIn = DeleteIn<LensContentType>;
export type LensDeleteOut = DeleteResult;
export type LensDeleteIn = LensCrudTypes['DeleteIn'];
export type LensDeleteOut = LensCrudTypes['DeleteOut'];
// ----------- SEARCH --------------
export interface LensSearchQuery {
types?: string[];
searchFields?: string[];
}
export type LensSearchIn = SearchIn<LensContentType, {}>;
export type LensSearchOut = SearchResult<LensSavedObject>;
export type LensSearchIn = LensCrudTypes['SearchIn'];
export type LensSearchOut = LensCrudTypes['SearchOut'];

View file

@ -129,7 +129,7 @@ export async function getLensServices(
settings: coreStart.settings,
application: coreStart.application,
notifications: coreStart.notifications,
savedObjectStore: new SavedObjectIndexStore(startDependencies.contentManagement.client),
savedObjectStore: new SavedObjectIndexStore(startDependencies.contentManagement),
presentationUtil: startDependencies.presentationUtil,
dataViewEditor: startDependencies.dataViewEditor,
dataViewFieldEditor: startDependencies.dataViewFieldEditor,

View file

@ -30,7 +30,7 @@ export function getLensAttributeService(
core: CoreStart,
startDependencies: LensPluginStartDependencies
): LensAttributeService {
const savedObjectStore = new SavedObjectIndexStore(startDependencies.contentManagement.client);
const savedObjectStore = new SavedObjectIndexStore(startDependencies.contentManagement);
return startDependencies.embeddable.getAttributeService<
LensSavedObjectAttributes,

View file

@ -0,0 +1,81 @@
/*
* 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 { SearchQuery } from '@kbn/content-management-plugin/common';
import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public';
import type {
SerializableAttributes,
VisualizationClient,
} from '@kbn/visualizations-plugin/public';
import { DOC_TYPE } from '../../common/constants';
import {
LensCreateIn,
LensCreateOut,
LensDeleteIn,
LensDeleteOut,
LensGetIn,
LensGetOut,
LensSearchIn,
LensSearchOut,
LensSearchQuery,
LensUpdateIn,
LensUpdateOut,
} from '../../common/content_management';
export function getLensClient<Attr extends SerializableAttributes = SerializableAttributes>(
cm: ContentManagementPublicStart
): VisualizationClient<'lens', Attr> {
const get = async (id: string) => {
return cm.client.get<LensGetIn, LensGetOut>({
contentTypeId: DOC_TYPE,
id,
});
};
const create = async ({ data, options }: Omit<LensCreateIn, 'contentTypeId'>) => {
const res = await cm.client.create<LensCreateIn, LensCreateOut>({
contentTypeId: DOC_TYPE,
data,
options,
});
return res;
};
const update = async ({ id, data, options }: Omit<LensUpdateIn, 'contentTypeId'>) => {
const res = await cm.client.update<LensUpdateIn, LensUpdateOut>({
contentTypeId: DOC_TYPE,
id,
data,
options,
});
return res;
};
const deleteLens = async (id: string) => {
const res = await cm.client.delete<LensDeleteIn, LensDeleteOut>({
contentTypeId: DOC_TYPE,
id,
});
return res;
};
const search = async (query: SearchQuery = {}, options?: LensSearchQuery) => {
return cm.client.search<LensSearchIn, LensSearchOut>({
contentTypeId: DOC_TYPE,
query,
options,
});
};
return {
get,
create,
update,
delete: deleteLens,
search,
} as unknown as VisualizationClient<'lens', Attr>;
}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { ContentClient } from '@kbn/content-management-plugin/public';
import { ContentManagementPublicStart } from '@kbn/content-management-plugin/public';
import { SavedObjectIndexStore } from './saved_object_store';
describe('LensStore', () => {
@ -18,7 +18,10 @@ describe('LensStore', () => {
return {
client,
store: new SavedObjectIndexStore(client as unknown as ContentClient),
store: new SavedObjectIndexStore({
client,
registry: jest.fn(),
} as unknown as ContentManagementPublicStart),
};
}

View file

@ -7,21 +7,12 @@
import { Filter, Query } from '@kbn/es-query';
import { SavedObjectReference } from '@kbn/core/public';
import { DataViewSpec } from '@kbn/data-views-plugin/public';
import { ContentClient } from '@kbn/content-management-plugin/public';
import { SearchQuery } from '@kbn/content-management-plugin/common';
import { DOC_TYPE } from '../../common/constants';
import {
LensCreateIn,
LensCreateOut,
LensGetIn,
LensGetOut,
LensSearchIn,
LensSearchOut,
LensSearchQuery,
LensUpdateIn,
LensUpdateOut,
} from '../../common/content_management';
import type { DataViewSpec } from '@kbn/data-views-plugin/public';
import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public';
import type { SearchQuery } from '@kbn/content-management-plugin/common';
import type { VisualizationClient } from '@kbn/visualizations-plugin/public';
import type { LensSavedObjectAttributes, LensSearchQuery } from '../../common/content_management';
import { getLensClient } from './lens_client';
export interface Document {
savedObjectId?: string;
@ -55,10 +46,10 @@ export interface DocumentLoader {
export type SavedObjectStore = DocumentLoader & DocumentSaver;
export class SavedObjectIndexStore implements SavedObjectStore {
private client: ContentClient;
private client: VisualizationClient<'lens', LensSavedObjectAttributes>;
constructor(client: ContentClient) {
this.client = client;
constructor(cm: ContentManagementPublicStart) {
this.client = getLensClient(cm);
}
save = async (vis: Document) => {
@ -66,8 +57,7 @@ export class SavedObjectIndexStore implements SavedObjectStore {
const attributes = rest;
if (savedObjectId) {
const result = await this.client.update<LensUpdateIn, LensUpdateOut>({
contentTypeId: 'lens',
const result = await this.client.update({
id: savedObjectId,
data: attributes,
options: {
@ -76,8 +66,7 @@ export class SavedObjectIndexStore implements SavedObjectStore {
});
return { ...vis, savedObjectId: result.item.id };
} else {
const result = await this.client.create<LensCreateIn, LensCreateOut>({
contentTypeId: 'lens',
const result = await this.client.create({
data: attributes,
options: {
references,
@ -88,10 +77,7 @@ export class SavedObjectIndexStore implements SavedObjectStore {
};
async load(savedObjectId: string) {
const resolveResult = await this.client.get<LensGetIn, LensGetOut>({
contentTypeId: DOC_TYPE,
id: savedObjectId,
});
const resolveResult = await this.client.get(savedObjectId);
if (resolveResult.item.error) {
throw resolveResult.item.error;
@ -101,11 +87,7 @@ export class SavedObjectIndexStore implements SavedObjectStore {
}
async search(query: SearchQuery, options: LensSearchQuery) {
const result = await this.client.search<LensSearchIn, LensSearchOut>({
contentTypeId: DOC_TYPE,
query,
options,
});
const result = await this.client.search(query, options);
return result;
}
}

View file

@ -8,6 +8,7 @@
import { i18n } from '@kbn/i18n';
import type { VisTypeAlias } from '@kbn/visualizations-plugin/public';
import { getBasePath, getEditPath } from '../common/constants';
import { getLensClient } from './persistence/lens_client';
export const getLensAliasConfig = (): VisTypeAlias => ({
aliasPath: getBasePath(),
@ -30,6 +31,8 @@ export const getLensAliasConfig = (): VisTypeAlias => ({
visualizations: {
docTypes: ['lens'],
searchFields: ['title^3'],
clientOptions: { update: { overwrite: true } },
client: getLensClient,
toListItem(savedObject) {
const { id, type, updatedAt, attributes } = savedObject;
const { title, description } = attributes as { title: string; description?: string };

View file

@ -5,34 +5,35 @@
* 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 type { SavedObjectsFindOptions } from '@kbn/core-saved-objects-api-server';
import type { StorageContext } from '@kbn/content-management-plugin/server';
import { SOContentStorage, tagsToFindOptions } from '@kbn/content-management-utils';
import type { SavedObject, SavedObjectReference } from '@kbn/core-saved-objects-api-server';
import { getMSearch, type GetMSearchType } from '@kbn/content-management-utils';
import { CONTENT_ID } from '../../common/content_management';
import { cmServicesDefinition } from '../../common/content_management/cm_services';
import type {
LensSavedObjectAttributes,
LensSavedObject,
PartialLensSavedObject,
LensContentType,
LensGetOut,
LensCreateIn,
LensCreateOut,
CreateOptions,
LensUpdateIn,
LensUpdateOut,
UpdateOptions,
LensDeleteOut,
LensSearchQuery,
LensSearchOut,
import {
CONTENT_ID,
type LensCrudTypes,
type LensSavedObject,
type LensSavedObjectAttributes,
type PartialLensSavedObject,
} from '../../common/content_management';
import { cmServicesDefinition } from '../../common/content_management/cm_services';
const searchArgsToSOFindOptions = (args: LensCrudTypes['SearchIn']): SavedObjectsFindOptions => {
const { query, contentTypeId, options } = args;
return {
type: contentTypeId,
searchFields: ['title^3', 'description'],
fields: ['description', 'title'],
search: query.text,
perPage: query.limit,
page: query.cursor ? +query.cursor : undefined,
defaultSearchOperator: 'AND',
...options,
...tagsToFindOptions(query.tags),
};
};
const savedObjectClientFromRequest = async (ctx: StorageContext) => {
if (!ctx.requestHandlerContext) {
@ -47,16 +48,6 @@ type PartialSavedObject<T> = Omit<SavedObject<Partial<T>>, 'references'> & {
references: SavedObjectReference[] | undefined;
};
function savedObjectToLensSavedObject(
savedObject: SavedObject<LensSavedObjectAttributes>,
partial: false
): LensSavedObject;
function savedObjectToLensSavedObject(
savedObject: PartialSavedObject<LensSavedObjectAttributes>,
partial: true
): PartialLensSavedObject;
function savedObjectToLensSavedObject(
savedObject:
| SavedObject<LensSavedObjectAttributes>
@ -90,117 +81,27 @@ function savedObjectToLensSavedObject(
};
}
const SO_TYPE: LensContentType = 'lens';
export class LensStorage implements ContentStorage<LensSavedObject, PartialLensSavedObject> {
mSearch: GetMSearchType<LensSavedObject>;
export class LensStorage extends SOContentStorage<LensCrudTypes> {
constructor() {
this.mSearch = getMSearch<LensSavedObject, LensSearchOut>({
savedObjectType: SO_TYPE,
super({
savedObjectType: CONTENT_ID,
cmServicesDefinition,
searchArgsToSOFindOptions,
enableMSearch: true,
allowedSavedObjectAttributes: ['title', 'description', 'visualizationType', 'state'],
});
}
async get(ctx: StorageContext, id: string): Promise<LensGetOut> {
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<LensSavedObjectAttributes>(SO_TYPE, id);
const response: LensGetOut = {
item: savedObjectToLensSavedObject(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<LensGetOut, LensGetOut>(
response
);
if (resultError) {
throw Boom.badRequest(`Invalid response. ${resultError.message}`);
}
return value;
}
async bulkGet(): Promise<never> {
// Not implemented. Lens does not use bulkGet
throw new Error(`[bulkGet] has not been implemented. See LensStorage class.`);
}
async create(
ctx: StorageContext,
data: LensCreateIn['data'],
options: CreateOptions
): Promise<LensCreateOut> {
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<
LensSavedObjectAttributes,
LensSavedObjectAttributes
>(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<LensSavedObjectAttributes>(
SO_TYPE,
dataToLatest,
optionsToLatest
);
// Validate DB response and DOWN transform to the request version
const { value, error: resultError } = transforms.create.out.result.down<
LensCreateOut,
LensCreateOut
>({
item: savedObjectToLensSavedObject(savedObject, false),
});
if (resultError) {
throw Boom.badRequest(`Invalid response. ${resultError.message}`);
}
return value;
}
/**
* Lens requires a custom update function because of https://github.com/elastic/kibana/issues/160116
* where a forced create with overwrite flag is used instead of regular update
*/
async update(
ctx: StorageContext,
id: string,
data: LensUpdateIn['data'],
options: UpdateOptions
): Promise<LensUpdateOut> {
data: LensCrudTypes['UpdateIn']['data'],
options: LensCrudTypes['UpdateOptions']
): Promise<LensCrudTypes['UpdateOut']> {
const {
utils: { getTransforms },
version: { request: requestVersion },
@ -217,8 +118,8 @@ export class LensStorage implements ContentStorage<LensSavedObject, PartialLensS
}
const { value: optionsToLatest, error: optionsError } = transforms.update.in.options.up<
CreateOptions,
CreateOptions
LensCrudTypes['CreateOptions'],
LensCrudTypes['CreateOptions']
>(options);
if (optionsError) {
throw Boom.badRequest(`Invalid options. ${optionsError.message}`);
@ -227,7 +128,7 @@ export class LensStorage implements ContentStorage<LensSavedObject, PartialLensS
// Save data in DB
const soClient = await savedObjectClientFromRequest(ctx);
const savedObject = await soClient.create<LensSavedObjectAttributes>(SO_TYPE, dataToLatest, {
const savedObject = await soClient.create<LensSavedObjectAttributes>(CONTENT_ID, dataToLatest, {
id,
overwrite: true,
...optionsToLatest,
@ -235,85 +136,10 @@ export class LensStorage implements ContentStorage<LensSavedObject, PartialLensS
// Validate DB response and DOWN transform to the request version
const { value, error: resultError } = transforms.update.out.result.down<
LensUpdateOut,
LensUpdateOut
LensCrudTypes['UpdateOut'],
LensCrudTypes['UpdateOut']
>({
item: savedObjectToLensSavedObject(savedObject, true),
});
if (resultError) {
throw Boom.badRequest(`Invalid response. ${resultError.message}`);
}
return value;
}
async delete(ctx: StorageContext, id: string): Promise<LensDeleteOut> {
const soClient = await savedObjectClientFromRequest(ctx);
await soClient.delete(SO_TYPE, id);
return { success: true };
}
async search(
ctx: StorageContext,
query: SearchQuery,
options: LensSearchQuery = {}
): Promise<LensSearchOut> {
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<
LensSearchQuery,
LensSearchQuery
>(options);
if (optionsError) {
throw Boom.badRequest(`Invalid payload. ${optionsError.message}`);
}
const { searchFields = ['title^3', 'description'], types = [CONTENT_ID] } = 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<LensSavedObjectAttributes>(soQuery);
// Validate the response and DOWN transform to the request version
const { value, error: resultError } = transforms.search.out.result.down<
LensSearchOut,
LensSearchOut
>({
hits: response.saved_objects.map((so) => savedObjectToLensSavedObject(so, false)),
pagination: {
total: response.total,
},
item: savedObjectToLensSavedObject(savedObject),
});
if (resultError) {

View file

@ -67,7 +67,7 @@ export const serviceDefinition: ServicesDefinition = {
update: {
in: {
options: {
schema: createOptionsSchema, // same schema as "create"
schema: createOptionsSchema, // same as create
},
data: {
schema: mapAttributesSchema,

View file

@ -8,7 +8,8 @@
import { i18n } from '@kbn/i18n';
import { OverlayStart } from '@kbn/core/public';
import { mapsClient } from './maps_client';
import type { MapAttributes } from '../../common/content_management';
import { getMapClient } from './maps_client';
const rejectErrorMessage = i18n.translate('xpack.maps.saveDuplicateRejectedDescription', {
defaultMessage: 'Save with duplicate title confirmation was rejected',
@ -48,7 +49,7 @@ export const checkForDuplicateTitle = async (
return true;
}
const { hits } = await mapsClient.search(
const { hits } = await getMapClient<MapAttributes>().search(
{
text: `"${title}"`,
limit: 10,

View file

@ -5,6 +5,6 @@
* 2.0.
*/
export { mapsClient } from './maps_client';
export { getMapClient } from './maps_client';
export { checkForDuplicateTitle } from './duplicate_title_check';

View file

@ -5,62 +5,65 @@
* 2.0.
*/
import type { SearchQuery } from '@kbn/content-management-plugin/common';
import { ContentManagementPublicStart } from '@kbn/content-management-plugin/public';
import type {
SerializableAttributes,
VisualizationClient,
} from '@kbn/visualizations-plugin/public/vis_types/vis_type_alias_registry';
import type { MapCrudTypes } from '../../common/content_management';
import { CONTENT_ID as contentTypeId } from '../../common/content_management';
import { getContentManagement } from '../kibana_services';
const get = async (id: string) => {
return getContentManagement().client.get<MapCrudTypes['GetIn'], MapCrudTypes['GetOut']>({
contentTypeId,
id,
});
};
export function getMapClient<Attr extends SerializableAttributes = SerializableAttributes>(
cm: ContentManagementPublicStart = getContentManagement()
): VisualizationClient<'map', Attr> {
const get = async (id: string) => {
return cm.client.get<MapCrudTypes['GetIn'], MapCrudTypes['GetOut']>({
contentTypeId,
id,
});
};
const create = async ({ data, options }: Omit<MapCrudTypes['CreateIn'], 'contentTypeId'>) => {
const res = await getContentManagement().client.create<
MapCrudTypes['CreateIn'],
MapCrudTypes['CreateOut']
>({
contentTypeId,
data,
options,
});
return res;
};
const create = async ({ data, options }: Omit<MapCrudTypes['CreateIn'], 'contentTypeId'>) => {
const res = await cm.client.create<MapCrudTypes['CreateIn'], MapCrudTypes['CreateOut']>({
contentTypeId,
data,
options,
});
return res;
};
const update = async ({ id, data, options }: Omit<MapCrudTypes['UpdateIn'], 'contentTypeId'>) => {
const res = await getContentManagement().client.update<
MapCrudTypes['UpdateIn'],
MapCrudTypes['UpdateOut']
>({
contentTypeId,
id,
data,
options,
});
return res;
};
const update = async ({ id, data, options }: Omit<MapCrudTypes['UpdateIn'], 'contentTypeId'>) => {
const res = await cm.client.update<MapCrudTypes['UpdateIn'], MapCrudTypes['UpdateOut']>({
contentTypeId,
id,
data,
options,
});
return res;
};
const deleteMap = async (id: string) => {
await getContentManagement().client.delete<MapCrudTypes['DeleteIn'], MapCrudTypes['DeleteOut']>({
contentTypeId,
id,
});
};
const deleteMap = async (id: string) => {
return await cm.client.delete<MapCrudTypes['DeleteIn'], MapCrudTypes['DeleteOut']>({
contentTypeId,
id,
});
};
const search = async (query: SearchQuery = {}, options?: MapCrudTypes['SearchOptions']) => {
return getContentManagement().client.search<MapCrudTypes['SearchIn'], MapCrudTypes['SearchOut']>({
contentTypeId,
query,
options,
});
};
const search = async (query: SearchQuery = {}, options?: MapCrudTypes['SearchOptions']) => {
return cm.client.search<MapCrudTypes['SearchIn'], MapCrudTypes['SearchOut']>({
contentTypeId,
query,
options,
});
};
export const mapsClient = {
get,
create,
update,
delete: deleteMap,
search,
};
return {
get,
create,
update,
delete: deleteMap,
search,
} as unknown as VisualizationClient<'map', Attr>;
}

View file

@ -13,7 +13,7 @@ import type { MapAttributes } from '../common/content_management';
import { MAP_EMBEDDABLE_NAME, MAP_SAVED_OBJECT_TYPE } from '../common/constants';
import { getCoreOverlays, getEmbeddableService } from './kibana_services';
import { extractReferences, injectReferences } from '../common/migrations/references';
import { mapsClient, checkForDuplicateTitle } from './content_management';
import { getMapClient, checkForDuplicateTitle } from './content_management';
import { MapByValueInput, MapByReferenceInput } from './embeddable/types';
export interface SharingSavedObjectProps {
@ -65,8 +65,12 @@ export function getMapAttributeService(): MapAttributeService {
const {
item: { id },
} = await (savedObjectId
? mapsClient.update({ id: savedObjectId, data: updatedAttributes, options: { references } })
: mapsClient.create({ data: updatedAttributes, options: { references } }));
? getMapClient().update({
id: savedObjectId,
data: updatedAttributes,
options: { references },
})
: getMapClient().create({ data: updatedAttributes, options: { references } }));
return { id };
},
unwrapMethod: async (
@ -78,7 +82,7 @@ export function getMapAttributeService(): MapAttributeService {
const {
item: savedObject,
meta: { outcome, aliasPurpose, aliasTargetId },
} = await mapsClient.get(savedObjectId);
} = await getMapClient<MapAttributes>().get(savedObjectId);
if (savedObject.error) {
throw savedObject.error;

View file

@ -16,6 +16,7 @@ import {
MAP_PATH,
MAP_SAVED_OBJECT_TYPE,
} from '../common/constants';
import { getMapClient } from './content_management';
export function getMapsVisTypeAlias() {
const appDescription = i18n.translate('xpack.maps.visTypeAlias.description', {
@ -34,6 +35,7 @@ export function getMapsVisTypeAlias() {
visualizations: {
docTypes: [MAP_SAVED_OBJECT_TYPE],
searchFields: ['title^3'],
client: getMapClient,
toListItem(mapItem: MapItem) {
const { id, type, updatedAt, attributes } = mapItem;
const { title, description } = attributes;

View file

@ -11,7 +11,7 @@ import { EmbeddableStateTransfer } from '@kbn/embeddable-plugin/public';
import { ScopedHistory } from '@kbn/core/public';
import { MapsListView } from './maps_list_view';
import { APP_ID } from '../../../common/constants';
import { mapsClient } from '../../content_management';
import { getMapClient } from '../../content_management';
interface Props {
history: ScopedHistory;
@ -26,7 +26,7 @@ export function LoadListAndRender(props: Props) {
props.stateTransfer.clearEditorState(APP_ID);
let ignore = false;
mapsClient
getMapClient()
.search({ limit: 1 })
.then((results) => {
if (!ignore) {

View file

@ -12,7 +12,7 @@ import { i18n } from '@kbn/i18n';
import { TableListView } from '@kbn/content-management-table-list-view';
import type { UserContentCommonSchema } from '@kbn/content-management-table-list-view';
import type { MapItem } from '../../../common/content_management';
import type { MapAttributes, MapItem } from '../../../common/content_management';
import { APP_ID, APP_NAME, getEditPath, MAP_PATH } from '../../../common/constants';
import {
getMapsCapabilities,
@ -23,7 +23,7 @@ import {
getUsageCollection,
getServerless,
} from '../../kibana_services';
import { mapsClient } from '../../content_management';
import { getMapClient } from '../../content_management';
const SAVED_OBJECTS_LIMIT_SETTING = 'savedObjects:listingLimit';
const SAVED_OBJECTS_PER_PAGE_SETTING = 'savedObjects:perPage';
@ -55,7 +55,7 @@ const toTableListViewSavedObject = (mapItem: MapItem): MapUserContent => {
};
async function deleteMaps(items: Array<{ id: string }>) {
await Promise.all(items.map(({ id }) => mapsClient.delete(id)));
await Promise.all(items.map(({ id }) => getMapClient().delete(id)));
}
interface Props {
@ -97,7 +97,7 @@ function MapsListViewComp({ history }: Props) {
referencesToExclude?: SavedObjectsFindOptionsReference[];
} = {}
) => {
return mapsClient
return getMapClient<MapAttributes>()
.search({
text: searchTerm ? `${searchTerm}*` : undefined,
limit: getUiSettings().get(SAVED_OBJECTS_LIMIT_SETTING),

View file

@ -762,6 +762,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(hasVisualOptionsButton).to.be(false);
});
it('should allow edit meta-data for Lens chart on listing page', async () => {
await PageObjects.visualize.gotoVisualizationLandingPage();
await listingTable.searchForItemWithName('Afancilenstest');
await listingTable.inspectVisualization();
await listingTable.editVisualizationDetails({
title: 'Anewfancilenstest',
description: 'new description',
});
await listingTable.searchForItemWithName('Anewfancilenstest');
await listingTable.expectItemsCount('visualize', 1);
});
it('should correctly optimize multiple percentile metrics', async () => {
await PageObjects.visualize.navigateToNewVisualization();
await PageObjects.visualize.clickVisType('lens');

View file

@ -9,7 +9,7 @@ import expect from '@kbn/expect';
export default function ({ getService, getPageObjects }) {
const PageObjects = getPageObjects(['visualize', 'header', 'maps']);
const listingTable = getService('listingTable');
const security = getService('security');
describe('visualize create menu', () => {
@ -85,5 +85,37 @@ export default function ({ getService, getPageObjects }) {
expect(hasLegecyViz).to.equal(false);
});
});
describe('edit meta-data', () => {
before(async () => {
await security.testUser.setRoles(
['global_maps_all', 'global_visualize_all', 'test_logstash_reader'],
{
skipBrowserRefresh: true,
}
);
await PageObjects.visualize.navigateToNewAggBasedVisualization();
});
after(async () => {
await security.testUser.restoreDefaults();
});
it('should allow to change meta-data on a map visualization', async () => {
await PageObjects.visualize.navigateToNewVisualization();
await PageObjects.visualize.clickMapsApp();
await PageObjects.maps.waitForLayersToLoad();
await PageObjects.maps.saveMap('myTestMap');
await PageObjects.visualize.gotoVisualizationLandingPage();
await listingTable.searchForItemWithName('myTestMap');
await listingTable.inspectVisualization();
await listingTable.editVisualizationDetails({
title: 'AnotherTestMap',
description: 'new description',
});
await listingTable.searchForItemWithName('AnotherTestMap');
await listingTable.expectItemsCount('visualize', 1);
});
});
});
}