From 5aa8a0a81815f393f9b25aafabb696b1ffc74c51 Mon Sep 17 00:00:00 2001 From: Peter Pisljar Date: Wed, 7 Jul 2021 22:42:16 +0200 Subject: [PATCH] persistable state migrations (#103680) --- ...erver.embeddablesetup.getallmigrations.md} | 6 +- ...ugins-embeddable-server.embeddablesetup.md | 2 +- examples/embeddable_examples/public/index.ts | 2 + .../public/migrations/index.ts | 10 ++++ .../public/migrations/migration.7.3.0.ts | 30 ++++++++++ .../migrations/migrations_embeddable.tsx | 45 +++++++++++++++ .../migrations_embeddable_factory.ts | 55 +++++++++++++++++++ examples/embeddable_examples/public/plugin.ts | 11 ++++ examples/embeddable_examples/server/plugin.ts | 12 +++- .../server/searchable_list_saved_object.ts | 43 +++++++++++++++ .../dashboard_migrations.test.ts | 28 +++------- .../saved_objects/dashboard_migrations.ts | 43 +++++++-------- .../common/lib/get_all_migrations.test.ts | 34 ++++++++++++ .../common/lib/get_all_migrations.ts | 44 +++++++++++++++ src/plugins/embeddable/common/lib/migrate.ts | 14 ++++- src/plugins/embeddable/common/mocks.ts | 2 +- src/plugins/embeddable/public/mocks.tsx | 2 +- src/plugins/embeddable/public/plugin.test.ts | 44 +++++++++++++-- src/plugins/embeddable/public/plugin.tsx | 8 ++- src/plugins/embeddable/server/mocks.ts | 2 +- src/plugins/embeddable/server/plugin.ts | 45 +++++++-------- src/plugins/embeddable/server/server.api.md | 4 +- .../common/persistable_state/index.ts | 1 + .../merge_migration_function_map.test.ts | 28 ++++++++++ .../merge_migration_function_map.ts | 24 ++++++++ .../common/persistable_state/types.ts | 34 ++++++------ 26 files changed, 470 insertions(+), 103 deletions(-) rename docs/development/plugins/embeddable/server/{kibana-plugin-plugins-embeddable-server.embeddablesetup.getmigrationversions.md => kibana-plugin-plugins-embeddable-server.embeddablesetup.getallmigrations.md} (60%) create mode 100644 examples/embeddable_examples/public/migrations/index.ts create mode 100644 examples/embeddable_examples/public/migrations/migration.7.3.0.ts create mode 100644 examples/embeddable_examples/public/migrations/migrations_embeddable.tsx create mode 100644 examples/embeddable_examples/public/migrations/migrations_embeddable_factory.ts create mode 100644 examples/embeddable_examples/server/searchable_list_saved_object.ts create mode 100644 src/plugins/embeddable/common/lib/get_all_migrations.test.ts create mode 100644 src/plugins/embeddable/common/lib/get_all_migrations.ts create mode 100644 src/plugins/kibana_utils/common/persistable_state/merge_migration_function_map.test.ts create mode 100644 src/plugins/kibana_utils/common/persistable_state/merge_migration_function_map.ts diff --git a/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.getmigrationversions.md b/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.getallmigrations.md similarity index 60% rename from docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.getmigrationversions.md rename to docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.getallmigrations.md index 0c8b29c85c05c5..6612683aee51c3 100644 --- a/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.getmigrationversions.md +++ b/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.getallmigrations.md @@ -1,11 +1,11 @@ -[Home](./index.md) > [kibana-plugin-plugins-embeddable-server](./kibana-plugin-plugins-embeddable-server.md) > [EmbeddableSetup](./kibana-plugin-plugins-embeddable-server.embeddablesetup.md) > [getMigrationVersions](./kibana-plugin-plugins-embeddable-server.embeddablesetup.getmigrationversions.md) +[Home](./index.md) > [kibana-plugin-plugins-embeddable-server](./kibana-plugin-plugins-embeddable-server.md) > [EmbeddableSetup](./kibana-plugin-plugins-embeddable-server.embeddablesetup.md) > [getAllMigrations](./kibana-plugin-plugins-embeddable-server.embeddablesetup.getallmigrations.md) -## EmbeddableSetup.getMigrationVersions property +## EmbeddableSetup.getAllMigrations property Signature: ```typescript -getMigrationVersions: () => string[]; +getAllMigrations: () => MigrateFunctionsObject; ``` diff --git a/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.md b/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.md index 9ecf966ece6522..74e2951105b997 100644 --- a/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.md +++ b/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.md @@ -14,7 +14,7 @@ export interface EmbeddableSetup extends PersistableStateService() => string[] | | +| [getAllMigrations](./kibana-plugin-plugins-embeddable-server.embeddablesetup.getallmigrations.md) | () => MigrateFunctionsObject | | | [registerEmbeddableFactory](./kibana-plugin-plugins-embeddable-server.embeddablesetup.registerembeddablefactory.md) | (factory: EmbeddableRegistryDefinition) => void | | | [registerEnhancement](./kibana-plugin-plugins-embeddable-server.embeddablesetup.registerenhancement.md) | (enhancement: EnhancementRegistryDefinition) => void | | diff --git a/examples/embeddable_examples/public/index.ts b/examples/embeddable_examples/public/index.ts index 8f267feeeaf61e..365c001559843b 100644 --- a/examples/embeddable_examples/public/index.ts +++ b/examples/embeddable_examples/public/index.ts @@ -17,6 +17,8 @@ export { TODO_EMBEDDABLE, TodoEmbeddableFactory } from './todo'; export { BOOK_EMBEDDABLE } from './book'; +export { SIMPLE_EMBEDDABLE } from './migrations'; + import { EmbeddableExamplesPlugin } from './plugin'; export { diff --git a/examples/embeddable_examples/public/migrations/index.ts b/examples/embeddable_examples/public/migrations/index.ts new file mode 100644 index 00000000000000..0cb619642f341c --- /dev/null +++ b/examples/embeddable_examples/public/migrations/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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './migrations_embeddable'; +export * from './migrations_embeddable_factory'; diff --git a/examples/embeddable_examples/public/migrations/migration.7.3.0.ts b/examples/embeddable_examples/public/migrations/migration.7.3.0.ts new file mode 100644 index 00000000000000..5b31cfe2b4e662 --- /dev/null +++ b/examples/embeddable_examples/public/migrations/migration.7.3.0.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { MigrateFunction } from '../../../../src/plugins/kibana_utils/common/persistable_state'; +import { SimpleEmbeddableInput } from './migrations_embeddable_factory'; +import { EmbeddableInput } from '../../../../src/plugins/embeddable/common'; + +// before 7.3.0 this embeddable received a very simple input with a variable named `number` +// eslint-disable-next-line @typescript-eslint/naming-convention +type SimpleEmbeddableInput_pre7_3_0 = EmbeddableInput & { + number: number; +}; + +type SimpleEmbeddable730MigrateFn = MigrateFunction< + SimpleEmbeddableInput_pre7_3_0, + SimpleEmbeddableInput +>; + +// when migrating old state we'll need to set a default title, or we should make title optional in the new state +const defaultTitle = 'no title'; + +export const migration730: SimpleEmbeddable730MigrateFn = (state) => { + const newState: SimpleEmbeddableInput = { ...state, title: defaultTitle, value: state.number }; + return newState; +}; diff --git a/examples/embeddable_examples/public/migrations/migrations_embeddable.tsx b/examples/embeddable_examples/public/migrations/migrations_embeddable.tsx new file mode 100644 index 00000000000000..871994ce2aaae2 --- /dev/null +++ b/examples/embeddable_examples/public/migrations/migrations_embeddable.tsx @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SIMPLE_EMBEDDABLE, SimpleEmbeddableInput } from '.'; +import { Embeddable, IContainer } from '../../../../src/plugins/embeddable/public'; + +export class SimpleEmbeddable extends Embeddable { + // The type of this embeddable. This will be used to find the appropriate factory + // to instantiate this kind of embeddable. + public readonly type = SIMPLE_EMBEDDABLE; + + constructor(initialInput: SimpleEmbeddableInput, parent?: IContainer) { + super( + // Input state is irrelevant to this embeddable, just pass it along. + initialInput, + // Initial output state - this embeddable does not do anything with output, so just + // pass along an empty object. + {}, + // Optional parent component, this embeddable can optionally be rendered inside a container. + parent + ); + } + + /** + * Render yourself at the dom node using whatever framework you like, angular, react, or just plain + * vanilla js. + * @param node + */ + public render(node: HTMLElement) { + const input = this.getInput(); + // eslint-disable-next-line no-unsanitized/property + node.innerHTML = `
${input.title} ${input.value}
`; + } + + /** + * This is mostly relevant for time based embeddables which need to update data + * even if EmbeddableInput has not changed at all. + */ + public reload() {} +} diff --git a/examples/embeddable_examples/public/migrations/migrations_embeddable_factory.ts b/examples/embeddable_examples/public/migrations/migrations_embeddable_factory.ts new file mode 100644 index 00000000000000..508ed780038c87 --- /dev/null +++ b/examples/embeddable_examples/public/migrations/migrations_embeddable_factory.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { + IContainer, + EmbeddableInput, + EmbeddableFactoryDefinition, + EmbeddableFactory, +} from '../../../../src/plugins/embeddable/public'; +import { SimpleEmbeddable } from './migrations_embeddable'; +import { migration730 } from './migration.7.3.0'; + +export const SIMPLE_EMBEDDABLE = 'SIMPLE_EMBEDDABLE'; + +// in 7.3.0 we added `title` to the input and renamed the `number` variable to `value` +export type SimpleEmbeddableInput = EmbeddableInput & { + title: string; + value: number; +}; + +export type SimpleEmbeddableFactory = EmbeddableFactory; +export class SimpleEmbeddableFactoryDefinition + implements EmbeddableFactoryDefinition { + public readonly type = SIMPLE_EMBEDDABLE; + + // we need to provide migration function every time we change the interface of our state + public readonly migrations = { + '7.3.0': migration730, + }; + + /** + * In our simple example, we let everyone have permissions to edit this. Most + * embeddables should check the UI Capabilities service to be sure of + * the right permissions. + */ + public async isEditable() { + return true; + } + + public async create(initialInput: SimpleEmbeddableInput, parent?: IContainer) { + return new SimpleEmbeddable(initialInput, parent); + } + + public getDisplayName() { + return i18n.translate('embeddableExamples.migrations.displayName', { + defaultMessage: 'hello world', + }); + } +} diff --git a/examples/embeddable_examples/public/plugin.ts b/examples/embeddable_examples/public/plugin.ts index 0ce8e5b63c2ec7..248072064e140b 100644 --- a/examples/embeddable_examples/public/plugin.ts +++ b/examples/embeddable_examples/public/plugin.ts @@ -49,6 +49,11 @@ import { import { UiActionsStart } from '../../../src/plugins/ui_actions/public'; import { createAddBookToLibraryAction } from './book/add_book_to_library_action'; import { createUnlinkBookFromLibraryAction } from './book/unlink_book_from_library_action'; +import { + SIMPLE_EMBEDDABLE, + SimpleEmbeddableFactory, + SimpleEmbeddableFactoryDefinition, +} from './migrations'; export interface EmbeddableExamplesSetupDependencies { embeddable: EmbeddableSetup; @@ -68,6 +73,7 @@ interface ExampleEmbeddableFactories { getTodoEmbeddableFactory: () => TodoEmbeddableFactory; getTodoRefEmbeddableFactory: () => TodoRefEmbeddableFactory; getBookEmbeddableFactory: () => BookEmbeddableFactory; + getMigrationsEmbeddableFactory: () => SimpleEmbeddableFactory; } export interface EmbeddableExamplesStart { @@ -94,6 +100,11 @@ export class EmbeddableExamplesPlugin new HelloWorldEmbeddableFactoryDefinition() ); + this.exampleEmbeddableFactories.getMigrationsEmbeddableFactory = deps.embeddable.registerEmbeddableFactory( + SIMPLE_EMBEDDABLE, + new SimpleEmbeddableFactoryDefinition() + ); + this.exampleEmbeddableFactories.getMultiTaskTodoEmbeddableFactory = deps.embeddable.registerEmbeddableFactory( MULTI_TASK_TODO_EMBEDDABLE, new MultiTaskTodoEmbeddableFactoryDefinition() diff --git a/examples/embeddable_examples/server/plugin.ts b/examples/embeddable_examples/server/plugin.ts index dff376de9232e4..a3c3d4b5284f7e 100644 --- a/examples/embeddable_examples/server/plugin.ts +++ b/examples/embeddable_examples/server/plugin.ts @@ -9,11 +9,19 @@ import { Plugin, CoreSetup, CoreStart } from 'kibana/server'; import { todoSavedObject } from './todo_saved_object'; import { bookSavedObject } from './book_saved_object'; +import { searchableListSavedObject } from './searchable_list_saved_object'; +import { EmbeddableSetup } from '../../../src/plugins/embeddable/server'; -export class EmbeddableExamplesPlugin implements Plugin { - public setup(core: CoreSetup) { +export interface EmbeddableExamplesSetupDependencies { + embeddable: EmbeddableSetup; +} + +export class EmbeddableExamplesPlugin + implements Plugin { + public setup(core: CoreSetup, { embeddable }: EmbeddableExamplesSetupDependencies) { core.savedObjects.registerType(todoSavedObject); core.savedObjects.registerType(bookSavedObject); + core.savedObjects.registerType(searchableListSavedObject(embeddable)); } public start(core: CoreStart) {} diff --git a/examples/embeddable_examples/server/searchable_list_saved_object.ts b/examples/embeddable_examples/server/searchable_list_saved_object.ts new file mode 100644 index 00000000000000..ac4656c7c2b77d --- /dev/null +++ b/examples/embeddable_examples/server/searchable_list_saved_object.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { mapValues } from 'lodash'; +import { SavedObjectsType, SavedObjectUnsanitizedDoc } from 'kibana/server'; +import { EmbeddableSetup } from '../../../src/plugins/embeddable/server'; + +export const searchableListSavedObject = (embeddable: EmbeddableSetup) => { + return { + name: 'searchableList', + hidden: false, + namespaceType: 'single', + management: { + icon: 'visualizeApp', + defaultSearchField: 'title', + importableAndExportable: true, + getTitle(obj: any) { + return obj.attributes.title; + }, + }, + mappings: { + properties: { + title: { type: 'text' }, + version: { type: 'integer' }, + }, + }, + migrations: () => { + // we assume all the migration will be done by embeddables service and that saved object holds no extra state besides that of searchable list embeddable input\ + // if saved object would hold additional information we would need to merge the response from embeddables.getAllMigrations with our custom migrations. + return mapValues(embeddable.getAllMigrations(), (migrate) => { + return (state: SavedObjectUnsanitizedDoc) => ({ + ...state, + attributes: migrate(state.attributes), + }); + }); + }, + } as SavedObjectsType; +}; diff --git a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.test.ts b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.test.ts index 1d4f83c88ba8d7..59dfa92cdbce02 100644 --- a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.test.ts +++ b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.test.ts @@ -39,7 +39,7 @@ const injectImplementation = ( }; embeddableSetupMock.extract.mockImplementation(extractImplementation); embeddableSetupMock.inject.mockImplementation(injectImplementation); -embeddableSetupMock.getMigrationVersions.mockImplementation(() => []); +embeddableSetupMock.getAllMigrations.mockImplementation(() => ({})); const migrations = createDashboardSavedObjectTypeMigrations({ embeddable: embeddableSetupMock, @@ -586,28 +586,14 @@ describe('dashboard', () => { type: 'dashboard', }; - it('should add all embeddable migrations for versions above 7.12.0 to dashboard saved object migrations', () => { - const newEmbeddableSetupMock = createEmbeddableSetupMock(); - newEmbeddableSetupMock.getMigrationVersions.mockImplementation(() => [ - '7.10.100', - '7.13.0', - '8.0.0', - ]); - const migrationsList = createDashboardSavedObjectTypeMigrations({ - embeddable: newEmbeddableSetupMock, - }); - expect(Object.keys(migrationsList).indexOf('8.0.0')).not.toBe(-1); - expect(Object.keys(migrationsList).indexOf('7.13.0')).not.toBe(-1); - expect(Object.keys(migrationsList).indexOf('7.10.100')).toBe(-1); - }); - it('runs migrations on by value panels only', () => { const newEmbeddableSetupMock = createEmbeddableSetupMock(); - newEmbeddableSetupMock.getMigrationVersions.mockImplementation(() => ['7.13.0']); - newEmbeddableSetupMock.migrate.mockImplementation((state: SerializableState) => { - state.superCoolKey = 'ONLY 4 BY VALUE EMBEDDABLES THANK YOU VERY MUCH'; - return state; - }); + newEmbeddableSetupMock.getAllMigrations.mockImplementation(() => ({ + '7.13.0': (state: SerializableState) => { + state.superCoolKey = 'ONLY 4 BY VALUE EMBEDDABLES THANK YOU VERY MUCH'; + return state; + }, + })); const migrationsList = createDashboardSavedObjectTypeMigrations({ embeddable: newEmbeddableSetupMock, }); diff --git a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts index 0bd100b3d58033..ceb77ba1b2f9e3 100644 --- a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts +++ b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts @@ -6,8 +6,7 @@ * Side Public License, v 1. */ -import semver from 'semver'; -import { get, flow, identity } from 'lodash'; +import { get, flow, mapValues } from 'lodash'; import { SavedObjectAttributes, SavedObjectMigrationFn, @@ -26,7 +25,12 @@ import { } from '../../common/embeddable/embeddable_saved_object_converters'; import { SavedObjectEmbeddableInput } from '../../../embeddable/common'; import { INDEX_PATTERN_SAVED_OBJECT_TYPE } from '../../../data/common'; -import { SerializableValue } from '../../../kibana_utils/common'; +import { + mergeMigrationFunctionMaps, + MigrateFunction, + MigrateFunctionsObject, + SerializableValue, +} from '../../../kibana_utils/common'; import { replaceIndexPatternReference } from './replace_index_pattern_reference'; function migrateIndexPattern(doc: DashboardDoc700To720) { @@ -156,7 +160,7 @@ type ValueOrReferenceInput = SavedObjectEmbeddableInput & { // Runs the embeddable migrations on each panel const migrateByValuePanels = ( - deps: DashboardSavedObjectTypeMigrationsDeps, + migrate: MigrateFunction, version: string ): SavedObjectMigrationFn => (doc: any) => { const { attributes } = doc; @@ -179,13 +183,10 @@ const migrateByValuePanels = ( // saved vis is used to store by value input for Visualize. This should eventually be renamed to `attributes` to align with Lens and Maps if (originalPanelState.explicitInput.attributes || originalPanelState.explicitInput.savedVis) { // If this panel is by value, migrate the state using embeddable migrations - const migratedInput = deps.embeddable.migrate( - { - ...originalPanelState.explicitInput, - type: originalPanelState.type, - }, - version - ); + const migratedInput = migrate({ + ...originalPanelState.explicitInput, + type: originalPanelState.type, + }); // Convert the embeddable state back into the panel shape newPanels.push( convertPanelStateToSavedDashboardPanel( @@ -216,16 +217,12 @@ export interface DashboardSavedObjectTypeMigrationsDeps { export const createDashboardSavedObjectTypeMigrations = ( deps: DashboardSavedObjectTypeMigrationsDeps ): SavedObjectMigrationMap => { - const embeddableMigrations = Object.fromEntries( - deps.embeddable - .getMigrationVersions() - .filter((version) => semver.gt(version, '7.12.0')) - .map((version): [string, SavedObjectMigrationFn] => { - return [version, migrateByValuePanels(deps, version)]; - }) - ); + const embeddableMigrations = mapValues( + deps.embeddable.getAllMigrations(), + migrateByValuePanels + ) as MigrateFunctionsObject; - return { + const dashboardMigrations = { /** * We need to have this migration twice, once with a version prior to 7.0.0 once with a version * after it. The reason for that is, that this migration has been introduced once 7.0.0 was already @@ -242,14 +239,14 @@ export const createDashboardSavedObjectTypeMigrations = ( '7.9.3': flow(migrateMatchAllQuery), '7.11.0': flow(createExtractPanelReferencesMigration(deps)), - ...embeddableMigrations, - /** * Any dashboard saved object migrations that come after this point will have to be wary of * potentially overwriting embeddable migrations. An example of how to mitigate this follows: */ // '7.x': flow(yourNewMigrationFunction, embeddableMigrations['7.x'] ?? identity), - '7.14.0': flow(replaceIndexPatternReference, embeddableMigrations['7.14.0'] ?? identity), + '7.14.0': flow(replaceIndexPatternReference), }; + + return mergeMigrationFunctionMaps(dashboardMigrations, embeddableMigrations); }; diff --git a/src/plugins/embeddable/common/lib/get_all_migrations.test.ts b/src/plugins/embeddable/common/lib/get_all_migrations.test.ts new file mode 100644 index 00000000000000..579477024ec329 --- /dev/null +++ b/src/plugins/embeddable/common/lib/get_all_migrations.test.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getAllMigrations } from './get_all_migrations'; + +describe('embeddable getAllMigratons', () => { + const factories = [{ migrations: { '7.11.0': (state: any) => state } }]; + const enhacements = [{ migrations: { '7.12.0': (state: any) => state } }]; + const migrateFn = jest.fn(); + + test('returns base migrations', () => { + expect(getAllMigrations([], [], migrateFn)).toEqual({}); + }); + + test('returns embeddable factory migrations', () => { + expect(getAllMigrations(factories as any, [], migrateFn)).toHaveProperty(['7.11.0']); + }); + + test('returns enhancement migrations', () => { + const migrations = getAllMigrations([], enhacements as any, migrateFn); + expect(migrations).toHaveProperty(['7.12.0']); + }); + + test('returns all migrations', () => { + const migrations = getAllMigrations(factories as any, enhacements as any, migrateFn); + expect(migrations).toHaveProperty(['7.11.0']); + expect(migrations).toHaveProperty(['7.12.0']); + }); +}); diff --git a/src/plugins/embeddable/common/lib/get_all_migrations.ts b/src/plugins/embeddable/common/lib/get_all_migrations.ts new file mode 100644 index 00000000000000..8e3233f447a3da --- /dev/null +++ b/src/plugins/embeddable/common/lib/get_all_migrations.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { baseEmbeddableMigrations } from './migrate_base_input'; +import { + MigrateFunctionsObject, + PersistableState, + PersistableStateMigrateFn, +} from '../../../kibana_utils/common/persistable_state'; + +export const getAllMigrations = ( + factories: unknown[], + enhancements: unknown[], + migrateFn: PersistableStateMigrateFn +) => { + const uniqueVersions = new Set(); + for (const baseMigrationVersion of Object.keys(baseEmbeddableMigrations)) { + uniqueVersions.add(baseMigrationVersion); + } + for (const factory of factories) { + Object.keys((factory as PersistableState).migrations).forEach((version) => + uniqueVersions.add(version) + ); + } + for (const enhancement of enhancements) { + Object.keys((enhancement as PersistableState).migrations).forEach((version) => + uniqueVersions.add(version) + ); + } + + const migrations: MigrateFunctionsObject = {}; + uniqueVersions.forEach((version) => { + migrations[version] = (state) => ({ + ...migrateFn(state, version), + }); + }); + + return migrations; +}; diff --git a/src/plugins/embeddable/common/lib/migrate.ts b/src/plugins/embeddable/common/lib/migrate.ts index fb8ea5cf2cd84e..7dde9e1d2b2abe 100644 --- a/src/plugins/embeddable/common/lib/migrate.ts +++ b/src/plugins/embeddable/common/lib/migrate.ts @@ -10,8 +10,10 @@ import { CommonEmbeddableStartContract } from '../types'; import { baseEmbeddableMigrations } from './migrate_base_input'; import { SerializableState } from '../../../kibana_utils/common/persistable_state'; +export type MigrateFunction = (state: SerializableState, version: string) => SerializableState; + export const getMigrateFunction = (embeddables: CommonEmbeddableStartContract) => { - return (state: SerializableState, version: string) => { + const migrateFn: MigrateFunction = (state: SerializableState, version: string) => { const enhancements = (state.enhancements as SerializableState) || {}; const factory = embeddables.getEmbeddableFactory(state.type as string); @@ -19,10 +21,16 @@ export const getMigrateFunction = (embeddables: CommonEmbeddableStartContract) = ? baseEmbeddableMigrations[version](state) : state; - if (factory && factory.migrations[version]) { + if (factory?.migrations[version]) { updatedInput = factory.migrations[version](updatedInput); } + if (factory?.isContainerType) { + updatedInput.panels = ((state.panels as SerializableState[]) || []).map((panel) => { + return migrateFn(panel, version); + }); + } + updatedInput.enhancements = {}; Object.keys(enhancements).forEach((key) => { if (!enhancements[key]) return; @@ -35,4 +43,6 @@ export const getMigrateFunction = (embeddables: CommonEmbeddableStartContract) = return updatedInput; }; + + return migrateFn; }; diff --git a/src/plugins/embeddable/common/mocks.ts b/src/plugins/embeddable/common/mocks.ts index b8125351c59b77..36d564d26b75da 100644 --- a/src/plugins/embeddable/common/mocks.ts +++ b/src/plugins/embeddable/common/mocks.ts @@ -12,7 +12,7 @@ export const createEmbeddablePersistableStateServiceMock = (): jest.Mocked state), extract: jest.fn((state) => ({ state, references: [] })), - migrate: jest.fn((state, version) => state), + getAllMigrations: jest.fn(() => ({})), telemetry: jest.fn((state, collector) => ({})), }; }; diff --git a/src/plugins/embeddable/public/mocks.tsx b/src/plugins/embeddable/public/mocks.tsx index a9a89754c54cae..ff2771f3fd5829 100644 --- a/src/plugins/embeddable/public/mocks.tsx +++ b/src/plugins/embeddable/public/mocks.tsx @@ -113,7 +113,7 @@ const createStartContract = (): Start => { telemetry: jest.fn(), extract: jest.fn(), inject: jest.fn(), - migrate: jest.fn(), + getAllMigrations: jest.fn(), EmbeddablePanel: jest.fn(), getStateTransfer: jest.fn(() => createEmbeddableStateTransferMock() as EmbeddableStateTransfer), getAttributeService: jest.fn(), diff --git a/src/plugins/embeddable/public/plugin.test.ts b/src/plugins/embeddable/public/plugin.test.ts index 53302e8e6870ce..b93dc02ebb5a85 100644 --- a/src/plugins/embeddable/public/plugin.test.ts +++ b/src/plugins/embeddable/public/plugin.test.ts @@ -106,8 +106,34 @@ describe('embeddable factory', () => { my: 'state', } as any; + const containerEmbeddableFactoryId = 'CONTAINER'; + const containerEmbeddableFactory = { + type: containerEmbeddableFactoryId, + create: jest.fn(), + getDisplayName: () => 'Container', + isContainer: true, + isEditable: () => Promise.resolve(true), + extract: jest.fn().mockImplementation((state) => ({ state, references: [] })), + inject: jest.fn().mockImplementation((state) => state), + telemetry: jest.fn().mockResolvedValue({}), + migrations: { '7.12.0': jest.fn().mockImplementation((state) => state) }, + }; + + const containerState = { + id: containerEmbeddableFactoryId, + type: containerEmbeddableFactoryId, + some: 'state', + panels: [ + { + ...embeddableState, + }, + ], + } as any; + + setup.registerEmbeddableFactory(embeddableFactoryId, embeddableFactory); + setup.registerEmbeddableFactory(containerEmbeddableFactoryId, containerEmbeddableFactory); + test('cannot register embeddable factory with the same ID', async () => { - setup.registerEmbeddableFactory(embeddableFactoryId, embeddableFactory); expect(() => setup.registerEmbeddableFactory(embeddableFactoryId, embeddableFactory) ).toThrowError( @@ -131,7 +157,12 @@ describe('embeddable factory', () => { }); test('embeddableFactory migrate function gets called when calling embeddable migrate', () => { - start.migrate(embeddableState, '7.11.0'); + start.getAllMigrations!()['7.11.0']!(embeddableState); + expect(embeddableFactory.migrations['7.11.0']).toBeCalledWith(embeddableState); + }); + + test('panels inside container get automatically migrated when migrating conta1iner', () => { + start.getAllMigrations!()['7.11.0']!(containerState); expect(embeddableFactory.migrations['7.11.0']).toBeCalledWith(embeddableState); }); }); @@ -156,8 +187,9 @@ describe('embeddable enhancements', () => { }, } as any; + setup.registerEnhancement(embeddableEnhancement); + test('cannot register embeddable enhancement with the same ID', async () => { - setup.registerEnhancement(embeddableEnhancement); expect(() => setup.registerEnhancement(embeddableEnhancement)).toThrowError( 'enhancement with id test already exists in the registry' ); @@ -179,7 +211,7 @@ describe('embeddable enhancements', () => { }); test('enhancement migrate function gets called when calling embeddable migrate', () => { - start.migrate(embeddableState, '7.11.0'); + start.getAllMigrations!()['7.11.0']!(embeddableState); expect(embeddableEnhancement.migrations['7.11.0']).toBeCalledWith( embeddableState.enhancements.test ); @@ -187,9 +219,9 @@ describe('embeddable enhancements', () => { test('doesnt fail if there is no migration function registered for specific version', () => { expect(() => { - start.migrate(embeddableState, '7.10.0'); + start.getAllMigrations!()['7.11.0']!(embeddableState); }).not.toThrow(); - expect(start.migrate(embeddableState, '7.10.0')).toEqual(embeddableState); + expect(start.getAllMigrations!()['7.11.0']!(embeddableState)).toEqual(embeddableState); }); }); diff --git a/src/plugins/embeddable/public/plugin.tsx b/src/plugins/embeddable/public/plugin.tsx index 4ddef89727ef1d..62ec9e15f564ce 100644 --- a/src/plugins/embeddable/public/plugin.tsx +++ b/src/plugins/embeddable/public/plugin.tsx @@ -49,6 +49,7 @@ import { getMigrateFunction, getTelemetryFunction, } from '../common/lib'; +import { getAllMigrations } from '../common/lib/get_all_migrations'; export interface EmbeddableSetupDependencies { uiActions: UiActionsSetup; @@ -205,7 +206,12 @@ export class EmbeddablePublicPlugin implements Plugin + getAllMigrations( + Array.from(this.embeddableFactories.values()), + Array.from(this.enhancements.values()), + getMigrateFunction(commonContract) + ), }; } diff --git a/src/plugins/embeddable/server/mocks.ts b/src/plugins/embeddable/server/mocks.ts index 63754cecb02703..f2a4b4e09d198f 100644 --- a/src/plugins/embeddable/server/mocks.ts +++ b/src/plugins/embeddable/server/mocks.ts @@ -12,7 +12,7 @@ import { EmbeddableSetup, EmbeddableStart } from './plugin'; export const createEmbeddableSetupMock = (): jest.Mocked => ({ ...createEmbeddablePersistableStateServiceMock(), registerEmbeddableFactory: jest.fn(), - getMigrationVersions: jest.fn().mockReturnValue([]), + getAllMigrations: jest.fn().mockReturnValue({}), registerEnhancement: jest.fn(), }); diff --git a/src/plugins/embeddable/server/plugin.ts b/src/plugins/embeddable/server/plugin.ts index 788f51adc327b7..c85f48e01d486e 100644 --- a/src/plugins/embeddable/server/plugin.ts +++ b/src/plugins/embeddable/server/plugin.ts @@ -16,19 +16,24 @@ import { EmbeddableRegistryDefinition, } from './types'; import { - baseEmbeddableMigrations, getExtractFunction, getInjectFunction, getMigrateFunction, getTelemetryFunction, } from '../common/lib'; -import { PersistableStateService, SerializableState } from '../../kibana_utils/common'; +import { + PersistableStateService, + SerializableState, + PersistableStateMigrateFn, + MigrateFunctionsObject, +} from '../../kibana_utils/common'; import { EmbeddableStateWithType } from '../common/types'; +import { getAllMigrations } from '../common/lib/get_all_migrations'; export interface EmbeddableSetup extends PersistableStateService { registerEmbeddableFactory: (factory: EmbeddableRegistryDefinition) => void; registerEnhancement: (enhancement: EnhancementRegistryDefinition) => void; - getMigrationVersions: () => string[]; + getAllMigrations: () => MigrateFunctionsObject; } export type EmbeddableStart = PersistableStateService; @@ -36,20 +41,27 @@ export type EmbeddableStart = PersistableStateService; export class EmbeddableServerPlugin implements Plugin { private readonly embeddableFactories: EmbeddableFactoryRegistry = new Map(); private readonly enhancements: EnhancementsRegistry = new Map(); + private migrateFn: PersistableStateMigrateFn | undefined; public setup(core: CoreSetup) { const commonContract = { getEmbeddableFactory: this.getEmbeddableFactory, getEnhancement: this.getEnhancement, }; + + this.migrateFn = getMigrateFunction(commonContract); return { - getMigrationVersions: this.getMigrationVersions, registerEmbeddableFactory: this.registerEmbeddableFactory, registerEnhancement: this.registerEnhancement, telemetry: getTelemetryFunction(commonContract), extract: getExtractFunction(commonContract), inject: getInjectFunction(commonContract), - migrate: getMigrateFunction(commonContract), + getAllMigrations: () => + getAllMigrations( + Array.from(this.embeddableFactories.values()), + Array.from(this.enhancements.values()), + this.migrateFn! + ), }; } @@ -63,7 +75,12 @@ export class EmbeddableServerPlugin implements Plugin + getAllMigrations( + Array.from(this.embeddableFactories.values()), + Array.from(this.enhancements.values()), + this.migrateFn! + ), }; } @@ -128,20 +145,4 @@ export class EmbeddableServerPlugin implements Plugin { - const uniqueVersions = new Set(); - for (const baseMigrationVersion of Object.keys(baseEmbeddableMigrations)) { - uniqueVersions.add(baseMigrationVersion); - } - const factories = this.embeddableFactories.values(); - for (const factory of factories) { - Object.keys(factory.migrations).forEach((version) => uniqueVersions.add(version)); - } - const enhancements = this.enhancements.values(); - for (const enhancement of enhancements) { - Object.keys(enhancement.migrations).forEach((version) => uniqueVersions.add(version)); - } - return Array.from(uniqueVersions); - }; } diff --git a/src/plugins/embeddable/server/server.api.md b/src/plugins/embeddable/server/server.api.md index d5c7ce29bab9ec..f8f3dcb0aa0ba4 100644 --- a/src/plugins/embeddable/server/server.api.md +++ b/src/plugins/embeddable/server/server.api.md @@ -23,8 +23,10 @@ export interface EmbeddableRegistryDefinition

{ + // Warning: (ae-forgotten-export) The symbol "MigrateFunctionsObject" needs to be exported by the entry point index.d.ts + // // (undocumented) - getMigrationVersions: () => string[]; + getAllMigrations: () => MigrateFunctionsObject; // (undocumented) registerEmbeddableFactory: (factory: EmbeddableRegistryDefinition) => void; // (undocumented) diff --git a/src/plugins/kibana_utils/common/persistable_state/index.ts b/src/plugins/kibana_utils/common/persistable_state/index.ts index 18f59186f61831..1f417002f27651 100644 --- a/src/plugins/kibana_utils/common/persistable_state/index.ts +++ b/src/plugins/kibana_utils/common/persistable_state/index.ts @@ -8,3 +8,4 @@ export * from './types'; export { migrateToLatest } from './migrate_to_latest'; +export { mergeMigrationFunctionMaps } from './merge_migration_function_map'; diff --git a/src/plugins/kibana_utils/common/persistable_state/merge_migration_function_map.test.ts b/src/plugins/kibana_utils/common/persistable_state/merge_migration_function_map.test.ts new file mode 100644 index 00000000000000..9a6d774d70475f --- /dev/null +++ b/src/plugins/kibana_utils/common/persistable_state/merge_migration_function_map.test.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { mergeMigrationFunctionMaps } from './merge_migration_function_map'; + +describe('mergeSavedObjectMigrationMaps', () => { + const obj1 = { + '7.12.1': (state: number) => state + 1, + '7.12.2': (state: number) => state + 2, + }; + + const obj2 = { + '7.12.0': (state: number) => state - 2, + '7.12.2': (state: number) => state + 2, + }; + + test('correctly merges two saved object migration maps', () => { + const result = mergeMigrationFunctionMaps(obj1, obj2); + expect(result['7.12.0'](5)).toEqual(3); + expect(result['7.12.1'](5)).toEqual(6); + expect(result['7.12.2'](5)).toEqual(9); + }); +}); diff --git a/src/plugins/kibana_utils/common/persistable_state/merge_migration_function_map.ts b/src/plugins/kibana_utils/common/persistable_state/merge_migration_function_map.ts new file mode 100644 index 00000000000000..fc48ab119b02c6 --- /dev/null +++ b/src/plugins/kibana_utils/common/persistable_state/merge_migration_function_map.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { mergeWith } from 'lodash'; +import { MigrateFunctionsObject, MigrateFunction, SerializableState } from './types'; + +export const mergeMigrationFunctionMaps = ( + obj1: MigrateFunctionsObject, + obj2: MigrateFunctionsObject +) => { + const customizer = (objValue: MigrateFunction, srcValue: MigrateFunction) => { + if (!srcValue || !objValue) { + return srcValue || objValue; + } + return (state: SerializableState) => objValue(srcValue(state)); + }; + + return mergeWith({ ...obj1 }, obj2, customizer); +}; diff --git a/src/plugins/kibana_utils/common/persistable_state/types.ts b/src/plugins/kibana_utils/common/persistable_state/types.ts index f7168b46e7fca6..ba2b923e3e475f 100644 --- a/src/plugins/kibana_utils/common/persistable_state/types.ts +++ b/src/plugins/kibana_utils/common/persistable_state/types.ts @@ -99,12 +99,22 @@ export interface PersistableState

}; export type MigrateFunction< FromVersion extends SerializableState = SerializableState, ToVersion extends SerializableState = SerializableState > = (state: FromVersion) => ToVersion; +/** + * migrate function runs the specified migration + * @param state + * @param version + */ +export type PersistableStateMigrateFn = ( + state: SerializableState, + version: string +) => SerializableState; + /** * @todo Shall we remove this? */ @@ -150,23 +160,6 @@ export interface PersistableStateService

VersionedState

; + + /** + * returns all registered migrations + */ + getAllMigrations?: () => MigrateFunctionsObject; }