diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index fefbd780db750..adef16572e777 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -787,6 +787,7 @@ "description", "state", "title", + "version", "visualizationType" ], "lens-ui-telemetry": [ diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index 54d30d0713d06..f1b61d074d50d 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -2610,6 +2610,9 @@ "title": { "type": "text" }, + "version": { + "type": "integer" + }, "visualizationType": { "type": "keyword" } diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index 4e9de5d1352f1..b45e8ebbf85d3 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -138,7 +138,7 @@ describe('checking migration metadata changes on all registered SO types', () => "inventory-view": "e125c6e6e49729055421e7b3a0544f24330d8dc6", "kql-telemetry": "92d6357aa3ce28727492f86a54783f802dc38893", "legacy-url-alias": "9b8cca3fbb2da46fd12823d3cd38fdf1c9f24bc8", - "lens": "6fa6bdc5de12859815de6e50488fa2a7b038278a", + "lens": "17438090d0184ea2d834692aec084f7ad4ded710", "lens-ui-telemetry": "d6c4e330d170eefc6214dbf77a53de913fa3eebc", "links": "53ae5a770d69eee34d842617be761cd059ab4b51", "maintenance-window": "f3f19d1828e91418d13703ce6009e9c76a1686f9", @@ -843,8 +843,9 @@ describe('checking migration metadata changes on all registered SO types', () => "legacy-url-alias|8.2.0: 4c58be29493363cb48eb37fa5612fb3fb0eed77e", "================================================================", "lens|global: 0267d37678684c5f234bd74f0f6ae2a5f6180eb4", - "lens|mappings: ccf7f3aea93efcf8d26faffa63e4bf6f0748ea80", + "lens|mappings: a7f9fb8ae5efb47f6ee930168cb1cdd221cd9d91", "lens|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", + "lens|10.1.0: 8f6fbdde199327bbfaba97d1d79ae3c9b9914b18", "lens|8.9.0: f6592efb2500e39d1d31b1effdfad7d7ad1eca32", "lens|8.6.0: 54c152a2584673672445346cf69d72bda587cc52", "lens|8.5.0: 54c152a2584673672445346cf69d72bda587cc52", @@ -1304,7 +1305,7 @@ describe('checking migration metadata changes on all registered SO types', () => "inventory-view": "10.2.0", "kql-telemetry": "10.0.0", "legacy-url-alias": "10.0.0", - "lens": "10.0.0", + "lens": "10.1.0", "lens-ui-telemetry": "10.0.0", "links": "10.0.0", "maintenance-window": "10.2.0", @@ -1451,7 +1452,7 @@ describe('checking migration metadata changes on all registered SO types', () => "inventory-view": "10.2.0", "kql-telemetry": "0.0.0", "legacy-url-alias": "8.2.0", - "lens": "8.9.0", + "lens": "10.1.0", "lens-ui-telemetry": "0.0.0", "links": "0.0.0", "maintenance-window": "10.2.0", diff --git a/src/platform/packages/shared/kbn-config-schema/src/types/object_type.ts b/src/platform/packages/shared/kbn-config-schema/src/types/object_type.ts index 43344d62dbb3a..8d0bf8f991cd3 100644 --- a/src/platform/packages/shared/kbn-config-schema/src/types/object_type.ts +++ b/src/platform/packages/shared/kbn-config-schema/src/types/object_type.ts @@ -227,8 +227,6 @@ export class ObjectType

extends Type> /** * Return the schema for this object's underlying properties - * - * @internal should only be used internal for type reflection */ public getPropSchemas(): P { return this.props; diff --git a/src/platform/packages/shared/kbn-content-management-utils/index.ts b/src/platform/packages/shared/kbn-content-management-utils/index.ts index 46499af9736ce..0d74f2979f2d2 100644 --- a/src/platform/packages/shared/kbn-content-management-utils/index.ts +++ b/src/platform/packages/shared/kbn-content-management-utils/index.ts @@ -11,4 +11,3 @@ export type * from './src/types'; export * from './src/schema'; export * from './src/saved_object_content_storage'; export * from './src/utils'; -export * from './src/msearch'; diff --git a/src/platform/packages/shared/kbn-content-management-utils/kibana.jsonc b/src/platform/packages/shared/kbn-content-management-utils/kibana.jsonc index 3125cc30da6a0..2db27a2a904c7 100644 --- a/src/platform/packages/shared/kbn-content-management-utils/kibana.jsonc +++ b/src/platform/packages/shared/kbn-content-management-utils/kibana.jsonc @@ -1,5 +1,5 @@ { - "type": "shared-server", + "type": "shared-common", "id": "@kbn/content-management-utils", "owner": [ "@elastic/kibana-data-discovery" diff --git a/src/platform/packages/shared/kbn-content-management-utils/src/msearch.ts b/src/platform/packages/shared/kbn-content-management-utils/src/msearch.ts deleted file mode 100644 index 70187ad574a39..0000000000000 --- a/src/platform/packages/shared/kbn-content-management-utils/src/msearch.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import Boom from '@hapi/boom'; -import { pick } from 'lodash'; - -import type { StorageContext } from '@kbn/content-management-plugin/server'; - -import type { - SavedObjectsFindResult, - SavedObject, - SavedObjectReference, -} from '@kbn/core-saved-objects-api-server'; - -import type { ServicesDefinitionSet, SOWithMetadata, SOWithMetadataPartial } from './types'; - -type PartialSavedObject = Omit>, 'references'> & { - references: SavedObjectReference[] | undefined; -}; - -interface GetMSearchParams { - savedObjectType: string; - cmServicesDefinition: ServicesDefinitionSet; - allowedSavedObjectAttributes: string[]; -} - -function savedObjectToItem( - savedObject: SavedObject | PartialSavedObject, - allowedSavedObjectAttributes: string[] -): SOWithMetadata | SOWithMetadataPartial { - const { - id, - type, - updated_at: updatedAt, - created_at: createdAt, - attributes, - references, - error, - namespaces, - version, - managed, - } = savedObject; - - return { - id, - type, - managed, - updatedAt, - createdAt, - attributes: pick(attributes, allowedSavedObjectAttributes), - references, - error, - namespaces, - version, - }; -} - -export interface GetMSearchType { - savedObjectType: string; - toItemResult: (ctx: StorageContext, savedObject: SavedObjectsFindResult) => ReturnItem; -} - -export const getMSearch = ({ - savedObjectType, - cmServicesDefinition, - allowedSavedObjectAttributes, -}: GetMSearchParams) => { - return { - savedObjectType, - toItemResult: (ctx: StorageContext, savedObject: SavedObjectsFindResult): ReturnItem => { - const transforms = ctx.utils.getTransforms(cmServicesDefinition); - - // Validate DB response and DOWN transform to the request version - const { value, error: resultError } = transforms.mSearch.out.result.down< - ReturnItem, - ReturnItem - >( - // Ran into a case where a schema was broken by a SO attribute that wasn't part of the definition - // so we specify which attributes are allowed - savedObjectToItem( - savedObject as SavedObjectsFindResult, - allowedSavedObjectAttributes - ) - ); - - if (resultError) { - throw Boom.badRequest(`Invalid response. ${resultError.message}`); - } - - return value; - }, - }; -}; diff --git a/src/platform/packages/shared/kbn-content-management-utils/src/saved_object_content_storage.test.ts b/src/platform/packages/shared/kbn-content-management-utils/src/saved_object_content_storage.test.ts index 23dbb7e4c7005..f771ef895a9ea 100644 --- a/src/platform/packages/shared/kbn-content-management-utils/src/saved_object_content_storage.test.ts +++ b/src/platform/packages/shared/kbn-content-management-utils/src/saved_object_content_storage.test.ts @@ -8,7 +8,7 @@ */ import { SOContentStorage } from './saved_object_content_storage'; -import { CMCrudTypes } from './types'; +import { ContentManagementCrudTypes } from './types'; import { loggerMock, MockedLogger } from '@kbn/logging-mocks'; import { schema } from '@kbn/config-schema'; @@ -16,11 +16,18 @@ import type { ContentManagementServicesDefinition as ServicesDefinition, Version, } from '@kbn/object-versioning'; -import { getContentManagmentServicesTransforms } from '@kbn/object-versioning'; +import { getContentManagementServicesTransforms } from '@kbn/object-versioning'; import { savedObjectSchema, objectTypeToGetResultSchema, createResultSchema } from './schema'; import { coreMock } from '@kbn/core/server/mocks'; -import type { SavedObject } from '@kbn/core/server'; +import type { RequestHandlerContext, SavedObject } from '@kbn/core/server'; +import { mockRouter } from '@kbn/core-http-router-server-mocks'; + +interface MockAttributes { + title: string; + description: string | null; +} +type MockCrudTypes = ContentManagementCrudTypes<'content-id', MockAttributes, {}, {}, {}>; const testAttributesSchema = schema.object( { @@ -74,9 +81,9 @@ export const cmServicesDefinition: { [version: Version]: ServicesDefinition } = 1: serviceDefinition, }; -const transforms = getContentManagmentServicesTransforms(cmServicesDefinition, 1); +const transforms = getContentManagementServicesTransforms(cmServicesDefinition, 1); -class TestSOContentStorage extends SOContentStorage { +class TestSOContentStorage extends SOContentStorage { constructor({ throwOnResultValidationError, logger, @@ -94,12 +101,12 @@ class TestSOContentStorage extends SOContentStorage { const setup = ({ storage }: { storage?: TestSOContentStorage } = {}) => { storage = storage ?? new TestSOContentStorage(); + const mockRequest = mockRouter.createFakeKibanaRequest({}); const requestHandlerCoreContext = coreMock.createRequestHandlerContext(); - - const requestHandlerContext = { + const requestHandlerContext = jest.mocked({ core: Promise.resolve(requestHandlerCoreContext), resolve: jest.fn(), - }; + }); return { get: (mockSavedObject: SavedObject) => { @@ -110,6 +117,7 @@ const setup = ({ storage }: { storage?: TestSOContentStorage } = {}) => { return storage!.get( { + request: mockRequest, requestHandlerContext, version: { request: 1, @@ -122,11 +130,12 @@ const setup = ({ storage }: { storage?: TestSOContentStorage } = {}) => { mockSavedObject.id ); }, - create: (mockSavedObject: SavedObject<{}>) => { + create: (mockSavedObject: SavedObject) => { requestHandlerCoreContext.savedObjects.client.create.mockResolvedValue(mockSavedObject); return storage!.create( { + request: mockRequest, requestHandlerContext, version: { request: 1, @@ -140,11 +149,12 @@ const setup = ({ storage }: { storage?: TestSOContentStorage } = {}) => { {} ); }, - update: (mockSavedObject: SavedObject<{}>) => { + update: (mockSavedObject: SavedObject) => { requestHandlerCoreContext.savedObjects.client.update.mockResolvedValue(mockSavedObject); return storage!.update( { + request: mockRequest, requestHandlerContext, version: { request: 1, @@ -159,7 +169,7 @@ const setup = ({ storage }: { storage?: TestSOContentStorage } = {}) => { {} ); }, - search: (mockSavedObject: SavedObject<{}>) => { + search: (mockSavedObject: SavedObject) => { requestHandlerCoreContext.savedObjects.client.find.mockResolvedValue({ saved_objects: [{ ...mockSavedObject, score: 100 }], total: 1, @@ -169,6 +179,7 @@ const setup = ({ storage }: { storage?: TestSOContentStorage } = {}) => { return storage!.search( { + request: mockRequest, requestHandlerContext, version: { request: 1, @@ -182,9 +193,10 @@ const setup = ({ storage }: { storage?: TestSOContentStorage } = {}) => { {} ); }, - mSearch: async (mockSavedObject: SavedObject<{}>) => { + mSearch: async (mockSavedObject: SavedObject) => { return storage!.mSearch!.toItemResult( { + request: mockRequest, requestHandlerContext, version: { request: 1, diff --git a/src/platform/packages/shared/kbn-content-management-utils/src/saved_object_content_storage.ts b/src/platform/packages/shared/kbn-content-management-utils/src/saved_object_content_storage.ts index 72af69544b675..3a4bbedd11270 100644 --- a/src/platform/packages/shared/kbn-content-management-utils/src/saved_object_content_storage.ts +++ b/src/platform/packages/shared/kbn-content-management-utils/src/saved_object_content_storage.ts @@ -32,66 +32,10 @@ import type { } from './types'; import { tagsToFindOptions } from './utils'; -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 = Omit>, 'references'> & { references: SavedObjectReference[] | undefined; }; -function savedObjectToItem( - savedObject: SavedObject, - allowedSavedObjectAttributes: string[], - partial: false -): Item; - -function savedObjectToItem( - savedObject: PartialSavedObject, - allowedSavedObjectAttributes: string[], - partial: true -): PartialItem; - -function savedObjectToItem( - savedObject: SavedObject | PartialSavedObject, - allowedSavedObjectAttributes: string[] -): SOWithMetadata | SOWithMetadataPartial { - const { - id, - type, - updated_at: updatedAt, - updated_by: updatedBy, - created_at: createdAt, - created_by: createdBy, - attributes, - references, - error, - namespaces, - version, - managed, - } = savedObject; - - return { - id, - type, - managed, - updatedBy, - updatedAt, - createdAt, - createdBy, - attributes: pick(attributes, allowedSavedObjectAttributes), - references, - error, - namespaces, - version, - }; -} - export interface SearchArgsToSOFindOptionsOptionsDefault { fields?: string[]; searchFields?: string[]; @@ -138,7 +82,7 @@ export interface SOContentStorageConstructorParams { savedObjectType: string; cmServicesDefinition: ServicesDefinitionSet; // this is necessary since unexpected saved object attributes could cause schema validation to fail - allowedSavedObjectAttributes: string[]; + allowedSavedObjectAttributes: Array; createArgsToSoCreateOptions?: CreateArgsToSoCreateOptions; updateArgsToSoUpdateOptions?: UpdateArgsToSoUpdateOptions; searchArgsToSOFindOptions?: SearchArgsToSOFindOptions; @@ -193,10 +137,8 @@ export abstract class SOContentStorage toItemResult: (ctx: StorageContext, savedObject: SavedObjectsFindResult): Types['Item'] => { const transforms = ctx.utils.getTransforms(this.cmServicesDefinition); - const contentItem = savedObjectToItem( - savedObject as SavedObjectsFindResult, - this.allowedSavedObjectAttributes, - false + const contentItem = this.savedObjectToItem( + savedObject as SavedObjectsFindResult ); const validationError = transforms.mSearch.out.result.validate(contentItem); @@ -228,24 +170,73 @@ export abstract class SOContentStorage } } - private throwOnResultValidationError: boolean; - private logger: Logger; - private savedObjectType: SOContentStorageConstructorParams['savedObjectType']; - private cmServicesDefinition: SOContentStorageConstructorParams['cmServicesDefinition']; - private createArgsToSoCreateOptions: CreateArgsToSoCreateOptions; - private updateArgsToSoUpdateOptions: UpdateArgsToSoUpdateOptions; - private searchArgsToSOFindOptions: SearchArgsToSOFindOptions; - private allowedSavedObjectAttributes: string[]; + protected static getSOClientFromRequest = async (ctx: StorageContext) => { + if (!ctx.requestHandlerContext) { + throw new Error('Storage context.requestHandlerContext missing.'); + } + + const { savedObjects } = await ctx.requestHandlerContext.core; + return savedObjects.client; + }; + + protected readonly throwOnResultValidationError: boolean; + protected readonly logger: Logger; + protected readonly savedObjectType: SOContentStorageConstructorParams['savedObjectType']; + protected readonly cmServicesDefinition: SOContentStorageConstructorParams['cmServicesDefinition']; + protected readonly createArgsToSoCreateOptions: CreateArgsToSoCreateOptions; + protected readonly updateArgsToSoUpdateOptions: UpdateArgsToSoUpdateOptions; + protected readonly searchArgsToSOFindOptions: SearchArgsToSOFindOptions; + protected readonly allowedSavedObjectAttributes: Array; + + protected savedObjectToItem(savedObject: SavedObject): Types['Item']; + protected savedObjectToItem( + savedObject: PartialSavedObject, + partial: true + ): Types['PartialItem']; + protected savedObjectToItem( + savedObject: SavedObject | PartialSavedObject + ): SOWithMetadata | SOWithMetadataPartial { + const { + id, + type, + updated_at: updatedAt, + updated_by: updatedBy, + created_at: createdAt, + created_by: createdBy, + attributes, + references, + error, + namespaces, + version, + managed, + } = savedObject; + + return { + id, + type, + managed, + updatedBy, + updatedAt, + createdAt, + createdBy, + attributes: pick(attributes, this.allowedSavedObjectAttributes), + references, + error, + namespaces, + version, + }; + } mSearch?: { savedObjectType: string; + // TODO: fix this typing, this does not always return the full Item toItemResult: (ctx: StorageContext, savedObject: SavedObjectsFindResult) => Types['Item']; additionalSearchFields?: string[]; }; async get(ctx: StorageContext, id: string): Promise { const transforms = ctx.utils.getTransforms(this.cmServicesDefinition); - const soClient = await savedObjectClientFromRequest(ctx); + const soClient = await SOContentStorage.getSOClientFromRequest(ctx); // Save data in DB const { @@ -256,7 +247,7 @@ export abstract class SOContentStorage } = await soClient.resolve(this.savedObjectType, id); const response: Types['GetOut'] = { - item: savedObjectToItem(savedObject, this.allowedSavedObjectAttributes, false), + item: this.savedObjectToItem(savedObject), meta: { aliasPurpose, aliasTargetId, @@ -301,7 +292,7 @@ export abstract class SOContentStorage options: Types['CreateOptions'] ): Promise { const transforms = ctx.utils.getTransforms(this.cmServicesDefinition); - const soClient = await savedObjectClientFromRequest(ctx); + const soClient = await SOContentStorage.getSOClientFromRequest(ctx); // Validate input (data & options) & UP transform them to the latest version const { value: dataToLatest, error: dataError } = transforms.create.in.data.up< @@ -330,7 +321,7 @@ export abstract class SOContentStorage ); const result = { - item: savedObjectToItem(savedObject, this.allowedSavedObjectAttributes, false), + item: this.savedObjectToItem(savedObject), }; const validationError = transforms.create.out.result.validate(result); @@ -366,7 +357,7 @@ export abstract class SOContentStorage options: Types['UpdateOptions'] ): Promise { const transforms = ctx.utils.getTransforms(this.cmServicesDefinition); - const soClient = await savedObjectClientFromRequest(ctx); + const soClient = await SOContentStorage.getSOClientFromRequest(ctx); // Validate input (data & options) & UP transform them to the latest version const { value: dataToLatest, error: dataError } = transforms.update.in.data.up< @@ -396,7 +387,7 @@ export abstract class SOContentStorage ); const result = { - item: savedObjectToItem(partialSavedObject, this.allowedSavedObjectAttributes, true), + item: this.savedObjectToItem(partialSavedObject, true), }; const validationError = transforms.update.out.result.validate(result); @@ -431,7 +422,7 @@ export abstract class SOContentStorage // force is necessary to delete saved objects that exist in multiple namespaces options?: { force: boolean } ): Promise { - const soClient = await savedObjectClientFromRequest(ctx); + const soClient = await SOContentStorage.getSOClientFromRequest(ctx); await soClient.delete(this.savedObjectType, id, { force: options?.force ?? false }); return { success: true }; } @@ -442,7 +433,7 @@ export abstract class SOContentStorage options: Types['SearchOptions'] = {} ): Promise { const transforms = ctx.utils.getTransforms(this.cmServicesDefinition); - const soClient = await savedObjectClientFromRequest(ctx); + const soClient = await SOContentStorage.getSOClientFromRequest(ctx); // Validate and UP transform the options const { value: optionsToLatest, error: optionsError } = transforms.search.in.options.up< @@ -461,9 +452,7 @@ export abstract class SOContentStorage // Execute the query in the DB const soResponse = await soClient.find(soQuery); const response = { - hits: soResponse.saved_objects.map((so) => - savedObjectToItem(so, this.allowedSavedObjectAttributes, false) - ), + hits: soResponse.saved_objects.map((so) => this.savedObjectToItem(so)), pagination: { total: soResponse.total, }, diff --git a/src/platform/packages/shared/kbn-content-management-utils/src/schema.ts b/src/platform/packages/shared/kbn-content-management-utils/src/schema.ts index c3ba8e91df933..c36eb59985741 100644 --- a/src/platform/packages/shared/kbn-content-management-utils/src/schema.ts +++ b/src/platform/packages/shared/kbn-content-management-utils/src/schema.ts @@ -18,7 +18,7 @@ export const apiError = schema.object({ export const referenceSchema = schema.object( { - name: schema.maybe(schema.string()), + name: schema.string(), type: schema.string(), id: schema.string(), }, @@ -27,7 +27,7 @@ export const referenceSchema = schema.object( export const referencesSchema = schema.arrayOf(referenceSchema); -export const savedObjectSchema = (attributesSchema: ObjectType) => +export const savedObjectSchema = >(attributesSchema: T) => schema.object( { id: schema.string(), @@ -35,16 +35,19 @@ export const savedObjectSchema = (attributesSchema: ObjectType) => version: schema.maybe(schema.string()), createdAt: schema.maybe(schema.string()), updatedAt: schema.maybe(schema.string()), + createdBy: schema.maybe(schema.string()), + updatedBy: schema.maybe(schema.string()), error: schema.maybe(apiError), attributes: attributesSchema, references: referencesSchema, namespaces: schema.maybe(schema.arrayOf(schema.string())), originId: schema.maybe(schema.string()), + managed: schema.maybe(schema.boolean()), }, { unknowns: 'allow' } ); -export const objectTypeToGetResultSchema = (soSchema: ObjectType) => +export const objectTypeToGetResultSchema = >(soSchema: T) => schema.object( { item: soSchema, @@ -111,15 +114,34 @@ export const updateOptionsSchema = { references: schema.maybe(referencesSchema), version: schema.maybe(schema.string()), refresh: schema.maybe(schema.oneOf([schema.boolean(), schema.literal('wait_for')])), - upsert: (attributesSchema: ObjectType) => schema.maybe(savedObjectSchema(attributesSchema)), + upsert: >(attributesSchema: T) => + schema.maybe(savedObjectSchema(attributesSchema)), retryOnConflict: schema.maybe(schema.number()), mergeAttributes: schema.maybe(schema.boolean()), }; -export const createResultSchema = (soSchema: ObjectType) => +export const createResultSchema = >(soSchema: T) => schema.object( { item: soSchema, }, { unknowns: 'forbid' } ); + +export const searchResultSchema = , M extends ObjectType = never>( + soSchema: T, + meta?: M +) => + schema.object( + { + hits: schema.arrayOf(soSchema), + pagination: schema.object({ + total: schema.number(), + cursor: schema.maybe(schema.string()), + }), + ...(meta && { + meta, + }), + }, + { unknowns: 'forbid' } + ); diff --git a/src/platform/packages/shared/kbn-content-management-utils/tsconfig.json b/src/platform/packages/shared/kbn-content-management-utils/tsconfig.json index 1f21540e1a90a..0135dc47df77d 100644 --- a/src/platform/packages/shared/kbn-content-management-utils/tsconfig.json +++ b/src/platform/packages/shared/kbn-content-management-utils/tsconfig.json @@ -24,5 +24,6 @@ "@kbn/logging", "@kbn/logging-mocks", "@kbn/core", + "@kbn/core-http-router-server-mocks", ] } diff --git a/src/platform/packages/shared/kbn-object-versioning/README.md b/src/platform/packages/shared/kbn-object-versioning/README.md index 37db4dc7e5f5a..30972d18253ed 100644 --- a/src/platform/packages/shared/kbn-object-versioning/README.md +++ b/src/platform/packages/shared/kbn-object-versioning/README.md @@ -1,3 +1,3 @@ # @kbn/object-versioning -Empty package generated by @kbn/generate +A package to handle versioning of Content Management. diff --git a/src/platform/packages/shared/kbn-object-versioning/index.ts b/src/platform/packages/shared/kbn-object-versioning/index.ts index 31bcc9117bfb7..6818362119397 100644 --- a/src/platform/packages/shared/kbn-object-versioning/index.ts +++ b/src/platform/packages/shared/kbn-object-versioning/index.ts @@ -9,7 +9,7 @@ export { initTransform, - getContentManagmentServicesTransforms, + getContentManagementServicesTransforms, compileServiceDefinitions, } from './lib'; diff --git a/src/platform/packages/shared/kbn-object-versioning/kibana.jsonc b/src/platform/packages/shared/kbn-object-versioning/kibana.jsonc index 5d20eefe37bd8..25a91b87ff240 100644 --- a/src/platform/packages/shared/kbn-object-versioning/kibana.jsonc +++ b/src/platform/packages/shared/kbn-object-versioning/kibana.jsonc @@ -1,5 +1,5 @@ { - "type": "shared-server", + "type": "shared-common", "id": "@kbn/object-versioning", "owner": [ "@elastic/appex-sharedux" diff --git a/src/platform/packages/shared/kbn-object-versioning/lib/index.ts b/src/platform/packages/shared/kbn-object-versioning/lib/index.ts index 814a49b9828b1..c3b09c5e9d23c 100644 --- a/src/platform/packages/shared/kbn-object-versioning/lib/index.ts +++ b/src/platform/packages/shared/kbn-object-versioning/lib/index.ts @@ -10,7 +10,7 @@ export { initTransform } from './object_transform'; export { - getTransforms as getContentManagmentServicesTransforms, + getTransforms as getContentManagementServicesTransforms, compile as compileServiceDefinitions, } from './content_management_services_versioning'; diff --git a/src/platform/packages/shared/kbn-object-versioning/lib/object_transform.ts b/src/platform/packages/shared/kbn-object-versioning/lib/object_transform.ts index f95cf22b39213..158989ac44ad3 100644 --- a/src/platform/packages/shared/kbn-object-versioning/lib/object_transform.ts +++ b/src/platform/packages/shared/kbn-object-versioning/lib/object_transform.ts @@ -57,7 +57,7 @@ const getTransformFns = ( let fn: ObjectTransform | undefined; if (to > from) { while (i <= to) { - fn = migrationDefinition[i].up; + fn = migrationDefinition[i]?.up; if (fn) { fns.push(fn); } @@ -65,7 +65,7 @@ const getTransformFns = ( } } else if (to < from) { while (i >= to) { - fn = migrationDefinition[i].down; + fn = migrationDefinition[i]?.down; if (fn) { fns.push(fn); } @@ -126,7 +126,7 @@ export const initTransform = try { if (!migrationDefinition[requestVersion]) { return { - error: new Error(`Unvalid version to up transform from [${requestVersion}].`), + error: new Error(`Invalid version to up transform from [${requestVersion}].`), value: null, }; } @@ -142,7 +142,7 @@ export const initTransform = if (!migrationDefinition[targetVersion]) { return { - error: new Error(`Unvalid version to up transform to [${to}].`), + error: new Error(`Invalid version to up transform to [${to}].`), value: null, }; } @@ -170,21 +170,22 @@ export const initTransform = try { if (!migrationDefinition[requestVersion]) { return { - error: new Error(`Unvalid version to down transform to [${requestVersion}].`), + error: new Error(`Invalid version to down transform to [${requestVersion}].`), value: null, }; } const fromVersion = getVersion(from); - if (!migrationDefinition[fromVersion]) { + // Only allow missing from definition for initial versioning (i.e. v0 → v1) + if (!migrationDefinition[fromVersion] && fromVersion !== 0) { return { - error: new Error(`Unvalid version to down transform from [${from}].`), + error: new Error(`Invalid version to down transform from [${from}].`), value: null, }; } - if (validate) { + if (validate && fromVersion !== 0) { const error = validateFn(obj, fromVersion); if (error) { return { error, value: null }; diff --git a/src/platform/packages/shared/kbn-object-versioning/lib/types.ts b/src/platform/packages/shared/kbn-object-versioning/lib/types.ts index a727ef54b47bc..589258014cb23 100644 --- a/src/platform/packages/shared/kbn-object-versioning/lib/types.ts +++ b/src/platform/packages/shared/kbn-object-versioning/lib/types.ts @@ -53,7 +53,7 @@ export interface ObjectTransforms< } ) => TransformReturn; down: ( - obj: DownIn, + obj: I, version?: Version | 'latest', options?: { /** Validate the object _before_ down transform */ diff --git a/src/platform/packages/shared/kbn-visualization-utils/src/get_lens_attributes.ts b/src/platform/packages/shared/kbn-visualization-utils/src/get_lens_attributes.ts index eb5666cc790f9..5ec59efad3660 100644 --- a/src/platform/packages/shared/kbn-visualization-utils/src/get_lens_attributes.ts +++ b/src/platform/packages/shared/kbn-visualization-utils/src/get_lens_attributes.ts @@ -23,6 +23,7 @@ export const getLensAttributesFromSuggestion = ({ suggestion: Suggestion | undefined; dataView?: DataView; }): { + // TODO: make these types reference the actual attributes references: Array<{ name: string; id: string; type: string }>; visualizationType: string; state: { @@ -32,6 +33,7 @@ export const getLensAttributesFromSuggestion = ({ filters: Filter[]; }; title: string; + version: 1; } => { const suggestionDatasourceState = Object.assign({}, suggestion?.datasourceState); const suggestionVisualizationState = Object.assign({}, suggestion?.visualizationState); @@ -65,6 +67,7 @@ export const getLensAttributesFromSuggestion = ({ }), }, visualizationType: suggestion ? suggestion.visualizationId : 'lnsXY', + version: 1 as const, }; return attributes; }; diff --git a/src/platform/plugins/private/event_annotation_listing/public/components/group_editor_flyout/lens_attributes.ts b/src/platform/plugins/private/event_annotation_listing/public/components/group_editor_flyout/lens_attributes.ts index 416c18c7f457f..05224e19d8c12 100644 --- a/src/platform/plugins/private/event_annotation_listing/public/components/group_editor_flyout/lens_attributes.ts +++ b/src/platform/plugins/private/event_annotation_listing/public/components/group_editor_flyout/lens_attributes.ts @@ -143,6 +143,7 @@ export const getLensAttributes = (group: EventAnnotationGroupConfig, timeField: name: `indexpattern-datasource-layer-${DATA_LAYER_ID}`, }, ], + version: 1 as const, } as TypedLensByValueInput['attributes']); export const getCurrentTimeField = (attributes: TypedLensByValueInput['attributes']) => { diff --git a/src/platform/plugins/private/links/server/content_management/links_storage.ts b/src/platform/plugins/private/links/server/content_management/links_storage.ts index 09228127b80b3..21e682b61893c 100644 --- a/src/platform/plugins/private/links/server/content_management/links_storage.ts +++ b/src/platform/plugins/private/links/server/content_management/links_storage.ts @@ -96,6 +96,7 @@ export class LinksStorage { // Validate response and DOWN transform to the request version const { value, error: resultError } = transforms.get.out.result.down( + // @ts-expect-error - need to fix response.item type here response, undefined, // do not override version { validate: false } // validation is done above @@ -231,6 +232,7 @@ export class LinksStorage { LinksUpdateOut, LinksUpdateOut >( + // @ts-expect-error - need to fix item type here { item }, undefined, // do not override version { validate: false } // validation is done above diff --git a/src/platform/plugins/shared/content_management/common/rpc/bulk_get.ts b/src/platform/plugins/shared/content_management/common/rpc/bulk_get.ts index e3c9ea127e35d..bfdb1ff8a8db9 100644 --- a/src/platform/plugins/shared/content_management/common/rpc/bulk_get.ts +++ b/src/platform/plugins/shared/content_management/common/rpc/bulk_get.ts @@ -14,7 +14,7 @@ import { GetResult, getResultSchema } from './get'; import type { ProcedureSchemas } from './types'; -export const bulkGetSchemas: ProcedureSchemas = { +export const bulkGetSchemas = { in: schema.object( { contentTypeId: schema.string(), @@ -31,7 +31,7 @@ export const bulkGetSchemas: ProcedureSchemas = { }, { unknowns: 'forbid' } ), -}; +} satisfies ProcedureSchemas; export interface BulkGetIn { contentTypeId: T; diff --git a/src/platform/plugins/shared/content_management/common/rpc/create.ts b/src/platform/plugins/shared/content_management/common/rpc/create.ts index 33f33637dbf4e..8c26438a3c7a7 100644 --- a/src/platform/plugins/shared/content_management/common/rpc/create.ts +++ b/src/platform/plugins/shared/content_management/common/rpc/create.ts @@ -14,7 +14,7 @@ import { versionSchema } from './constants'; import type { ItemResult, ProcedureSchemas } from './types'; -export const createSchemas: ProcedureSchemas = { +export const createSchemas = { in: schema.object( { contentTypeId: schema.string(), @@ -32,7 +32,7 @@ export const createSchemas: ProcedureSchemas = { }, { unknowns: 'forbid' } ), -}; +} satisfies ProcedureSchemas; export interface CreateIn< T extends string = string, diff --git a/src/platform/plugins/shared/content_management/common/rpc/delete.ts b/src/platform/plugins/shared/content_management/common/rpc/delete.ts index 7b67b17ff1f6d..cad122815bc28 100644 --- a/src/platform/plugins/shared/content_management/common/rpc/delete.ts +++ b/src/platform/plugins/shared/content_management/common/rpc/delete.ts @@ -13,7 +13,7 @@ import { versionSchema } from './constants'; import type { ProcedureSchemas } from './types'; -export const deleteSchemas: ProcedureSchemas = { +export const deleteSchemas = { in: schema.object( { contentTypeId: schema.string(), @@ -35,7 +35,7 @@ export const deleteSchemas: ProcedureSchemas = { }, { unknowns: 'forbid' } ), -}; +} satisfies ProcedureSchemas; export interface DeleteIn { contentTypeId: T; diff --git a/src/platform/plugins/shared/content_management/common/rpc/get.ts b/src/platform/plugins/shared/content_management/common/rpc/get.ts index 6aa51279c5318..bbb7ac7cb1b29 100644 --- a/src/platform/plugins/shared/content_management/common/rpc/get.ts +++ b/src/platform/plugins/shared/content_management/common/rpc/get.ts @@ -22,7 +22,7 @@ export const getResultSchema = schema.object( { unknowns: 'forbid' } ); -export const getSchemas: ProcedureSchemas = { +export const getSchemas = { in: schema.object( { contentTypeId: schema.string(), @@ -33,7 +33,7 @@ export const getSchemas: ProcedureSchemas = { { unknowns: 'forbid' } ), out: getResultSchema, -}; +} satisfies ProcedureSchemas; export interface GetIn { id: string; diff --git a/src/platform/plugins/shared/content_management/common/rpc/msearch.ts b/src/platform/plugins/shared/content_management/common/rpc/msearch.ts index 7020b66ff94c6..de2d15d713a5a 100644 --- a/src/platform/plugins/shared/content_management/common/rpc/msearch.ts +++ b/src/platform/plugins/shared/content_management/common/rpc/msearch.ts @@ -14,7 +14,7 @@ import { searchQuerySchema, searchResultSchema, SearchQuery, SearchResult } from import type { ProcedureSchemas } from './types'; -export const mSearchSchemas: ProcedureSchemas = { +export const mSearchSchemas = { in: schema.object( { contentTypes: schema.arrayOf( @@ -36,7 +36,7 @@ export const mSearchSchemas: ProcedureSchemas = { }, { unknowns: 'forbid' } ), -}; +} satisfies ProcedureSchemas; export type MSearchQuery = SearchQuery; diff --git a/src/platform/plugins/shared/content_management/common/rpc/rpc.ts b/src/platform/plugins/shared/content_management/common/rpc/rpc.ts index 5ddb7608fb5f9..b392d21acda3e 100644 --- a/src/platform/plugins/shared/content_management/common/rpc/rpc.ts +++ b/src/platform/plugins/shared/content_management/common/rpc/rpc.ts @@ -17,9 +17,7 @@ import { deleteSchemas } from './delete'; import { searchSchemas } from './search'; import { mSearchSchemas } from './msearch'; -export const schemas: { - [key in ProcedureName]: ProcedureSchemas; -} = { +export const schemas = { get: getSchemas, bulkGet: bulkGetSchemas, create: createSchemas, @@ -27,4 +25,6 @@ export const schemas: { delete: deleteSchemas, search: searchSchemas, mSearch: mSearchSchemas, +} satisfies { + [key in ProcedureName]: ProcedureSchemas; }; diff --git a/src/platform/plugins/shared/content_management/common/rpc/search.ts b/src/platform/plugins/shared/content_management/common/rpc/search.ts index 1d79d115b71d0..3f977450d81b8 100644 --- a/src/platform/plugins/shared/content_management/common/rpc/search.ts +++ b/src/platform/plugins/shared/content_management/common/rpc/search.ts @@ -40,7 +40,7 @@ export const searchResultSchema = schema.object({ }), }); -export const searchSchemas: ProcedureSchemas = { +export const searchSchemas = { in: schema.object( { contentTypeId: schema.string(), @@ -58,7 +58,7 @@ export const searchSchemas: ProcedureSchemas = { }, { unknowns: 'forbid' } ), -}; +} satisfies ProcedureSchemas; export interface SearchQuery { /** The text to search for */ diff --git a/src/platform/plugins/shared/content_management/common/rpc/update.ts b/src/platform/plugins/shared/content_management/common/rpc/update.ts index ad581c0b97380..c387fd8c49067 100644 --- a/src/platform/plugins/shared/content_management/common/rpc/update.ts +++ b/src/platform/plugins/shared/content_management/common/rpc/update.ts @@ -14,7 +14,7 @@ import { versionSchema } from './constants'; import type { ItemResult, ProcedureSchemas } from './types'; -export const updateSchemas: ProcedureSchemas = { +export const updateSchemas = { in: schema.object( { contentTypeId: schema.string(), @@ -33,7 +33,7 @@ export const updateSchemas: ProcedureSchemas = { }, { unknowns: 'forbid' } ), -}; +} satisfies ProcedureSchemas; export interface UpdateIn< T extends string = string, diff --git a/src/platform/plugins/shared/content_management/server/content_client/content_client_factory.ts b/src/platform/plugins/shared/content_management/server/content_client/content_client_factory.ts index 090ed494bb500..1edb113c93699 100644 --- a/src/platform/plugins/shared/content_management/server/content_client/content_client_factory.ts +++ b/src/platform/plugins/shared/content_management/server/content_client/content_client_factory.ts @@ -7,12 +7,12 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import type { Logger, KibanaRequest } from '@kbn/core/server'; import type { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server'; -import type { KibanaRequest } from '@kbn/core-http-server'; import { Version } from '@kbn/object-versioning'; import type { MSearchIn, MSearchOut } from '../../common'; -import type { ContentRegistry } from '../core'; +import type { ContentRegistry, StorageContext } from '../core'; import { MSearchService } from '../core/msearch'; import { getServiceObjectTransformFactory, getStorageContext } from '../utils'; import { ContentClient } from './content_client'; @@ -21,17 +21,19 @@ export const getContentClientFactory = ({ contentRegistry }: { contentRegistry: ContentRegistry }) => (contentTypeId: string) => { const getForRequest = ({ + request, requestHandlerContext, version, }: { - requestHandlerContext: RequestHandlerContext; request: KibanaRequest; + requestHandlerContext: RequestHandlerContext; version?: Version; }) => { const contentDefinition = contentRegistry.getDefinition(contentTypeId); const storageContext = getStorageContext({ contentTypeId, + request, version: version ?? contentDefinition.version.latest, ctx: { contentRegistry, @@ -60,35 +62,46 @@ export const getMSearchClientFactory = ({ contentRegistry, mSearchService, + logger, }: { contentRegistry: ContentRegistry; mSearchService: MSearchService; + logger: Logger; }) => ({ requestHandlerContext, + request, }: { requestHandlerContext: RequestHandlerContext; request: KibanaRequest; }) => { const msearch = async ({ contentTypes, query }: MSearchIn): Promise => { - const contentTypesWithStorageContext = contentTypes.map(({ contentTypeId, version }) => { + const contentTypesWithStorageContext = contentTypes.reduce< + Array<{ contentTypeId: string; ctx: StorageContext }> + >((acc, { contentTypeId, version }) => { const contentDefinition = contentRegistry.getDefinition(contentTypeId); - const storageContext = getStorageContext({ - contentTypeId, - version: version ?? contentDefinition.version.latest, - ctx: { - contentRegistry, - requestHandlerContext, - getTransformsFactory: getServiceObjectTransformFactory, - }, - }); + if (contentDefinition.storage.mSearch) { + const storageContext = getStorageContext({ + request, + contentTypeId, + version: version ?? contentDefinition.version.latest, + ctx: { + contentRegistry, + requestHandlerContext, + getTransformsFactory: getServiceObjectTransformFactory, + }, + }); + acc.push({ + contentTypeId, + ctx: storageContext, + }); + } else { + logger.warn(`mSearch method missing for content type "${contentTypeId}" v${version}.`); + } - return { - contentTypeId, - ctx: storageContext, - }; - }); + return acc; + }, []); const result = await mSearchService.search(contentTypesWithStorageContext, query); diff --git a/src/platform/plugins/shared/content_management/server/core/core.test.ts b/src/platform/plugins/shared/content_management/server/core/core.test.ts index c076588c0fa52..d001c339db059 100644 --- a/src/platform/plugins/shared/content_management/server/core/core.test.ts +++ b/src/platform/plugins/shared/content_management/server/core/core.test.ts @@ -37,6 +37,8 @@ import type { SearchItemError, } from './event_types'; import { ContentStorage, ContentTypeDefinition, StorageContext } from './types'; +import { mockRouter } from '@kbn/core-http-router-server-mocks'; +import { RequestHandlerContext } from '@kbn/core/server'; const spyMsearch = jest.fn(); const getmSearchSpy = () => spyMsearch; @@ -64,7 +66,8 @@ const setup = ({ latestVersion = 2, }: { registerFooType?: boolean; storage?: ContentStorage; latestVersion?: number } = {}) => { const ctx: StorageContext = { - requestHandlerContext: {} as any, + request: mockRouter.createFakeKibanaRequest({}), + requestHandlerContext: jest.mocked({} as any), version: { latest: latestVersion, request: 1, diff --git a/src/platform/plugins/shared/content_management/server/core/core.ts b/src/platform/plugins/shared/content_management/server/core/core.ts index 04c96ad737bf0..3717a8e15b87c 100644 --- a/src/platform/plugins/shared/content_management/server/core/core.ts +++ b/src/platform/plugins/shared/content_management/server/core/core.ts @@ -146,6 +146,7 @@ export class Core { const msearchClientFactory = getMSearchClientFactory({ contentRegistry: this.contentRegistry, mSearchService, + logger: this.ctx.logger, }); const msearchClient = msearchClientFactory({ requestHandlerContext, request }); diff --git a/src/platform/plugins/shared/content_management/server/core/msearch.test.ts b/src/platform/plugins/shared/content_management/server/core/msearch.test.ts index a60c77f66104f..2115e0db55161 100644 --- a/src/platform/plugins/shared/content_management/server/core/msearch.test.ts +++ b/src/platform/plugins/shared/content_management/server/core/msearch.test.ts @@ -13,6 +13,7 @@ import { ContentRegistry } from './registry'; import { createMockedStorage } from './mocks'; import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; import { StorageContext } from '.'; +import { mockRouter } from '@kbn/core-http-router-server-mocks'; const SEARCH_LISTING_LIMIT = 100; const SEARCH_PER_PAGE = 10; @@ -65,6 +66,7 @@ const setup = () => { const mockStorageContext = (ctx: Partial = {}): StorageContext => { return { + request: mockRouter.createFakeKibanaRequest({}), requestHandlerContext: 'mockRequestHandlerContext' as any, utils: 'mockUtils' as any, version: { diff --git a/src/platform/plugins/shared/content_management/server/core/types.ts b/src/platform/plugins/shared/content_management/server/core/types.ts index cabd3b35b7261..1f28aa7c28714 100644 --- a/src/platform/plugins/shared/content_management/server/core/types.ts +++ b/src/platform/plugins/shared/content_management/server/core/types.ts @@ -15,6 +15,7 @@ import type { } from '@kbn/object-versioning'; import type { SavedObjectsFindResult } from '@kbn/core-saved-objects-api-server'; +import { KibanaRequest } from '@kbn/core/server'; import type { GetResult, BulkGetResult, @@ -32,6 +33,7 @@ export type StorageContextGetTransformFn = ( /** Context that is sent to all storage instance methods */ export interface StorageContext { + request: KibanaRequest; /** The Core HTTP request handler context */ requestHandlerContext: RequestHandlerContext; version: { diff --git a/src/platform/plugins/shared/content_management/server/plugin.ts b/src/platform/plugins/shared/content_management/server/plugin.ts index 0215f3d36771b..99cf11da13aee 100755 --- a/src/platform/plugins/shared/content_management/server/plugin.ts +++ b/src/platform/plugins/shared/content_management/server/plugin.ts @@ -68,7 +68,7 @@ export class ContentManagementPlugin const { api: coreApi, contentRegistry } = this.core.setup(); const rpc = new RpcService(); - registerProcedures(rpc); + registerProcedures(rpc, this.logger); const router = core.http.createRouter(); initRpcRoutes(procedureNames, router, { diff --git a/src/platform/plugins/shared/content_management/server/rpc/procedures/all_procedures.ts b/src/platform/plugins/shared/content_management/server/rpc/procedures/all_procedures.ts index 2a3b145fd50eb..55f566465289e 100644 --- a/src/platform/plugins/shared/content_management/server/rpc/procedures/all_procedures.ts +++ b/src/platform/plugins/shared/content_management/server/rpc/procedures/all_procedures.ts @@ -7,6 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import type { Logger } from '@kbn/core/server'; + import type { ProcedureName } from '../../../common'; import type { ProcedureDefinition } from '../rpc_service'; import type { Context } from '../types'; @@ -16,14 +18,18 @@ import { create } from './create'; import { update } from './update'; import { deleteProc } from './delete'; import { search } from './search'; -import { mSearch } from './msearch'; +import { getMSearch } from './msearch'; -export const procedures: { [key in ProcedureName]: ProcedureDefinition } = { +export const getProcedures = ( + logger: Logger +): { + [key in ProcedureName]: ProcedureDefinition; +} => ({ get, bulkGet, create, update, delete: deleteProc, search, - mSearch, -}; + mSearch: getMSearch(logger), +}); diff --git a/src/platform/plugins/shared/content_management/server/rpc/procedures/bulk_get.test.ts b/src/platform/plugins/shared/content_management/server/rpc/procedures/bulk_get.test.ts index 0bc8cc8c12f87..3a97bf4f2d5d7 100644 --- a/src/platform/plugins/shared/content_management/server/rpc/procedures/bulk_get.test.ts +++ b/src/platform/plugins/shared/content_management/server/rpc/procedures/bulk_get.test.ts @@ -25,9 +25,9 @@ jest.mock('@kbn/object-versioning', () => { const original = jest.requireActual('@kbn/object-versioning'); return { ...original, - getContentManagmentServicesTransforms: (...args: any[]) => { + getContentManagementServicesTransforms: (...args: any[]) => { spy()(...args); - return original.getContentManagmentServicesTransforms(...args); + return original.getContentManagementServicesTransforms(...args); }, }; }); diff --git a/src/platform/plugins/shared/content_management/server/rpc/procedures/create.test.ts b/src/platform/plugins/shared/content_management/server/rpc/procedures/create.test.ts index 492be13d7de30..65a5d3ecc70d7 100644 --- a/src/platform/plugins/shared/content_management/server/rpc/procedures/create.test.ts +++ b/src/platform/plugins/shared/content_management/server/rpc/procedures/create.test.ts @@ -25,9 +25,9 @@ jest.mock('@kbn/object-versioning', () => { const original = jest.requireActual('@kbn/object-versioning'); return { ...original, - getContentManagmentServicesTransforms: (...args: any[]) => { + getContentManagementServicesTransforms: (...args: any[]) => { spy()(...args); - return original.getContentManagmentServicesTransforms(...args); + return original.getContentManagementServicesTransforms(...args); }, }; }); diff --git a/src/platform/plugins/shared/content_management/server/rpc/procedures/delete.test.ts b/src/platform/plugins/shared/content_management/server/rpc/procedures/delete.test.ts index 8c70b906d4372..1771b443d4d26 100644 --- a/src/platform/plugins/shared/content_management/server/rpc/procedures/delete.test.ts +++ b/src/platform/plugins/shared/content_management/server/rpc/procedures/delete.test.ts @@ -25,9 +25,9 @@ jest.mock('@kbn/object-versioning', () => { const original = jest.requireActual('@kbn/object-versioning'); return { ...original, - getContentManagmentServicesTransforms: (...args: any[]) => { + getContentManagementServicesTransforms: (...args: any[]) => { spy()(...args); - return original.getContentManagmentServicesTransforms(...args); + return original.getContentManagementServicesTransforms(...args); }, }; }); diff --git a/src/platform/plugins/shared/content_management/server/rpc/procedures/get.test.ts b/src/platform/plugins/shared/content_management/server/rpc/procedures/get.test.ts index 04d93fddda04f..eaba9e4c3cab7 100644 --- a/src/platform/plugins/shared/content_management/server/rpc/procedures/get.test.ts +++ b/src/platform/plugins/shared/content_management/server/rpc/procedures/get.test.ts @@ -25,9 +25,9 @@ jest.mock('@kbn/object-versioning', () => { const original = jest.requireActual('@kbn/object-versioning'); return { ...original, - getContentManagmentServicesTransforms: (...args: any[]) => { + getContentManagementServicesTransforms: (...args: any[]) => { spy()(...args); - return original.getContentManagmentServicesTransforms(...args); + return original.getContentManagementServicesTransforms(...args); }, }; }); diff --git a/src/platform/plugins/shared/content_management/server/rpc/procedures/index.ts b/src/platform/plugins/shared/content_management/server/rpc/procedures/index.ts index 83c2d76d95765..e4d12cb76af7c 100644 --- a/src/platform/plugins/shared/content_management/server/rpc/procedures/index.ts +++ b/src/platform/plugins/shared/content_management/server/rpc/procedures/index.ts @@ -7,10 +7,12 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { Logger } from '@kbn/core/server'; + import type { ProcedureName } from '../../../common'; import type { RpcService } from '../rpc_service'; import type { Context } from '../types'; -import { procedures } from './all_procedures'; +import { getProcedures } from './all_procedures'; // Type utility to correclty set the type of JS Object.entries() type Entries = Array< @@ -19,7 +21,8 @@ type Entries = Array< }[keyof T] >; -export function registerProcedures(rpc: RpcService) { +export function registerProcedures(rpc: RpcService, logger: Logger) { + const procedures = getProcedures(logger); (Object.entries(procedures) as Entries).forEach(([name, definition]) => { rpc.register(name, definition); }); diff --git a/src/platform/plugins/shared/content_management/server/rpc/procedures/msearch.test.ts b/src/platform/plugins/shared/content_management/server/rpc/procedures/msearch.test.ts index c50ce4307105e..b557b8107736f 100644 --- a/src/platform/plugins/shared/content_management/server/rpc/procedures/msearch.test.ts +++ b/src/platform/plugins/shared/content_management/server/rpc/procedures/msearch.test.ts @@ -7,6 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { loggingSystemMock } from '@kbn/core/server/mocks'; import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; import { MSearchIn, MSearchQuery } from '../../../common'; @@ -15,24 +16,26 @@ import { ContentRegistry } from '../../core/registry'; import { createMockedStorage } from '../../core/mocks'; import { EventBus } from '../../core/event_bus'; import { MSearchService } from '../../core/msearch'; -import { mSearch } from './msearch'; +import { getMSearch } from './msearch'; disableTransformsCache(); const storageContextGetTransforms = jest.fn(); const spy = () => storageContextGetTransforms; +const mockLoggerFactory = loggingSystemMock.create(); +const mockLogger = mockLoggerFactory.get('mock logger'); jest.mock('@kbn/object-versioning', () => { const original = jest.requireActual('@kbn/object-versioning'); return { ...original, - getContentManagmentServicesTransforms: (...args: any[]) => { + getContentManagementServicesTransforms: (...args: any[]) => { spy()(...args); - return original.getContentManagmentServicesTransforms(...args); + return original.getContentManagementServicesTransforms(...args); }, }; }); -const { fn, schemas } = mSearch; +const { fn, schemas } = getMSearch(mockLogger); const inputSchema = schemas?.in; const outputSchema = schemas?.out; diff --git a/src/platform/plugins/shared/content_management/server/rpc/procedures/msearch.ts b/src/platform/plugins/shared/content_management/server/rpc/procedures/msearch.ts index 2de3133ce099c..c1c8accc4e0b7 100644 --- a/src/platform/plugins/shared/content_management/server/rpc/procedures/msearch.ts +++ b/src/platform/plugins/shared/content_management/server/rpc/procedures/msearch.ts @@ -7,21 +7,26 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import type { Logger } from '@kbn/core/server'; + import { rpcSchemas } from '../../../common/schemas'; import type { MSearchIn, MSearchOut } from '../../../common'; import type { ProcedureDefinition } from '../rpc_service'; import type { Context } from '../types'; import { getMSearchClientFactory } from '../../content_client'; -export const mSearch: ProcedureDefinition = { +export const getMSearch = ( + logger: Logger +): ProcedureDefinition => ({ schemas: rpcSchemas.mSearch, fn: async (ctx, { contentTypes, query }) => { const clientFactory = getMSearchClientFactory({ contentRegistry: ctx.contentRegistry, mSearchService: ctx.mSearchService, + logger, }); const mSearchClient = clientFactory(ctx); return mSearchClient.msearch({ contentTypes, query }); }, -}; +}); diff --git a/src/platform/plugins/shared/content_management/server/rpc/procedures/search.test.ts b/src/platform/plugins/shared/content_management/server/rpc/procedures/search.test.ts index 0c97818f84a22..cd8ae1202d090 100644 --- a/src/platform/plugins/shared/content_management/server/rpc/procedures/search.test.ts +++ b/src/platform/plugins/shared/content_management/server/rpc/procedures/search.test.ts @@ -26,9 +26,9 @@ jest.mock('@kbn/object-versioning', () => { const original = jest.requireActual('@kbn/object-versioning'); return { ...original, - getContentManagmentServicesTransforms: (...args: any[]) => { + getContentManagementServicesTransforms: (...args: any[]) => { spy()(...args); - return original.getContentManagmentServicesTransforms(...args); + return original.getContentManagementServicesTransforms(...args); }, }; }); diff --git a/src/platform/plugins/shared/content_management/server/rpc/procedures/update.test.ts b/src/platform/plugins/shared/content_management/server/rpc/procedures/update.test.ts index 6a008e2129e99..ac555f5906db4 100644 --- a/src/platform/plugins/shared/content_management/server/rpc/procedures/update.test.ts +++ b/src/platform/plugins/shared/content_management/server/rpc/procedures/update.test.ts @@ -25,9 +25,9 @@ jest.mock('@kbn/object-versioning', () => { const original = jest.requireActual('@kbn/object-versioning'); return { ...original, - getContentManagmentServicesTransforms: (...args: any[]) => { + getContentManagementServicesTransforms: (...args: any[]) => { spy()(...args); - return original.getContentManagmentServicesTransforms(...args); + return original.getContentManagementServicesTransforms(...args); }, }; }); diff --git a/src/platform/plugins/shared/content_management/server/rpc/routes/routes.ts b/src/platform/plugins/shared/content_management/server/rpc/routes/routes.ts index 021fd224c7fe6..12d36273a048a 100644 --- a/src/platform/plugins/shared/content_management/server/rpc/routes/routes.ts +++ b/src/platform/plugins/shared/content_management/server/rpc/routes/routes.ts @@ -11,7 +11,7 @@ import { schema } from '@kbn/config-schema'; import type { IRouter } from '@kbn/core/server'; import { LISTING_LIMIT_SETTING, PER_PAGE_SETTING } from '@kbn/saved-objects-settings'; -import { ProcedureName } from '../../../common'; +import { API_ENDPOINT, ProcedureName } from '../../../common'; import type { ContentRegistry } from '../../core'; import { MSearchService } from '../../core/msearch'; @@ -41,7 +41,7 @@ export function initRpcRoutes( */ router.post( { - path: '/api/content_management/rpc/{name}', + path: `${API_ENDPOINT}/{name}`, security: { authz: { enabled: false, diff --git a/src/platform/plugins/shared/content_management/server/utils/services_transforms_factory.ts b/src/platform/plugins/shared/content_management/server/utils/services_transforms_factory.ts index 780d44ba465e2..ca34370153cd8 100644 --- a/src/platform/plugins/shared/content_management/server/utils/services_transforms_factory.ts +++ b/src/platform/plugins/shared/content_management/server/utils/services_transforms_factory.ts @@ -12,7 +12,7 @@ import type { ObjectMigrationDefinition } from '@kbn/object-versioning'; import type { ContentManagementServiceDefinitionVersioned, Version } from '@kbn/object-versioning'; import { compileServiceDefinitions, - getContentManagmentServicesTransforms, + getContentManagementServicesTransforms, } from '@kbn/object-versioning'; import type { StorageContextGetTransformFn } from '../core'; @@ -31,13 +31,13 @@ const compiledCache = new LRUCache @@ -48,7 +48,7 @@ export const getServiceObjectTransformFactory = const compiledFromCache = compiledCache.get(contentTypeId); if (compiledFromCache) { - return getContentManagmentServicesTransforms( + return getContentManagementServicesTransforms( definitions, requestVersion, compiledFromCache @@ -62,5 +62,5 @@ export const getServiceObjectTransformFactory = compiledCache.set(contentTypeId, compiled); } - return getContentManagmentServicesTransforms(definitions, requestVersion, compiled); + return getContentManagementServicesTransforms(definitions, requestVersion, compiled); }; diff --git a/src/platform/plugins/shared/content_management/server/utils/utils.ts b/src/platform/plugins/shared/content_management/server/utils/utils.ts index 8012592048fb9..69e564a77f470 100644 --- a/src/platform/plugins/shared/content_management/server/utils/utils.ts +++ b/src/platform/plugins/shared/content_management/server/utils/utils.ts @@ -8,6 +8,7 @@ */ import { Type, ValidationError } from '@kbn/config-schema'; +import { KibanaRequest } from '@kbn/core/server'; import type { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server'; import { validateVersion } from '@kbn/object-versioning/lib/utils'; import type { Version } from '@kbn/object-versioning'; @@ -47,10 +48,12 @@ const validateRequestVersion = ( }; export const getStorageContext = ({ + request, contentTypeId, version: _version, ctx: { contentRegistry, requestHandlerContext, getTransformsFactory }, }: { + request: KibanaRequest; contentTypeId: string; version?: number; ctx: { @@ -62,6 +65,7 @@ export const getStorageContext = ({ const contentDefinition = contentRegistry.getDefinition(contentTypeId); const version = validateRequestVersion(_version, contentDefinition.version.latest); const storageContext: StorageContext = { + request, requestHandlerContext, version: { request: version, diff --git a/src/platform/plugins/shared/content_management/tsconfig.json b/src/platform/plugins/shared/content_management/tsconfig.json index e02852e8fad3d..0cf799198a7b6 100644 --- a/src/platform/plugins/shared/content_management/tsconfig.json +++ b/src/platform/plugins/shared/content_management/tsconfig.json @@ -19,6 +19,7 @@ "@kbn/content-management-favorites-server", "@kbn/usage-collection-plugin", "@kbn/object-versioning-utils", + "@kbn/core-http-router-server-mocks", ], "exclude": [ "target/**/*", diff --git a/src/platform/plugins/shared/dashboard/server/content_management/dashboard_storage.ts b/src/platform/plugins/shared/dashboard/server/content_management/dashboard_storage.ts index 94c4e2b6b79fe..d30bb069b1f62 100644 --- a/src/platform/plugins/shared/dashboard/server/content_management/dashboard_storage.ts +++ b/src/platform/plugins/shared/dashboard/server/content_management/dashboard_storage.ts @@ -210,6 +210,7 @@ export class DashboardStorage { DashboardGetOut, DashboardGetOut >( + // @ts-expect-error - need to fix response.item type here response, undefined, // do not override version { validate: false } // validation is done above @@ -296,6 +297,7 @@ export class DashboardStorage { const { value, error: resultError } = transforms.create.out.result.down< CreateResult >( + // @ts-expect-error - need to fix item type here { item }, undefined, // do not override version { validate: false } // validation is done above @@ -380,6 +382,7 @@ export class DashboardStorage { DashboardUpdateOut, DashboardUpdateOut >( + // @ts-expect-error - need to fix item type here { item }, undefined, // do not override version { validate: false } // validation is done above @@ -458,6 +461,7 @@ export class DashboardStorage { DashboardSearchOut, DashboardSearchOut >( + // @ts-expect-error - need to fix response.hits type here response, undefined, // do not override version { validate: false } // validation is done above diff --git a/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx b/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx index 147a8a4c92a08..a97f2709b7fe8 100644 --- a/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx +++ b/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx @@ -15,6 +15,7 @@ import { v4 as generateId } from 'uuid'; import { PhaseTracker } from './phase_tracker'; import { getReactEmbeddableFactory } from './react_embeddable_registry'; import { DefaultEmbeddableApi, EmbeddableApiRegistration } from './types'; +import { getTransforms, hasTransforms } from '../transforms_registry'; /** * Renders a component from the React Embeddable registry into a Presentation Panel. @@ -63,6 +64,7 @@ export const EmbeddableRenderer = < const buildEmbeddable = async () => { const factory = await getReactEmbeddableFactory(type); + const transforms = hasTransforms(type) ? await getTransforms(type) : null; const finalizeApi = ( apiRegistration: EmbeddableApiRegistration @@ -84,8 +86,14 @@ export const EmbeddableRenderer = < const initialState = parentApi.getSerializedStateForChild(uuid) ?? { rawState: {} as SerializedState, }; + const transformedInitialState = + transforms?.transformOut?.(initialState.rawState, initialState.references) ?? + initialState.rawState; const { api, Component } = await factory.buildEmbeddable({ - initialState, + initialState: { + ...initialState, + rawState: transformedInitialState, + }, finalizeApi, uuid, parentApi, diff --git a/src/platform/plugins/shared/visualizations/public/content_management/visualization_client.ts b/src/platform/plugins/shared/visualizations/public/content_management/visualization_client.ts index 220ba191fbc6c..17d6f32137822 100644 --- a/src/platform/plugins/shared/visualizations/public/content_management/visualization_client.ts +++ b/src/platform/plugins/shared/visualizations/public/content_management/visualization_client.ts @@ -69,6 +69,9 @@ const deleteVisualization = async (id: string) => { const search = async (query: SearchQuery = {}, options?: VisualizationSearchQuery) => { if (options && options.types && options.types.length > 1) { const { types } = options; + // TODO: Fix types - This assumes the return types are all VisualizationSavedObject but that is false + // these can be any content type provided. There should be a separate mSearch method that returns + // saved objects with generic shared attributes (i.e. `title`, `description`) needed to list items. return getContentManagement().client.mSearch({ contentTypes: types.map((type) => ({ contentTypeId: type })), query, diff --git a/src/platform/plugins/shared/visualizations/public/index.ts b/src/platform/plugins/shared/visualizations/public/index.ts index fbffa8c09fa90..eb9174a03e674 100644 --- a/src/platform/plugins/shared/visualizations/public/index.ts +++ b/src/platform/plugins/shared/visualizations/public/index.ts @@ -36,6 +36,7 @@ export type { Schema, ISchemas, VisualizationClient, + BasicVisualizationClient, SerializableAttributes, } from './vis_types'; export type { VisualizeEditorInput } from './embeddable/types'; diff --git a/src/platform/plugins/shared/visualizations/public/utils/saved_objects_utils/update_basic_attributes.ts b/src/platform/plugins/shared/visualizations/public/utils/saved_objects_utils/update_basic_attributes.ts index 108bba58f7252..ba1af19b30306 100644 --- a/src/platform/plugins/shared/visualizations/public/utils/saved_objects_utils/update_basic_attributes.ts +++ b/src/platform/plugins/shared/visualizations/public/utils/saved_objects_utils/update_basic_attributes.ts @@ -7,32 +7,34 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { HttpStart } from '@kbn/core/public'; 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'; +import { BasicVisualizationClient, TypesStart } from '../../vis_types'; interface UpdateBasicSoAttributesDependencies { savedObjectsTagging?: SavedObjectsTaggingApi; overlays: OverlayStart; typesService: TypesStart; contentManagement: ContentManagementPublicStart; + http: HttpStart; } function getClientForType( type: string, typesService: TypesStart, - contentManagement: ContentManagementPublicStart -) { + contentManagement: ContentManagementPublicStart, + http: HttpStart +): BasicVisualizationClient { const visAliases = typesService.getAliases(); - return ( - visAliases - .find((v) => v.appExtensions?.visualizations.docTypes.includes(type)) - ?.appExtensions?.visualizations.client(contentManagement) || visualizationsClient - ); + return (visAliases + .find((v) => v.appExtensions?.visualizations.docTypes.includes(type)) + ?.appExtensions?.visualizations.client(contentManagement, http) || + visualizationsClient) as BasicVisualizationClient; } function getAdditionalOptionsForUpdate( @@ -58,7 +60,12 @@ export const updateBasicSoAttributes = async ( }, dependencies: UpdateBasicSoAttributesDependencies ) => { - const client = getClientForType(type, dependencies.typesService, dependencies.contentManagement); + const client = getClientForType( + type, + dependencies.typesService, + dependencies.contentManagement, + dependencies.http + ); const so = await client.get(soId); const extractedReferences = extractReferences({ diff --git a/src/platform/plugins/shared/visualizations/public/vis_types/index.ts b/src/platform/plugins/shared/visualizations/public/vis_types/index.ts index a006d621b2405..7931b2c206361 100644 --- a/src/platform/plugins/shared/visualizations/public/vis_types/index.ts +++ b/src/platform/plugins/shared/visualizations/public/vis_types/index.ts @@ -12,4 +12,8 @@ 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'; +export type { + VisualizationClient, + BasicVisualizationClient, + SerializableAttributes, +} from './vis_type_alias_registry'; diff --git a/src/platform/plugins/shared/visualizations/public/vis_types/vis_type_alias_registry.ts b/src/platform/plugins/shared/visualizations/public/vis_types/vis_type_alias_registry.ts index bffd3150f515b..2703013268ee9 100644 --- a/src/platform/plugins/shared/visualizations/public/vis_types/vis_type_alias_registry.ts +++ b/src/platform/plugins/shared/visualizations/public/vis_types/vis_type_alias_registry.ts @@ -14,6 +14,7 @@ import type { SavedObjectCreateOptions, SavedObjectUpdateOptions, } from '@kbn/content-management-utils'; +import { HttpStart } from '@kbn/data-view-editor-plugin/public/shared_imports'; import { BaseVisType } from './base_vis_type'; import { VisualizationSavedObject } from '../../common'; @@ -75,6 +76,17 @@ export interface VisualizationClient< ) => Promise['SearchOut']>; } +/** + * A slim visualization client used but the vis plugin to save basic attributes + * + * TODO cleanup the vis-type client code. + * All viz pass this client require type overrides to adopt VisualizationClient + */ +export type BasicVisualizationClient< + ContentType extends string = string, + Attr extends SerializableAttributes = SerializableAttributes +> = Pick, 'get' | 'update'>; + export interface VisualizationsAppExtension { docTypes: string[]; searchFields?: string[]; @@ -83,7 +95,10 @@ export interface VisualizationsAppExtension { update?: { overwrite?: boolean; [otherOption: string]: unknown }; create?: { [otherOption: string]: unknown }; }; - client: (contentManagement: ContentManagementPublicStart) => VisualizationClient; + client: ( + contentManagement: ContentManagementPublicStart, + http: HttpStart + ) => BasicVisualizationClient; toListItem: (savedObject: VisualizationSavedObject) => VisualizationListItem; } diff --git a/src/platform/plugins/shared/visualizations/public/visualize_app/components/visualize_listing.tsx b/src/platform/plugins/shared/visualizations/public/visualize_app/components/visualize_listing.tsx index 48e8a2ad54872..4df2638363932 100644 --- a/src/platform/plugins/shared/visualizations/public/visualize_app/components/visualize_listing.tsx +++ b/src/platform/plugins/shared/visualizations/public/visualize_app/components/visualize_listing.tsx @@ -134,6 +134,7 @@ const useTableListViewProps = ( toastNotifications, visualizeCapabilities, contentManagement, + http, ...startServices }, } = useKibana(); @@ -212,12 +213,13 @@ const useTableListViewProps = ( savedObjectsTagging, typesService: getTypes(), contentManagement, + http, ...startServices, } ); } }, - [savedObjectsTagging, contentManagement, startServices] + [savedObjectsTagging, contentManagement, http, startServices] ); const contentEditorValidators: OpenContentEditorParams['customValidators'] = useMemo( diff --git a/x-pack/examples/embedded_lens_example/public/app.tsx b/x-pack/examples/embedded_lens_example/public/app.tsx index 200b44bf6eebf..cda86c89b6e95 100644 --- a/x-pack/examples/embedded_lens_example/public/app.tsx +++ b/x-pack/examples/embedded_lens_example/public/app.tsx @@ -28,6 +28,7 @@ import type { } from '@kbn/lens-plugin/public'; import { ActionExecutionContext } from '@kbn/ui-actions-plugin/public'; +import { LENS_ITEM_LATEST_VERSION } from '@kbn/lens-plugin/common/constants'; import type { StartDependencies } from './plugin'; // Generate a Lens state based on some app-specific input parameters. @@ -82,6 +83,7 @@ function getLensAttributes( return { visualizationType: 'lnsXY', title: 'Prefilled from example app', + version: LENS_ITEM_LATEST_VERSION, references: [ { id: dataView.id!, diff --git a/x-pack/examples/testing_embedded_lens/public/app.tsx b/x-pack/examples/testing_embedded_lens/public/app.tsx index 56aaf72623667..54f4c22e44109 100644 --- a/x-pack/examples/testing_embedded_lens/public/app.tsx +++ b/x-pack/examples/testing_embedded_lens/public/app.tsx @@ -22,7 +22,7 @@ import { } from '@elastic/eui'; import type { CoreStart } from '@kbn/core/public'; import useDebounce from 'react-use/lib/useDebounce'; -import { DOCUMENT_FIELD_NAME } from '@kbn/lens-plugin/common/constants'; +import { DOCUMENT_FIELD_NAME, LENS_ITEM_LATEST_VERSION } from '@kbn/lens-plugin/common/constants'; import type { DataView } from '@kbn/data-views-plugin/public'; import type { TypedLensByValueInput, @@ -150,6 +150,7 @@ function getBaseAttributes( const finalDataLayer = dataLayer ?? getDataLayer(finalType, fields[finalType]); return { title: 'Prefilled from example app', + version: LENS_ITEM_LATEST_VERSION, references: [ { id: defaultIndexPattern.id!, diff --git a/x-pack/examples/third_party_lens_navigation_prompt/public/plugin.ts b/x-pack/examples/third_party_lens_navigation_prompt/public/plugin.ts index f38d8d9e3e8bf..2bad7ca615a5e 100644 --- a/x-pack/examples/third_party_lens_navigation_prompt/public/plugin.ts +++ b/x-pack/examples/third_party_lens_navigation_prompt/public/plugin.ts @@ -15,6 +15,7 @@ import { } from '@kbn/lens-plugin/public'; import { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public'; import { TypedLensByValueInput, PersistedIndexPatternLayer } from '@kbn/lens-plugin/public'; +import { LENS_ITEM_LATEST_VERSION } from '@kbn/lens-plugin/common/constants'; import image from './image.png'; export interface SetupDependencies { @@ -55,6 +56,7 @@ function getLensAttributes(defaultDataView: DataView): TypedLensByValueInput['at }; return { + version: LENS_ITEM_LATEST_VERSION, visualizationType: 'lnsDatatable', title: 'Prefilled from example app', references: [ diff --git a/x-pack/examples/third_party_vis_lens_example/public/plugin.ts b/x-pack/examples/third_party_vis_lens_example/public/plugin.ts index a05c76e22c9dc..9aa5ced6c7ec6 100644 --- a/x-pack/examples/third_party_vis_lens_example/public/plugin.ts +++ b/x-pack/examples/third_party_vis_lens_example/public/plugin.ts @@ -12,6 +12,7 @@ import { DataViewsPublicPluginStart, DataView } from '@kbn/data-views-plugin/pub import { LensPublicSetup, LensPublicStart } from '@kbn/lens-plugin/public'; import { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public'; import { TypedLensByValueInput, PersistedIndexPatternLayer } from '@kbn/lens-plugin/public'; +import { LENS_ITEM_LATEST_VERSION } from '@kbn/lens-plugin/common/constants'; import { getRotatingNumberRenderer, rotatingNumberFunction } from './expression'; import { getRotatingNumberVisualization } from './visualization'; import { RotatingNumberState } from '../common/types'; @@ -51,6 +52,7 @@ function getLensAttributes(defaultDataView: DataView): TypedLensByValueInput['at }; return { + version: LENS_ITEM_LATEST_VERSION, visualizationType: 'rotatingNumber', title: 'Prefilled from example app', references: [ diff --git a/x-pack/platform/plugins/shared/lens/common/constants.ts b/x-pack/platform/plugins/shared/lens/common/constants.ts index 91e47eea2b97b..cc826b6bf1c0a 100644 --- a/x-pack/platform/plugins/shared/lens/common/constants.ts +++ b/x-pack/platform/plugins/shared/lens/common/constants.ts @@ -9,6 +9,14 @@ import rison from '@kbn/rison'; import type { RefreshInterval, TimeRange } from '@kbn/data-plugin/common'; import type { Filter } from '@kbn/es-query'; +import { LENS_ITEM_VERSION_V1 } from './content_management/constants'; + +export { + LENS_ITEM_VERSION_V1, + LENS_ITEM_LATEST_VERSION, + LENS_CONTENT_TYPE, +} from './content_management/constants'; + export const PLUGIN_ID = 'lens'; export const APP_ID = PLUGIN_ID; export const DOC_TYPE = 'lens'; @@ -20,6 +28,12 @@ export const LENS_EDIT_BY_VALUE = 'edit_by_value'; export const LENS_ICON = 'lensApp'; export const STAGE_ID = 'production'; +export const LENS_API_CONTENT_MANAGEMENT_VERSION = LENS_ITEM_VERSION_V1; +export const LENS_API_VERSION = '1'; +export const LENS_API_ACCESS = 'internal'; +export const LENS_API_PATH = '/api/lens'; +export const LENS_VIS_API_PATH = `${LENS_API_PATH}/visualizations`; + export const INDEX_PATTERN_TYPE = 'index-pattern'; export const PieChartTypes = { diff --git a/x-pack/platform/plugins/shared/lens/common/content_management/constants.ts b/x-pack/platform/plugins/shared/lens/common/content_management/constants.ts index eecfbceae8325..5c9a99b1484ed 100644 --- a/x-pack/platform/plugins/shared/lens/common/content_management/constants.ts +++ b/x-pack/platform/plugins/shared/lens/common/content_management/constants.ts @@ -5,6 +5,20 @@ * 2.0. */ -export const LATEST_VERSION = 1; +/** + * Lens CM Item Version `v1` + */ +export const LENS_ITEM_VERSION_V1 = 1 as const; +export type LENS_ITEM_VERSION_V1 = typeof LENS_ITEM_VERSION_V1; + +/** + * Latest Lens CM Item Version + */ +export const LENS_ITEM_LATEST_VERSION = LENS_ITEM_VERSION_V1; +export type LENS_ITEM_LATEST_VERSION = typeof LENS_ITEM_LATEST_VERSION; -export const CONTENT_ID = 'lens'; +/** + * Lens CM Item content type + */ +export const LENS_CONTENT_TYPE = 'lens'; +export type LENS_CONTENT_TYPE = typeof LENS_CONTENT_TYPE; diff --git a/x-pack/platform/plugins/shared/lens/common/content_management/index.ts b/x-pack/platform/plugins/shared/lens/common/content_management/index.ts deleted file mode 100644 index f3363af80ab02..0000000000000 --- a/x-pack/platform/plugins/shared/lens/common/content_management/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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 { LensContentType } from './types'; - -export type { - LensSavedObject, - PartialLensSavedObject, - LensSavedObjectAttributes, - LensGetIn, - LensGetOut, - LensCreateIn, - LensCreateOut, - CreateOptions, - LensUpdateIn, - LensUpdateOut, - UpdateOptions, - LensDeleteIn, - LensDeleteOut, - LensSearchIn, - LensSearchOut, - LensSearchQuery, - LensCrudTypes, -} from './latest'; - -export type * as LensV1 from './v1'; diff --git a/x-pack/platform/plugins/shared/lens/common/content_management/v1/index.ts b/x-pack/platform/plugins/shared/lens/common/content_management/v1/index.ts deleted file mode 100644 index 9e07159b8a5f5..0000000000000 --- a/x-pack/platform/plugins/shared/lens/common/content_management/v1/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export type { - LensSavedObject, - PartialLensSavedObject, - LensSavedObjectAttributes, - LensGetIn, - LensGetOut, - LensCreateIn, - LensCreateOut, - CreateOptions, - LensUpdateIn, - LensUpdateOut, - UpdateOptions, - LensDeleteIn, - LensDeleteOut, - LensSearchIn, - LensSearchOut, - LensSearchQuery, - LensCrudTypes, -} from './types'; diff --git a/x-pack/platform/plugins/shared/lens/common/content_management/v1/types.ts b/x-pack/platform/plugins/shared/lens/common/content_management/v1/types.ts deleted file mode 100644 index 17de8624ccb94..0000000000000 --- a/x-pack/platform/plugins/shared/lens/common/content_management/v1/types.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ContentManagementCrudTypes } from '@kbn/content-management-utils'; -import type { Reference } from '@kbn/content-management-utils'; -import type { UpdateIn } from '@kbn/content-management-plugin/common'; - -import type { LensContentType } from '../types'; - -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; - description?: string; - visualizationType: string | null; - state?: 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 }; - -export type LensSavedObject = LensCrudTypes['Item']; -export type PartialLensSavedObject = LensCrudTypes['PartialItem']; - -// ----------- GET -------------- -export type LensGetIn = LensCrudTypes['GetIn']; -export type LensGetOut = LensCrudTypes['GetOut']; - -// ----------- CREATE -------------- -export type LensCreateIn = LensCrudTypes['CreateIn']; -export type LensCreateOut = LensCrudTypes['CreateOut']; - -// ----------- UPDATE -------------- -export type LensUpdateIn = LensCrudTypes['UpdateIn']; -export type LensUpdateOut = LensCrudTypes['UpdateOut']; - -// ----------- DELETE -------------- -export type LensDeleteIn = LensCrudTypes['DeleteIn']; -export type LensDeleteOut = LensCrudTypes['DeleteOut']; - -// ----------- SEARCH -------------- -export type LensSearchIn = LensCrudTypes['SearchIn']; -export type LensSearchOut = LensCrudTypes['SearchOut']; diff --git a/x-pack/platform/plugins/shared/lens/common/transforms/config_builder_stub.ts b/x-pack/platform/plugins/shared/lens/common/transforms/config_builder_stub.ts new file mode 100644 index 0000000000000..a1c79389cd4b3 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/common/transforms/config_builder_stub.ts @@ -0,0 +1,34 @@ +/* + * 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 { LensAPIConfig, LensItem } from '../../server/content_management'; + +export function isNewApiFormat(config: unknown): config is LensAPIConfig { + return (config as LensAPIConfig)?.state?.isNewApiFormat; +} + +export const ConfigBuilderStub = { + /** + * @returns Lens item + */ + in(config: LensAPIConfig): LensItem { + return config; + }, + + /** + * @returns Lens API config + */ + out(item: LensItem): LensAPIConfig { + return { + ...item, + state: { + ...item.state, + isNewApiFormat: true, + }, + }; + }, +}; diff --git a/x-pack/platform/plugins/shared/lens/common/transforms/index.ts b/x-pack/platform/plugins/shared/lens/common/transforms/index.ts new file mode 100644 index 0000000000000..1ff8685ea5ca3 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/common/transforms/index.ts @@ -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 { ConfigBuilderStub } from './config_builder_stub'; +export type * from './types'; diff --git a/x-pack/platform/plugins/shared/lens/common/transforms/transforms.ts b/x-pack/platform/plugins/shared/lens/common/transforms/transforms.ts new file mode 100644 index 0000000000000..6b978f1f1bd4d --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/common/transforms/transforms.ts @@ -0,0 +1,33 @@ +/* + * 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 { EmbeddableTransforms } from '@kbn/embeddable-plugin/common'; + +import type { LensSerializedState } from '../../public'; +import { + LENS_ITEM_VERSION as LENS_ITEM_VERSION_V1, + transformToV1LensItemAttributes, +} from '../content_management/v1'; + +export const lensTransforms = { + transformOut(state: LensSerializedState): LensSerializedState { + // skip by-ref Lens panels + if (!state.attributes) return state; + + const version = state.attributes.version ?? 0; + const newState = { ...state }; + + if (version < LENS_ITEM_VERSION_V1) { + newState.attributes = transformToV1LensItemAttributes({ + ...state.attributes, + description: state.attributes.description ?? '', + }) as LensSerializedState['attributes']; + } + + return newState; + }, +} satisfies EmbeddableTransforms; diff --git a/x-pack/platform/plugins/shared/lens/common/transforms/types.ts b/x-pack/platform/plugins/shared/lens/common/transforms/types.ts new file mode 100644 index 0000000000000..4e453a092ee41 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/common/transforms/types.ts @@ -0,0 +1,19 @@ +/* + * 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 { LensItem as LensItemV1, LensAttributesV0 } from '../content_management/v1'; +import type { LensItem } from '../content_management'; + +/** + * Any of the versioned Lens Item + */ +export type VersionLensItem = LensItem | LensItemV1; + +/** + * Any of the versioned or unversioned Lens Item + */ +export type UnknownLensItem = LensAttributesV0 | VersionLensItem; diff --git a/x-pack/platform/plugins/shared/lens/public/app_plugin/lens_document_equality.test.ts b/x-pack/platform/plugins/shared/lens/public/app_plugin/lens_document_equality.test.ts index 9b7bbb9432da8..f4bc994ad6f7e 100644 --- a/x-pack/platform/plugins/shared/lens/public/app_plugin/lens_document_equality.test.ts +++ b/x-pack/platform/plugins/shared/lens/public/app_plugin/lens_document_equality.test.ts @@ -15,6 +15,7 @@ import { Visualization, VisualizationMap, } from '../types'; +import { LENS_ITEM_LATEST_VERSION } from '../../common/constants'; const visualizationType = 'lnsSomeVis'; @@ -47,6 +48,7 @@ const defaultDoc: LensDocument = { type: 'index-pattern', }, ], + version: LENS_ITEM_LATEST_VERSION, }; describe('lens document equality', () => { diff --git a/x-pack/platform/plugins/shared/lens/public/app_plugin/mounter.tsx b/x-pack/platform/plugins/shared/lens/public/app_plugin/mounter.tsx index a3b3e62ee1c9f..a302d18380fd1 100644 --- a/x-pack/platform/plugins/shared/lens/public/app_plugin/mounter.tsx +++ b/x-pack/platform/plugins/shared/lens/public/app_plugin/mounter.tsx @@ -118,7 +118,7 @@ export async function getLensServices( attributeService, eventAnnotationService, uiActions: startDependencies.uiActions, - lensDocumentService: new LensDocumentService(startDependencies.contentManagement), + lensDocumentService: new LensDocumentService(coreStart.http), presentationUtil: startDependencies.presentationUtil, dataViewEditor: startDependencies.dataViewEditor, dataViewFieldEditor: startDependencies.dataViewFieldEditor, diff --git a/x-pack/platform/plugins/shared/lens/public/app_plugin/shared/edit_on_the_fly/get_edit_lens_configuration.tsx b/x-pack/platform/plugins/shared/lens/public/app_plugin/shared/edit_on_the_fly/get_edit_lens_configuration.tsx index 3564016e1e3dd..efe3a46f125d7 100644 --- a/x-pack/platform/plugins/shared/lens/public/app_plugin/shared/edit_on_the_fly/get_edit_lens_configuration.tsx +++ b/x-pack/platform/plugins/shared/lens/public/app_plugin/shared/edit_on_the_fly/get_edit_lens_configuration.tsx @@ -134,7 +134,7 @@ export async function getEditLensConfiguration( const lensServices = await getLensServices( coreStart, startDependencies, - getLensAttributeService(startDependencies) + getLensAttributeService(coreStart.http) ); return ({ @@ -172,7 +172,7 @@ export async function getEditLensConfiguration( */ const saveByRef = useCallback( async (attrs: LensDocument) => { - const lensDocumentService = new LensDocumentService(lensServices.contentManagement); + const lensDocumentService = new LensDocumentService(lensServices.http); await lensDocumentService.save({ ...attrs, savedObjectId, diff --git a/x-pack/platform/plugins/shared/lens/public/app_plugin/shared/saved_modal_lazy.tsx b/x-pack/platform/plugins/shared/lens/public/app_plugin/shared/saved_modal_lazy.tsx index 8722175e1f19d..97069c16415a1 100644 --- a/x-pack/platform/plugins/shared/lens/public/app_plugin/shared/saved_modal_lazy.tsx +++ b/x-pack/platform/plugins/shared/lens/public/app_plugin/shared/saved_modal_lazy.tsx @@ -45,7 +45,7 @@ export function getSaveModalComponent( const lensServicesT = await getLensServices( coreStart, startDependencies, - getLensAttributeService(startDependencies) + getLensAttributeService(coreStart.http) ); setLensServices(lensServicesT); diff --git a/x-pack/platform/plugins/shared/lens/public/chart_info_api.test.ts b/x-pack/platform/plugins/shared/lens/public/chart_info_api.test.ts index f647e2289c5bf..8e8d8674865ae 100644 --- a/x-pack/platform/plugins/shared/lens/public/chart_info_api.test.ts +++ b/x-pack/platform/plugins/shared/lens/public/chart_info_api.test.ts @@ -9,6 +9,7 @@ import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { createChartInfoApi } from './chart_info_api'; import { LensDocument } from './persistence'; import { DatasourceMap, VisualizationMap } from './types'; +import { LENS_ITEM_LATEST_VERSION } from '../common/constants'; const mockGetVisualizationInfo = jest.fn().mockReturnValue({ layers: [ @@ -70,6 +71,7 @@ describe('createChartInfoApi', () => { query: '', }, references: [], + version: LENS_ITEM_LATEST_VERSION, } as LensDocument; const chartInfo = await chartInfoApi.getChartInfo(vis); diff --git a/x-pack/platform/plugins/shared/lens/public/lens_attribute_service.ts b/x-pack/platform/plugins/shared/lens/public/lens_attribute_service.ts index 4ea62078bbd69..f66e106c7ae43 100644 --- a/x-pack/platform/plugins/shared/lens/public/lens_attribute_service.ts +++ b/x-pack/platform/plugins/shared/lens/public/lens_attribute_service.ts @@ -10,8 +10,8 @@ import { OnSaveProps } from '@kbn/saved-objects-plugin/public'; import { SavedObjectCommon } from '@kbn/saved-objects-finder-plugin/common'; import { noop } from 'lodash'; import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; -import type { LensPluginStartDependencies } from './plugin'; -import type { LensSavedObjectAttributes as LensSavedObjectAttributesWithoutReferences } from '../common/content_management'; +import { HttpStart } from '@kbn/core/public'; +import type { LensAttributes } from '../server/content_management'; import { extract, inject } from '../common/embeddable_factory'; import { LensDocumentService } from './persistence'; import { DOC_TYPE } from '../common/constants'; @@ -32,7 +32,7 @@ export interface LensAttributesService { managed: boolean; }>; saveToLibrary: ( - attributes: LensSavedObjectAttributesWithoutReferences, + attributes: LensAttributes, references: Reference[], savedObjectId?: string ) => Promise; @@ -48,7 +48,7 @@ export interface LensAttributesService { } export const savedObjectToEmbeddableAttributes = ( - savedObject: SavedObjectCommon + savedObject: SavedObjectCommon ): LensSavedObjectAttributes => { return { ...savedObject.attributes, @@ -57,10 +57,8 @@ export const savedObjectToEmbeddableAttributes = ( }; }; -export function getLensAttributeService({ - contentManagement, -}: LensPluginStartDependencies): LensAttributesService { - const lensDocumentService = new LensDocumentService(contentManagement); +export function getLensAttributeService(http: HttpStart): LensAttributesService { + const lensDocumentService = new LensDocumentService(http); return { loadFromLibrary: async ( @@ -70,11 +68,11 @@ export function getLensAttributeService({ sharingSavedObjectProps: SharingSavedObjectProps; managed: boolean; }> => { - const { meta, item } = await lensDocumentService.load(savedObjectId); + const { item, meta } = await lensDocumentService.load(savedObjectId); return { attributes: { - ...item.attributes, - state: item.attributes.state as LensSavedObjectAttributes['state'], + ...item, + state: item.state as LensSavedObjectAttributes['state'], references: item.references, }, sharingSavedObjectProps: { @@ -83,11 +81,11 @@ export function getLensAttributeService({ aliasPurpose: meta.aliasPurpose, sourceId: item.id, }, - managed: Boolean(item.managed), + managed: Boolean(meta.managed), }; }, saveToLibrary: async ( - attributes: LensSavedObjectAttributesWithoutReferences, + attributes: LensAttributes, references: Reference[], savedObjectId?: string ) => { diff --git a/x-pack/platform/plugins/shared/lens/public/mocks/services_mock.tsx b/x-pack/platform/plugins/shared/lens/public/mocks/services_mock.tsx index 72a83c1a906a3..5a6bf737c3477 100644 --- a/x-pack/platform/plugins/shared/lens/public/mocks/services_mock.tsx +++ b/x-pack/platform/plugins/shared/lens/public/mocks/services_mock.tsx @@ -33,6 +33,7 @@ import { getLensInspectorService } from '../lens_inspector_service'; import { LensDocument, LensDocumentService } from '../persistence'; import { LensAttributesService } from '../lens_attribute_service'; import { mockDatasourceStates } from './store_mocks'; +import { LENS_ITEM_LATEST_VERSION } from '../../common/constants'; const startMock = coreMock.createStart(); @@ -47,6 +48,7 @@ export const defaultDoc: LensDocument = { visualization: {}, }, references: [{ type: 'index-pattern', id: '1', name: 'index-pattern-0' }], + version: LENS_ITEM_LATEST_VERSION, }; export const exactMatchDoc = { diff --git a/x-pack/platform/plugins/shared/lens/public/persistence/basic_lens_client.ts b/x-pack/platform/plugins/shared/lens/public/persistence/basic_lens_client.ts new file mode 100644 index 0000000000000..d0a8e15179e94 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/persistence/basic_lens_client.ts @@ -0,0 +1,63 @@ +/* + * 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 { HttpStart } from '@kbn/core/public'; +import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; +import type { + SerializableAttributes, + BasicVisualizationClient, +} from '@kbn/visualizations-plugin/public'; + +import type { LensAttributes } from '../../server/content_management'; +import { LensClient } from './lens_client'; +import { getLensSOFromResponse } from './utils'; + +/** + * This is a wrapper client used only to update basic attributes from the vis plugin + */ +export function getLensBasicClient( + cm: ContentManagementPublicStart, + http: HttpStart +): BasicVisualizationClient<'lens', Attr> { + const lensClient = new LensClient(http); + + return { + get: async (id: string) => { + const result = await lensClient.get(id); + const lensSavedObject = getLensSOFromResponse(result); + + return { + item: { + ...lensSavedObject, + // TODO: Fix this attributes type when config builder changes are applied + attributes: lensSavedObject.attributes as unknown as Attr, + }, + meta: { + outcome: result.meta.outcome, + }, + } satisfies Awaited['get']>>; + }, + + update: async ({ id, options = {}, data = {} }) => { + const result = await lensClient.update( + id, + data as unknown as LensAttributes, + options.references ?? [] + ); + + const lensSavedObject = getLensSOFromResponse(result); + + return { + item: { + ...lensSavedObject, + // TODO fix this attributes type when config builder changes are applied + attributes: lensSavedObject.attributes as unknown as Attr, + }, + } satisfies Awaited['update']>>; + }, + }; +} diff --git a/x-pack/platform/plugins/shared/lens/public/persistence/lens_client.ts b/x-pack/platform/plugins/shared/lens/public/persistence/lens_client.ts index 7517c26c87a10..9de419299a738 100644 --- a/x-pack/platform/plugins/shared/lens/public/persistence/lens_client.ts +++ b/x-pack/platform/plugins/shared/lens/public/persistence/lens_client.ts @@ -5,77 +5,141 @@ * 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 { HttpStart } from '@kbn/core/public'; +import type { Reference } from '@kbn/content-management-utils'; + +import { omit } from 'lodash'; +import { LENS_API_VERSION, LENS_VIS_API_PATH } from '../../common/constants'; +import type { LensAttributes, LensItem } from '../../server/content_management'; +import { ConfigBuilderStub } from '../../common/transforms'; import { - LensCreateIn, - LensCreateOut, - LensDeleteIn, - LensDeleteOut, - LensGetIn, - LensGetOut, - LensSearchIn, - LensSearchOut, - LensSearchQuery, - LensUpdateIn, - LensUpdateOut, -} from '../../common/content_management'; - -export function getLensClient( - cm: ContentManagementPublicStart -): VisualizationClient<'lens', Attr> { - const get = async (id: string) => { - return cm.client.get({ - contentTypeId: DOC_TYPE, - id, + type LensGetResponseBody, + type LensCreateRequestBody, + type LensCreateResponseBody, + type LensUpdateRequestBody, + type LensUpdateResponseBody, + type LensSearchRequestQuery, + type LensSearchResponseBody, +} from '../../server'; + +export class LensClient { + constructor(private http: HttpStart) {} + + // TODO add error handling logic with try/catch + + async get(id: string) { + const { data, meta } = await this.http.get(`${LENS_VIS_API_PATH}/${id}`, { + version: LENS_API_VERSION, }); - }; - const create = async ({ data, options }: Omit) => { - const res = await cm.client.create({ - contentTypeId: DOC_TYPE, - data, + return { + item: ConfigBuilderStub.in(data), + meta, // TODO: see if we still need this meta data + }; + } + + async create( + { description, visualizationType, state, title, version }: LensAttributes, + references: Reference[], + options: LensCreateRequestBody['options'] = {} + ) { + const body: LensCreateRequestBody = { + // TODO: Find a better way to conditionally omit id + data: omit( + ConfigBuilderStub.out({ + id: '', + description, + visualizationType, + state, + title, + version, + references, + }), + 'id' + ), options, + }; + + const { data, meta } = await this.http.post(LENS_VIS_API_PATH, { + body: JSON.stringify(body), + version: LENS_API_VERSION, }); - return res; - }; - - const update = async ({ id, data, options }: Omit) => { - const res = await cm.client.update({ - contentTypeId: DOC_TYPE, - id, - data, + + return { + item: ConfigBuilderStub.in(data), + meta, + }; + } + + async update( + id: string, + { description, visualizationType, state, title, version }: LensAttributes, + references: Reference[], + options: LensUpdateRequestBody['options'] = {} + ) { + const body: LensUpdateRequestBody = { + // TODO: Find a better way to conditionally omit id + data: omit( + ConfigBuilderStub.out({ + id: '', + description, + visualizationType, + state, + title, + version, + references, + }), + 'id' + ), options, - }); - return res; - }; + }; + + const { data, meta } = await this.http.put( + `${LENS_VIS_API_PATH}/${id}`, + { + body: JSON.stringify(body), + version: LENS_API_VERSION, + } + ); + + return { + item: ConfigBuilderStub.in(data), + meta, + }; + } - const deleteLens = async (id: string) => { - const res = await cm.client.delete({ - contentTypeId: DOC_TYPE, - id, + async delete(id: string): Promise<{ success: boolean }> { + const response = await this.http.delete(`${LENS_VIS_API_PATH}/${id}`, { + asResponse: true, + version: LENS_API_VERSION, }); - return res; - }; + const success = response.response?.ok ?? false; - const search = async (query: SearchQuery = {}, options?: LensSearchQuery) => { - return cm.client.search({ - contentTypeId: DOC_TYPE, - query, - options, + return { success }; // TODO remove if not used + } + + async search({ + query, + page, + perPage, + fields, + searchFields, + }: LensSearchRequestQuery): Promise { + // TODO add all CM search options to query + const result = await this.http.get(LENS_VIS_API_PATH, { + query: { + query, + page, + perPage, + fields, + searchFields, + } satisfies LensSearchRequestQuery, + version: LENS_API_VERSION, }); - }; - - return { - get, - create, - update, - delete: deleteLens, - search, - } as unknown as VisualizationClient<'lens', Attr>; + + return result.data.map(({ data }) => ({ + ...data, + attributes: ConfigBuilderStub.in(data), + })); + } } diff --git a/x-pack/platform/plugins/shared/lens/public/persistence/lens_document_service.test.ts b/x-pack/platform/plugins/shared/lens/public/persistence/lens_document_service.test.ts index 5d7353fab9c18..a6ec89a1c5d1c 100644 --- a/x-pack/platform/plugins/shared/lens/public/persistence/lens_document_service.test.ts +++ b/x-pack/platform/plugins/shared/lens/public/persistence/lens_document_service.test.ts @@ -5,30 +5,40 @@ * 2.0. */ -import { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; +import { coreMock } from '@kbn/core/public/mocks'; + +import { LENS_ITEM_LATEST_VERSION } from '../../common/constants'; +import { LensClient } from './lens_client'; import { LensDocumentService } from './lens_document_service'; +import { LensDocument } from './types'; -describe('LensStore', () => { - function testStore(testId?: string) { - const client = { - create: jest.fn(() => Promise.resolve({ item: { id: testId || 'testid' } })), - update: jest.fn(() => Promise.resolve({ item: { id: testId || 'testid' } })), - get: jest.fn(), - }; +jest.mock('./lens_client', () => { + const mockClient = { + create: jest.fn(), + get: jest.fn(), + update: jest.fn(), + search: jest.fn(), + }; + return { + LensClient: jest.fn(() => mockClient), + }; +}); + +const startMock = coreMock.createStart(); +describe('LensStore', () => { + function testStore() { + const httpMock = startMock.http; return { - client, - service: new LensDocumentService({ - client, - registry: jest.fn(), - } as unknown as ContentManagementPublicStart), + client: new LensClient(httpMock), // mock client + service: new LensDocumentService(httpMock), }; } describe('save', () => { - test('creates and returns a visualization document', async () => { - const { client, service } = testStore('FOO'); - const doc = await service.save({ + test('creates and returns a Lens document', async () => { + const { client, service } = testStore(); + const docToSave: LensDocument = { title: 'Hello', description: 'My doc', visualizationType: 'bar', @@ -41,50 +51,30 @@ describe('LensStore', () => { query: { query: '', language: 'lucene' }, filters: [], }, - }); + version: LENS_ITEM_LATEST_VERSION, + }; + + jest.mocked(client.create).mockImplementation(async (item, references) => ({ + item: { id: 'new-id', ...item, references, extraProp: 'test' }, + meta: { type: 'lens' }, + })); + const doc = await service.save(docToSave); expect(doc).toEqual({ - savedObjectId: 'FOO', - title: 'Hello', - description: 'My doc', - visualizationType: 'bar', - references: [], - state: { - datasourceStates: { - indexpattern: { type: 'index_pattern', indexPattern: '.kibana_test' }, - }, - visualization: { x: 'foo', y: 'baz' }, - query: { query: '', language: 'lucene' }, - filters: [], - }, + savedObjectId: 'new-id', + ...docToSave, + extraProp: 'test', // should replace doc with response properties }); expect(client.create).toHaveBeenCalledTimes(1); - expect(client.create).toHaveBeenCalledWith({ - contentTypeId: 'lens', - data: { - title: 'Hello', - description: 'My doc', - visualizationType: 'bar', - state: { - datasourceStates: { - indexpattern: { type: 'index_pattern', indexPattern: '.kibana_test' }, - }, - visualization: { x: 'foo', y: 'baz' }, - query: { query: '', language: 'lucene' }, - filters: [], - }, - }, - options: { - references: [], - }, - }); + const { references, ...attributes } = docToSave; + expect(client.create).toHaveBeenCalledWith(attributes, references); }); - test('updates and returns a visualization document', async () => { - const { client, service } = testStore('Gandalf'); - const doc = await service.save({ - savedObjectId: 'Gandalf', + test('updates and returns a Lens document', async () => { + const { client, service } = testStore(); + const docToUpdate: LensDocument = { + savedObjectId: 'update-id', title: 'Even the very wise cannot see all ends.', visualizationType: 'line', references: [], @@ -94,62 +84,30 @@ describe('LensStore', () => { query: { query: '', language: 'lucene' }, filters: [], }, - }); + version: LENS_ITEM_LATEST_VERSION, + }; - expect(doc).toEqual({ - savedObjectId: 'Gandalf', - title: 'Even the very wise cannot see all ends.', - visualizationType: 'line', - references: [], - state: { - datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: 'lotr' } }, - visualization: { gear: ['staff', 'pointy hat'] }, - query: { query: '', language: 'lucene' }, - filters: [], - }, - }); + jest.mocked(client.update).mockImplementation(async (id, item, references) => ({ + item: { id, ...item, references, extraProp: 'test' }, + meta: { type: 'lens' }, + })); + + const doc = await service.save(docToUpdate); + // should replace doc with response properties + expect(doc).toEqual({ ...docToUpdate, extraProp: 'test' }); expect(client.update).toHaveBeenCalledTimes(1); - expect(client.update.mock.calls).toEqual([ - [ - { - contentTypeId: 'lens', - id: 'Gandalf', - data: { - title: 'Even the very wise cannot see all ends.', - visualizationType: 'line', - state: { - datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: 'lotr' } }, - visualization: { gear: ['staff', 'pointy hat'] }, - query: { query: '', language: 'lucene' }, - filters: [], - }, - }, - options: { references: [] }, - }, - ], - ]); + const { savedObjectId, references, ...attributes } = docToUpdate; + expect(client.update).toHaveBeenCalledWith(savedObjectId, attributes, references); }); }); describe('load', () => { test('throws if an error is returned', async () => { const { client, service } = testStore(); - client.get = jest.fn(async () => ({ - meta: { outcome: 'exactMatch' }, - item: { - id: 'Paul', - type: 'lens', - attributes: { - title: 'Hope clouds observation.', - visualizationType: 'dune', - state: '{ "datasource": { "giantWorms": true } }', - }, - error: new Error('shoot dang!'), - }, - })); + jest.mocked(client.get).mockRejectedValue(new Error('shoot dang!')); - await expect(service.load('Paul')).rejects.toThrow('shoot dang!'); + await expect(service.load('123')).rejects.toThrow('shoot dang!'); }); }); }); diff --git a/x-pack/platform/plugins/shared/lens/public/persistence/lens_document_service.ts b/x-pack/platform/plugins/shared/lens/public/persistence/lens_document_service.ts index e0b12e56b159d..2e98339606fd2 100644 --- a/x-pack/platform/plugins/shared/lens/public/persistence/lens_document_service.ts +++ b/x-pack/platform/plugins/shared/lens/public/persistence/lens_document_service.ts @@ -5,14 +5,12 @@ * 2.0. */ -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 { HttpStart } from '@kbn/core/public'; -import type { LensSavedObjectAttributes, LensSearchQuery } from '../../common/content_management'; -import { getLensClient } from './lens_client'; +import { LensClient } from './lens_client'; import { SAVE_DUPLICATE_REJECTED } from './constants'; import { LensDocument } from './types'; +import type { LensSearchRequestQuery } from '../../server'; export interface CheckDuplicateTitleOptions { id?: string; @@ -33,46 +31,40 @@ interface ILensDocumentService { } export class LensDocumentService implements ILensDocumentService { - private client: VisualizationClient<'lens', LensSavedObjectAttributes>; + private client: LensClient; - constructor(cm: ContentManagementPublicStart) { - this.client = getLensClient(cm); + constructor(http: HttpStart) { + this.client = new LensClient(http); } save = async (vis: LensDocument) => { - const { savedObjectId, type, references, ...attributes } = vis; + // TODO: Flatten LenDocument types to align with new LensItem, for now just keep it. + const { savedObjectId, references, ...attributes } = vis; if (savedObjectId) { - const result = await this.client.update({ - id: savedObjectId, - data: attributes, - options: { - references, - }, - }); - return { ...vis, savedObjectId: result.item.id }; + const { + item: { id, ...newVis }, + } = await this.client.update(savedObjectId, attributes, references); + return { ...newVis, savedObjectId: id }; } - const result = await this.client.create({ - data: attributes, - options: { - references, - }, - }); - return { ...vis, savedObjectId: result.item.id }; + + const { + item: { id: newId, ...newVis }, + } = await this.client.create(attributes, references); + + return { ...newVis, savedObjectId: newId }; }; async load(savedObjectId: string) { - const resolveResult = await this.client.get(savedObjectId); - - if (resolveResult.item.error) { - throw resolveResult.item.error; + try { + return await this.client.get(savedObjectId); + } catch (error) { + throw error; } - - return resolveResult; } - async search(query: SearchQuery, options: LensSearchQuery) { - const result = await this.client.search(query, options); + async search(options: LensSearchRequestQuery) { + const result = await this.client.search(options); return result; } @@ -104,18 +96,13 @@ export class LensDocumentService implements ILensDocumentService { // Elasticsearch 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 this.search( - { - limit: 10, - text: `"${title}"`, - }, - { - searchFields: ['title'], - } - ); - const duplicate = response.hits.find( - (obj) => obj.attributes.title.toLowerCase() === title.toLowerCase() - ); + const response = await this.search({ + perPage: 10, + query: `"${title}"`, + searchFields: ['title'], + }); + + const duplicate = response.find((item) => item.title.toLowerCase() === title.toLowerCase()); if (!duplicate || duplicate.id === id) { return true; diff --git a/x-pack/platform/plugins/shared/lens/public/persistence/types.ts b/x-pack/platform/plugins/shared/lens/public/persistence/types.ts index 20b9073e4d230..5b3a0ef4f8105 100644 --- a/x-pack/platform/plugins/shared/lens/public/persistence/types.ts +++ b/x-pack/platform/plugins/shared/lens/public/persistence/types.ts @@ -8,13 +8,14 @@ import type { AggregateQuery, Filter, Query } from '@kbn/es-query'; import type { Reference } from '@kbn/content-management-utils'; import type { DataViewSpec } from '@kbn/data-views-plugin/public'; +import type { LENS_ITEM_LATEST_VERSION } from '../../common/constants'; export interface LensDocument { savedObjectId?: string; type?: string; - visualizationType: string | null; title: string; description?: string; + visualizationType: string | null; state: { datasourceStates: Record; visualization: unknown; @@ -29,4 +30,5 @@ export interface LensDocument { internalReferences?: Reference[]; }; references: Reference[]; + version?: LENS_ITEM_LATEST_VERSION; } diff --git a/x-pack/platform/plugins/shared/lens/public/persistence/utils.ts b/x-pack/platform/plugins/shared/lens/public/persistence/utils.ts new file mode 100644 index 0000000000000..baf82d5253cfc --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/persistence/utils.ts @@ -0,0 +1,34 @@ +/* + * 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 { LensItem, LensItemMeta, LensSavedObject } from '../../server/content_management'; + +/** + * Converts Lens Response Item to Lens Saved Object + * + * This is only needed as the visualize plugin assumes we only use CM. + */ +export function getLensSOFromResponse({ + item: { id, references, ...attributes }, + meta: { type, createdAt, updatedAt, createdBy, updatedBy, managed, originId }, +}: { + item: LensItem; + meta: LensItemMeta; +}): LensSavedObject { + return { + id, + references, + attributes, + type, + createdAt, + updatedAt, + createdBy, + updatedBy, + managed, + originId, + }; +} diff --git a/x-pack/platform/plugins/shared/lens/public/plugin.ts b/x-pack/platform/plugins/shared/lens/public/plugin.ts index 1168f938d4b6a..79647fc678f12 100644 --- a/x-pack/platform/plugins/shared/lens/public/plugin.ts +++ b/x-pack/platform/plugins/shared/lens/public/plugin.ts @@ -135,11 +135,8 @@ import { ChartInfoApi } from './chart_info_api'; import { type LensAppLocator, LensAppLocatorDefinition } from '../common/locator/locator'; import { downloadCsvLensShareProvider } from './app_plugin/csv_download_provider/csv_download_provider'; import type { LensDocument } from './persistence'; -import { - CONTENT_ID, - LATEST_VERSION, - LensSavedObjectAttributes, -} from '../common/content_management'; +import { LENS_CONTENT_TYPE, LENS_ITEM_LATEST_VERSION } from '../common/constants'; +import type { LensAttributes } from '../server/content_management'; import type { EditLensConfigurationProps } from './app_plugin/shared/edit_on_the_fly/get_edit_lens_configuration'; import { LensRenderer } from './react_embeddable/renderer/lens_custom_renderer_component'; import { @@ -363,7 +360,7 @@ export class LensPlugin { return { ...plugins, - attributeService: getLensAttributeService(plugins), + attributeService: getLensAttributeService(coreStart.http), capabilities: coreStart.application.capabilities, coreHttp: coreStart.http, coreStart, @@ -397,8 +394,13 @@ export class LensPlugin { return createLensEmbeddableFactory(deps); }); + embeddable.registerTransforms(LENS_EMBEDDABLE_TYPE, async () => { + const { lensTransforms } = await import('../common/transforms/transforms'); + return lensTransforms; + }); + // Let Dashboard know about the Lens panel type - embeddable.registerAddFromLibraryType({ + embeddable.registerAddFromLibraryType({ onAdd: async (container, savedObject) => { const { SAVED_OBJECT_REF_NAME } = await import('@kbn/presentation-publishing'); container.addNewPanel( @@ -460,9 +462,9 @@ export class LensPlugin { ); contentManagement.registry.register({ - id: CONTENT_ID, + id: LENS_CONTENT_TYPE, version: { - latest: LATEST_VERSION, + latest: LENS_ITEM_LATEST_VERSION, }, name: i18n.translate('xpack.lens.content.name', { defaultMessage: 'Lens Visualization', @@ -516,7 +518,7 @@ export class LensPlugin { const frameStart = this.editorFrameService!.start(coreStart, deps); return mountApp(core, params, { createEditorFrame: frameStart.createInstance, - attributeService: getLensAttributeService(deps), + attributeService: getLensAttributeService(coreStart.http), topNavMenuEntryGenerators: this.topNavMenuEntries, locator: this.locator, }); diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/esql.ts b/x-pack/platform/plugins/shared/lens/public/react_embeddable/esql.ts index 1015c58a43c6f..c75ff358fbf5e 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/esql.ts +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/esql.ts @@ -13,7 +13,12 @@ import { } from '@kbn/esql-utils'; import { getLensAttributesFromSuggestion } from '@kbn/visualization-utils'; import { isESQLModeEnabled } from './initializers/utils'; -import type { LensEmbeddableStartServices } from './types'; +import type { LensEmbeddableStartServices, LensSerializedState } from './types'; + +export type ESQLStartServices = Pick< + LensEmbeddableStartServices, + 'dataViews' | 'data' | 'visualizationMap' | 'datasourceMap' | 'uiSettings' +>; export async function loadESQLAttributes({ dataViews, @@ -21,10 +26,7 @@ export async function loadESQLAttributes({ visualizationMap, datasourceMap, uiSettings, -}: Pick< - LensEmbeddableStartServices, - 'dataViews' | 'data' | 'visualizationMap' | 'datasourceMap' | 'uiSettings' ->) { +}: ESQLStartServices): Promise { // Early exit if ESQL is not supported if (!isESQLModeEnabled({ uiSettings })) { return; diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/helper.ts b/x-pack/platform/plugins/shared/lens/public/react_embeddable/helper.ts index 54e781f681e16..784a44843f2bf 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/helper.ts +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/helper.ts @@ -25,11 +25,12 @@ import type { LensSerializedState, StructuredDatasourceStates, } from './types'; -import { loadESQLAttributes } from './esql'; +import { ESQLStartServices, loadESQLAttributes } from './esql'; import { DatasourceStates, GeneralDatasourceStates } from '../state_management'; import { FormBasedPersistedState } from '../datasources/form_based/types'; import { TextBasedPersistedState } from '../datasources/form_based/esql_layer/types'; import { DOC_TYPE } from '../../common/constants'; +import { LENS_ITEM_LATEST_VERSION } from '../../common/constants'; export function createEmptyLensState( visualizationType: null | string = null, @@ -41,6 +42,7 @@ export function createEmptyLensState( const isTextBased = query && isOfAggregateQueryType(query); return { attributes: { + version: LENS_ITEM_LATEST_VERSION, title: title ?? '', description: description ?? '', visualizationType, @@ -56,29 +58,23 @@ export function createEmptyLensState( }; } -// Shared logic to ensure the attributes are correctly loaded -// Make sure to inject references from the container down to the runtime state -// this ensure migrations/copy to spaces works correctly +/** + * Shared logic to ensure the attributes are correctly loaded + * Make sure to inject references from the container down to the runtime state + * this ensure migrations/copy to spaces works correctly + **/ export async function deserializeState( { attributeService, ...services - }: Pick< - LensEmbeddableStartServices, - | 'attributeService' - | 'data' - | 'dataViews' - | 'data' - | 'visualizationMap' - | 'datasourceMap' - | 'uiSettings' - >, + }: Pick & ESQLStartServices, rawState: LensSerializedState, references?: Reference[] -) { +): Promise { const fallbackAttributes = createEmptyLensState().attributes; const savedObjectRef = findSavedObjectRef(DOC_TYPE, references); const savedObjectId = savedObjectRef?.id ?? rawState.savedObjectId; + if (savedObjectId) { try { const { attributes, managed, sharingSavedObjectProps } = @@ -89,11 +85,13 @@ export async function deserializeState( return { ...rawState, attributes: fallbackAttributes }; } } + // Inject applied only to by-value SOs const newState = attributeService.injectReferences( ('attributes' in rawState ? rawState : { attributes: rawState }) as LensRuntimeState, references?.length ? references : undefined ); + if (newState.isNewPanel) { try { const newAttributes = await loadESQLAttributes(services); @@ -107,6 +105,7 @@ export async function deserializeState( return { ...newState, attributes: fallbackAttributes }; } } + return newState; } diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/inline_editing/state_management.tsx b/x-pack/platform/plugins/shared/lens/public/react_embeddable/inline_editing/state_management.tsx index 2a4f1f48fd0dc..74334cd66b1d6 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/inline_editing/state_management.tsx +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/inline_editing/state_management.tsx @@ -47,9 +47,10 @@ export function getStateManagementForInlineEditing( viz.state.adHocDataViews || {}, { visualizationMap, datasourceMap, extractFilterReferences } ); - const newDoc = { + const newDoc: TypedLensSerializedState['attributes'] = { ...viz, ...newViz, + visualizationType: newViz?.visualizationType ?? viz.visualizationType, }; if (newDoc.state) { diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/renderer/lens_custom_renderer_component.tsx b/x-pack/platform/plugins/shared/lens/public/react_embeddable/renderer/lens_custom_renderer_component.tsx index 885fda4c4aea2..a738d78ded647 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/renderer/lens_custom_renderer_component.tsx +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/renderer/lens_custom_renderer_component.tsx @@ -140,6 +140,7 @@ export function LensRenderer({ type={LENS_EMBEDDABLE_TYPE} maybeId={id} + // TODO type this ParentApi, all these are untyped and some unused getParentApi={() => ({ // forward the Lens components to the embeddable ...props, diff --git a/x-pack/platform/plugins/shared/lens/public/state_management/init_middleware/load_initial.ts b/x-pack/platform/plugins/shared/lens/public/state_management/init_middleware/load_initial.ts index 868b94cd9de89..4547866d8d388 100644 --- a/x-pack/platform/plugins/shared/lens/public/state_management/init_middleware/load_initial.ts +++ b/x-pack/platform/plugins/shared/lens/public/state_management/init_middleware/load_initial.ts @@ -8,7 +8,14 @@ import { MiddlewareAPI } from '@reduxjs/toolkit'; import { i18n } from '@kbn/i18n'; import { History } from 'history'; -import { setState, initExisting, initEmpty, LensStoreDeps, LensAppState } from '..'; +import { + setState, + initExisting, + initEmpty, + LensStoreDeps, + LensAppState, + VisualizationState, +} from '..'; import { type InitialAppState, disableAutoApply, getPreloadedState } from '../lens_slice'; import { SharingSavedObjectProps } from '../../types'; import { getInitialDatasourceId, getInitialDataViewsObject } from '../../utils'; @@ -252,7 +259,7 @@ async function loadFromSavedObject( data.query.filterManager.setAppFilters(filters); } - const docVisualizationState = { + const docVisualizationState: VisualizationState = { activeId: doc.visualizationType, state: doc.state.visualization, }; diff --git a/x-pack/platform/plugins/shared/lens/public/state_management/shared_logic.ts b/x-pack/platform/plugins/shared/lens/public/state_management/shared_logic.ts index 2e9ac200879fa..999816a3dac09 100644 --- a/x-pack/platform/plugins/shared/lens/public/state_management/shared_logic.ts +++ b/x-pack/platform/plugins/shared/lens/public/state_management/shared_logic.ts @@ -14,6 +14,7 @@ import { DOC_TYPE, INDEX_PATTERN_TYPE } from '../../common/constants'; import { VisualizationState, DatasourceStates } from '.'; import { LensDocument } from '../persistence'; import { DatasourceMap, VisualizationMap, Datasource } from '../types'; +import { LENS_ITEM_LATEST_VERSION } from '../../common/constants'; // This piece of logic is shared between the main editor code base and the inline editor one within the embeddable export function mergeToNewDoc( @@ -33,7 +34,7 @@ export function mergeToNewDoc( visualizationMap: VisualizationMap; extractFilterReferences: FilterManager['extract']; } -) { +): LensDocument | undefined { const activeVisualization = visualization.state && visualization.activeId ? visualizationMap[visualization.activeId] : null; const activeDatasource = @@ -124,7 +125,8 @@ export function mergeToNewDoc( internalReferences, adHocDataViews: persistableAdHocDataViews, }, - }; + version: LENS_ITEM_LATEST_VERSION, + } satisfies LensDocument; } export function getActiveDataFromDatatable( diff --git a/x-pack/platform/plugins/shared/lens/public/trigger_actions/open_lens_config/in_app_embeddable_edit/in_app_embeddable_edit_action.test.tsx b/x-pack/platform/plugins/shared/lens/public/trigger_actions/open_lens_config/in_app_embeddable_edit/in_app_embeddable_edit_action.test.tsx index b00cf33bcd911..733bd685191c8 100644 --- a/x-pack/platform/plugins/shared/lens/public/trigger_actions/open_lens_config/in_app_embeddable_edit/in_app_embeddable_edit_action.test.tsx +++ b/x-pack/platform/plugins/shared/lens/public/trigger_actions/open_lens_config/in_app_embeddable_edit/in_app_embeddable_edit_action.test.tsx @@ -11,6 +11,7 @@ import { createMockStartDependencies } from '../../../editor_frame_service/mocks import { EditLensEmbeddableAction } from './in_app_embeddable_edit_action'; import { TypedLensSerializedState } from '../../../react_embeddable/types'; import { BehaviorSubject } from 'rxjs'; +import { LENS_ITEM_LATEST_VERSION } from '../../../../common/constants'; describe('inapp editing of Lens embeddable', () => { const core = coreMock.createStart(); @@ -33,6 +34,7 @@ describe('inapp editing of Lens embeddable', () => { visualization: {}, }, references: [{ type: 'index-pattern', id: '1', name: 'index-pattern-0' }], + version: LENS_ITEM_LATEST_VERSION, } as TypedLensSerializedState['attributes']; it('is incompatible for ESQL charts and if ui setting for ES|QL is off', async () => { const inAppEditAction = new EditLensEmbeddableAction(core, { diff --git a/x-pack/platform/plugins/shared/lens/public/vis_type_alias.ts b/x-pack/platform/plugins/shared/lens/public/vis_type_alias.ts index cf6e11c84ac7a..8f986c3770118 100644 --- a/x-pack/platform/plugins/shared/lens/public/vis_type_alias.ts +++ b/x-pack/platform/plugins/shared/lens/public/vis_type_alias.ts @@ -15,7 +15,7 @@ import { LENS_ICON, STAGE_ID, } from '../common/constants'; -import { getLensClient } from './persistence/lens_client'; +import { getLensBasicClient } from './persistence/basic_lens_client'; export const lensVisTypeAlias: VisTypeAlias = { alias: { @@ -39,7 +39,7 @@ export const lensVisTypeAlias: VisTypeAlias = { docTypes: [LENS_EMBEDDABLE_TYPE], searchFields: ['title^3'], clientOptions: { update: { overwrite: true } }, - client: getLensClient, + client: getLensBasicClient, toListItem(savedObject) { const { id, type, updatedAt, attributes, managed } = savedObject; const { title, description } = attributes; diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/xy/persistence.ts b/x-pack/platform/plugins/shared/lens/public/visualizations/xy/persistence.ts index 830cb38f22866..fda5ab3700548 100644 --- a/x-pack/platform/plugins/shared/lens/public/visualizations/xy/persistence.ts +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/xy/persistence.ts @@ -23,16 +23,11 @@ import { isAnnotationsLayer, isByReferenceAnnotationsLayer } from './visualizati import { nonNullable } from '../../utils'; import { annotationLayerHasUnsavedChanges } from './state_helpers'; -export const isPersistedByReferenceAnnotationsLayer = ( +const isPersistedByReferenceAnnotationsLayer = ( layer: XYPersistedAnnotationLayerConfig ): layer is XYPersistedByReferenceAnnotationLayerConfig => isPersistedAnnotationsLayer(layer) && layer.persistanceType === 'byReference'; -export const isPersistedLinkedByValueAnnotationsLayer = ( - layer: XYPersistedAnnotationLayerConfig -): layer is XYPersistedLinkedByValueAnnotationLayerConfig => - isPersistedAnnotationsLayer(layer) && layer.persistanceType === 'linked'; - /** * This is the type of hybrid layer we get after the user has made a change to * a by-reference annotation layer and saved the visualization without @@ -111,9 +106,10 @@ export function convertToPersistable(state: XYState) { persistableLayers.push({ ...persistableLayer, persistanceType: 'byValue' }); return; } - /** + + /* * by reference annotation layer needs to be handled carefully - **/ + */ // make this id stable so that it won't retrigger all the time a change diff const referenceName = `ref-${layer.layerId}`; @@ -158,7 +154,11 @@ export function convertToPersistable(state: XYState) { name: getLayerReferenceName(layer.layerId), }); }); - return { references, state: { ...persistableState, layers: persistableLayers } }; + + return { + references, + state: { ...persistableState, layers: persistableLayers }, + }; } export const isPersistedAnnotationsLayer = ( diff --git a/x-pack/platform/plugins/shared/lens/server/api/routes/index.ts b/x-pack/platform/plugins/shared/lens/server/api/routes/index.ts index 9515f4da9df0b..8fca254df9094 100644 --- a/x-pack/platform/plugins/shared/lens/server/api/routes/index.ts +++ b/x-pack/platform/plugins/shared/lens/server/api/routes/index.ts @@ -11,3 +11,5 @@ import { registerLensVisualizationsAPIRoutes } from './visualizations'; export function registerLensAPIRoutes(args: RegisterAPIRoutesArgs) { registerLensVisualizationsAPIRoutes(args); } + +export * from './schema'; diff --git a/x-pack/platform/plugins/shared/lens/server/api/routes/schema.ts b/x-pack/platform/plugins/shared/lens/server/api/routes/schema.ts new file mode 100644 index 0000000000000..7091bb3f6b898 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/api/routes/schema.ts @@ -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 './visualizations/schema'; diff --git a/x-pack/platform/plugins/shared/lens/server/api/routes/types.ts b/x-pack/platform/plugins/shared/lens/server/api/routes/types.ts new file mode 100644 index 0000000000000..8b6ccbffe8615 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/api/routes/types.ts @@ -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 * from './visualizations/types'; diff --git a/x-pack/platform/plugins/shared/lens/server/api/routes/utils.ts b/x-pack/platform/plugins/shared/lens/server/api/routes/utils.ts new file mode 100644 index 0000000000000..ba060788f4945 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/api/routes/utils.ts @@ -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 type { LensResponseItem, LensSavedObject } from '../../content_management'; +import { ConfigBuilderStub } from '../../../common/transforms'; + +/** + * Converts Lens Saved Object to Lens Response Item + */ +export function getLensResponseItem({ + // Data params + id, + references, + attributes, + + // Meta params + type, + createdAt, + updatedAt, + createdBy, + updatedBy, + managed, + originId, +}: LensSavedObject): LensResponseItem { + return { + data: ConfigBuilderStub.out({ + ...attributes, + id, + references, + }), + meta: { + type, + createdAt, + updatedAt, + createdBy, + updatedBy, + managed, + originId, + }, + }; +} diff --git a/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/create.ts b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/create.ts index e1fc00bf06bab..232626b944f02 100644 --- a/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/create.ts +++ b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/create.ts @@ -5,30 +5,31 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; - +import { omit } from 'lodash'; import { boomify, isBoom } from '@hapi/boom'; -import { CONTENT_ID, type LensSavedObject } from '../../../../common/content_management'; -import { - PUBLIC_API_PATH, - PUBLIC_API_VERSION, - PUBLIC_API_CONTENT_MANAGEMENT_VERSION, - PUBLIC_API_ACCESS, -} from '../../constants'; + +import { TypeOf } from '@kbn/config-schema'; + import { - lensAttributesSchema, - lensCreateOptionsSchema, - lensSavedObjectSchema, -} from '../../../content_management/v1'; + LENS_VIS_API_PATH, + LENS_API_VERSION, + LENS_API_ACCESS, + LENS_CONTENT_TYPE, +} from '../../../../common/constants'; +import type { LensCreateIn, LensSavedObject } from '../../../content_management'; import { RegisterAPIRouteFn } from '../../types'; +import { ConfigBuilderStub } from '../../../../common/transforms'; +import { lensCreateRequestBodySchema, lensCreateResponseBodySchema } from './schema'; +import { getLensResponseItem } from '../utils'; +import { isNewApiFormat } from '../../../../common/transforms/config_builder_stub'; export const registerLensVisualizationsCreateAPIRoute: RegisterAPIRouteFn = ( router, { contentManagement } ) => { const createRoute = router.post({ - path: `${PUBLIC_API_PATH}/visualizations`, - access: PUBLIC_API_ACCESS, + path: LENS_VIS_API_PATH, + access: LENS_API_ACCESS, enableQueryVersion: true, summary: 'Create Lens visualization', description: 'Create a new Lens visualization.', @@ -48,17 +49,14 @@ export const registerLensVisualizationsCreateAPIRoute: RegisterAPIRouteFn = ( createRoute.addVersion( { - version: PUBLIC_API_VERSION, + version: LENS_API_VERSION, validate: { request: { - body: schema.object({ - options: lensCreateOptionsSchema, - data: lensAttributesSchema, - }), + body: lensCreateRequestBodySchema, }, response: { 201: { - body: () => lensSavedObjectSchema, + body: () => lensCreateResponseBodySchema, description: 'Created', }, 400: { @@ -77,14 +75,34 @@ export const registerLensVisualizationsCreateAPIRoute: RegisterAPIRouteFn = ( }, }, async (ctx, req, res) => { - let result; - const { data, options } = req.body; + // TODO fix IContentClient to type this client based on the actual const client = contentManagement.contentClient .getForRequest({ request: req, requestHandlerContext: ctx }) - .for(CONTENT_ID, PUBLIC_API_CONTENT_MANAGEMENT_VERSION); + .for(LENS_CONTENT_TYPE); + + const { references, ...lensItem } = isNewApiFormat(req.body.data) + ? // TODO: Find a better way to conditionally omit id + omit(ConfigBuilderStub.in(req.body.data), 'id') + : // For now we need to be able to create old SO, this may be moved to the config builder + ({ + ...req.body.data, + description: req.body.data.description ?? undefined, + visualizationType: req.body.data.visualizationType ?? null, + } satisfies LensCreateIn['data']); try { - ({ result } = await client.create(data, options)); + // Note: these types are to enforce loose param typings of client methods + const data: LensCreateIn['data'] = lensItem; + const options: LensCreateIn['options'] = { ...req.body.options, references }; + const { result } = await client.create(data, options); + + if (result.item.error) { + throw result.item.error; + } + + return res.created>({ + body: getLensResponseItem(result.item), + }); } catch (error) { if (isBoom(error) && error.output.statusCode === 403) { return res.forbidden(); @@ -92,8 +110,6 @@ export const registerLensVisualizationsCreateAPIRoute: RegisterAPIRouteFn = ( return boomify(error); // forward unknown error } - - return res.created({ body: result.item }); } ); }; diff --git a/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/delete.ts b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/delete.ts index b8a596fac0255..a1ea770fa0cbc 100644 --- a/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/delete.ts +++ b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/delete.ts @@ -5,25 +5,24 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; - import { boomify, isBoom } from '@hapi/boom'; -import { CONTENT_ID, type LensSavedObject } from '../../../../common/content_management'; import { - PUBLIC_API_PATH, - PUBLIC_API_VERSION, - PUBLIC_API_CONTENT_MANAGEMENT_VERSION, - PUBLIC_API_ACCESS, -} from '../../constants'; + LENS_VIS_API_PATH, + LENS_API_VERSION, + LENS_API_ACCESS, + LENS_CONTENT_TYPE, +} from '../../../../common/constants'; +import type { LensSavedObject } from '../../../content_management'; import { RegisterAPIRouteFn } from '../../types'; +import { lensDeleteRequestParamsSchema } from './schema'; export const registerLensVisualizationsDeleteAPIRoute: RegisterAPIRouteFn = ( router, { contentManagement } ) => { const deleteRoute = router.delete({ - path: `${PUBLIC_API_PATH}/visualizations/{id}`, - access: PUBLIC_API_ACCESS, + path: `${LENS_VIS_API_PATH}/{id}`, + access: LENS_API_ACCESS, enableQueryVersion: true, summary: 'Delete Lens visualization', description: 'Delete a Lens visualization by id.', @@ -43,16 +42,10 @@ export const registerLensVisualizationsDeleteAPIRoute: RegisterAPIRouteFn = ( deleteRoute.addVersion( { - version: PUBLIC_API_VERSION, + version: LENS_API_VERSION, validate: { request: { - params: schema.object({ - id: schema.string({ - meta: { - description: 'The saved object id of a Lens visualization.', - }, - }), - }), + params: lensDeleteRequestParamsSchema, }, response: { 204: { @@ -77,18 +70,25 @@ export const registerLensVisualizationsDeleteAPIRoute: RegisterAPIRouteFn = ( }, }, async (ctx, req, res) => { + // TODO fix IContentClient to type this client based on the actual const client = contentManagement.contentClient .getForRequest({ request: req, requestHandlerContext: ctx }) - .for(CONTENT_ID, PUBLIC_API_CONTENT_MANAGEMENT_VERSION); + .for(LENS_CONTENT_TYPE); try { - await client.delete(req.params.id); + const { result } = await client.delete(req.params.id); + + if (!result.success) { + throw new Error(`Failed to delete Lens visualization with id [${req.params.id}].`); + } + + return res.noContent(); } catch (error) { if (isBoom(error)) { if (error.output.statusCode === 404) { return res.notFound({ body: { - message: `A Lens visualization with saved object id [${req.params.id}] was not found.`, + message: `A Lens visualization with id [${req.params.id}] was not found.`, }, }); } @@ -99,8 +99,6 @@ export const registerLensVisualizationsDeleteAPIRoute: RegisterAPIRouteFn = ( return boomify(error); // forward unknown error } - - return res.noContent(); } ); }; diff --git a/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/get.ts b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/get.ts index 185a897bf398d..ca7a79cb58aaf 100644 --- a/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/get.ts +++ b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/get.ts @@ -5,26 +5,28 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; - import { boomify, isBoom } from '@hapi/boom'; -import { CONTENT_ID, type LensSavedObject } from '../../../../common/content_management'; + +import { TypeOf } from '@kbn/config-schema'; + import { - PUBLIC_API_PATH, - PUBLIC_API_VERSION, - PUBLIC_API_CONTENT_MANAGEMENT_VERSION, - PUBLIC_API_ACCESS, -} from '../../constants'; -import { lensSavedObjectSchema } from '../../../content_management/v1'; + LENS_VIS_API_PATH, + LENS_API_VERSION, + LENS_API_ACCESS, + LENS_CONTENT_TYPE, +} from '../../../../common/constants'; +import type { LensSavedObject } from '../../../content_management'; import { RegisterAPIRouteFn } from '../../types'; +import { lensGetRequestParamsSchema, lensGetResponseBodySchema } from './schema'; +import { getLensResponseItem } from '../utils'; export const registerLensVisualizationsGetAPIRoute: RegisterAPIRouteFn = ( router, { contentManagement } ) => { const getRoute = router.get({ - path: `${PUBLIC_API_PATH}/visualizations/{id}`, - access: PUBLIC_API_ACCESS, + path: `${LENS_VIS_API_PATH}/{id}`, + access: LENS_API_ACCESS, enableQueryVersion: true, summary: 'Get Lens visualization', description: 'Get a Lens visualization from id.', @@ -44,20 +46,14 @@ export const registerLensVisualizationsGetAPIRoute: RegisterAPIRouteFn = ( getRoute.addVersion( { - version: PUBLIC_API_VERSION, + version: LENS_API_VERSION, validate: { request: { - params: schema.object({ - id: schema.string({ - meta: { - description: 'The saved object id of a Lens visualization.', - }, - }), - }), + params: lensGetRequestParamsSchema, }, response: { 200: { - body: () => lensSavedObjectSchema, + body: () => lensGetResponseBodySchema, description: 'Ok', }, 400: { @@ -79,19 +75,34 @@ export const registerLensVisualizationsGetAPIRoute: RegisterAPIRouteFn = ( }, }, async (ctx, req, res) => { - let result; + // TODO fix IContentClient to type this client based on the actual const client = contentManagement.contentClient .getForRequest({ request: req, requestHandlerContext: ctx }) - .for(CONTENT_ID, PUBLIC_API_CONTENT_MANAGEMENT_VERSION); + .for(LENS_CONTENT_TYPE); try { - ({ result } = await client.get(req.params.id)); + const { result } = await client.get(req.params.id); + + if (result.item.error) { + throw result.item.error; + } + + const body = getLensResponseItem(result.item); + return res.ok>({ + body: { + ...body, + meta: { + ...body.meta, + ...result.meta, + }, + }, + }); } catch (error) { if (isBoom(error)) { if (error.output.statusCode === 404) { return res.notFound({ body: { - message: `A Lens visualization with saved object id [${req.params.id}] was not found.`, + message: `A Lens visualization with id [${req.params.id}] was not found.`, }, }); } @@ -102,8 +113,6 @@ export const registerLensVisualizationsGetAPIRoute: RegisterAPIRouteFn = ( return boomify(error); // forward unknown error } - - return res.ok({ body: result.item }); } ); }; diff --git a/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/schema/create.ts b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/schema/create.ts new file mode 100644 index 0000000000000..7b57caf26b49d --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/schema/create.ts @@ -0,0 +1,43 @@ +/* + * 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 { + pickFromObjectSchema, + lensResponseItemSchema, + lensAPIConfigSchema, + lensAPIAttributesSchema, + lensCMCreateOptionsSchema, +} from '../../../../content_management'; +import { lensCreateRequestBodyDataSchemaV0 } from '../../../../content_management/v0'; + +const apiConfigData = schema.object( + { + ...lensAPIAttributesSchema.getPropSchemas(), + // omit id on create options + ...pickFromObjectSchema(lensAPIConfigSchema.getPropSchemas(), ['references']), + }, + { unknowns: 'forbid' } +); + +export const lensCreateRequestBodySchema = schema.object( + { + // Permit passing old v0 SO attributes on create + data: schema.oneOf([apiConfigData, lensCreateRequestBodyDataSchemaV0]), + // TODO should these options be here or in params? + options: schema.object( + { + ...pickFromObjectSchema(lensCMCreateOptionsSchema.getPropSchemas(), ['overwrite']), + }, + { unknowns: 'forbid' } + ), + }, + { unknowns: 'forbid' } +); + +export const lensCreateResponseBodySchema = lensResponseItemSchema; diff --git a/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/schema/delete.ts b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/schema/delete.ts new file mode 100644 index 0000000000000..a8885fefa4880 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/schema/delete.ts @@ -0,0 +1,19 @@ +/* + * 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'; + +export const lensDeleteRequestParamsSchema = schema.object( + { + id: schema.string({ + meta: { + description: 'The saved object id of a Lens visualization.', + }, + }), + }, + { unknowns: 'forbid' } +); diff --git a/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/schema/get.ts b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/schema/get.ts new file mode 100644 index 0000000000000..a65da3195fc47 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/schema/get.ts @@ -0,0 +1,35 @@ +/* + * 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 { lensCMGetResultSchema, lensResponseItemSchema } from '../../../../content_management'; + +export const lensGetRequestParamsSchema = schema.object( + { + id: schema.string({ + meta: { + description: 'The saved object id of a Lens visualization.', + }, + }), + }, + { unknowns: 'forbid' } +); + +export const lensGetResponseBodySchema = schema.object( + { + data: lensResponseItemSchema.getPropSchemas().data, + meta: schema.object( + { + ...lensCMGetResultSchema.getPropSchemas().meta.getPropSchemas(), // include CM meta data + ...lensResponseItemSchema.getPropSchemas().meta.getPropSchemas(), + }, + { unknowns: 'forbid' } + ), + }, + { unknowns: 'forbid' } +); diff --git a/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/schema/index.ts b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/schema/index.ts new file mode 100644 index 0000000000000..6098db1fceab8 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/schema/index.ts @@ -0,0 +1,12 @@ +/* + * 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 './get'; +export * from './create'; +export * from './update'; +export * from './delete'; +export * from './search'; diff --git a/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/schema/search.ts b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/schema/search.ts new file mode 100644 index 0000000000000..02e21bd0d683a --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/schema/search.ts @@ -0,0 +1,60 @@ +/* + * 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 { searchOptionsSchemas } from '@kbn/content-management-utils'; + +import { + pickFromObjectSchema, + lensCMSearchOptionsSchema, + lensResponseItemSchema, +} from '../../../../content_management'; + +// TODO cleanup and align search options types with client side options +// TODO align defaults with cm and other schema definitions (i.e. searchOptionsSchemas) +// TODO See if these should be in body or params? +export const lensSearchRequestQuerySchema = schema.object({ + ...lensCMSearchOptionsSchema.getPropSchemas(), + query: schema.maybe( + schema.string({ + meta: { + description: 'The text to search for Lens visualizations', + }, + }) + ), + page: schema.number({ + meta: { + description: 'Specifies the current page number of the paginated result.', + }, + min: 1, + defaultValue: 1, + }), + perPage: schema.number({ + meta: { + description: 'Maximum number of Lens visualizations included in a single response', + }, + defaultValue: 20, + min: 1, + max: 1000, + }), +}); + +const lensSearchResponseMetaSchema = schema.object( + { + ...pickFromObjectSchema(searchOptionsSchemas, ['page', 'perPage']), + total: schema.number(), // TODO use shared definition + }, + { unknowns: 'forbid' } +); + +export const lensSearchResponseBodySchema = schema.object( + { + data: schema.arrayOf(lensResponseItemSchema), + meta: lensSearchResponseMetaSchema, + }, + { unknowns: 'forbid' } +); diff --git a/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/schema/update.ts b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/schema/update.ts new file mode 100644 index 0000000000000..e9792ab339001 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/schema/update.ts @@ -0,0 +1,53 @@ +/* + * 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 { omit } from 'lodash'; +import { + lensResponseItemSchema, + lensAPIAttributesSchema, + pickFromObjectSchema, + lensAPIConfigSchema, + lensCMUpdateOptionsSchema, +} from '../../../../content_management'; + +export const lensUpdateRequestParamsSchema = schema.object( + { + id: schema.string({ + meta: { + description: 'The saved object id of a Lens visualization.', + }, + }), + }, + { unknowns: 'forbid' } +); + +export const lensUpdateRequestBodySchema = schema.object( + { + data: schema.object( + { + ...lensAPIAttributesSchema.getPropSchemas(), + // omit id on create options + ...pickFromObjectSchema(lensAPIConfigSchema.getPropSchemas(), ['references']), + }, + { unknowns: 'forbid' } + ), + // TODO should these options be here? + options: schema.object( + { + ...omit(lensCMUpdateOptionsSchema.getPropSchemas(), ['references']), + }, + { unknowns: 'forbid' } + ), + }, + { + unknowns: 'forbid', + } +); + +export const lensUpdateResponseBodySchema = lensResponseItemSchema; diff --git a/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/search.ts b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/search.ts index 3ab47a50824c0..f87e1cc1d64c8 100644 --- a/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/search.ts +++ b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/search.ts @@ -5,26 +5,27 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; - import { isBoom, boomify } from '@hapi/boom'; -import { CONTENT_ID, type LensSavedObject } from '../../../../common/content_management'; + +import { TypeOf } from '@kbn/config-schema'; import { - PUBLIC_API_PATH, - PUBLIC_API_VERSION, - PUBLIC_API_CONTENT_MANAGEMENT_VERSION, - PUBLIC_API_ACCESS, -} from '../../constants'; -import { lensSavedObjectSchema } from '../../../content_management/v1'; + LENS_VIS_API_PATH, + LENS_API_VERSION, + LENS_API_ACCESS, + LENS_CONTENT_TYPE, +} from '../../../../common/constants'; +import type { LensSearchIn, LensSavedObject } from '../../../content_management'; import { RegisterAPIRouteFn } from '../../types'; +import { lensSearchRequestQuerySchema, lensSearchResponseBodySchema } from './schema'; +import { getLensResponseItem } from '../utils'; export const registerLensVisualizationsSearchAPIRoute: RegisterAPIRouteFn = ( router, { contentManagement } ) => { const searchRoute = router.get({ - path: `${PUBLIC_API_PATH}/visualizations`, - access: PUBLIC_API_ACCESS, + path: LENS_VIS_API_PATH, + access: LENS_API_ACCESS, enableQueryVersion: true, summary: 'Search Lens visualizations', description: 'Get list of Lens visualizations.', @@ -44,37 +45,14 @@ export const registerLensVisualizationsSearchAPIRoute: RegisterAPIRouteFn = ( searchRoute.addVersion( { - version: PUBLIC_API_VERSION, + version: LENS_API_VERSION, validate: { request: { - query: schema.object({ - query: schema.maybe( - schema.string({ - meta: { - description: 'The text to search for Lens visualizations', - }, - }) - ), - page: schema.number({ - meta: { - description: 'Specifies the current page number of the paginated result.', - }, - min: 1, - defaultValue: 1, - }), - perPage: schema.number({ - meta: { - description: 'Maximum number of Lens visualizations included in a single response', - }, - defaultValue: 20, - min: 1, - max: 1000, - }), - }), + query: lensSearchRequestQuerySchema, }, response: { 200: { - body: () => schema.arrayOf(lensSavedObjectSchema), + body: () => lensSearchResponseBodySchema, description: 'Ok', }, 400: { @@ -93,23 +71,44 @@ export const registerLensVisualizationsSearchAPIRoute: RegisterAPIRouteFn = ( }, }, async (ctx, req, res) => { - let result; - const { query, page, perPage: limit } = req.query; + // TODO fix IContentClient to type this client based on the actual const client = contentManagement.contentClient .getForRequest({ request: req, requestHandlerContext: ctx }) - .for(CONTENT_ID, PUBLIC_API_CONTENT_MANAGEMENT_VERSION); + .for(LENS_CONTENT_TYPE); + + const { query: q, page, perPage, ...reqOptions } = req.query; try { - ({ result } = await client.search( - { - text: query, - cursor: page.toString(), - limit, + // Note: these types are to enforce loose param typings of client methods + const query: LensSearchIn['query'] = { + text: q, + cursor: page.toString(), + limit: perPage, + }; + const options: LensSearchIn['options'] = reqOptions; + + const { + result: { hits, pagination }, + } = await client.search(query, options); + + // TODO: see if this check is actually needed + const error = hits.find((item) => item.error); + if (error) { + throw error; + } + + return res.ok>({ + body: { + data: hits.map((item) => { + return getLensResponseItem(item); + }), + meta: { + page, + perPage, + total: pagination.total, + }, }, - { - searchFields: ['title', 'description'], - } - )); + }); } catch (error) { if (isBoom(error) && error.output.statusCode === 403) { return res.forbidden(); @@ -117,8 +116,6 @@ export const registerLensVisualizationsSearchAPIRoute: RegisterAPIRouteFn = ( return boomify(error); // forward unknown error } - - return res.ok({ body: result.hits }); } ); }; diff --git a/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/types.ts b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/types.ts new file mode 100644 index 0000000000000..e9c435a9e65f9 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/types.ts @@ -0,0 +1,41 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; + +import { Optional } from 'utility-types'; +import { + lensCreateRequestBodySchema, + lensCreateResponseBodySchema, + lensDeleteRequestParamsSchema, + lensGetRequestParamsSchema, + lensGetResponseBodySchema, + lensSearchRequestQuerySchema, + lensSearchResponseBodySchema, + lensUpdateRequestBodySchema, + lensUpdateRequestParamsSchema, + lensUpdateResponseBodySchema, +} from './schema'; + +export type LensCreateRequestBody = TypeOf; +export type LensCreateResponseBody = TypeOf; + +export type LensUpdateRequestParams = TypeOf; +export type LensUpdateRequestBody = TypeOf; +export type LensUpdateResponseBody = TypeOf; + +export type LensGetRequestParams = TypeOf; +export type LensGetResponseBody = TypeOf; + +export type LensSearchRequestQuery = Optional< + // TODO: find out why default values show as required, adding maybe returns undefined values + TypeOf, + 'page' | 'perPage' +>; +export type LensSearchResponseBody = TypeOf; + +export type LensDeleteRequestParams = TypeOf; diff --git a/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/update.ts b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/update.ts index 1f3279a5976dd..dddbd2434697e 100644 --- a/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/update.ts +++ b/x-pack/platform/plugins/shared/lens/server/api/routes/visualizations/update.ts @@ -5,30 +5,33 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; import { boomify, isBoom } from '@hapi/boom'; -import { CONTENT_ID, type LensSavedObject } from '../../../../common/content_management'; +import { TypeOf } from '@kbn/config-schema'; +import { omit } from 'lodash'; import { - PUBLIC_API_PATH, - PUBLIC_API_VERSION, - PUBLIC_API_CONTENT_MANAGEMENT_VERSION, - PUBLIC_API_ACCESS, -} from '../../constants'; -import { - lensAttributesSchema, - lensCreateOptionsSchema, - lensSavedObjectSchema, -} from '../../../content_management/v1'; + LENS_VIS_API_PATH, + LENS_API_VERSION, + LENS_API_ACCESS, + LENS_CONTENT_TYPE, +} from '../../../../common/constants'; +import type { LensUpdateIn, LensSavedObject } from '../../../content_management'; import { RegisterAPIRouteFn } from '../../types'; +import { ConfigBuilderStub } from '../../../../common/transforms'; +import { + lensUpdateRequestBodySchema, + lensUpdateRequestParamsSchema, + lensUpdateResponseBodySchema, +} from './schema'; +import { getLensResponseItem } from '../utils'; export const registerLensVisualizationsUpdateAPIRoute: RegisterAPIRouteFn = ( router, { contentManagement } ) => { const updateRoute = router.put({ - path: `${PUBLIC_API_PATH}/visualizations/{id}`, - access: PUBLIC_API_ACCESS, + path: `${LENS_VIS_API_PATH}/{id}`, + access: LENS_API_ACCESS, enableQueryVersion: true, summary: 'Update Lens visualization', description: 'Update an existing Lens visualization.', @@ -48,24 +51,15 @@ export const registerLensVisualizationsUpdateAPIRoute: RegisterAPIRouteFn = ( updateRoute.addVersion( { - version: PUBLIC_API_VERSION, + version: LENS_API_VERSION, validate: { request: { - params: schema.object({ - id: schema.string({ - meta: { - description: 'The saved object id of a Lens visualization.', - }, - }), - }), - body: schema.object({ - options: lensCreateOptionsSchema, - data: lensAttributesSchema, - }), + params: lensUpdateRequestParamsSchema, + body: lensUpdateRequestBodySchema, }, response: { 200: { - body: () => lensSavedObjectSchema, + body: () => lensUpdateResponseBodySchema, description: 'Ok', }, 400: { @@ -87,20 +81,38 @@ export const registerLensVisualizationsUpdateAPIRoute: RegisterAPIRouteFn = ( }, }, async (ctx, req, res) => { - let result; - const { data, options } = req.body; + // TODO fix IContentClient to type this client based on the actual const client = contentManagement.contentClient .getForRequest({ request: req, requestHandlerContext: ctx }) - .for(CONTENT_ID, PUBLIC_API_CONTENT_MANAGEMENT_VERSION); + .for(LENS_CONTENT_TYPE); + + const { references, ...lensItem } = omit( + ConfigBuilderStub.in({ + id: '', // TODO: Find a better way to conditionally omit id + ...req.body.data, + }), + 'id' + ); try { - ({ result } = await client.update(req.params.id, data, options)); + // Note: these types are to enforce loose param typings of client methods + const data: LensUpdateIn['data'] = lensItem; + const options: LensUpdateIn['options'] = { references }; + const { result } = await client.update(req.params.id, data, options); + + if (result.item.error) { + throw result.item.error; + } + + return res.ok>({ + body: getLensResponseItem(result.item), + }); } catch (error) { if (isBoom(error)) { if (error.output.statusCode === 404) { return res.notFound({ body: { - message: `A Lens visualization with saved object id [${req.params.id}] was not found.`, + message: `A Lens visualization with id [${req.params.id}] was not found.`, }, }); } @@ -111,8 +123,6 @@ export const registerLensVisualizationsUpdateAPIRoute: RegisterAPIRouteFn = ( return boomify(error); // forward unknown error } - - return res.ok({ body: result.item }); } ); }; diff --git a/x-pack/platform/plugins/shared/lens/common/content_management/latest.ts b/x-pack/platform/plugins/shared/lens/server/api/schema.ts similarity index 88% rename from x-pack/platform/plugins/shared/lens/common/content_management/latest.ts rename to x-pack/platform/plugins/shared/lens/server/api/schema.ts index ab3f7581b2043..8a8b2475c1c0f 100644 --- a/x-pack/platform/plugins/shared/lens/common/content_management/latest.ts +++ b/x-pack/platform/plugins/shared/lens/server/api/schema.ts @@ -5,4 +5,4 @@ * 2.0. */ -export type * from './v1'; +export * from './routes/schema'; diff --git a/x-pack/platform/plugins/shared/lens/server/api/types.ts b/x-pack/platform/plugins/shared/lens/server/api/types.ts index 68ff2865183b6..55fa468634cc6 100644 --- a/x-pack/platform/plugins/shared/lens/server/api/types.ts +++ b/x-pack/platform/plugins/shared/lens/server/api/types.ts @@ -9,6 +9,8 @@ import { HttpServiceSetup, Logger, RequestHandlerContext } from '@kbn/core/serve import { ContentManagementServerSetup } from '@kbn/content-management-plugin/server'; import { VersionedRouter } from '@kbn/core-http-server'; +export type * from './routes/types'; + export interface RegisterAPIRoutesArgs { http: HttpServiceSetup; contentManagement: ContentManagementServerSetup; diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/cm_services.ts b/x-pack/platform/plugins/shared/lens/server/content_management/cm_services.ts deleted file mode 100644 index 74cb11396c764..0000000000000 --- a/x-pack/platform/plugins/shared/lens/server/content_management/cm_services.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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, -}; diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/index.ts b/x-pack/platform/plugins/shared/lens/server/content_management/index.ts index f93ea0da74723..67fbe4f3a7a1d 100644 --- a/x-pack/platform/plugins/shared/lens/server/content_management/index.ts +++ b/x-pack/platform/plugins/shared/lens/server/content_management/index.ts @@ -6,3 +6,9 @@ */ export { LensStorage } from './lens_storage'; +export { servicesDefinitions } from './services'; +export * from './v1/schema/utils'; + +export * from './latest'; + +export type * as LensV1 from './v1'; diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/latest.ts b/x-pack/platform/plugins/shared/lens/server/content_management/latest.ts new file mode 100644 index 0000000000000..87c4b6b9a609a --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/latest.ts @@ -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. + */ + +// When we introduce a new version we need to export a union of all the version schemas +export * from './v1'; diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/lens_storage.ts b/x-pack/platform/plugins/shared/lens/server/content_management/lens_storage.ts index 41a0b0953a779..0884a1d8ca29b 100644 --- a/x-pack/platform/plugins/shared/lens/server/content_management/lens_storage.ts +++ b/x-pack/platform/plugins/shared/lens/server/content_management/lens_storage.ts @@ -4,100 +4,219 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import Boom from '@hapi/boom'; -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 type { Logger } from '@kbn/logging'; +import type { + SavedObject, + SavedObjectsFindOptions, + SavedObjectsFindResult, +} from '@kbn/core-saved-objects-api-server'; +import type { StorageContext } from '@kbn/content-management-plugin/server'; +import { SOContentStorage, SOWithMetadata, tagsToFindOptions } from '@kbn/content-management-utils'; -import { - CONTENT_ID, - type LensCrudTypes, - type LensSavedObject, - type LensSavedObjectAttributes, - type PartialLensSavedObject, -} from '../../common/content_management'; -import { cmServicesDefinition } from './cm_services'; +import { UserContentCommonSchema } from '@kbn/content-management-table-list-view-common'; +import { LENS_CONTENT_TYPE } from '../../common/constants'; +import type { + LensAttributes, + LensGetOut, + LensSearchIn, + LensUpdateIn, + LensUpdateOut, + LensCrud, + LensCreateOut, + LensSearchOut, + LensSavedObject, + LensCreateIn, +} from './latest'; +import { servicesDefinitions } from './services'; -const searchArgsToSOFindOptions = (args: LensCrudTypes['SearchIn']): SavedObjectsFindOptions => { +const searchArgsToSOFindOptions = (args: LensSearchIn): SavedObjectsFindOptions => { const { query, contentTypeId, options } = args; + // Shared schema type allows string here, need to align manually + const searchFields = (options?.searchFields + ? Array.isArray(options.searchFields) + ? options.searchFields + : [options.searchFields] + : null) ?? ['title^3', 'description']; + 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), - }; + searchFields, + } satisfies SavedObjectsFindOptions; }; -const savedObjectClientFromRequest = async (ctx: StorageContext) => { - if (!ctx.requestHandlerContext) { - throw new Error('Storage context.requestHandlerContext missing.'); +export class LensStorage extends SOContentStorage { + constructor(params: { logger: Logger; throwOnResultValidationError: boolean }) { + super({ + savedObjectType: LENS_CONTENT_TYPE, + cmServicesDefinition: servicesDefinitions, + searchArgsToSOFindOptions, + enableMSearch: true, + allowedSavedObjectAttributes: [ + 'title', + 'description', + 'visualizationType', + 'version', + 'state', + ], + logger: params.logger, + throwOnResultValidationError: params.throwOnResultValidationError, + }); + + this.mSearch!.toItemResult = (ctx: StorageContext, savedObject: SavedObjectsFindResult) => { + const transforms = ctx.utils.getTransforms(servicesDefinitions); + const { attributes, ...rest } = this.savedObjectToItem( + // TODO: Fix this typing, this is not true we don't necessarily have all the Lens attributes + savedObject as SavedObject + ) as SOWithMetadata; + const commonContentItem: UserContentCommonSchema = { + updatedAt: '', // type misalignment + ...rest, + attributes: { + title: attributes.title, + description: attributes.description ?? '', + }, + }; + + const validationError = transforms.mSearch.out.result.validate(commonContentItem); + + if (validationError) { + if (this.throwOnResultValidationError) { + throw Boom.badRequest(`Invalid response. ${validationError.message}`); + } else { + this.logger.warn(`Invalid response. ${validationError.message}`); + } + } + + // TODO: Fix this typing, this expects a full item attributes but only has title and description + return commonContentItem as LensSavedObject; + }; } - const { savedObjects } = await ctx.requestHandlerContext.core; - return savedObjects.client; -}; + async get(ctx: StorageContext, id: string): Promise { + const soClient = await LensStorage.getSOClientFromRequest(ctx); -type PartialSavedObject = Omit>, 'references'> & { - references: SavedObjectReference[] | undefined; -}; + // Save data in DB + const { + saved_object: savedObject, + alias_purpose: aliasPurpose, + alias_target_id: aliasTargetId, + outcome, + } = await soClient.resolve(LENS_CONTENT_TYPE, id); -function savedObjectToLensSavedObject( - savedObject: - | SavedObject - | PartialSavedObject -): LensSavedObject | PartialLensSavedObject { - const { - id, - type, - updated_at: updatedAt, - created_at: createdAt, - attributes: { title, description, state, visualizationType }, - references, - error, - namespaces, - } = savedObject; + const response: LensGetOut = { + item: this.savedObjectToItem(savedObject), + meta: { + aliasPurpose, + aliasTargetId, + outcome, + }, + }; - return { - id, - type, - updatedAt, - createdAt, - attributes: { - title, - description, - visualizationType, - state, - }, - references, - error, - namespaces, - }; -} + const itemVersion = response.item.attributes.version ?? 0; + const transforms = ctx.utils.getTransforms(servicesDefinitions); -export class LensStorage extends SOContentStorage { - constructor( - private params: { - logger: Logger; - throwOnResultValidationError: boolean; + // transform item from given version to latest version + const { value, error: resultError } = transforms.get.out.result.down( + response, + itemVersion, + { validate: false } // validation is done after transform below + ); + + if (resultError) { + throw Boom.badRequest(`Transform error. ${resultError.message}`); } - ) { - super({ - savedObjectType: CONTENT_ID, - cmServicesDefinition, - searchArgsToSOFindOptions, - enableMSearch: true, - allowedSavedObjectAttributes: ['title', 'description', 'visualizationType', 'state'], - logger: params.logger, - throwOnResultValidationError: params.throwOnResultValidationError, - }); + + const validationError = transforms.get.out.result.validate(value); + + if (validationError) { + if (this.throwOnResultValidationError) { + throw Boom.badRequest(`Invalid response. ${validationError.message}`); + } else { + this.logger.warn(`Invalid response. ${validationError.message}`); + } + } + + return value; + } + + async create( + ctx: StorageContext, + data: LensCreateIn['data'], + options: LensCreateIn['options'] = {} + ): Promise { + const transforms = ctx.utils.getTransforms(servicesDefinitions); + const soClient = await SOContentStorage.getSOClientFromRequest(ctx); + + const itemVersion = data.version ?? 0; // Check that this always has a version + + // transform data from given version to latest version + const { value: dataToLatest, error: dataError } = transforms.create.in.data.down< + LensCreateIn['data'], + LensCreateIn['data'] + >(data, itemVersion); + + if (dataError) { + throw Boom.badRequest(`Invalid data. ${dataError.message}`); + } + + // transform options from given version to latest version + const { value: optionsToLatest, error: optionsError } = transforms.create.in.options.down< + LensCreateIn['options'], + LensCreateIn['options'] + >(options, itemVersion); + + if (optionsError) { + throw Boom.badRequest(`Invalid options. ${optionsError.message}`); + } + + const createOptions = this.createArgsToSoCreateOptions(optionsToLatest ?? {}); + + // Save data in DB + const savedObject = await soClient.create( + LENS_CONTENT_TYPE, + dataToLatest, + createOptions + ); + + const result = { + item: this.savedObjectToItem(savedObject), + }; + + const validationError = transforms.create.out.result.validate(result); + + if (validationError) { + if (this.throwOnResultValidationError) { + throw Boom.badRequest(`Invalid response. ${validationError.message}`); + } else { + this.logger.warn(`Invalid response. ${validationError.message}`); + } + } + + // transform result from latest version to request version + const { value, error: resultError } = transforms.create.out.result.down< + LensCreateOut, + LensCreateOut + >( + result, + 'latest', + { validate: false } // validation is done above + ); + + if (resultError) { + throw Boom.badRequest(`Invalid response. ${resultError.message}`); + } + + return value; } /** @@ -107,64 +226,68 @@ export class LensStorage extends SOContentStorage { async update( ctx: StorageContext, id: string, - data: LensCrudTypes['UpdateIn']['data'], - options: LensCrudTypes['UpdateOptions'] - ): Promise { + data: LensUpdateIn['data'], + options: LensUpdateIn['options'] + ): Promise { const { utils: { getTransforms }, version: { request: requestVersion }, } = ctx; - const transforms = getTransforms(cmServicesDefinition, requestVersion); + const transforms = getTransforms(servicesDefinitions, requestVersion); + const itemVersion = data.version ?? 0; // Check that this always has a version + + // transform data from given version to latest version + const { value: dataToLatest, error: dataError } = transforms.update.in.data.down< + LensAttributes, + LensAttributes + >(data, itemVersion); - // Validate input (data & options) & UP transform them to the latest version - const { value: dataToLatest, error: dataError } = transforms.update.in.data.up< - LensSavedObjectAttributes, - LensSavedObjectAttributes - >(data); if (dataError) { throw Boom.badRequest(`Invalid data. ${dataError.message}`); } - const { value: optionsToLatest, error: optionsError } = transforms.update.in.options.up< - LensCrudTypes['CreateOptions'], - LensCrudTypes['CreateOptions'] - >(options); + // transform options from given version to latest version + const { value: optionsToLatest, error: optionsError } = transforms.update.in.options.down< + LensUpdateIn['options'], + LensUpdateIn['options'] + >(options, itemVersion); + if (optionsError) { throw Boom.badRequest(`Invalid options. ${optionsError.message}`); } - // Save data in DB - const soClient = await savedObjectClientFromRequest(ctx); + const soClient = await LensStorage.getSOClientFromRequest(ctx); - // since we use create below this is to throw if SO id not found - await soClient.get(CONTENT_ID, id); + // since we use create below this call is meant to throw if SO id not found + await soClient.get(LENS_CONTENT_TYPE, id); - const savedObject = await soClient.create(CONTENT_ID, dataToLatest, { + const savedObject = await soClient.create(LENS_CONTENT_TYPE, dataToLatest, { id, overwrite: true, ...optionsToLatest, }); const result = { - item: savedObjectToLensSavedObject(savedObject), + item: this.savedObjectToItem(savedObject), }; const validationError = transforms.update.out.result.validate(result); + if (validationError) { - if (this.params.throwOnResultValidationError) { + if (this.throwOnResultValidationError) { throw Boom.badRequest(`Invalid response. ${validationError.message}`); } else { - this.params.logger.warn(`Invalid response. ${validationError.message}`); + this.logger.warn(`Invalid response. ${validationError.message}`); } } - // Validate DB response and DOWN transform to the request version + // transform result from latest version to request version const { value, error: resultError } = transforms.update.out.result.down< - LensCrudTypes['UpdateOut'], - LensCrudTypes['UpdateOut'] + LensUpdateOut, + LensUpdateOut >( result, - undefined, // do not override version + 'latest', { validate: false } // validation is done above ); @@ -174,4 +297,70 @@ export class LensStorage extends SOContentStorage { return value; } + + async search( + ctx: StorageContext, + query: LensSearchIn['query'], + options: LensSearchIn['options'] = {} + ): Promise { + const transforms = ctx.utils.getTransforms(servicesDefinitions); + const soClient = await SOContentStorage.getSOClientFromRequest(ctx); + const requestVersion = 'latest'; // this should eventually come from the request when there is a v2 + + // Validate and UP transform the options + const { value: optionsToLatest, error: optionsError } = transforms.search.in.options.up< + LensSearchIn['options'], + LensSearchIn['options'] + >(options, requestVersion); + if (optionsError) { + throw Boom.badRequest(`Invalid payload. ${optionsError.message}`); + } + + const soQuery: SavedObjectsFindOptions = this.searchArgsToSOFindOptions({ + contentTypeId: LENS_CONTENT_TYPE, + query, + options: optionsToLatest, + }); + + // Execute the query in the DB + const soResponse = await soClient.find(soQuery); + const items = soResponse.saved_objects.map((so) => this.savedObjectToItem(so)); + + const transformedItems = items.map((item) => { + // transform item from given version to latest version + const { value: transformedItem, error: itemError } = transforms.search.out.result.down< + LensSavedObject, + LensSavedObject + >( + item, + item.attributes.version ?? 0, + { validate: false } // validation is done after transform below + ); + + if (itemError) { + throw Boom.badRequest(`Transform error. ${itemError.message}`); + } + + return transformedItem; + }); + + const response = { + hits: transformedItems, + pagination: { + total: soResponse.total, + }, + }; + + const validationError = transforms.search.out.result.validate(response); + + if (validationError) { + if (this.throwOnResultValidationError) { + throw Boom.badRequest(`Invalid response. ${validationError.message}`); + } else { + this.logger.warn(`Invalid response. ${validationError.message}`); + } + } + + return response; + } } diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/services.ts b/x-pack/platform/plugins/shared/lens/server/content_management/services.ts new file mode 100644 index 0000000000000..a8de60e430778 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/services.ts @@ -0,0 +1,14 @@ +/* + * 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, Version } from '@kbn/object-versioning'; + +import { serviceDefinition as v1 } from './v1/service'; + +export const servicesDefinitions: { [version: Version]: ContentManagementServicesDefinition } = { + 1: v1, +}; diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v0/index.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v0/index.ts new file mode 100644 index 0000000000000..8c503964d466f --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v0/index.ts @@ -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 { lensItemAttributesSchemaV0, lensCreateRequestBodyDataSchemaV0 } from './schema'; diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v0/schema.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v0/schema.ts new file mode 100644 index 0000000000000..2a8ae66312087 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v0/schema.ts @@ -0,0 +1,37 @@ +/* + * 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 { referencesSchema } from '@kbn/content-management-utils'; + +/** + * Pre-existing Lens SO attributes (aka `v0`). + * + * We could still require handling see these attributes and should allow + * saving them as is with unknown version. The CM will eventually apply the transforms. + */ +export const lensItemAttributesSchemaV0 = schema.object( + { + title: schema.string(), + description: schema.maybe(schema.nullable(schema.string())), + visualizationType: schema.maybe(schema.string()), + state: schema.maybe(schema.any()), + uiStateJSON: schema.maybe(schema.string()), + visState: schema.maybe(schema.string()), + savedSearchRefName: schema.maybe(schema.string()), + }, + { unknowns: 'ignore' } +); + +/** + * Pre-existing Lens SO create body data (aka `v0`). + * + * We may require the ability to create a Lens SO with and old state. + */ +export const lensCreateRequestBodyDataSchemaV0 = lensItemAttributesSchemaV0.extends({ + references: referencesSchema, +}); diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/cm_services.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/cm_services.ts deleted file mode 100644 index afbd000805f82..0000000000000 --- a/x-pack/platform/plugins/shared/lens/server/content_management/v1/cm_services.ts +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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); - -export const lensAttributesSchema = schema.object( - { - title: schema.string(), - description: schema.maybe(schema.nullable(schema.string())), - visualizationType: schema.maybe(schema.string()), - state: schema.maybe(schema.any()), - uiStateJSON: schema.maybe(schema.string()), - visState: schema.maybe(schema.string()), - savedSearchRefName: schema.maybe(schema.string()), - }, - { unknowns: 'forbid' } -); - -export const lensSavedObjectSchema = 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: lensAttributesSchema, - references: referencesSchema, - namespaces: schema.maybe(schema.arrayOf(schema.string())), - originId: schema.maybe(schema.string()), - }, - { unknowns: 'allow' } -); - -const lensGetResultSchema = schema.object( - { - item: lensSavedObjectSchema, - 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' } -); - -export const lensCreateOptionsSchema = schema.object({ - overwrite: schema.maybe(schema.boolean()), - references: schema.maybe(referencesSchema), -}); - -export const lensSearchOptionsSchema = schema.maybe( - schema.object( - { - searchFields: schema.maybe(schema.arrayOf(schema.string())), - types: schema.maybe(schema.arrayOf(schema.string())), - }, - { unknowns: 'forbid' } - ) -); - -const lensCreateResultSchema = schema.object( - { - item: lensSavedObjectSchema, - }, - { unknowns: 'forbid' } -); - -// Content management service definition. -// We need it for BWC support between different versions of the content -export const serviceDefinition: ServicesDefinition = { - get: { - out: { - result: { - schema: lensGetResultSchema, - }, - }, - }, - create: { - in: { - data: { - schema: lensAttributesSchema, - }, - options: { - schema: lensCreateOptionsSchema, - }, - }, - out: { - result: { - schema: lensCreateResultSchema, - }, - }, - }, - update: { - in: { - data: { - schema: lensAttributesSchema, - }, - options: { - schema: lensCreateOptionsSchema, - }, - }, - out: { - result: { - schema: lensCreateResultSchema, - }, - }, - }, - search: { - in: { - options: { - schema: lensSearchOptionsSchema, - }, - }, - }, -}; diff --git a/x-pack/platform/plugins/shared/lens/server/api/constants.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/constants.ts similarity index 57% rename from x-pack/platform/plugins/shared/lens/server/api/constants.ts rename to x-pack/platform/plugins/shared/lens/server/content_management/v1/constants.ts index ef40c7044882e..22552421296f4 100644 --- a/x-pack/platform/plugins/shared/lens/server/api/constants.ts +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/constants.ts @@ -5,7 +5,9 @@ * 2.0. */ -export const PUBLIC_API_VERSION = '1'; -export const PUBLIC_API_CONTENT_MANAGEMENT_VERSION = 1; -export const PUBLIC_API_PATH = '/api/lens'; -export const PUBLIC_API_ACCESS = 'internal'; +import { LENS_ITEM_VERSION_V1 } from '../../../common/content_management/constants'; + +/** + * Lens CM Item Version `v1` + */ +export const LENS_ITEM_VERSION = LENS_ITEM_VERSION_V1; diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/index.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/index.ts index e0be3d04393f7..303206c709a42 100644 --- a/x-pack/platform/plugins/shared/lens/server/content_management/v1/index.ts +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/index.ts @@ -5,4 +5,11 @@ * 2.0. */ -export * from './cm_services'; +export { LENS_ITEM_VERSION } from './constants'; + +export * from './transforms'; +export * from './service'; +export * from './schema'; +export type * from './types'; + +export type { LensSavedObjectV0, LensAttributesV0 } from './transforms'; diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/common.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/common.ts new file mode 100644 index 0000000000000..acdc86b5de398 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/common.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { savedObjectSchema } from '@kbn/content-management-utils'; + +import { pickFromObjectSchema } from './utils'; +import { LENS_ITEM_VERSION } from '../constants'; + +export const lensItemAttributesSchema = schema.object( + { + title: schema.string(), + description: schema.maybe(schema.string()), + visualizationType: schema.nullable(schema.string()), + state: schema.maybe(schema.any()), + // TODO make version required + version: schema.maybe(schema.literal(LENS_ITEM_VERSION)), // pin version explicitly + }, + { unknowns: 'forbid' } +); + +export const lensAPIStateSchema = schema.object( + { + isNewApiFormat: schema.literal(true), // pin this to validate CB transformations + }, + { unknowns: 'allow' } +); + +export const lensAPIAttributesSchema = schema.object( + { + ...lensItemAttributesSchema.getPropSchemas(), + state: lensAPIStateSchema, + }, + { unknowns: 'forbid' } +); + +/** + * The underlying SO type used to store Lens state in Content Management. + * + * Only used in lens server-side Content Management. + */ +export const lensSavedObjectSchema = savedObjectSchema(lensItemAttributesSchema); + +/** + * The common SO type used for mSearch items. + */ +export const lensCommonSavedObjectSchema = savedObjectSchema( + schema.object( + { + ...pickFromObjectSchema(lensItemAttributesSchema.getPropSchemas(), ['title', 'description']), + }, + { unknowns: 'forbid' } + ) +); + +/** + * The Lens item data returned from the server + */ +export const lensItemSchema = schema.object( + { + ...pickFromObjectSchema(lensSavedObjectSchema.getPropSchemas(), ['id', 'references']), + // Spread attributes at root + ...lensSavedObjectSchema.getPropSchemas().attributes.getPropSchemas(), + }, + { unknowns: 'forbid' } +); + +/** + * The Lens item data returned from the server + */ +export const lensAPIConfigSchema = schema.object( + { + // TODO flatten this with new CB shape + ...pickFromObjectSchema(lensSavedObjectSchema.getPropSchemas(), ['id', 'references']), + // Spread attributes at root + ...lensAPIAttributesSchema.getPropSchemas(), + }, + { unknowns: 'forbid' } +); + +/** + * The Lens item meta returned from the server + */ +export const lensItemMetaSchema = schema.object( + { + ...pickFromObjectSchema(lensSavedObjectSchema.getPropSchemas(), [ + 'type', + 'createdAt', + 'updatedAt', + 'createdBy', + 'updatedBy', + 'originId', // maybe?? + 'managed', + ]), + }, + { unknowns: 'forbid' } +); + +/** + * The Lens response item returned from the server + */ +export const lensResponseItemSchema = schema.object( + { + data: lensAPIConfigSchema, + meta: lensItemMetaSchema, + }, + { unknowns: 'forbid' } +); diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/create.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/create.ts new file mode 100644 index 0000000000000..ed541fe1760d2 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/create.ts @@ -0,0 +1,33 @@ +/* + * 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 { createOptionsSchemas, createResultSchema } from '@kbn/content-management-utils'; + +import { lensItemAttributesSchemaV0 as lensItemAttributesSchemaV0 } from '../../v0/schema'; +import { lensItemAttributesSchema, lensSavedObjectSchema } from './common'; +import { pickFromObjectSchema } from './utils'; + +export const lensCMCreateOptionsSchema = schema.object( + { + ...pickFromObjectSchema(createOptionsSchemas, ['overwrite', 'references']), + }, + { unknowns: 'forbid' } +); + +export const lensCMCreateBodySchema = schema.object( + { + options: lensCMCreateOptionsSchema, + // Permit passing old SO attributes on create + data: schema.oneOf([lensItemAttributesSchema, lensItemAttributesSchemaV0]), + }, + { + unknowns: 'forbid', + } +); + +export const lensCMCreateResultSchema = createResultSchema(lensSavedObjectSchema); diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/get.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/get.ts new file mode 100644 index 0000000000000..8014134cae56d --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/get.ts @@ -0,0 +1,12 @@ +/* + * 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 { objectTypeToGetResultSchema } from '@kbn/content-management-utils'; + +import { lensSavedObjectSchema } from './common'; + +export const lensCMGetResultSchema = objectTypeToGetResultSchema(lensSavedObjectSchema); diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/index.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/index.ts new file mode 100644 index 0000000000000..c3f465f0f7ba3 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/index.ts @@ -0,0 +1,13 @@ +/* + * 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 './common'; +export * from './get'; +export * from './create'; +export * from './update'; +export * from './search'; +export * from './m_search'; diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/m_search.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/m_search.ts new file mode 100644 index 0000000000000..2630ae86abef7 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/m_search.ts @@ -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. + */ + +import { lensCommonSavedObjectSchema } from './common'; + +export const lensCMMSearchResultSchema = lensCommonSavedObjectSchema; diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/search.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/search.ts new file mode 100644 index 0000000000000..2ebda2bd32102 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/search.ts @@ -0,0 +1,24 @@ +/* + * 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 { searchOptionsSchemas, searchResultSchema } from '@kbn/content-management-utils'; + +import { lensSavedObjectSchema } from './common'; +import { pickFromObjectSchema } from './utils'; + +export const lensCMSearchOptionsSchema = schema.object( + { + // TODO: add support for more search options + ...pickFromObjectSchema(searchOptionsSchemas, ['fields', 'searchFields']), + }, + { + unknowns: 'forbid', + } +); + +export const lensCMSearchResultSchema = searchResultSchema(lensSavedObjectSchema); diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/update.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/update.ts new file mode 100644 index 0000000000000..8d195f03c8067 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/update.ts @@ -0,0 +1,36 @@ +/* + * 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 { omit } from 'lodash'; + +import { schema } from '@kbn/config-schema'; +import { createResultSchema, updateOptionsSchema } from '@kbn/content-management-utils'; + +import { lensItemAttributesSchema, lensSavedObjectSchema } from './common'; +import { pickFromObjectSchema } from './utils'; + +export const lensCMUpdateOptionsSchema = schema.object( + { + ...pickFromObjectSchema( + omit(updateOptionsSchema, 'upsert'), // `upsert` is not a schema type + ['references'] + ), + }, + { unknowns: 'forbid' } +); + +export const lensCMUpdateBodySchema = schema.object( + { + options: lensCMUpdateOptionsSchema, + data: lensItemAttributesSchema, + }, + { + unknowns: 'forbid', + } +); + +export const lensCMUpdateResultSchema = createResultSchema(lensSavedObjectSchema); diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/utils.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/utils.ts new file mode 100644 index 0000000000000..b9f9892dfd2a6 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/schema/utils.ts @@ -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. + */ + +import { pick } from 'lodash'; + +import { Props } from '@kbn/config-schema'; +import { Assign } from 'utility-types'; + +/** + * Picks a subset of props from base schema definition + * + * TODO: move this to `@kbn/config-schema` + */ +export function pickFromObjectSchema( + schema: T, + keys: K[] +): Assign<{}, Pick> { + // Note: Assign type is required to prevent omitted key pollution on spread + + // lodash.pick types do not infer the object type to enforce keyof T + return pick(schema, keys); +} diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/service.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/service.ts new file mode 100644 index 0000000000000..1593818dece8d --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/service.ts @@ -0,0 +1,89 @@ +/* + * 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 } from '@kbn/object-versioning'; + +import { + lensCMGetResultSchema, + lensItemAttributesSchema, + lensCMCreateOptionsSchema, + lensCMCreateResultSchema, + lensCMSearchOptionsSchema, + lensCMUpdateResultSchema, + lensCMSearchResultSchema, + lensCMUpdateOptionsSchema, +} from './schema'; +import { transformToV1LensSavedObject, transformToV1LensItemAttributes } from './transforms'; +import { LensAttributes, LensGetOut, LensSavedObject } from './types'; + +export const serviceDefinition = { + get: { + out: { + result: { + schema: lensCMGetResultSchema, + up: (result: LensGetOut) => { + return { + ...result, + item: transformToV1LensSavedObject(result.item), + } satisfies LensGetOut; + }, + }, + }, + }, + create: { + in: { + data: { + schema: lensItemAttributesSchema, + up: (attributes: LensAttributes) => { + return transformToV1LensItemAttributes(attributes); + }, + }, + options: { + schema: lensCMCreateOptionsSchema, + }, + }, + out: { + result: { + schema: lensCMCreateResultSchema, + }, + }, + }, + update: { + in: { + data: { + schema: lensItemAttributesSchema, + up: (attributes: LensAttributes) => { + return transformToV1LensItemAttributes(attributes); + }, + }, + options: { + schema: lensCMUpdateOptionsSchema, + }, + }, + out: { + result: { + schema: lensCMUpdateResultSchema, + }, + }, + }, + search: { + in: { + options: { + schema: lensCMSearchOptionsSchema, + }, + }, + out: { + result: { + schema: lensCMSearchResultSchema, + up: (item: LensSavedObject) => { + // apply v1 transform per item, items may have different versions + return transformToV1LensSavedObject(item); + }, + }, + }, + }, +} satisfies ContentManagementServicesDefinition; diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/add_version.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/add_version.ts new file mode 100644 index 0000000000000..c7e35a9686dde --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/add_version.ts @@ -0,0 +1,16 @@ +/* + * 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 { LENS_ITEM_VERSION } from '../constants'; +import { LensAttributes } from '../types'; + +export function addVersion(attributes: LensAttributes): LensAttributes { + return { + ...attributes, + version: LENS_ITEM_VERSION, + }; +} diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/index.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/index.ts new file mode 100644 index 0000000000000..239e9d4f376bc --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/index.ts @@ -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 type * from './types'; +export * from './transforms'; diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/legend_stats/index.test.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/legend_stats/index.test.ts new file mode 100644 index 0000000000000..5c89e482d4da3 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/legend_stats/index.test.ts @@ -0,0 +1,73 @@ +/* + * 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 { convertToLegendStats } from '.'; +import { LensAttributes } from '../../types'; +import { convertPartitionToLegendStats } from './partition'; +import { convertXYToLegendStats } from './xy'; + +jest.mock('./xy', () => ({ + convertXYToLegendStats: jest.fn().mockReturnValue('new xyVisState'), +})); +jest.mock('./partition', () => ({ + convertPartitionToLegendStats: jest.fn().mockReturnValue('new partitionVisState'), +})); + +describe('Legend stat transforms', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return original attributes if no state', () => { + const attributes = { + state: undefined, + } as LensAttributes; + const result = convertToLegendStats(attributes); + + expect(result).toBe(attributes); + }); + + it('should return original attributes for noop visualizationTypes', () => { + const attributes = { + state: {}, + visualizationType: 'noop', + } as LensAttributes; + const result = convertToLegendStats(attributes); + + expect(result).toBe(attributes); + }); + + it('should convert lnsXY attributes', () => { + const attributes = { + state: { + visualization: 'xyVisState', + }, + visualizationType: 'lnsXY', + } as LensAttributes; + const result = convertToLegendStats(attributes); + + expect(convertXYToLegendStats).toBeCalledWith('xyVisState'); + expect(result.state).toMatchObject({ + visualization: 'new xyVisState', + }); + }); + + it('should convert lnsPie attributes', () => { + const attributes = { + state: { + visualization: 'partitionVisState', + }, + visualizationType: 'lnsPie', + } as LensAttributes; + const result = convertToLegendStats(attributes); + + expect(convertPartitionToLegendStats).toBeCalledWith('partitionVisState'); + expect(result.state).toMatchObject({ + visualization: 'new partitionVisState', + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/legend_stats/index.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/legend_stats/index.ts new file mode 100644 index 0000000000000..e832a5e301087 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/legend_stats/index.ts @@ -0,0 +1,56 @@ +/* + * 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 { XYState } from '../../../../../public'; +import type { PieVisualizationState } from '../../../../../common/types'; +import type { LensAttributes } from '../../types'; +import { + convertPartitionToLegendStats, + type DeprecatedLegendValuePieVisualizationState, +} from './partition'; +import { convertXYToLegendStats, type DeprecatedLegendValueXYState } from './xy'; + +export function convertToLegendStats(attributes: LensAttributes): LensAttributes { + if ( + !attributes.state || + (attributes.visualizationType !== 'lnsXY' && attributes.visualizationType !== 'lnsPie') + ) { + return attributes; + } + + const newVisualizationState = getUpdatedVisualizationState( + attributes.visualizationType, + attributes.state as Record + ); + + return { + ...attributes, + state: { + ...(attributes.state as Record), + visualization: newVisualizationState, + }, + }; +} + +export function getUpdatedVisualizationState( + visualizationType: LensAttributes['visualizationType'], + state: LensAttributes['state'] & { visualization?: unknown } +): LensAttributes['state'] { + if (visualizationType === 'lnsXY' && state?.visualization) { + const visState = state.visualization as XYState | DeprecatedLegendValueXYState; + return convertXYToLegendStats(visState); + } + + if (visualizationType === 'lnsPie' && state?.visualization) { + const visState = state.visualization as + | PieVisualizationState + | DeprecatedLegendValuePieVisualizationState; + return convertPartitionToLegendStats(visState); + } + + return state.visualization; +} diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/legend_stats/partition.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/legend_stats/partition.ts new file mode 100644 index 0000000000000..f813d0ad19f3a --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/legend_stats/partition.ts @@ -0,0 +1,42 @@ +/* + * 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 { LegendValue } from '@elastic/charts'; + +import { PieLayerState, PieVisualizationState } from '../../../../../common/types'; + +/** @deprecated */ +type DeprecatedLegendValueLayer = PieLayerState & { + showValuesInLegend?: boolean; +}; + +/** + * Old color mapping state meant for type safety during runtime migrations of old configurations + * + * @deprecated + */ +export type DeprecatedLegendValuePieVisualizationState = Omit & { + layers: DeprecatedLegendValueLayer[]; +}; + +export function convertPartitionToLegendStats( + state: DeprecatedLegendValuePieVisualizationState | PieVisualizationState +) { + state.layers.forEach((l) => { + if ('showValuesInLegend' in l) { + l.legendStats = [ + ...new Set([ + ...(l.showValuesInLegend ? [LegendValue.Value] : []), + ...(l.legendStats ?? []), + ]), + ]; + } + delete (l as DeprecatedLegendValueLayer).showValuesInLegend; + }); + + return state; +} diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/legend_stats/types.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/legend_stats/types.ts new file mode 100644 index 0000000000000..3c1338772d318 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/legend_stats/types.ts @@ -0,0 +1,13 @@ +/* + * 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 { DeprecatedLegendValuePieVisualizationState } from './partition'; +import { DeprecatedLegendValueXYState } from './xy'; + +export type DeprecatedLegendValueState = + | DeprecatedLegendValueXYState + | DeprecatedLegendValuePieVisualizationState; diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/legend_stats/xy.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/legend_stats/xy.ts new file mode 100644 index 0000000000000..c815cb01a5358 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/legend_stats/xy.ts @@ -0,0 +1,42 @@ +/* + * 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 { LegendValue } from '@elastic/charts'; + +import type { XYState } from '../../../../../public/visualizations/xy/types'; + +/** + * Old color mapping state meant for type safety during runtime migrations of old configurations + * + * @deprecated + */ +export interface DeprecatedLegendValueXYState extends XYState { + valuesInLegend?: boolean; +} + +export function convertXYToLegendStats(state: DeprecatedLegendValueXYState | XYState): XYState { + if ('valuesInLegend' in state) { + const valuesInLegend = state.valuesInLegend; + delete state.valuesInLegend; + + const result: XYState = { + ...state, + legend: { + ...state.legend, + legendStats: [ + ...new Set([ + ...(valuesInLegend ? [LegendValue.CurrentAndLastValue] : []), + ...(state.legend.legendStats ?? []), + ]), + ], + }, + }; + + return result; + } + return state; +} diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/common/converter.test.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/common/converter.test.ts new file mode 100644 index 0000000000000..2be0c0278349c --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/common/converter.test.ts @@ -0,0 +1,435 @@ +/* + * 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 { ColorMapping } from '@kbn/coloring'; +import { SerializedRangeKey } from '@kbn/data-plugin/common/search'; + +import type { DataType, GenericIndexPatternColumn } from '../../../../../../public'; +import type { DeprecatedColorMappingConfig } from './types'; +import { convertToRawColorMappings, isDeprecatedColorMapping } from './converter'; + +type OldAssignment = DeprecatedColorMappingConfig['assignments'][number]; +type OldRule = OldAssignment['rule']; +type OldSpecialRule = DeprecatedColorMappingConfig['specialAssignments'][number]['rule']; + +const baseConfig = { + assignments: [], + specialAssignments: [], + paletteId: 'default', + colorMode: { + type: 'categorical', + }, +} satisfies DeprecatedColorMappingConfig | ColorMapping.Config; + +const getOldColorMapping = ( + rules: OldRule[], + specialRules: OldSpecialRule[] = [] +): DeprecatedColorMappingConfig => ({ + assignments: rules.map((rule, i) => ({ + rule, + color: { + type: 'categorical', + paletteId: 'default', + colorIndex: i, + }, + touched: false, + })), + specialAssignments: specialRules.map((rule) => ({ + rule, + color: { + type: 'loop', + }, + touched: false, + })), + paletteId: 'default', + colorMode: { + type: 'categorical', + }, +}); + +function buildOldColorMapping( + rules: OldRule[], + specialRules: OldSpecialRule[] = [] +): DeprecatedColorMappingConfig { + return getOldColorMapping(rules, specialRules); +} + +describe('converter', () => { + describe('#convertToRawColorMappings', () => { + it('should convert config with no assignments', () => { + const oldConfig = buildOldColorMapping([]); + const newConfig = convertToRawColorMappings(oldConfig); + expect(newConfig.assignments).toHaveLength(0); + }); + + it('should keep top-level config', () => { + const oldConfig = buildOldColorMapping([]); + const newConfig = convertToRawColorMappings(oldConfig); + expect(newConfig).toMatchObject({ + paletteId: 'default', + colorMode: { + type: 'categorical', + }, + }); + }); + + describe('type - auto', () => { + it('should convert single auto rule', () => { + const oldConfig = buildOldColorMapping([{ type: 'auto' }]); + const newConfig = convertToRawColorMappings(oldConfig); + expect(newConfig.assignments).toHaveLength(1); + expect(newConfig.assignments[0].color).toBeDefined(); + expect(newConfig.assignments[0].rules).toEqual([]); + }); + + it('should convert multiple auto rule', () => { + const oldConfig = buildOldColorMapping([ + { type: 'auto' }, + { type: 'matchExactly', values: [] }, + { type: 'auto' }, + ]); + const newConfig = convertToRawColorMappings(oldConfig); + expect(newConfig.assignments).toHaveLength(3); + expect(newConfig.assignments[0].rules).toEqual([]); + expect(newConfig.assignments[2].rules).toEqual([]); + }); + }); + + describe('type - matchExactly', () => { + type ExpectedRule = Partial; + interface ExpectedRulesByType { + types: Array; + expectedRule: ExpectedRule; + } + type MatchExactlyTestCase = [ + oldStringValue: string | string[], + defaultExpectedRule: ExpectedRule, + expectedRulesByType: ExpectedRulesByType[] + ]; + + const buildOldColorMappingFromValues = (values: Array) => + buildOldColorMapping([{ type: 'matchExactly', values }]); + + it('should handle missing column', () => { + const oldConfig = buildOldColorMapping([{ type: 'matchExactly', values: ['test'] }]); + const newConfig = convertToRawColorMappings(oldConfig, undefined); + expect(newConfig.assignments).toHaveLength(1); + expect(newConfig.assignments[0].rules).toHaveLength(1); + expect(newConfig.assignments[0].rules[0]).toEqual({ + type: 'match', + pattern: 'test', + matchEntireWord: true, + matchCase: true, + }); + }); + + describe('multi_terms', () => { + it('should convert array of string values as MultiFieldKey', () => { + const values: string[] = ['some-string', '123', '0', '1', '1744261200000', '__other__']; + const oldConfig = buildOldColorMappingFromValues([values]); + const newConfig = convertToRawColorMappings(oldConfig, {}); + const rule = newConfig.assignments[0].rules[0]; + + expect(rule).toEqual({ + type: 'raw', + value: { + keys: ['some-string', '123', '0', '1', '1744261200000', '__other__'], + type: 'multiFieldKey', + }, + }); + }); + + it('should convert array of strings in multi_terms as MultiFieldKey', () => { + const oldConfig = buildOldColorMappingFromValues([['some-string']]); + const newConfig = convertToRawColorMappings(oldConfig, { + fieldType: 'multi_terms', + }); + const rule = newConfig.assignments[0].rules[0]; + + expect(rule).toEqual({ + type: 'raw', + value: { + keys: ['some-string'], + type: 'multiFieldKey', + }, + }); + }); + + it('should convert single string as basic match even in multi_terms column', () => { + const oldConfig = buildOldColorMappingFromValues(['some-string']); + const newConfig = convertToRawColorMappings(oldConfig, { + fieldType: 'multi_terms', + }); + const rule = newConfig.assignments[0].rules[0]; + + expect(rule).toEqual({ + type: 'match', + pattern: 'some-string', + matchEntireWord: true, + matchCase: true, + }); + }); + }); + + describe('range', () => { + it.each<[rangeString: string, expectedRange: Pick]>([ + ['from:0,to:1000', { from: 0, to: 1000 }], + ['from:-1000,to:1000', { from: -1000, to: 1000 }], + ['from:-1000,to:0', { from: -1000, to: 0 }], + ['from:1000,to:undefined', { from: 1000, to: null }], + ['from:undefined,to:1000', { from: null, to: 1000 }], + ['from:undefined,to:undefined', { from: null, to: null }], + ])('should convert range string %j to RangeKey', (rangeString, expectedRange) => { + const oldConfig = buildOldColorMappingFromValues([rangeString]); + const newConfig = convertToRawColorMappings(oldConfig, { + fieldType: 'range', + }); + const rule = newConfig.assignments[0].rules[0]; + + expect(rule).toEqual({ + type: 'raw', + value: { + ...expectedRange, + type: 'rangeKey', + ranges: [], + }, + }); + }); + + it('should convert non-range string to match', () => { + const oldConfig = buildOldColorMappingFromValues(['not-a-range']); + const newConfig = convertToRawColorMappings(oldConfig, { + fieldType: 'range', + }); + const rule = newConfig.assignments[0].rules[0]; + + expect(rule).toEqual({ + type: 'match', + pattern: 'not-a-range', + matchEntireWord: true, + matchCase: true, + }); + }); + }); + + describe.each([ + 'number', + 'boolean', + 'date', + 'string', + 'ip', + undefined, // other + ])('Column dataType - %s', (dataType) => { + const column: Partial = { dataType }; + + it.each([ + [ + '123.456', + { + type: 'raw', + value: 123.456, + }, + [ + { types: ['string', 'ip'], expectedRule: { type: 'raw', value: '123.456' } }, + { + types: ['undefined', 'boolean'], + expectedRule: { type: 'match', pattern: '123.456' }, + }, + ], + ], + [ + '1744261200000', + { + type: 'raw', + value: 1744261200000, + }, + [ + { types: ['string', 'ip'], expectedRule: { type: 'raw', value: '1744261200000' } }, + { + types: ['undefined', 'boolean'], + expectedRule: { type: 'match', pattern: '1744261200000' }, + }, + ], + ], + [ + '__other__', + { + type: 'raw', + value: '__other__', + }, + [{ types: ['undefined'], expectedRule: { type: 'match', pattern: '__other__' } }], + ], + [ + 'some-string', + { + type: 'raw', + value: 'some-string', + }, + [ + { + types: ['undefined', 'number', 'boolean', 'date'], + expectedRule: { type: 'match', pattern: 'some-string' }, + }, + ], + ], + [ + 'false', + { + type: 'raw', + value: 'false', + }, + [ + { + types: ['undefined', 'number', 'date'], + expectedRule: { type: 'match', pattern: 'false' }, + }, + ], + ], + [ + 'true', + { + type: 'raw', + value: 'true', + }, + [ + { + types: ['undefined', 'number', 'date'], + expectedRule: { type: 'match', pattern: 'true' }, + }, + ], + ], + [ + '0', // false + { + type: 'raw', + value: 0, + }, + [ + { types: ['string', 'ip'], expectedRule: { type: 'raw', value: '0' } }, + { types: ['undefined'], expectedRule: { type: 'match', pattern: '0' } }, + ], + ], + [ + '1', // true + { + type: 'raw', + value: 1, + }, + [ + { types: ['string', 'ip'], expectedRule: { type: 'raw', value: '1' } }, + { types: ['undefined'], expectedRule: { type: 'match', pattern: '1' } }, + ], + ], + [ + '127.0.0.1', + { + type: 'raw', + value: '127.0.0.1', + }, + [ + { + types: ['undefined', 'number', 'boolean', 'date'], + expectedRule: { type: 'match', pattern: '127.0.0.1' }, + }, + ], + ], + ])('should correctly convert %j', (value, defaultExpectedRule, expectedRulesByType) => { + const oldConfig = buildOldColorMappingFromValues([value]); + const expectedRule = + expectedRulesByType.find((r) => r.types.includes(dataType ?? 'undefined')) + ?.expectedRule ?? defaultExpectedRule; + const newConfig = convertToRawColorMappings(oldConfig, { ...column }); + const rule = newConfig.assignments[0].rules[0]; + + if (expectedRule.type === 'match') { + // decorate match type with default constants + expectedRule.matchEntireWord = true; + expectedRule.matchCase = true; + } + + expect(rule).toEqual(expectedRule); + }); + }); + }); + }); + + describe('#isDeprecatedColorMapping', () => { + const baseAssignment = { + color: { + type: 'categorical', + paletteId: 'default', + colorIndex: 3, + }, + touched: false, + } satisfies Omit; + + it('should return true if assignments.rule exists', () => { + const isDeprecated = isDeprecatedColorMapping({ + ...baseConfig, + assignments: [ + { + ...baseAssignment, + rule: { + type: 'auto', + }, + }, + ], + }); + expect(isDeprecated).toBe(true); + }); + + it('should return true if specialAssignments.rule exists', () => { + const isDeprecated = isDeprecatedColorMapping({ + ...baseConfig, + specialAssignments: [ + { + ...baseAssignment, + rule: { + type: 'other', + }, + }, + ], + }); + expect(isDeprecated).toBe(true); + }); + + it('should return false if assignments.rule does not exist', () => { + const isDeprecated = isDeprecatedColorMapping({ + ...baseConfig, + assignments: [ + { + ...baseAssignment, + rules: [ + { + type: 'match', + pattern: 'test', + }, + ], + }, + ], + }); + expect(isDeprecated).toBe(false); + }); + + it('should return false if specialAssignments.rule does not exist', () => { + const isDeprecated = isDeprecatedColorMapping({ + ...baseConfig, + specialAssignments: [ + { + ...baseAssignment, + rules: [ + { + type: 'other', + }, + ], + }, + ], + }); + expect(isDeprecated).toBe(false); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/common/converter.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/common/converter.ts new file mode 100644 index 0000000000000..96f3af4f51f18 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/common/converter.ts @@ -0,0 +1,161 @@ +/* + * 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 { ColorMapping } from '@kbn/coloring'; +import { MultiFieldKey, RangeKey, SerializedValue } from '@kbn/data-plugin/common'; + +import type { DeprecatedColorMappingConfig } from './types'; +import type { ColumnMeta } from './utils'; + +/** + * Converts old stringified colorMapping configs to new raw value configs + */ +export function convertToRawColorMappings( + colorMapping: DeprecatedColorMappingConfig | ColorMapping.Config, + columnMeta?: ColumnMeta | null +): ColorMapping.Config { + return { + ...colorMapping, + assignments: colorMapping.assignments.map((oldAssignment) => { + if (isValidColorMappingAssignment(oldAssignment)) return oldAssignment; + return convertColorMappingAssignment(oldAssignment, columnMeta); + }), + specialAssignments: colorMapping.specialAssignments.map((oldAssignment) => { + if (isValidColorMappingAssignment(oldAssignment)) return oldAssignment; + return { + color: oldAssignment.color, + touched: oldAssignment.touched, + rules: [oldAssignment.rule], + }; + }), + }; +} + +function convertColorMappingAssignment( + oldAssignment: DeprecatedColorMappingConfig['assignments'][number], + columnMeta?: ColumnMeta | null +): ColorMapping.Assignment { + return { + color: oldAssignment.color, + touched: oldAssignment.touched, + rules: convertColorMappingRule(oldAssignment.rule, columnMeta), + }; +} + +const NO_VALUE = Symbol('no-value'); + +function convertColorMappingRule( + rule: DeprecatedColorMappingConfig['assignments'][number]['rule'], + columnMeta?: ColumnMeta | null +): ColorMapping.ColorRule[] { + switch (rule.type) { + case 'auto': + return []; + case 'matchExactly': + return rule.values.map((value) => { + const rawValue = convertToRawValue(value, columnMeta); + + if (rawValue !== NO_VALUE) { + return { + type: 'raw', + value: rawValue, + }; + } + + return { + type: 'match', + pattern: String(value), + matchEntireWord: true, + matchCase: true, + }; + }); + + // Rules below not yet used, adding conversions for completeness + case 'matchExactlyCI': + return rule.values.map((value) => ({ + type: 'match', + pattern: Array.isArray(value) ? value.join(' ') : value, + matchEntireWord: true, + matchCase: false, + })); + case 'regex': + return [{ type: rule.type, pattern: rule.values }]; + case 'range': + default: + return [rule]; + } +} + +/** + * Attempts to convert the previously stringified raw values into their raw/serialized form + * + * Note: we use the `NO_VALUE` symbol to avoid collisions with falsy raw values + */ +function convertToRawValue( + value: string | string[], + columnMeta?: ColumnMeta | null +): SerializedValue | symbol { + if (!columnMeta) return NO_VALUE; + + // all array values are multi-term + if (columnMeta.fieldType === 'multi_terms' || Array.isArray(value)) { + if (typeof value === 'string') return NO_VALUE; // cannot assume this as multi-field + return new MultiFieldKey({ key: value }).serialize(); + } + + if (columnMeta.fieldType === 'range') { + return RangeKey.isRangeKeyString(value) ? RangeKey.fromString(value).serialize() : NO_VALUE; + } + + switch (columnMeta.dataType) { + case 'boolean': + if (value === '__other__' || value === 'true' || value === 'false') return value; // bool could have __other__ as a string + if (value === '0' || value === '1') return Number(value); + break; + case 'number': + case 'date': + if (value === '__other__') return value; // numbers can have __other__ as a string + const numberValue = Number(value); + if (isFinite(numberValue)) return numberValue; + break; + case 'string': + case 'ip': + return value; // unable to distinguish manually added values + default: + return NO_VALUE; // treat all other other dataType as custom match string values + } + return NO_VALUE; +} + +function isValidColorMappingAssignment< + T extends + | DeprecatedColorMappingConfig['assignments'][number] + | DeprecatedColorMappingConfig['specialAssignments'][number] + | ColorMapping.Config['assignments'][number] + | ColorMapping.Config['specialAssignments'][number] +>( + assignment: T +): assignment is Exclude< + T, + | DeprecatedColorMappingConfig['assignments'][number] + | DeprecatedColorMappingConfig['specialAssignments'][number] +> { + return 'rules' in assignment; +} + +export function isDeprecatedColorMapping< + T extends DeprecatedColorMappingConfig | ColorMapping.Config +>(colorMapping?: T): colorMapping is Exclude { + if (!colorMapping) return false; + return Boolean( + colorMapping.assignments && + (colorMapping.assignments.some((assignment) => !isValidColorMappingAssignment(assignment)) || + colorMapping.specialAssignments.some( + (specialAssignment) => !isValidColorMappingAssignment(specialAssignment) + )) + ); +} diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/common/index.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/common/index.ts new file mode 100644 index 0000000000000..9e05cb0b43aa1 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/common/index.ts @@ -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 type { DeprecatedColorMappingConfig } from './types'; +export { convertToRawColorMappings, isDeprecatedColorMapping } from './converter'; +export { getColumnMetaFn } from './utils'; diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/common/types.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/common/types.ts new file mode 100644 index 0000000000000..be3c2dc239097 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/common/types.ts @@ -0,0 +1,117 @@ +/* + * 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. + */ + +/** @deprecated */ +interface DeprecatedColorMappingColorCode { + type: 'colorCode'; + colorCode: string; +} + +/** @deprecated */ +interface DeprecatedColorMappingCategoricalColor { + type: 'categorical'; + paletteId: string; + colorIndex: number; +} + +/** @deprecated */ +interface DeprecatedColorMappingGradientColor { + type: 'gradient'; +} + +/** @deprecated */ +interface DeprecatedColorMappingLoopColor { + type: 'loop'; +} + +/** @deprecated */ +interface DeprecatedColorMappingRuleAuto { + type: 'auto'; +} +/** @deprecated */ +interface DeprecatedColorMappingRuleMatchExactly { + type: 'matchExactly'; + values: Array; +} +/** @deprecated */ +interface DeprecatedColorMappingRuleMatchExactlyCI { + type: 'matchExactlyCI'; + values: string[]; +} + +/** @deprecated */ +interface DeprecatedColorMappingRuleRange { + type: 'range'; + min: number; + max: number; + minInclusive: boolean; + maxInclusive: boolean; +} + +/** @deprecated */ +interface DeprecatedColorMappingRuleRegExp { + type: 'regex'; + values: string; +} + +/** @deprecated */ +interface DeprecatedColorMappingRuleOthers { + type: 'other'; +} + +/** @deprecated */ +interface DeprecatedColorMappingAssignment { + rule: R; + color: C; + touched: boolean; +} + +/** @deprecated */ +interface DeprecatedColorMappingCategoricalColorMode { + type: 'categorical'; +} + +/** @deprecated */ +interface DeprecatedColorMappingGradientColorMode { + type: 'gradient'; + steps: Array< + (DeprecatedColorMappingCategoricalColor | DeprecatedColorMappingColorCode) & { + touched: boolean; + } + >; + sort: 'asc' | 'desc'; +} + +/** + * Old color mapping state meant for type safety during runtime migrations of old configurations + * + * @deprecated Use `ColorMapping.Config` + */ +export interface DeprecatedColorMappingConfig { + paletteId: string; + colorMode: DeprecatedColorMappingCategoricalColorMode | DeprecatedColorMappingGradientColorMode; + assignments: Array< + DeprecatedColorMappingAssignment< + | DeprecatedColorMappingRuleAuto + | DeprecatedColorMappingRuleMatchExactly + | DeprecatedColorMappingRuleMatchExactlyCI + | DeprecatedColorMappingRuleRange + | DeprecatedColorMappingRuleRegExp, + | DeprecatedColorMappingCategoricalColor + | DeprecatedColorMappingColorCode + | DeprecatedColorMappingGradientColor + > + >; + specialAssignments: Array< + DeprecatedColorMappingAssignment< + DeprecatedColorMappingRuleOthers, + | DeprecatedColorMappingCategoricalColor + | DeprecatedColorMappingColorCode + | DeprecatedColorMappingLoopColor + > + >; +} diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/common/utils.test.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/common/utils.test.ts new file mode 100644 index 0000000000000..342148f2e93bd --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/common/utils.test.ts @@ -0,0 +1,153 @@ +/* + * 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 { + FormBasedLayer, + FormBasedPersistedState, + TextBasedPersistedState, +} from '../../../../../../public'; +import type { TextBasedLayer } from '../../../../../../public/datasources/form_based/esql_layer/types'; +import type { StructuredDatasourceStates } from '../../../../../../public/react_embeddable/types'; +import { ColumnMeta, getColumnMetaFn } from './utils'; + +const layerId = 'layer-1'; +const columnId = 'column-1'; + +const getDatasourceStatesMock = ( + type: keyof StructuredDatasourceStates, + dataType?: ColumnMeta['dataType'], + fieldType?: ColumnMeta['fieldType'] +) => { + if (type === 'formBased') { + return { + formBased: { + layers: { + [layerId]: { + columns: { + [columnId]: { + dataType, + params: { + parentFormat: { id: fieldType }, + }, + }, + }, + } as unknown as FormBasedLayer, + }, + } satisfies FormBasedPersistedState, + }; + } + + if (type === 'textBased') { + return { + textBased: { + layers: { + [layerId]: { + columns: [{ columnId, meta: { type: dataType } }], + } as unknown as TextBasedLayer, + }, + } satisfies TextBasedPersistedState, + }; + } +}; + +describe('utils', () => { + describe('getColumnMetaFn', () => { + const mockDataType = 'string'; + const mockFieldType = 'terms'; + + it('should return null if neither type exists', () => { + const mockDatasourceState = {}; + const resultFn = getColumnMetaFn(mockDatasourceState); + + expect(resultFn).toBeNull(); + }); + + describe('formBased datasourceState', () => { + it('should correct dataType and fieldType', () => { + const mockDatasourceState = getDatasourceStatesMock( + 'formBased', + mockDataType, + mockFieldType + ); + const resultFn = getColumnMetaFn(mockDatasourceState)!; + const result = resultFn(layerId, columnId); + + expect(result.dataType).toBe(mockDataType); + expect(result.fieldType).toBe(mockFieldType); + }); + + it('should undefined dataType and fieldType if column not found', () => { + const mockDatasourceState = getDatasourceStatesMock('formBased'); + const resultFn = getColumnMetaFn(mockDatasourceState)!; + const result = resultFn(layerId, 'bad-column'); + + expect(result.dataType).toBeUndefined(); + expect(result.fieldType).toBeUndefined(); + }); + + it('should undefined dataType and fieldType if layer not found', () => { + const mockDatasourceState = getDatasourceStatesMock('formBased'); + const resultFn = getColumnMetaFn(mockDatasourceState)!; + const result = resultFn('bad-layer', columnId); + + expect(result.dataType).toBeUndefined(); + expect(result.fieldType).toBeUndefined(); + }); + + it('should undefined dataType and fieldType if missing', () => { + const mockDatasourceState = getDatasourceStatesMock('formBased'); + const resultFn = getColumnMetaFn(mockDatasourceState)!; + const result = resultFn(layerId, columnId); + + expect(result.dataType).toBeUndefined(); + expect(result.fieldType).toBeUndefined(); + }); + }); + + describe('textBased datasourceState', () => { + it('should correct dataType and fieldType', () => { + const mockDatasourceState = getDatasourceStatesMock( + 'textBased', + mockDataType, + mockFieldType + ); + const resultFn = getColumnMetaFn(mockDatasourceState)!; + const result = resultFn(layerId, columnId); + + expect(result.dataType).toBe(mockDataType); + expect(result.fieldType).toBeUndefined(); // no fieldType needed for textBased + }); + + it('should undefined dataType and fieldType if column not found', () => { + const mockDatasourceState = getDatasourceStatesMock('textBased'); + const resultFn = getColumnMetaFn(mockDatasourceState)!; + const result = resultFn(layerId, 'bad-column'); + + expect(result.dataType).toBeUndefined(); + expect(result.fieldType).toBeUndefined(); + }); + + it('should undefined dataType and fieldType if layer not found', () => { + const mockDatasourceState = getDatasourceStatesMock('textBased'); + const resultFn = getColumnMetaFn(mockDatasourceState)!; + const result = resultFn('bad-layer', columnId); + + expect(result.dataType).toBeUndefined(); + expect(result.fieldType).toBeUndefined(); + }); + + it('should undefined dataType and fieldType if missing', () => { + const mockDatasourceState = getDatasourceStatesMock('textBased'); + const resultFn = getColumnMetaFn(mockDatasourceState)!; + const result = resultFn(layerId, columnId); + + expect(result.dataType).toBeUndefined(); + expect(result.fieldType).toBeUndefined(); + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/common/utils.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/common/utils.ts new file mode 100644 index 0000000000000..9a0cea6c89a77 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/common/utils.ts @@ -0,0 +1,48 @@ +/* + * 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 { DatatableColumnType } from '@kbn/expressions-plugin/common'; +import type { GenericIndexPatternColumn } from '../../../../../../public'; +import type { StructuredDatasourceStates } from '../../../../../../public/react_embeddable/types'; + +export interface ColumnMeta { + fieldType?: string | 'multi_terms' | 'range'; + dataType?: GenericIndexPatternColumn['dataType'] | DatatableColumnType; +} + +export function getColumnMetaFn( + datasourceStates?: StructuredDatasourceStates +): ((layerId: string, columnId: string) => ColumnMeta) | null { + if (datasourceStates?.formBased?.layers) { + const layers = datasourceStates.formBased.layers; + + return (layerId, columnId) => { + const column = layers[layerId]?.columns?.[columnId]; + return { + fieldType: + column && 'params' in column + ? (column.params as { parentFormat?: { id?: string } })?.parentFormat?.id + : undefined, + dataType: column?.dataType, + }; + }; + } + + if (datasourceStates?.textBased?.layers) { + const layers = datasourceStates.textBased.layers; + + return (layerId, columnId) => { + const column = layers[layerId]?.columns?.find((c) => c.columnId === columnId); + + return { + dataType: column?.meta?.type, + }; + }; + } + + return null; +} diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/datatable.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/datatable.ts new file mode 100644 index 0000000000000..5a440a15018e9 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/datatable.ts @@ -0,0 +1,61 @@ +/* + * 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 { DatatableVisualizationState } from '../../../../../public'; +import type { GeneralDatasourceStates } from '../../../../../public/state_management'; +import { ColumnState } from '../../../../../common/expressions'; +import { + convertToRawColorMappings, + getColumnMetaFn, + isDeprecatedColorMapping, + type DeprecatedColorMappingConfig, +} from './common'; + +/** @deprecated */ +interface DeprecatedColorMappingColumn extends Omit { + colorMapping: DeprecatedColorMappingConfig; +} + +/** + * Old color mapping state meant for type safety during runtime migrations of old configurations + * + * @deprecated Use respective vis state (i.e. `DatatableVisualizationState`) + */ +export interface DeprecatedColorMappingsDatatableState + extends Omit { + columns: Array; +} + +export const convertDatatableToRawColorMappings = ( + state: DatatableVisualizationState | DeprecatedColorMappingsDatatableState, + datasourceStates?: Readonly +): DatatableVisualizationState => { + const getColumnMeta = getColumnMetaFn(datasourceStates); + const hasDeprecatedColorMappings = state.columns.some((column) => { + return isDeprecatedColorMapping(column.colorMapping); + }); + + if (!hasDeprecatedColorMappings) return state as DatatableVisualizationState; + + const convertedColumns = state.columns.map((column) => { + if (column.colorMapping?.assignments || column.colorMapping?.specialAssignments) { + const columnMeta = getColumnMeta?.(state.layerId, column.columnId); + + return { + ...column, + colorMapping: convertToRawColorMappings(column.colorMapping, columnMeta), + } satisfies ColumnState; + } + + return column as ColumnState; + }); + + return { + ...state, + columns: convertedColumns, + } satisfies DatatableVisualizationState; +}; diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/index.test.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/index.test.ts new file mode 100644 index 0000000000000..d3b72fb17f5f8 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/index.test.ts @@ -0,0 +1,121 @@ +/* + * 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 { convertToRawColorMappingsFn } from '.'; +import { LensAttributes } from '../../types'; +import { convertXYToRawColorMappings } from './xy'; +import { convertPieToRawColorMappings } from './partition'; +import { convertDatatableToRawColorMappings } from './datatable'; +import { convertTagcloudToRawColorMappings } from './tagcloud'; + +jest.mock('./xy', () => ({ + convertXYToRawColorMappings: jest.fn().mockReturnValue('new xyVisState'), +})); +jest.mock('./partition', () => ({ + convertPieToRawColorMappings: jest.fn().mockReturnValue('new partitionVisState'), +})); +jest.mock('./datatable', () => ({ + convertDatatableToRawColorMappings: jest.fn().mockReturnValue('new datatableVisState'), +})); +jest.mock('./tagcloud', () => ({ + convertTagcloudToRawColorMappings: jest.fn().mockReturnValue('new tagcloudVisState'), +})); + +describe('Legend stat transforms', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return original attributes if no state', () => { + const attributes = { + state: undefined, + } as LensAttributes; + const result = convertToRawColorMappingsFn(attributes); + + expect(result).toBe(attributes); + }); + + it('should return original attributes for noop visualizationTypes', () => { + const attributes = { + state: {}, + visualizationType: 'noop', + } as LensAttributes; + const result = convertToRawColorMappingsFn(attributes); + + expect(result).toBe(attributes); + }); + + it('should convert lnsXY attributes', () => { + const attributes = { + state: { + visualization: 'xyVisState', + datasourceStates: 'datasourceStates', + }, + visualizationType: 'lnsXY', + } as LensAttributes; + const result = convertToRawColorMappingsFn(attributes); + + expect(convertXYToRawColorMappings).toBeCalledWith('xyVisState', 'datasourceStates'); + expect(result.state).toMatchObject({ + visualization: 'new xyVisState', + }); + }); + + it('should convert lnsPie attributes', () => { + const attributes = { + state: { + visualization: 'partitionVisState', + datasourceStates: 'datasourceStates', + }, + visualizationType: 'lnsPie', + } as LensAttributes; + const result = convertToRawColorMappingsFn(attributes); + + expect(convertPieToRawColorMappings).toBeCalledWith('partitionVisState', 'datasourceStates'); + expect(result.state).toMatchObject({ + visualization: 'new partitionVisState', + }); + }); + + it('should convert lnsDatatable attributes', () => { + const attributes = { + state: { + visualization: 'datatableVisState', + datasourceStates: 'datasourceStates', + }, + visualizationType: 'lnsDatatable', + } as LensAttributes; + const result = convertToRawColorMappingsFn(attributes); + + expect(convertDatatableToRawColorMappings).toBeCalledWith( + 'datatableVisState', + 'datasourceStates' + ); + expect(result.state).toMatchObject({ + visualization: 'new datatableVisState', + }); + }); + + it('should convert lnsTagcloud attributes', () => { + const attributes = { + state: { + visualization: 'tagcloudVisState', + datasourceStates: 'datasourceStates', + }, + visualizationType: 'lnsTagcloud', + } as LensAttributes; + const result = convertToRawColorMappingsFn(attributes); + + expect(convertTagcloudToRawColorMappings).toBeCalledWith( + 'tagcloudVisState', + 'datasourceStates' + ); + expect(result.state).toMatchObject({ + visualization: 'new tagcloudVisState', + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/index.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/index.ts new file mode 100644 index 0000000000000..6fea4e8bc4dad --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/index.ts @@ -0,0 +1,87 @@ +/* + * 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 { DatatableVisualizationState, TagcloudState, XYState } from '../../../../../public'; +import type { StructuredDatasourceStates } from '../../../../../public/react_embeddable/types'; +import type { PieVisualizationState } from '../../../../../common/types'; +import type { LensAttributes } from '../../types'; +import { convertXYToRawColorMappings, type DeprecatedColorMappingsXYState } from './xy'; +import { + DeprecatedColorMappingPieVisualizationState, + convertPieToRawColorMappings, +} from './partition'; +import { + convertDatatableToRawColorMappings, + type DeprecatedColorMappingsDatatableState, +} from './datatable'; +import { + convertTagcloudToRawColorMappings, + type DeprecatedColorMappingTagcloudState, +} from './tagcloud'; + +export function convertToRawColorMappingsFn(attributes: LensAttributes): LensAttributes { + if ( + !attributes.state || + (attributes.visualizationType !== 'lnsXY' && + attributes.visualizationType !== 'lnsPie' && + attributes.visualizationType !== 'lnsDatatable' && + attributes.visualizationType !== 'lnsTagcloud') + ) { + return attributes; + } + + const newVisualizationState = getUpdatedVisualizationState( + attributes.visualizationType, + attributes.state as Record + ); + + return { + ...attributes, + state: { + ...(attributes.state as Record), + visualization: newVisualizationState, + }, + }; +} + +export function getUpdatedVisualizationState( + visualizationType: LensAttributes['visualizationType'], + state: LensAttributes['state'] & { + visualization?: unknown; + datasourceStates?: unknown; + } +): LensAttributes['state'] { + if (!state?.visualization) return state.visualization; + + const datasourceStates = state.datasourceStates as StructuredDatasourceStates | undefined; + + if (visualizationType === 'lnsXY') { + const visState = state.visualization as XYState | DeprecatedColorMappingsXYState; + return convertXYToRawColorMappings(visState, datasourceStates); + } + + if (visualizationType === 'lnsPie') { + const visState = state.visualization as + | PieVisualizationState + | DeprecatedColorMappingPieVisualizationState; + return convertPieToRawColorMappings(visState, datasourceStates); + } + + if (visualizationType === 'lnsDatatable') { + const visState = state.visualization as + | DatatableVisualizationState + | DeprecatedColorMappingsDatatableState; + return convertDatatableToRawColorMappings(visState, datasourceStates); + } + + if (visualizationType === 'lnsTagcloud') { + const visState = state.visualization as TagcloudState | DeprecatedColorMappingTagcloudState; + return convertTagcloudToRawColorMappings(visState, datasourceStates); + } + + return state.visualization; +} diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/partition.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/partition.ts new file mode 100644 index 0000000000000..56247af84b654 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/partition.ts @@ -0,0 +1,64 @@ +/* + * 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 { GeneralDatasourceStates } from '../../../../../public/state_management'; +import { PieLayerState, PieVisualizationState } from '../../../../../common/types'; +import { + convertToRawColorMappings, + getColumnMetaFn, + isDeprecatedColorMapping, + type DeprecatedColorMappingConfig, +} from './common'; + +/** @deprecated */ +interface DeprecatedColorMappingLayer extends Omit { + colorMapping: DeprecatedColorMappingConfig; +} + +/** + * Old color mapping state meant for type safety during runtime migrations of old configurations + * + * @deprecated + */ +export interface DeprecatedColorMappingPieVisualizationState + extends Omit { + layers: Array; +} + +export const convertPieToRawColorMappings = ( + state: PieVisualizationState | DeprecatedColorMappingPieVisualizationState, + datasourceStates?: Readonly +): PieVisualizationState => { + const getColumnMeta = getColumnMetaFn(datasourceStates); + const hasDeprecatedColorMappings = state.layers.some((layer) => { + return layer.layerType === 'data' && isDeprecatedColorMapping(layer.colorMapping); + }); + + if (!hasDeprecatedColorMappings) return state as PieVisualizationState; + + const convertedLayers = state.layers.map((layer) => { + if ( + layer.layerType === 'data' && + (layer.colorMapping?.assignments || layer.colorMapping?.specialAssignments) + ) { + const [accessor] = layer.primaryGroups; + const columnMeta = accessor ? getColumnMeta?.(layer.layerId, accessor) : null; + + return { + ...layer, + colorMapping: convertToRawColorMappings(layer.colorMapping, columnMeta), + } satisfies PieLayerState; + } + + return layer as PieLayerState; + }); + + return { + ...state, + layers: convertedLayers, + } satisfies PieVisualizationState; +}; diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/tagcloud.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/tagcloud.ts new file mode 100644 index 0000000000000..99b22927caba9 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/tagcloud.ts @@ -0,0 +1,43 @@ +/* + * 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 { TagcloudState } from '../../../../../public'; +import type { GeneralDatasourceStates } from '../../../../../public/state_management'; +import { + convertToRawColorMappings, + getColumnMetaFn, + isDeprecatedColorMapping, + type DeprecatedColorMappingConfig, +} from './common'; + +/** + * Old color mapping state meant for type safety during runtime migrations of old configurations + * + * @deprecated + */ +export interface DeprecatedColorMappingTagcloudState extends Omit { + colorMapping: DeprecatedColorMappingConfig; +} + +export const convertTagcloudToRawColorMappings = ( + state: TagcloudState | DeprecatedColorMappingTagcloudState, + datasourceStates?: Readonly +): TagcloudState => { + const getColumnMeta = getColumnMetaFn(datasourceStates); + const hasDeprecatedColorMapping = state.colorMapping + ? isDeprecatedColorMapping(state.colorMapping) + : false; + + if (!hasDeprecatedColorMapping) return state as TagcloudState; + + const columnMeta = state.tagAccessor ? getColumnMeta?.(state.layerId, state.tagAccessor) : null; + + return { + ...state, + colorMapping: state.colorMapping && convertToRawColorMappings(state.colorMapping, columnMeta), + } satisfies TagcloudState; +}; diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/types.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/types.ts new file mode 100644 index 0000000000000..724cd5135c136 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/types.ts @@ -0,0 +1,17 @@ +/* + * 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 { DeprecatedColorMappingsXYState } from './xy'; +import type { DeprecatedColorMappingPieVisualizationState } from './partition'; +import type { DeprecatedColorMappingsDatatableState } from './datatable'; +import type { DeprecatedColorMappingTagcloudState } from './tagcloud'; + +export type DeprecatedColorMappingsState = + | DeprecatedColorMappingsXYState + | DeprecatedColorMappingPieVisualizationState + | DeprecatedColorMappingsDatatableState + | DeprecatedColorMappingTagcloudState; diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/xy.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/xy.ts new file mode 100644 index 0000000000000..7930831db7e4e --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/raw_color_mappings/xy.ts @@ -0,0 +1,63 @@ +/* + * 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 { XYDataLayerConfig, XYLayerConfig, XYState } from '../../../../../public'; +import type { GeneralDatasourceStates } from '../../../../../public/state_management'; +import { + convertToRawColorMappings, + getColumnMetaFn, + isDeprecatedColorMapping, + type DeprecatedColorMappingConfig, +} from './common'; + +/** @deprecated */ +interface DeprecatedColorMappingLayer extends Omit { + colorMapping: DeprecatedColorMappingConfig; +} + +/** + * Old color mapping state meant for type safety during runtime migrations of old configurations + * + * @deprecated + */ +export interface DeprecatedColorMappingsXYState extends Omit { + layers: Array; +} + +export const convertXYToRawColorMappings = ( + state: XYState | DeprecatedColorMappingsXYState, + datasourceStates?: Readonly +): XYState => { + const getColumnMeta = getColumnMetaFn(datasourceStates); + const hasDeprecatedColorMappings = state.layers.some((layer) => { + return layer.layerType === 'data' && isDeprecatedColorMapping(layer.colorMapping); + }); + + if (!hasDeprecatedColorMappings) return state as XYState; + + const convertedLayers = state.layers.map((layer) => { + if ( + layer.layerType === 'data' && + (layer.colorMapping?.assignments || layer.colorMapping?.specialAssignments) + ) { + const accessor = layer.splitAccessor; + const columnMeta = accessor ? getColumnMeta?.(layer.layerId, accessor) : null; + + return { + ...layer, + colorMapping: convertToRawColorMappings(layer.colorMapping, columnMeta), + } satisfies XYDataLayerConfig; + } + + return layer as XYLayerConfig; + }); + + return { + ...state, + layers: convertedLayers, + }; +}; diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/transforms.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/transforms.ts new file mode 100644 index 0000000000000..433d43b996d78 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/transforms.ts @@ -0,0 +1,46 @@ +/* + * 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 { LensAttributes, LensSavedObject } from '../types'; +import { addVersion } from './add_version'; +import { convertToLegendStats } from './legend_stats'; +import { convertToRawColorMappingsFn } from './raw_color_mappings'; +import { LensSavedObjectV0, LensAttributesV0 } from './types'; + +/** + * Transforms existing unversioned Lens SO attributes to v1 Lens Item attributes + * + * Includes: + * - Legend value → Legend stats + * - Stringified color mapping values → Raw color mappings values + * - Add version property + */ +export function transformToV1LensItemAttributes( + attributes: LensAttributesV0 | LensAttributes +): LensAttributes { + return [convertToLegendStats, convertToRawColorMappingsFn, addVersion].reduce( + (newState, fn) => fn(newState), + attributes + ); +} + +/** + * Transforms existing unversioned Lens SO to v1 Lens SO + * + * Includes: + * - Legend value → Legend stats + * - Stringified color mapping values → Raw color mappings values + * - Add version property + */ +export function transformToV1LensSavedObject( + so: LensSavedObjectV0 | LensSavedObject +): LensSavedObject { + return { + ...so, + attributes: transformToV1LensItemAttributes(so.attributes as LensAttributesV0 | LensAttributes), + }; +} diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/types.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/types.ts new file mode 100644 index 0000000000000..099c251459532 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/transforms/types.ts @@ -0,0 +1,24 @@ +/* + * 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 { SOWithMetadata } from '@kbn/content-management-utils'; + +import { LensAttributes } from '../types'; +import { DeprecatedLegendValueState } from './legend_stats/types'; +import { DeprecatedColorMappingsState } from './raw_color_mappings/types'; + +export type DeprecatedV0State = DeprecatedLegendValueState | DeprecatedColorMappingsState; + +export type LensAttributesV0 = Omit & { + version: never; // explicitly set as no version + state: LensAttributes['state'] | DeprecatedV0State; +}; + +/** + * An unversioned Lens item that may or may not include old runtime migrations. + */ +export type LensSavedObjectV0 = SOWithMetadata; diff --git a/x-pack/platform/plugins/shared/lens/server/content_management/v1/types.ts b/x-pack/platform/plugins/shared/lens/server/content_management/v1/types.ts new file mode 100644 index 0000000000000..536f559b799ba --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/server/content_management/v1/types.ts @@ -0,0 +1,88 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; +import type { + GetResultSO, + SOWithMetadata, + SOWithMetadataPartial, +} from '@kbn/content-management-utils'; +import type { + CreateIn, + CreateResult, + DeleteIn, + DeleteResult, + GetIn, + SearchIn, + SearchResult, + UpdateIn, + UpdateResult, +} from '@kbn/content-management-plugin/common'; + +import { + lensItemAttributesSchema, + lensCMCreateOptionsSchema, + lensCMUpdateOptionsSchema, + lensCMSearchOptionsSchema, + lensItemSchema, + lensItemMetaSchema, + lensResponseItemSchema, + lensAPIAttributesSchema, + lensAPIConfigSchema, +} from './schema'; +import { LENS_CONTENT_TYPE } from '../../../common/constants'; + +export type LensAttributes = TypeOf; +export type LensAPIAttributes = TypeOf; + +export type LensItem = TypeOf; +export type LensItemMeta = TypeOf; + +export type LensAPIConfig = TypeOf; +export type LensResponseItem = TypeOf; + +export type LensCreateOptions = TypeOf; +export type LensUpdateOptions = TypeOf; +export type LensSearchOptions = TypeOf; + +export type LensSavedObject = SOWithMetadata; +export type LensPartialSavedObject = SOWithMetadataPartial; + +export type LensGetIn = GetIn; +export type LensGetOut = GetResultSO; + +export type LensCreateIn = CreateIn; +export type LensCreateOut = CreateResult; + +// Need to handle Lens UpdateIn a bit differently +export type LensUpdateIn = UpdateIn; +export type LensUpdateOut = UpdateResult; + +export type LensDeleteIn = DeleteIn; +export type LensDeleteOut = DeleteResult; + +export type LensSearchIn = SearchIn; +export type LensSearchOut = SearchResult; + +export interface LensCrud { + Attributes: LensAttributes; + Item: LensSavedObject; + PartialItem: LensPartialSavedObject; + GetIn: LensGetIn; + GetOut: LensGetOut; + CreateIn: LensCreateIn; + CreateOut: LensCreateOut; + CreateOptions: LensCreateOptions; + SearchIn: LensSearchIn; + SearchOut: LensSearchOut; + SearchOptions: LensSearchOptions; + UpdateIn: LensUpdateIn; + UpdateOut: LensUpdateOut; + UpdateOptions: LensUpdateOptions; + DeleteIn: LensDeleteIn; + DeleteOut: LensDeleteOut; +} diff --git a/x-pack/platform/plugins/shared/lens/server/index.ts b/x-pack/platform/plugins/shared/lens/server/index.ts index d3f3eeb83c6c6..8f1bd4db15eb0 100644 --- a/x-pack/platform/plugins/shared/lens/server/index.ts +++ b/x-pack/platform/plugins/shared/lens/server/index.ts @@ -13,6 +13,32 @@ export const plugin = async (initContext: PluginInitializerContext) => { return new LensServerPlugin(initContext); }; -export { PUBLIC_API_PATH, PUBLIC_API_VERSION } from './api/constants'; +export { + lensGetRequestParamsSchema, + lensGetResponseBodySchema, + lensCreateRequestBodySchema, + lensCreateResponseBodySchema, + lensUpdateRequestParamsSchema, + lensUpdateRequestBodySchema, + lensUpdateResponseBodySchema, + lensDeleteRequestParamsSchema, + lensSearchRequestQuerySchema, + lensSearchResponseBodySchema, +} from './api/schema'; export type { LensDocShape715 } from './migrations/types'; + +export type { + LensCreateRequestBody, + LensCreateResponseBody, + LensUpdateRequestParams, + LensUpdateRequestBody, + LensUpdateResponseBody, + LensGetRequestParams, + LensGetResponseBody, + LensSearchRequestQuery, + LensSearchResponseBody, + LensDeleteRequestParams, + RegisterAPIRoutesArgs, + RegisterAPIRouteFn, +} from './types'; diff --git a/x-pack/platform/plugins/shared/lens/server/plugin.tsx b/x-pack/platform/plugins/shared/lens/server/plugin.tsx index b3c41328fc853..76a1fa1fba39f 100644 --- a/x-pack/platform/plugins/shared/lens/server/plugin.tsx +++ b/x-pack/platform/plugins/shared/lens/server/plugin.tsx @@ -28,9 +28,11 @@ import { setupExpressions } from './expressions'; import { makeLensEmbeddableFactory } from './embeddable/make_lens_embeddable_factory'; import type { CustomVisualizationMigrations } from './migrations/types'; import { LensAppLocatorDefinition } from '../common/locator/locator'; -import { CONTENT_ID, LATEST_VERSION } from '../common/content_management'; +import { LENS_CONTENT_TYPE, LENS_ITEM_LATEST_VERSION } from '../common/constants'; import { LensStorage } from './content_management'; import { registerLensAPIRoutes } from './api/routes'; +import { lensTransforms } from '../common/transforms/transforms'; +import { LENS_EMBEDDABLE_TYPE } from '../common/constants'; export interface PluginSetupContract { taskManager?: TaskManagerSetupContract; @@ -84,13 +86,13 @@ export class LensServerPlugin } plugins.contentManagement.register({ - id: CONTENT_ID, + id: LENS_CONTENT_TYPE, storage: new LensStorage({ throwOnResultValidationError: this.initializerContext.env.mode.dev, logger: this.initializerContext.logger.get('storage'), }), version: { - latest: LATEST_VERSION, + latest: LENS_ITEM_LATEST_VERSION, }, }); @@ -100,6 +102,7 @@ export class LensServerPlugin this.customVisualizationMigrations ); plugins.embeddable.registerEmbeddableFactory(lensEmbeddableFactory()); + plugins.embeddable.registerTransforms(LENS_EMBEDDABLE_TYPE, lensTransforms); registerLensAPIRoutes({ http: core.http, diff --git a/x-pack/platform/plugins/shared/lens/server/saved_objects.ts b/x-pack/platform/plugins/shared/lens/server/saved_objects.ts index 3b05772f7743c..02acda925f6a2 100644 --- a/x-pack/platform/plugins/shared/lens/server/saved_objects.ts +++ b/x-pack/platform/plugins/shared/lens/server/saved_objects.ts @@ -12,6 +12,11 @@ import { MigrateFunctionsObject } from '@kbn/kibana-utils-plugin/common'; import { getEditPath } from '../common/constants'; import { getAllMigrations } from './migrations/saved_object_migrations'; import { CustomVisualizationMigrations } from './migrations/types'; +import { lensItemAttributesSchemaV0 } from './content_management/v0'; +import { + LENS_ITEM_VERSION as LENS_ITEM_VERSION_V1, + lensItemAttributesSchema as lensItemAttributesSchemaV1, +} from './content_management/v1'; export function setupSavedObjects( core: CoreSetup, @@ -40,6 +45,24 @@ export function setupSavedObjects( DataViewPersistableStateService.getAllMigrations(), customVisualizationMigrations ), + modelVersions: { + [LENS_ITEM_VERSION_V1]: { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + version: { + type: 'integer', + }, + }, + }, + ], + schemas: { + forwardCompatibility: lensItemAttributesSchemaV1.extendsDeep({ unknowns: 'ignore' }), + create: lensItemAttributesSchemaV0, + }, + }, + }, mappings: { properties: { title: { @@ -51,6 +74,9 @@ export function setupSavedObjects( visualizationType: { type: 'keyword', }, + version: { + type: 'integer', + }, state: { dynamic: false, properties: {}, diff --git a/x-pack/platform/plugins/shared/lens/common/content_management/types.ts b/x-pack/platform/plugins/shared/lens/server/types.ts similarity index 86% rename from x-pack/platform/plugins/shared/lens/common/content_management/types.ts rename to x-pack/platform/plugins/shared/lens/server/types.ts index 0bf6227d945e1..1966a3a2c5915 100644 --- a/x-pack/platform/plugins/shared/lens/common/content_management/types.ts +++ b/x-pack/platform/plugins/shared/lens/server/types.ts @@ -5,4 +5,4 @@ * 2.0. */ -export type LensContentType = 'lens'; +export type * from './api/types'; diff --git a/x-pack/platform/plugins/shared/lens/tsconfig.json b/x-pack/platform/plugins/shared/lens/tsconfig.json index f7e61c08abdf3..73814e63bd05b 100644 --- a/x-pack/platform/plugins/shared/lens/tsconfig.json +++ b/x-pack/platform/plugins/shared/lens/tsconfig.json @@ -125,6 +125,7 @@ "@kbn/deeplinks-analytics", "@kbn/core-http-server", "@kbn/presentation-util", + "@kbn/content-management-table-list-view-common", ], "exclude": ["target/**/*"] } diff --git a/x-pack/platform/plugins/shared/maps/server/content_management/maps_storage.ts b/x-pack/platform/plugins/shared/maps/server/content_management/maps_storage.ts index bd4f24255c461..d8dca9e134437 100644 --- a/x-pack/platform/plugins/shared/maps/server/content_management/maps_storage.ts +++ b/x-pack/platform/plugins/shared/maps/server/content_management/maps_storage.ts @@ -98,6 +98,7 @@ export class MapsStorage { } } const { value, error: resultError } = transforms.get.out.result.down( + // @ts-expect-error - need to fix item type here response, undefined, { validate: false } @@ -232,6 +233,7 @@ export class MapsStorage { MapsUpdateOut, MapsUpdateOut >( + // @ts-expect-error - need to fix item type here { item }, undefined, // do not override version { validate: false } // validation is done above diff --git a/x-pack/platform/test/api_integration/apis/lens/visualizations/create/main.ts b/x-pack/platform/test/api_integration/apis/lens/visualizations/create/main.ts index e390eb2f51bbe..d0cc483b4798a 100644 --- a/x-pack/platform/test/api_integration/apis/lens/visualizations/create/main.ts +++ b/x-pack/platform/test/api_integration/apis/lens/visualizations/create/main.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { PUBLIC_API_PATH, PUBLIC_API_VERSION } from '@kbn/lens-plugin/server'; +import { LENS_VIS_API_PATH, LENS_API_VERSION } from '@kbn/lens-plugin/common/constants'; import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; import type { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -18,9 +18,9 @@ export default function ({ getService }: FtrProviderContext) { describe('main', () => { it('should create a lens visualization', async () => { const response = await supertest - .post(`${PUBLIC_API_PATH}/visualizations`) + .post(LENS_VIS_API_PATH) .set('kbn-xsrf', 'true') - .set(ELASTIC_HTTP_VERSION_HEADER, PUBLIC_API_VERSION) + .set(ELASTIC_HTTP_VERSION_HEADER, LENS_API_VERSION) .send(getExampleLensBody()); expect(response.status).to.be(201); diff --git a/x-pack/platform/test/api_integration/apis/lens/visualizations/create/validation.ts b/x-pack/platform/test/api_integration/apis/lens/visualizations/create/validation.ts index 07992cbd74818..1d0338568ddc2 100644 --- a/x-pack/platform/test/api_integration/apis/lens/visualizations/create/validation.ts +++ b/x-pack/platform/test/api_integration/apis/lens/visualizations/create/validation.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { PUBLIC_API_PATH, PUBLIC_API_VERSION } from '@kbn/lens-plugin/server'; +import { LENS_VIS_API_PATH, LENS_API_VERSION } from '@kbn/lens-plugin/common/constants'; import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; import type { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -16,9 +16,9 @@ export default function ({ getService }: FtrProviderContext) { describe('validation', () => { it('should return error if body is empty', async () => { const response = await supertest - .post(`${PUBLIC_API_PATH}/visualizations`) + .post(LENS_VIS_API_PATH) .set('kbn-xsrf', 'true') - .set(ELASTIC_HTTP_VERSION_HEADER, PUBLIC_API_VERSION) + .set(ELASTIC_HTTP_VERSION_HEADER, LENS_API_VERSION) .send({}); expect(response.status).to.be(400); diff --git a/x-pack/platform/test/api_integration/apis/lens/visualizations/delete/main.ts b/x-pack/platform/test/api_integration/apis/lens/visualizations/delete/main.ts index b954b0e312363..261b95a299279 100644 --- a/x-pack/platform/test/api_integration/apis/lens/visualizations/delete/main.ts +++ b/x-pack/platform/test/api_integration/apis/lens/visualizations/delete/main.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { PUBLIC_API_PATH, PUBLIC_API_VERSION } from '@kbn/lens-plugin/server'; +import { LENS_VIS_API_PATH, LENS_API_VERSION } from '@kbn/lens-plugin/common/constants'; import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; import type { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -18,9 +18,9 @@ export default function ({ getService }: FtrProviderContext) { it('should delete a lens visualization', async () => { const id = '71c9c185-3e6d-49d0-b7e5-f966eaf51625'; // known id const response = await supertest - .delete(`${PUBLIC_API_PATH}/visualizations/${id}`) + .delete(`${LENS_VIS_API_PATH}/${id}`) .set('kbn-xsrf', 'true') - .set(ELASTIC_HTTP_VERSION_HEADER, PUBLIC_API_VERSION) + .set(ELASTIC_HTTP_VERSION_HEADER, LENS_API_VERSION) .send(); expect(response.status).to.be(204); @@ -29,9 +29,9 @@ export default function ({ getService }: FtrProviderContext) { it('should error when deleting an unknown lens visualization', async () => { const id = '123'; // unknown id const response = await supertest - .delete(`${PUBLIC_API_PATH}/visualizations/${id}`) + .delete(`${LENS_VIS_API_PATH}/${id}`) .set('kbn-xsrf', 'true') - .set(ELASTIC_HTTP_VERSION_HEADER, PUBLIC_API_VERSION) + .set(ELASTIC_HTTP_VERSION_HEADER, LENS_API_VERSION) .send(); expect(response.status).to.be(404); diff --git a/x-pack/platform/test/api_integration/apis/lens/visualizations/get/main.ts b/x-pack/platform/test/api_integration/apis/lens/visualizations/get/main.ts index f30684a456c87..e6ec2641618bb 100644 --- a/x-pack/platform/test/api_integration/apis/lens/visualizations/get/main.ts +++ b/x-pack/platform/test/api_integration/apis/lens/visualizations/get/main.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { PUBLIC_API_PATH, PUBLIC_API_VERSION } from '@kbn/lens-plugin/server'; +import { LENS_VIS_API_PATH, LENS_API_VERSION } from '@kbn/lens-plugin/common/constants'; import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; import type { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -18,8 +18,8 @@ export default function ({ getService }: FtrProviderContext) { it('should get a lens visualization', async () => { const id = '71c9c185-3e6d-49d0-b7e5-f966eaf51625'; // known id const response = await supertest - .get(`${PUBLIC_API_PATH}/visualizations/${id}`) - .set(ELASTIC_HTTP_VERSION_HEADER, PUBLIC_API_VERSION) + .get(`${LENS_VIS_API_PATH}/${id}`) + .set(ELASTIC_HTTP_VERSION_HEADER, LENS_API_VERSION) .send(); expect(response.status).to.be(200); @@ -29,8 +29,8 @@ export default function ({ getService }: FtrProviderContext) { it('should error when fetching an unknown lens visualization', async () => { const id = '123'; // unknown id const response = await supertest - .get(`${PUBLIC_API_PATH}/visualizations/${id}`) - .set(ELASTIC_HTTP_VERSION_HEADER, PUBLIC_API_VERSION) + .get(`${LENS_VIS_API_PATH}/${id}`) + .set(ELASTIC_HTTP_VERSION_HEADER, LENS_API_VERSION) .send(); expect(response.status).to.be(404); diff --git a/x-pack/platform/test/api_integration/apis/lens/visualizations/search/main.ts b/x-pack/platform/test/api_integration/apis/lens/visualizations/search/main.ts index f0a86f93ce854..c803574bc0749 100644 --- a/x-pack/platform/test/api_integration/apis/lens/visualizations/search/main.ts +++ b/x-pack/platform/test/api_integration/apis/lens/visualizations/search/main.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { PUBLIC_API_PATH, PUBLIC_API_VERSION } from '@kbn/lens-plugin/server'; +import { LENS_VIS_API_PATH, LENS_API_VERSION } from '@kbn/lens-plugin/common/constants'; import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; import type { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -17,8 +17,8 @@ export default function ({ getService }: FtrProviderContext) { describe('main', () => { it('should get list of lens visualizations', async () => { const response = await supertest - .get(`${PUBLIC_API_PATH}/visualizations`) - .set(ELASTIC_HTTP_VERSION_HEADER, PUBLIC_API_VERSION) + .get(LENS_VIS_API_PATH) + .set(ELASTIC_HTTP_VERSION_HEADER, LENS_API_VERSION) .send(); expect(response.status).to.be(200); @@ -27,9 +27,9 @@ export default function ({ getService }: FtrProviderContext) { it('should filter lens visualizations by title and description', async () => { const response = await supertest - .get(`${PUBLIC_API_PATH}/visualizations`) + .get(LENS_VIS_API_PATH) .query({ query: '1' }) - .set(ELASTIC_HTTP_VERSION_HEADER, PUBLIC_API_VERSION) + .set(ELASTIC_HTTP_VERSION_HEADER, LENS_API_VERSION) .send(); expect(response.status).to.be(200); diff --git a/x-pack/platform/test/api_integration/apis/lens/visualizations/search/validation.ts b/x-pack/platform/test/api_integration/apis/lens/visualizations/search/validation.ts index 3cc8eeff85a00..65257c9e589ef 100644 --- a/x-pack/platform/test/api_integration/apis/lens/visualizations/search/validation.ts +++ b/x-pack/platform/test/api_integration/apis/lens/visualizations/search/validation.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { PUBLIC_API_PATH, PUBLIC_API_VERSION } from '@kbn/lens-plugin/server'; +import { LENS_VIS_API_PATH, LENS_API_VERSION } from '@kbn/lens-plugin/common/constants'; import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; import type { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -16,9 +16,9 @@ export default function ({ getService }: FtrProviderContext) { describe('validation', () => { it('should return error if using unknown params', async () => { const response = await supertest - .get(`${PUBLIC_API_PATH}/visualizations`) + .get(LENS_VIS_API_PATH) .query({ xyz: 'unknown param' }) - .set(ELASTIC_HTTP_VERSION_HEADER, PUBLIC_API_VERSION) + .set(ELASTIC_HTTP_VERSION_HEADER, LENS_API_VERSION) .send({}); expect(response.status).to.be(400); diff --git a/x-pack/platform/test/api_integration/apis/lens/visualizations/update/main.ts b/x-pack/platform/test/api_integration/apis/lens/visualizations/update/main.ts index c5e38106a6ab1..8babe2b46bc60 100644 --- a/x-pack/platform/test/api_integration/apis/lens/visualizations/update/main.ts +++ b/x-pack/platform/test/api_integration/apis/lens/visualizations/update/main.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { PUBLIC_API_PATH, PUBLIC_API_VERSION } from '@kbn/lens-plugin/server'; +import { LENS_VIS_API_PATH, LENS_API_VERSION } from '@kbn/lens-plugin/common/constants'; import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; import type { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -19,9 +19,9 @@ export default function ({ getService }: FtrProviderContext) { it('should update a lens visualization', async () => { const id = '71c9c185-3e6d-49d0-b7e5-f966eaf51625'; // known id const response = await supertest - .put(`${PUBLIC_API_PATH}/visualizations/${id}`) + .put(`${LENS_VIS_API_PATH}/${id}`) .set('kbn-xsrf', 'true') - .set(ELASTIC_HTTP_VERSION_HEADER, PUBLIC_API_VERSION) + .set(ELASTIC_HTTP_VERSION_HEADER, LENS_API_VERSION) .send(getExampleLensBody('Custom title')); expect(response.status).to.be(200); @@ -31,9 +31,9 @@ export default function ({ getService }: FtrProviderContext) { it('should error when updating an unknown lens visualization', async () => { const id = '123'; // unknown id const response = await supertest - .put(`${PUBLIC_API_PATH}/visualizations/${id}`) + .put(`${LENS_VIS_API_PATH}/${id}`) .set('kbn-xsrf', 'true') - .set(ELASTIC_HTTP_VERSION_HEADER, PUBLIC_API_VERSION) + .set(ELASTIC_HTTP_VERSION_HEADER, LENS_API_VERSION) .send(getExampleLensBody('Custom title')); expect(response.status).to.be(404); diff --git a/x-pack/platform/test/api_integration/apis/lens/visualizations/update/validation.ts b/x-pack/platform/test/api_integration/apis/lens/visualizations/update/validation.ts index 777cc9cc82c2b..e1c4c0e269397 100644 --- a/x-pack/platform/test/api_integration/apis/lens/visualizations/update/validation.ts +++ b/x-pack/platform/test/api_integration/apis/lens/visualizations/update/validation.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { PUBLIC_API_PATH, PUBLIC_API_VERSION } from '@kbn/lens-plugin/server'; +import { LENS_VIS_API_PATH, LENS_API_VERSION } from '@kbn/lens-plugin/common/constants'; import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; import type { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -17,9 +17,9 @@ export default function ({ getService }: FtrProviderContext) { it('should return error if body is empty', async () => { const id = '71c9c185-3e6d-49d0-b7e5-f966eaf51625'; // known id const response = await supertest - .put(`${PUBLIC_API_PATH}/visualizations/${id}`) + .put(`${LENS_VIS_API_PATH}/${id}`) .set('kbn-xsrf', 'true') - .set(ELASTIC_HTTP_VERSION_HEADER, PUBLIC_API_VERSION) + .set(ELASTIC_HTTP_VERSION_HEADER, LENS_API_VERSION) .send({}); expect(response.status).to.be(400); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/ai/cost_savings_metric.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/ai/cost_savings_metric.ts index c42016efb33aa..bebe90e29321e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/ai/cost_savings_metric.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/ai/cost_savings_metric.ts @@ -118,8 +118,5 @@ export const getCostSavingsMetricLensAttributes: MyGetLensAttributes = ({ type: 'index-pattern', }, ], - type: 'lens', - updated_at: '2025-07-21T15:51:38.660Z', - version: 'WzI0LDFd', } as LensAttributes; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/ai/cost_savings_trend_area.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/ai/cost_savings_trend_area.ts index 84fe4144545dc..8e3c76eba5d95 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/ai/cost_savings_trend_area.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/ai/cost_savings_trend_area.ts @@ -186,8 +186,5 @@ export const getCostSavingsTrendAreaLensAttributes: MyGetLensAttributes = ({ type: 'index-pattern', }, ], - type: 'lens', - updated_at: '2025-07-21T15:51:38.660Z', - version: 'WzI0LDFd', } as LensAttributes; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/ai/time_saved_metric.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/ai/time_saved_metric.ts index bbfcdd15b3583..eed364526637c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/ai/time_saved_metric.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/ai/time_saved_metric.ts @@ -106,8 +106,5 @@ export const getTimeSavedMetricLensAttributes: MyGetLensAttributes = ({ type: 'index-pattern', }, ], - type: 'lens', - updated_at: '2025-07-21T15:51:38.660Z', - version: 'WzI0LDFd', } as LensAttributes; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_area.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_area.ts index 5120365243a4f..83911f6dd4597 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_area.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_area.ts @@ -128,8 +128,5 @@ export const getKpiUniqueIpsAreaLensAttributes: GetLensAttributes = ({ euiTheme type: 'index-pattern', }, ], - type: 'lens', - updated_at: '2022-02-09T17:44:03.359Z', - version: 'WzI5MTI5OSwzXQ==', } as LensAttributes; };