diff --git a/examples/embeddable_examples/public/migrations/migration.7.3.0.ts b/examples/embeddable_examples/public/migrations/migration_definitions.ts similarity index 75% rename from examples/embeddable_examples/public/migrations/migration.7.3.0.ts rename to examples/embeddable_examples/public/migrations/migration_definitions.ts index 79b61848a016..56e03bed313e 100644 --- a/examples/embeddable_examples/public/migrations/migration.7.3.0.ts +++ b/examples/embeddable_examples/public/migrations/migration_definitions.ts @@ -11,20 +11,16 @@ import { EmbeddableInput } from '@kbn/embeddable-plugin/common'; import { SimpleEmbeddableInput } from './migrations_embeddable_factory'; // 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 & { +type SimpleEmbeddableInputV1 = EmbeddableInput & { number: number; }; -type SimpleEmbeddable730MigrateFn = MigrateFunction< - SimpleEmbeddableInput_pre7_3_0, - SimpleEmbeddableInput ->; +type SimpleEmbeddable730MigrateFn = MigrateFunction; // 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) => { +export const migrateToVersion2: SimpleEmbeddable730MigrateFn = (state) => { const newState: SimpleEmbeddableInput = { ...state, title: defaultTitle, value: state.number }; return newState; }; diff --git a/examples/embeddable_examples/public/migrations/migrations_embeddable_factory.ts b/examples/embeddable_examples/public/migrations/migrations_embeddable_factory.ts index 19efd95a12dd..fb96fbff77b3 100644 --- a/examples/embeddable_examples/public/migrations/migrations_embeddable_factory.ts +++ b/examples/embeddable_examples/public/migrations/migrations_embeddable_factory.ts @@ -15,7 +15,7 @@ import { EmbeddableFactory, } from '@kbn/embeddable-plugin/public'; import { SimpleEmbeddable } from './migrations_embeddable'; -import { migration730 } from './migration.7.3.0'; +import { migrateToVersion2 } from './migration_definitions'; export const SIMPLE_EMBEDDABLE = 'SIMPLE_EMBEDDABLE'; @@ -30,10 +30,11 @@ export class SimpleEmbeddableFactoryDefinition implements EmbeddableFactoryDefinition { public readonly type = SIMPLE_EMBEDDABLE; + public latestVersion = '2'; // we need to provide migration function every time we change the interface of our state public readonly migrations = { - '7.3.0': migration730, + '2': migrateToVersion2, }; public extract(state: EmbeddableStateWithType) { diff --git a/src/plugins/dashboard/common/content_management/v1/types.ts b/src/plugins/dashboard/common/content_management/v1/types.ts index 9d89d724ed00..05d216e8a785 100644 --- a/src/plugins/dashboard/common/content_management/v1/types.ts +++ b/src/plugins/dashboard/common/content_management/v1/types.ts @@ -49,8 +49,14 @@ export interface SavedDashboardPanel { panelRefName?: string; gridData: GridData; panelIndex: string; - version: string; title?: string; + + /** + * This version key was used to store Kibana version information from versions 7.3.0 -> 8.11.0. + * As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the + * embeddable's input. (embeddableConfig in this type). + */ + version?: string; } /* eslint-disable-next-line @typescript-eslint/consistent-type-definitions */ diff --git a/src/plugins/dashboard/common/dashboard_container/types.ts b/src/plugins/dashboard/common/dashboard_container/types.ts index 139277be9315..b4c6a874f065 100644 --- a/src/plugins/dashboard/common/dashboard_container/types.ts +++ b/src/plugins/dashboard/common/dashboard_container/types.ts @@ -29,6 +29,13 @@ export interface DashboardPanelState< > extends PanelState { readonly gridData: GridData; panelRefName?: string; + + /** + * This version key was used to store Kibana version information from versions 7.3.0 -> 8.11.0. + * As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the + * embeddable's input. This key is needed for BWC, but its value will be removed on Dashboard save. + */ + version?: string; } export type DashboardContainerByReferenceInput = SavedObjectEmbeddableInput; diff --git a/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.test.ts b/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.test.ts index c43654187634..e93def3fa47a 100644 --- a/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.test.ts +++ b/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.test.ts @@ -51,190 +51,6 @@ const commonAttributes: DashboardAttributes = { title: '', }; -describe('legacy extract references', () => { - test('extracts references from panelsJSON', () => { - const doc = { - id: '1', - attributes: { - ...commonAttributes, - foo: true, - panelsJSON: JSON.stringify([ - { - type: 'visualization', - id: '1', - title: 'Title 1', - version: '7.0.0', - }, - { - type: 'visualization', - id: '2', - title: 'Title 2', - version: '7.0.0', - }, - ]), - }, - references: [], - }; - const updatedDoc = extractReferences(doc, deps); - - expect(updatedDoc).toMatchInlineSnapshot(` - Object { - "attributes": Object { - "description": "", - "foo": true, - "kibanaSavedObjectMeta": Object { - "searchSourceJSON": "", - }, - "panelsJSON": "[{\\"title\\":\\"Title 1\\",\\"version\\":\\"7.0.0\\",\\"panelRefName\\":\\"panel_0\\"},{\\"title\\":\\"Title 2\\",\\"version\\":\\"7.0.0\\",\\"panelRefName\\":\\"panel_1\\"}]", - "timeRestore": false, - "title": "", - "version": 1, - }, - "references": Array [ - Object { - "id": "1", - "name": "panel_0", - "type": "visualization", - }, - Object { - "id": "2", - "name": "panel_1", - "type": "visualization", - }, - ], - } - `); - }); - - test('fails when "type" attribute is missing from a panel', () => { - const doc = { - id: '1', - attributes: { - ...commonAttributes, - foo: true, - panelsJSON: JSON.stringify([ - { - id: '1', - title: 'Title 1', - version: '7.0.0', - }, - ]), - }, - references: [], - }; - expect(() => extractReferences(doc, deps)).toThrowErrorMatchingInlineSnapshot( - `"\\"type\\" attribute is missing from panel \\"0\\""` - ); - }); - - test('passes when "id" attribute is missing from a panel', () => { - const doc = { - id: '1', - attributes: { - ...commonAttributes, - foo: true, - panelsJSON: JSON.stringify([ - { - type: 'visualization', - title: 'Title 1', - version: '7.9.1', - }, - ]), - }, - references: [], - }; - expect(extractReferences(doc, deps)).toMatchInlineSnapshot(` - Object { - "attributes": Object { - "description": "", - "foo": true, - "kibanaSavedObjectMeta": Object { - "searchSourceJSON": "", - }, - "panelsJSON": "[{\\"version\\":\\"7.9.1\\",\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\"}]", - "timeRestore": false, - "title": "", - "version": 1, - }, - "references": Array [], - } - `); - }); - - // https://github.com/elastic/kibana/issues/93772 - test('passes when received older RAW SO with older panels', () => { - const doc = { - id: '1', - attributes: { - hits: 0, - timeFrom: 'now-16h/h', - timeTo: 'now', - refreshInterval: { - display: '1 minute', - section: 2, - value: 60000, - pause: false, - }, - description: '', - uiStateJSON: '{"P-1":{"vis":{"legendOpen":false}}}', - title: 'Errors/Fatals/Warnings dashboard', - timeRestore: true, - version: 1, - panelsJSON: - '[{"col":1,"id":"544891f0-2cf2-11e8-9735-93e95b055f48","panelIndex":1,"row":1,"size_x":12,"size_y":8,"type":"visualization"}]', - optionsJSON: '{"darkTheme":true}', - kibanaSavedObjectMeta: { - searchSourceJSON: - '{"highlightAll":true,"filter":[{"query":{"query_string":{"analyze_wildcard":true,"query":"*"}}}]}', - }, - }, - references: [], - }; - const updatedDoc = extractReferences(doc, deps); - - expect(updatedDoc).toMatchInlineSnapshot(` - Object { - "attributes": Object { - "description": "", - "hits": 0, - "kibanaSavedObjectMeta": Object { - "searchSourceJSON": "{\\"highlightAll\\":true,\\"filter\\":[{\\"query\\":{\\"query_string\\":{\\"analyze_wildcard\\":true,\\"query\\":\\"*\\"}}}]}", - }, - "optionsJSON": "{\\"darkTheme\\":true}", - "panelsJSON": "[{\\"col\\":1,\\"panelIndex\\":1,\\"row\\":1,\\"size_x\\":12,\\"size_y\\":8,\\"panelRefName\\":\\"panel_0\\"}]", - "refreshInterval": Object { - "display": "1 minute", - "pause": false, - "section": 2, - "value": 60000, - }, - "timeFrom": "now-16h/h", - "timeRestore": true, - "timeTo": "now", - "title": "Errors/Fatals/Warnings dashboard", - "uiStateJSON": "{\\"P-1\\":{\\"vis\\":{\\"legendOpen\\":false}}}", - "version": 1, - }, - "references": Array [ - Object { - "id": "544891f0-2cf2-11e8-9735-93e95b055f48", - "name": "panel_0", - "type": "visualization", - }, - ], - } - `); - - const panel = JSON.parse(updatedDoc.attributes.panelsJSON as string)[0]; - - // unknown older panel keys are left untouched - expect(panel).toHaveProperty('col'); - expect(panel).toHaveProperty('row'); - expect(panel).toHaveProperty('size_x'); - expect(panel).toHaveProperty('size_y'); - }); -}); - describe('extractReferences', () => { test('extracts references from panelsJSON', () => { const doc = { diff --git a/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.ts b/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.ts index f8a1c089d2fc..b25c07a4342c 100644 --- a/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.ts +++ b/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.ts @@ -5,7 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import semverGt from 'semver/functions/gt'; import { Reference } from '@kbn/content-management-utils'; import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common/types'; @@ -22,10 +21,6 @@ export interface InjectExtractDeps { embeddablePersistableStateService: EmbeddablePersistableStateService; } -const isPre730Panel = (panel: Record): boolean => { - return 'version' in panel && panel.version ? semverGt('7.3.0', panel.version) : true; -}; - function parseDashboardAttributesWithType( attributes: DashboardAttributes ): ParsedDashboardAttributesWithType { @@ -82,10 +77,6 @@ export function extractReferences( const panels = parsedAttributes.panels; - if ((Object.values(panels) as unknown as Array>).some(isPre730Panel)) { - return pre730ExtractReferences({ attributes, references }); - } - const panelMissingType = Object.values(panels).find((panel) => panel.type === undefined); if (panelMissingType) { throw new Error( @@ -117,41 +108,3 @@ export function extractReferences( attributes: newAttributes, }; } - -function pre730ExtractReferences({ - attributes, - references = [], -}: DashboardAttributesAndReferences): DashboardAttributesAndReferences { - if (typeof attributes.panelsJSON !== 'string') { - return { attributes, references }; - } - const panelReferences: Reference[] = []; - const panels: Array> = JSON.parse(String(attributes.panelsJSON)); - - panels.forEach((panel, i) => { - if (!panel.type) { - throw new Error(`"type" attribute is missing from panel "${i}"`); - } - if (!panel.id) { - // Embeddables are not required to be backed off a saved object. - return; - } - - panel.panelRefName = `panel_${i}`; - panelReferences.push({ - name: `panel_${i}`, - type: panel.type, - id: panel.id, - }); - delete panel.type; - delete panel.id; - }); - - return { - references: [...references, ...panelReferences], - attributes: { - ...attributes, - panelsJSON: JSON.stringify(panels), - }, - }; -} diff --git a/src/plugins/dashboard/common/lib/dashboard_panel_converters.test.ts b/src/plugins/dashboard/common/lib/dashboard_panel_converters.test.ts index f9da81d5d998..b1865571e42b 100644 --- a/src/plugins/dashboard/common/lib/dashboard_panel_converters.test.ts +++ b/src/plugins/dashboard/common/lib/dashboard_panel_converters.test.ts @@ -89,7 +89,7 @@ test('convertPanelStateToSavedDashboardPanel', () => { type: 'search', }; - expect(convertPanelStateToSavedDashboardPanel(dashboardPanel, '6.3.0')).toEqual({ + expect(convertPanelStateToSavedDashboardPanel(dashboardPanel)).toEqual({ type: 'search', embeddableConfig: { something: 'hi!', @@ -103,7 +103,6 @@ test('convertPanelStateToSavedDashboardPanel', () => { w: 15, i: '123', }, - version: '6.3.0', }); }); @@ -123,7 +122,7 @@ test('convertPanelStateToSavedDashboardPanel will not add an undefined id when n type: 'search', }; - const converted = convertPanelStateToSavedDashboardPanel(dashboardPanel, '8.0.0'); + const converted = convertPanelStateToSavedDashboardPanel(dashboardPanel); expect(converted.hasOwnProperty('id')).toBe(false); }); @@ -143,7 +142,49 @@ test('convertPanelStateToSavedDashboardPanel will not leave title as part of emb type: 'search', }; - const converted = convertPanelStateToSavedDashboardPanel(dashboardPanel, '8.0.0'); + const converted = convertPanelStateToSavedDashboardPanel(dashboardPanel); expect(converted.embeddableConfig.hasOwnProperty('title')).toBe(false); expect(converted.title).toBe('title'); }); + +test('convertPanelStateToSavedDashboardPanel retains legacy version info when not passed removeLegacyVersion', () => { + const dashboardPanel: DashboardPanelState = { + gridData: { + x: 0, + y: 0, + h: 15, + w: 15, + i: '123', + }, + explicitInput: { + id: '123', + title: 'title', + } as EmbeddableInput, + type: 'search', + version: '8.10.0', + }; + + const converted = convertPanelStateToSavedDashboardPanel(dashboardPanel); + expect(converted.version).toBe('8.10.0'); +}); + +test('convertPanelStateToSavedDashboardPanel removes legacy version info when passed removeLegacyVersion', () => { + const dashboardPanel: DashboardPanelState = { + gridData: { + x: 0, + y: 0, + h: 15, + w: 15, + i: '123', + }, + explicitInput: { + id: '123', + title: 'title', + } as EmbeddableInput, + type: 'search', + version: '8.10.0', + }; + + const converted = convertPanelStateToSavedDashboardPanel(dashboardPanel, true); + expect(converted.version).not.toBeDefined(); +}); diff --git a/src/plugins/dashboard/common/lib/dashboard_panel_converters.ts b/src/plugins/dashboard/common/lib/dashboard_panel_converters.ts index 117d949c6c09..052dd6532fc4 100644 --- a/src/plugins/dashboard/common/lib/dashboard_panel_converters.ts +++ b/src/plugins/dashboard/common/lib/dashboard_panel_converters.ts @@ -17,7 +17,6 @@ export function convertSavedDashboardPanelToPanelState< >(savedDashboardPanel: SavedDashboardPanel): DashboardPanelState { return { type: savedDashboardPanel.type, - version: savedDashboardPanel.version, gridData: savedDashboardPanel.gridData, panelRefName: savedDashboardPanel.panelRefName, explicitInput: { @@ -26,16 +25,29 @@ export function convertSavedDashboardPanelToPanelState< ...(savedDashboardPanel.title !== undefined && { title: savedDashboardPanel.title }), ...savedDashboardPanel.embeddableConfig, } as TEmbeddableInput, + + /** + * Version information used to be stored in the panel until 8.11 when it was moved + * to live inside the explicit Embeddable Input. If version information is given here, we'd like to keep it. + * It will be removed on Dashboard save + */ + version: savedDashboardPanel.version, }; } export function convertPanelStateToSavedDashboardPanel( panelState: DashboardPanelState, - version?: string + removeLegacyVersion?: boolean ): SavedDashboardPanel { const savedObjectId = (panelState.explicitInput as SavedObjectEmbeddableInput).savedObjectId; return { - version: version ?? (panelState.version as string), // temporary cast. Version will be mandatory at a later date. + /** + * Version information used to be stored in the panel until 8.11 when it was moved to live inside the + * explicit Embeddable Input. If removeLegacyVersion is not passed, we'd like to keep this information for + * the time being. + */ + ...(!removeLegacyVersion ? { version: panelState.version } : {}), + type: panelState.type, gridData: panelState.gridData, panelIndex: panelState.explicitInput.id, @@ -56,9 +68,9 @@ export const convertSavedPanelsToPanelMap = (panels?: SavedDashboardPanel[]): Da export const convertPanelMapToSavedPanels = ( panels: DashboardPanelMap, - versionOverride?: string + removeLegacyVersion?: boolean ) => { return Object.values(panels).map((panel) => - convertPanelStateToSavedDashboardPanel(panel, versionOverride) + convertPanelStateToSavedDashboardPanel(panel, removeLegacyVersion) ); }; diff --git a/src/plugins/dashboard/public/dashboard_app/_dashboard_app_strings.ts b/src/plugins/dashboard/public/dashboard_app/_dashboard_app_strings.ts index ec2bb389755a..aa25647610e2 100644 --- a/src/plugins/dashboard/public/dashboard_app/_dashboard_app_strings.ts +++ b/src/plugins/dashboard/public/dashboard_app/_dashboard_app_strings.ts @@ -51,6 +51,15 @@ export const unsavedChangesBadgeStrings = { defaultMessage: ' You have unsaved changes in this dashboard. To remove this label, save the dashboard.', }), + getHasRunMigrationsText: () => + i18n.translate('dashboard.hasRunMigrationsBadge', { + defaultMessage: 'Save recommended', + }), + getHasRunMigrationsToolTipContent: () => + i18n.translate('dashboard.hasRunMigrationsBadgeToolTipContent', { + defaultMessage: + 'One or more panels on this dashboard have been updated to a new version. Save the dashboard so it loads faster next time.', + }), }; export const leaveConfirmStrings = { diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_top_nav.tsx index d6e61f9e1c88..8fbd259be6ee 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_top_nav.tsx @@ -16,8 +16,9 @@ import { } from '@kbn/presentation-util-plugin/public'; import { ViewMode } from '@kbn/embeddable-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/public'; - +import { TopNavMenuProps } from '@kbn/navigation-plugin/public'; import { EuiHorizontalRule, EuiIcon, EuiToolTipProps } from '@elastic/eui'; + import { getDashboardTitle, leaveConfirmStrings, @@ -74,6 +75,9 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr const dashboard = useDashboardAPI(); const PresentationUtilContextProvider = getPresentationUtilContextProvider(); + const hasRunMigrations = dashboard.select( + (state) => state.componentState.hasRunClientsideMigrations + ); const hasUnsavedChanges = dashboard.select((state) => state.componentState.hasUnsavedChanges); const fullScreenMode = dashboard.select((state) => state.componentState.fullScreenMode); const savedQueryId = dashboard.select((state) => state.componentState.savedQueryId); @@ -232,6 +236,37 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr dashboard.clearOverlays(); }); + const badges = useMemo(() => { + if (viewMode !== ViewMode.EDIT) return; + const allBadges: TopNavMenuProps['badges'] = []; + if (hasUnsavedChanges) { + allBadges.push({ + 'data-test-subj': 'dashboardUnsavedChangesBadge', + badgeText: unsavedChangesBadgeStrings.getUnsavedChangedBadgeText(), + title: '', + color: 'warning', + toolTipProps: { + content: unsavedChangesBadgeStrings.getUnsavedChangedBadgeToolTipContent(), + position: 'bottom', + } as EuiToolTipProps, + }); + } + if (hasRunMigrations) { + allBadges.push({ + 'data-test-subj': 'dashboardSaveRecommendedBadge', + badgeText: unsavedChangesBadgeStrings.getHasRunMigrationsText(), + title: '', + color: 'success', + iconType: 'save', + toolTipProps: { + content: unsavedChangesBadgeStrings.getHasRunMigrationsToolTipContent(), + position: 'bottom', + } as EuiToolTipProps, + }); + } + return allBadges; + }, [hasRunMigrations, hasUnsavedChanges, viewMode]); + return (

{ if (isUpdate === false) { dashboard.forceRefresh(); diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.test.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.test.tsx index 8beba68e51da..17ea8618ef57 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.test.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.test.tsx @@ -130,12 +130,9 @@ describe('ShowShareModal', () => { locatorParams: { params: DashboardAppLocatorParams }; } ).locatorParams.params; - const { - initializerContext: { kibanaVersion }, - } = pluginServices.getServices(); const rawDashboardState = { ...unsavedDashboardState, - panels: convertPanelMapToSavedPanels(unsavedDashboardState.panels, kibanaVersion), + panels: convertPanelMapToSavedPanels(unsavedDashboardState.panels), }; unsavedStateKeys.forEach((key) => { expect(shareLocatorParams[key]).toStrictEqual( diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx index eee127d51fdd..98a899d6cac7 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx @@ -58,7 +58,6 @@ export function ShowShareModal({ }, }, }, - initializerContext: { kibanaVersion }, share: { toggleShareContextMenu }, } = pluginServices.getServices(); @@ -131,8 +130,7 @@ export function ShowShareModal({ controlGroupInput: unsavedDashboardState.controlGroupInput as SerializableControlGroupInput, panels: unsavedDashboardState.panels ? (convertPanelMapToSavedPanels( - unsavedDashboardState.panels, - kibanaVersion + unsavedDashboardState.panels ) as DashboardAppLocatorParams['panels']) : undefined, diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx index 51fad4a852ef..ed28b2727ca8 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx @@ -48,6 +48,9 @@ export const useDashboardMenuItems = ({ */ const dashboard = useDashboardAPI(); + const hasRunMigrations = dashboard.select( + (state) => state.componentState.hasRunClientsideMigrations + ); const hasUnsavedChanges = dashboard.select((state) => state.componentState.hasUnsavedChanges); const hasOverlays = dashboard.select((state) => state.componentState.hasOverlays); const lastSavedId = dashboard.select((state) => state.componentState.lastSavedId); @@ -179,7 +182,7 @@ export const useDashboardMenuItems = ({ emphasize: true, isLoading: isSaveInProgress, testId: 'dashboardQuickSaveMenuItem', - disableButton: disableTopNav || !hasUnsavedChanges, + disableButton: disableTopNav || !(hasRunMigrations || hasUnsavedChanges), run: () => quickSaveDashboard(), } as TopNavMenuData, @@ -229,6 +232,7 @@ export const useDashboardMenuItems = ({ }, [ disableTopNav, isSaveInProgress, + hasRunMigrations, hasUnsavedChanges, lastSavedId, showShare, diff --git a/src/plugins/dashboard/public/dashboard_app/url/search_sessions_integration.ts b/src/plugins/dashboard/public/dashboard_app/url/search_sessions_integration.ts index 2cf31c0ec0e0..c66042030bc1 100644 --- a/src/plugins/dashboard/public/dashboard_app/url/search_sessions_integration.ts +++ b/src/plugins/dashboard/public/dashboard_app/url/search_sessions_integration.ts @@ -77,7 +77,6 @@ function getLocatorParams({ }, search: { session }, }, - initializerContext: { kibanaVersion }, } = pluginServices.getServices(); const { @@ -102,9 +101,6 @@ function getLocatorParams({ : undefined, panels: lastSavedId ? undefined - : (convertPanelMapToSavedPanels( - panels, - kibanaVersion - ) as DashboardAppLocatorParams['panels']), + : (convertPanelMapToSavedPanels(panels) as DashboardAppLocatorParams['panels']), }; } diff --git a/src/plugins/dashboard/public/dashboard_app/url/sync_dashboard_url_state.ts b/src/plugins/dashboard/public/dashboard_app/url/sync_dashboard_url_state.ts index 6d6872ae73b0..45574312ec8e 100644 --- a/src/plugins/dashboard/public/dashboard_app/url/sync_dashboard_url_state.ts +++ b/src/plugins/dashboard/public/dashboard_app/url/sync_dashboard_url_state.ts @@ -32,7 +32,12 @@ import { migrateLegacyQuery } from '../../services/dashboard_content_management/ */ export const isPanelVersionTooOld = (panels: SavedDashboardPanel[]) => { for (const panel of panels) { - if (!panel.version || semverSatisfies(panel.version, '<7.3')) return true; + if ( + !panel.gridData || + !panel.embeddableConfig || + (panel.version && semverSatisfies(panel.version, '<7.3')) + ) + return true; } return false; }; diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts index 753b881e5a94..fad615e481ef 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts @@ -91,6 +91,7 @@ export const createDashboard = async ( reduxEmbeddablePackage, searchSessionId, savedObjectResult?.dashboardInput, + savedObjectResult.anyMigrationRun, dashboardCreationStartTime, undefined, creationOptions, diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx index df0e728a16d1..3027cddd167d 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx @@ -127,6 +127,7 @@ export class DashboardContainer extends Container ) => { state.componentState.lastSavedInput = action.payload; + + // if we set the last saved input, it means we have saved this Dashboard - therefore clientside migrations have + // been serialized into the SO. + state.componentState.hasRunClientsideMigrations = false; }, /** diff --git a/src/plugins/dashboard/public/dashboard_container/types.ts b/src/plugins/dashboard/public/dashboard_container/types.ts index 8ec0082d4109..8bcc62a04995 100644 --- a/src/plugins/dashboard/public/dashboard_container/types.ts +++ b/src/plugins/dashboard/public/dashboard_container/types.ts @@ -31,6 +31,7 @@ export type DashboardStateFromSettingsFlyout = DashboardStateFromSaveModal & Das export interface DashboardPublicState { lastSavedInput: DashboardContainerInput; + hasRunClientsideMigrations?: boolean; animatePanelTransforms?: boolean; isEmbeddedExternally?: boolean; hasUnsavedChanges?: boolean; diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/dashboard_versioning.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/dashboard_versioning.ts new file mode 100644 index 000000000000..a1d5fa45e1c4 --- /dev/null +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/dashboard_versioning.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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. + */ + +/** + * since version is saved as a number for BWC reasons, we need to convert the semver version to a number before + * saving it. For the time being we can just remove the minor and patch version info. + */ +export const convertDashboardVersionToNumber = (dashboardSemver: string) => { + return +dashboardSemver.split('.')[0]; +}; + +/** + * since version is saved as a number for BWC reasons, we need to convert the numeric version to a semver version. For the + * time being we can just convert the numeric version into the MAJOR version of a semver string. + */ +export const convertNumberToDashboardVersion = (numericVersion: number) => `${numericVersion}.0.0`; diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts index 538162a8eacb..bb4a2a9a8334 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts @@ -20,9 +20,11 @@ import { type DashboardOptions, convertSavedPanelsToPanelMap, } from '../../../../common'; +import { migrateDashboardInput } from './migrate_dashboard_input'; import { DashboardCrudTypes } from '../../../../common/content_management'; import type { LoadDashboardFromSavedObjectProps, LoadDashboardReturn } from '../types'; import { DASHBOARD_CONTENT_ID, DEFAULT_DASHBOARD_INPUT } from '../../../dashboard_constants'; +import { convertNumberToDashboardVersion } from './dashboard_versioning'; export function migrateLegacyQuery(query: Query | { [key: string]: any } | string): Query { // Lucene was the only option before, so language-less queries are all lucene @@ -66,6 +68,7 @@ export const loadDashboardState = async ({ .catch((e) => { throw new SavedObjectNotFound(DASHBOARD_CONTENT_ID, id); }); + if (!rawDashboardContent || !rawDashboardContent.version) { return { dashboardInput: newDashboardState, @@ -118,6 +121,7 @@ export const loadDashboardState = async ({ optionsJSON, panelsJSON, timeFrom, + version, timeTo, title, } = attributes; @@ -136,11 +140,8 @@ export const loadDashboardState = async ({ const options: DashboardOptions = optionsJSON ? JSON.parse(optionsJSON) : undefined; const panels = convertSavedPanelsToPanelMap(panelsJSON ? JSON.parse(panelsJSON) : []); - return { - resolveMeta, - dashboardFound: true, - dashboardId: savedObjectId, - dashboardInput: { + const { dashboardInput, anyMigrationRun } = migrateDashboardInput( + { ...DEFAULT_DASHBOARD_INPUT, ...options, @@ -160,6 +161,17 @@ export const loadDashboardState = async ({ controlGroupInput: attributes.controlGroupInput && rawControlGroupAttributesToControlGroupInput(attributes.controlGroupInput), + + version: convertNumberToDashboardVersion(version), }, + embeddable + ); + + return { + resolveMeta, + dashboardInput, + anyMigrationRun, + dashboardFound: true, + dashboardId: savedObjectId, }; }; diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/migrate_dashboard_input.test.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/migrate_dashboard_input.test.ts new file mode 100644 index 000000000000..10c7c5c40b58 --- /dev/null +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/migrate_dashboard_input.test.ts @@ -0,0 +1,72 @@ +/* + * 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 { ControlGroupInput } from '@kbn/controls-plugin/common'; +import { controlGroupInputBuilder } from '@kbn/controls-plugin/public'; + +import { DashboardContainerInput } from '../../../../common'; +import { migrateDashboardInput } from './migrate_dashboard_input'; +import { DashboardEmbeddableService } from '../../embeddable/types'; +import { getSampleDashboardInput, getSampleDashboardPanel } from '../../../mocks'; + +jest.mock('@kbn/embeddable-plugin/public', () => { + return { + ...jest.requireActual('@kbn/embeddable-plugin/public'), + runEmbeddableFactoryMigrations: jest + .fn() + .mockImplementation((input) => ({ input, migrationRun: true })), + }; +}); + +describe('Migrate dashboard input', () => { + it('should run factory migrations on all Dashboard content', () => { + const dashboardInput: DashboardContainerInput = getSampleDashboardInput(); + dashboardInput.panels = { + panel1: getSampleDashboardPanel({ type: 'superLens', explicitInput: { id: 'panel1' } }), + panel2: getSampleDashboardPanel({ type: 'superLens', explicitInput: { id: 'panel2' } }), + panel3: getSampleDashboardPanel({ type: 'ultraDiscover', explicitInput: { id: 'panel3' } }), + panel4: getSampleDashboardPanel({ type: 'ultraDiscover', explicitInput: { id: 'panel4' } }), + }; + const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput; + controlGroupInputBuilder.addOptionsListControl(controlGroupInput, { + dataViewId: 'positions-remain-fixed', + title: 'Results can be mixed', + fieldName: 'theres-a-stasis', + width: 'medium', + grow: false, + }); + controlGroupInputBuilder.addRangeSliderControl(controlGroupInput, { + dataViewId: 'an-object-set-in-motion', + title: 'The arbiter of time', + fieldName: 'unexpressed-emotion', + width: 'medium', + grow: false, + }); + controlGroupInputBuilder.addTimeSliderControl(controlGroupInput); + dashboardInput.controlGroupInput = controlGroupInput; + + const embeddableService: DashboardEmbeddableService = { + getEmbeddableFactory: jest.fn(() => ({ + latestVersion: '1.0.0', + migrations: {}, + })), + } as unknown as DashboardEmbeddableService; + + const result = migrateDashboardInput(dashboardInput, embeddableService); + + // migration run should be true because the runEmbeddableFactoryMigrations mock above returns true. + expect(result.anyMigrationRun).toBe(true); + + expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledTimes(7); // should be called 4 times for the panels, and 3 times for the controls + expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledWith('superLens'); + expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledWith('ultraDiscover'); + expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledWith('optionsListControl'); + expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledWith('rangeSliderControl'); + expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledWith('timeSlider'); + }); +}); diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/migrate_dashboard_input.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/migrate_dashboard_input.ts new file mode 100644 index 000000000000..57da6e85618f --- /dev/null +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/migrate_dashboard_input.ts @@ -0,0 +1,72 @@ +/* + * 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 { + runEmbeddableFactoryMigrations, + EmbeddableFactoryNotFoundError, +} from '@kbn/embeddable-plugin/public'; +import { ControlGroupInput } from '@kbn/controls-plugin/common'; + +import { type DashboardEmbeddableService } from '../../embeddable/types'; +import { DashboardContainerInput, DashboardPanelState } from '../../../../common'; + +/** + * Run Dashboard migrations clientside. We pre-emptively run all migrations for all content on this Dashboard so that + * we can ensure the `last saved state` which eventually resides in the Dashboard public state is fully migrated. + * This prevents the reset button from un-migrating the panels on the Dashboard. This also means that the migrations may + * get skipped at Embeddable create time - unless states with older versions are saved in the URL or session storage. + */ +export const migrateDashboardInput = ( + dashboardInput: DashboardContainerInput, + embeddable: DashboardEmbeddableService +) => { + let anyMigrationRun = false; + if (!dashboardInput) return dashboardInput; + if (dashboardInput.controlGroupInput) { + /** + * If any Control Group migrations are required, we will need to start storing a Control Group Input version + * string in Dashboard Saved Objects and then running the whole Control Group input through the embeddable + * factory migrations here. + */ + + // Migrate all of the Control children as well. + const migratedControls: ControlGroupInput['panels'] = {}; + + Object.entries(dashboardInput.controlGroupInput.panels).forEach(([id, panel]) => { + const factory = embeddable.getEmbeddableFactory(panel.type); + if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); + const { input: newInput, migrationRun: controlMigrationRun } = runEmbeddableFactoryMigrations( + panel.explicitInput, + factory + ); + if (controlMigrationRun) anyMigrationRun = true; + panel.explicitInput = newInput as DashboardPanelState['explicitInput']; + migratedControls[id] = panel; + }); + } + const migratedPanels: DashboardContainerInput['panels'] = {}; + Object.entries(dashboardInput.panels).forEach(([id, panel]) => { + const factory = embeddable.getEmbeddableFactory(panel.type); + if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); + // run last saved migrations for by value panels only. + if (!panel.explicitInput.savedObjectId) { + const { input: newInput, migrationRun: panelMigrationRun } = runEmbeddableFactoryMigrations( + panel.explicitInput, + factory + ); + if (panelMigrationRun) anyMigrationRun = true; + panel.explicitInput = newInput as DashboardPanelState['explicitInput']; + } else if (factory.latestVersion) { + // by reference panels are always considered to be of the latest version + panel.explicitInput.version = factory.latestVersion; + } + migratedPanels[id] = panel; + }); + dashboardInput.panels = migratedPanels; + return { dashboardInput, anyMigrationRun }; +}; diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts index 60d1a0f8972e..7f0ed1e8305c 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts @@ -29,8 +29,10 @@ import { } from '../types'; import { DashboardStartDependencies } from '../../../plugin'; import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants'; +import { LATEST_DASHBOARD_CONTAINER_VERSION } from '../../../dashboard_container'; import { DashboardCrudTypes, DashboardAttributes } from '../../../../common/content_management'; import { dashboardSaveToastStrings } from '../../../dashboard_container/_dashboard_container_strings'; +import { convertDashboardVersionToNumber } from './dashboard_versioning'; export const serializeControlGroupInput = ( controlGroupInput: DashboardContainerInput['controlGroupInput'] @@ -75,7 +77,6 @@ export const saveDashboardState = async ({ savedObjectsTagging, dashboardSessionStorage, notifications: { toasts }, - initializerContext: { kibanaVersion }, }: SaveDashboardStateProps): Promise => { const { search: dataSearchService, @@ -90,6 +91,7 @@ export const saveDashboardState = async ({ title, panels, filters, + version, timeRestore, description, controlGroupInput, @@ -128,7 +130,7 @@ export const saveDashboardState = async ({ syncTooltips, hidePanelTitles, }); - const panelsJSON = JSON.stringify(convertPanelMapToSavedPanels(panels, kibanaVersion)); + const panelsJSON = JSON.stringify(convertPanelMapToSavedPanels(panels, true)); /** * Parse global time filter settings @@ -146,6 +148,7 @@ export const saveDashboardState = async ({ : undefined; const rawDashboardAttributes: DashboardAttributes = { + version: convertDashboardVersionToNumber(version ?? LATEST_DASHBOARD_CONTAINER_VERSION), controlGroupInput: serializeControlGroupInput(controlGroupInput), kibanaSavedObjectMeta: { searchSourceJSON }, description: description ?? '', @@ -156,7 +159,6 @@ export const saveDashboardState = async ({ timeFrom, title, timeTo, - version: 1, // todo - where does version come from? Why is it needed? }; /** @@ -169,6 +171,7 @@ export const saveDashboardState = async ({ }, { embeddablePersistableStateService: embeddable } ); + const references = savedObjectsTagging.updateTagsReferences ? savedObjectsTagging.updateTagsReferences(dashboardReferences, tags) : dashboardReferences; diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/types.ts b/src/plugins/dashboard/public/services/dashboard_content_management/types.ts index 41e1ce55f979..288f8d4143f7 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/types.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/types.ts @@ -66,6 +66,7 @@ export interface LoadDashboardReturn { dashboardId?: string; resolveMeta?: DashboardResolveMeta; dashboardInput: DashboardContainerInput; + anyMigrationRun?: boolean; } /** diff --git a/src/plugins/dashboard/server/content_management/dashboard_storage.ts b/src/plugins/dashboard/server/content_management/dashboard_storage.ts index 80e2e134dd01..fbbfa0ef26a4 100644 --- a/src/plugins/dashboard/server/content_management/dashboard_storage.ts +++ b/src/plugins/dashboard/server/content_management/dashboard_storage.ts @@ -46,6 +46,7 @@ export class DashboardStorage extends SOContentStorage { 'optionsJSON', 'panelsJSON', 'timeFrom', + 'version', 'timeTo', 'title', ], diff --git a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_by_value_dashboard_panels.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_by_value_dashboard_panels.ts index 1b671112e1b1..9f768f8d5191 100644 --- a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_by_value_dashboard_panels.ts +++ b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_by_value_dashboard_panels.ts @@ -74,15 +74,13 @@ export const migrateByValueDashboardPanels = type: originalPanelState.type, }); // Convert the embeddable state back into the panel shape - newPanels.push( - convertPanelStateToSavedDashboardPanel( - { - ...originalPanelState, - explicitInput: { ...migratedInput, id: migratedInput.id as string }, - }, - version - ) - ); + newPanels.push({ + ...convertPanelStateToSavedDashboardPanel({ + ...originalPanelState, + explicitInput: { ...migratedInput, id: migratedInput.id as string }, + }), + version, + }); } else { newPanels.push(panel); } diff --git a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_hidden_titles.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_hidden_titles.ts index d071a094ac19..61ad21904802 100644 --- a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_hidden_titles.ts +++ b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_hidden_titles.ts @@ -37,19 +37,16 @@ export const migrateExplicitlyHiddenTitles: SavedObjectMigrationFn = ( // Convert each panel into the dashboard panel state const originalPanelState = convertSavedDashboardPanelToPanelState(panel); newPanels.push( - convertPanelStateToSavedDashboardPanel( - { - ...originalPanelState, - explicitInput: { - ...originalPanelState.explicitInput, - ...(originalPanelState.explicitInput.title === '' && - !originalPanelState.explicitInput.hidePanelTitles - ? { hidePanelTitles: true } - : {}), - }, + convertPanelStateToSavedDashboardPanel({ + ...originalPanelState, + explicitInput: { + ...originalPanelState.explicitInput, + ...(originalPanelState.explicitInput.title === '' && + !originalPanelState.explicitInput.hidePanelTitles + ? { hidePanelTitles: true } + : {}), }, - panel.version - ) + }) ); }); return { diff --git a/src/plugins/embeddable/common/types.ts b/src/plugins/embeddable/common/types.ts index bbf57603d0c9..85cfc42301f6 100644 --- a/src/plugins/embeddable/common/types.ts +++ b/src/plugins/embeddable/common/types.ts @@ -22,6 +22,7 @@ export enum ViewMode { } export type EmbeddableInput = { + version?: string; viewMode?: ViewMode; title?: string; description?: string; @@ -72,7 +73,9 @@ export type EmbeddableInput = { executionContext?: KibanaExecutionContext; }; -export interface PanelState { +export interface PanelState< + E extends EmbeddableInput & { id: string } = { id: string; version?: string } +> { // The type of embeddable in this panel. Will be used to find the factory in which to // load the embeddable. type: string; @@ -80,9 +83,6 @@ export interface PanelState & { id: string }; - - // allows individual embeddable panels to maintain versioning information separate from the main Kibana version - version?: string; } export type EmbeddableStateWithType = EmbeddableInput & { type: string }; diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 42244c70ed9c..91e6efcdc41c 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -80,6 +80,7 @@ export { shouldRefreshFilterCompareOptions, PANEL_HOVER_TRIGGER, panelHoverTrigger, + runEmbeddableFactoryMigrations, } from './lib'; export { EmbeddablePanel } from './embeddable_panel'; diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index a53cfb8725fa..fceae56cf4b9 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -37,8 +37,8 @@ import { PanelState, EmbeddableContainerSettings, } from './i_container'; -import { PanelNotFoundError, EmbeddableFactoryNotFoundError } from '../errors'; import { EmbeddableStart } from '../../plugin'; +import { PanelNotFoundError, EmbeddableFactoryNotFoundError } from '../errors'; import { isSavedObjectEmbeddableInput } from '../../../common/lib/saved_object_embeddable'; const getKeys = (o: T): Array => Object.keys(o) as Array; @@ -345,6 +345,7 @@ export abstract class Container< explicitInput: { ...explicitInput, id: embeddableId, + version: factory.latestVersion, } as TEmbeddableInput, }; } @@ -491,6 +492,7 @@ export abstract class Container< } else if (embeddable === undefined) { this.removeEmbeddable(panel.explicitInput.id); } + return embeddable; } diff --git a/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts b/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts index e7b8893848c9..472840208e13 100644 --- a/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts +++ b/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts @@ -7,11 +7,13 @@ */ import { SavedObjectAttributes } from '@kbn/core/public'; -import { EmbeddableFactoryDefinition } from './embeddable_factory_definition'; -import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from './i_embeddable'; + +import { IContainer } from '..'; import { EmbeddableFactory } from './embeddable_factory'; import { EmbeddableStateWithType } from '../../../common/types'; -import { IContainer } from '..'; +import { EmbeddableFactoryDefinition } from './embeddable_factory_definition'; +import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from './i_embeddable'; +import { runEmbeddableFactoryMigrations } from '../factory_migrations/run_factory_migrations'; export const defaultEmbeddableFactoryProvider = < I extends EmbeddableInput = EmbeddableInput, @@ -21,7 +23,14 @@ export const defaultEmbeddableFactoryProvider = < >( def: EmbeddableFactoryDefinition ): EmbeddableFactory => { + if (def.migrations && !def.latestVersion) { + throw new Error( + 'To run clientside Embeddable migrations a latest version key is required on the factory' + ); + } + const factory: EmbeddableFactory = { + latestVersion: def.latestVersion, isContainerType: def.isContainerType ?? false, canCreateNew: def.canCreateNew ? def.canCreateNew.bind(def) : () => true, getDefaultInput: def.getDefaultInput ? def.getDefaultInput.bind(def) : () => ({}), @@ -33,7 +42,12 @@ export const defaultEmbeddableFactoryProvider = < : (savedObjectId: string, input: Partial, parent?: IContainer) => { throw new Error(`Creation from saved object not supported by type ${def.type}`); }, - create: def.create.bind(def), + create: (...args) => { + const [initialInput, ...otherArgs] = args; + const { input } = runEmbeddableFactoryMigrations(initialInput, def); + const createdEmbeddable = def.create.bind(def)(input as I, ...otherArgs); + return createdEmbeddable; + }, type: def.type, isEditable: def.isEditable.bind(def), getDisplayName: def.getDisplayName.bind(def), diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx index 695c23fa0a2e..d145bfb3c1ae 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx @@ -94,7 +94,6 @@ export abstract class Embeddable< this.onResetInput(newInput); }); } - this.getOutput$() .pipe( map(({ title }) => title || ''), diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts index 0d4aa5f150ab..8f6b51ec41b2 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts @@ -36,6 +36,14 @@ export interface EmbeddableFactory< >, TSavedObjectAttributes = unknown > extends PersistableState { + /** + * The version of this Embeddable factory. This will be used in the client side migration system + * to ensure that input from any source is compatible with the latest version of this embeddable. + * If the latest version is not defined, all clientside migrations will be skipped. If migrations + * are added to this factory but a latestVersion is not set, an error will be thrown on server start + */ + readonly latestVersion?: string; + // A unique identified for this factory, which will be used to map an embeddable spec to // a factory that can generate an instance of it. readonly type: string; @@ -115,10 +123,8 @@ export interface EmbeddableFactory< ): Promise; /** - * Resolves to undefined if a new Embeddable cannot be directly created and the user will instead be redirected - * elsewhere. - * - * This will likely change in future iterations when we improve in place editing capabilities. + * Creates an Embeddable instance, running the inital input through all registered migrations. Resolves to undefined if a new Embeddable + * cannot be directly created and the user will instead be redirected elsewhere. */ create( initialInput: TEmbeddableInput, diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory_definition.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory_definition.ts index 06f3a268c9ae..4c360ffd40eb 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory_definition.ts +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory_definition.ts @@ -17,7 +17,10 @@ export type EmbeddableFactoryDefinition< T = unknown > = // Required parameters - Pick, 'create' | 'type' | 'isEditable' | 'getDisplayName'> & + Pick< + EmbeddableFactory, + 'create' | 'type' | 'latestVersion' | 'isEditable' | 'getDisplayName' + > & // Optional parameters Partial< Pick< diff --git a/src/plugins/embeddable/public/lib/factory_migrations/run_factory_migrations.test.ts b/src/plugins/embeddable/public/lib/factory_migrations/run_factory_migrations.test.ts new file mode 100644 index 000000000000..291950d1fa98 --- /dev/null +++ b/src/plugins/embeddable/public/lib/factory_migrations/run_factory_migrations.test.ts @@ -0,0 +1,76 @@ +/* + * 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 { EmbeddableInput } from '../embeddables'; +import { runEmbeddableFactoryMigrations } from './run_factory_migrations'; + +describe('Run embeddable factory migrations', () => { + interface TestInputTypeVersion009 extends EmbeddableInput { + version: '0.0.9'; + keyThatAlwaysExists: string; + keyThatGetsRemoved: string; + } + interface TestInputTypeVersion100 extends EmbeddableInput { + version: '1.0.0'; + id: string; + keyThatAlwaysExists: string; + keyThatGetsAdded: string; + } + + const migrations = { + '1.0.0': (input: TestInputTypeVersion009): TestInputTypeVersion100 => { + const newInput: TestInputTypeVersion100 = { + id: input.id, + version: '1.0.0', + keyThatAlwaysExists: input.keyThatAlwaysExists, + keyThatGetsAdded: 'I just got born', + }; + return newInput; + }, + }; + + it('should return the initial input and migrationRun=false if the current version is the latest', () => { + const initialInput: TestInputTypeVersion100 = { + id: 'superId', + version: '1.0.0', + keyThatAlwaysExists: 'Inside Problems', + keyThatGetsAdded: 'Oh my - I just got born', + }; + + const factory = { + latestVersion: '1.0.0', + migrations, + }; + + const result = runEmbeddableFactoryMigrations(initialInput, factory); + + expect(result.input).toBe(initialInput); + expect(result.migrationRun).toBe(false); + }); + + it('should return migrated input and migrationRun=true if version does not match latestVersion', () => { + const initialInput: TestInputTypeVersion009 = { + id: 'superId', + version: '0.0.9', + keyThatAlwaysExists: 'Inside Problems', + keyThatGetsRemoved: 'juvenile plumage', + }; + + const factory = { + latestVersion: '1.0.0', + migrations, + }; + + const result = runEmbeddableFactoryMigrations(initialInput, factory); + + expect(result.migrationRun).toBe(true); + expect(result.input.version).toBe('1.0.0'); + expect((result.input as unknown as TestInputTypeVersion009).keyThatGetsRemoved).toBeUndefined(); + expect(result.input.keyThatGetsAdded).toEqual('I just got born'); + }); +}); diff --git a/src/plugins/embeddable/public/lib/factory_migrations/run_factory_migrations.ts b/src/plugins/embeddable/public/lib/factory_migrations/run_factory_migrations.ts new file mode 100644 index 000000000000..9bd49eec331e --- /dev/null +++ b/src/plugins/embeddable/public/lib/factory_migrations/run_factory_migrations.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 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 { cloneDeep } from 'lodash'; +import compare from 'semver/functions/compare'; + +import { migrateToLatest } from '@kbn/kibana-utils-plugin/common'; +import { EmbeddableFactory, EmbeddableInput } from '../embeddables'; + +/** + * A helper function that migrates an Embeddable Input to its latest version. Note that this function + * only runs the embeddable factory's migrations. + */ +export const runEmbeddableFactoryMigrations = ( + initialInput: { version?: string }, + factory: { migrations?: EmbeddableFactory['migrations']; latestVersion?: string } +): { input: ToType; migrationRun: boolean } => { + if (!factory.latestVersion) { + return { input: initialInput as unknown as ToType, migrationRun: false }; + } + + // any embeddable with no version set is considered to require all clientside migrations so we default to 0.0.0 + const inputVersion = initialInput.version ?? '0.0.0'; + const migrationRun = compare(inputVersion, factory.latestVersion, true) !== 0; + + // return early to avoid extra operations when there are no migrations to run. + if (!migrationRun) return { input: initialInput as unknown as ToType, migrationRun }; + + const factoryMigrations = + typeof factory?.migrations === 'function' ? factory?.migrations() : factory?.migrations || {}; + const migratedInput = migrateToLatest( + factoryMigrations ?? {}, + { + state: cloneDeep(initialInput), + version: inputVersion, + }, + true + ); + migratedInput.version = factory.latestVersion; + return { input: migratedInput as ToType, migrationRun }; +}; diff --git a/src/plugins/embeddable/public/lib/index.ts b/src/plugins/embeddable/public/lib/index.ts index ad25c74ca3ba..ecf37a506bef 100644 --- a/src/plugins/embeddable/public/lib/index.ts +++ b/src/plugins/embeddable/public/lib/index.ts @@ -15,3 +15,4 @@ export * from './state_transfer'; export * from './reference_or_value_embeddable'; export * from './self_styled_embeddable'; export * from './filterable_embeddable'; +export * from './factory_migrations/run_factory_migrations'; diff --git a/src/plugins/embeddable/public/plugin.test.ts b/src/plugins/embeddable/public/plugin.test.ts index 82d980150353..743b672bbf58 100644 --- a/src/plugins/embeddable/public/plugin.test.ts +++ b/src/plugins/embeddable/public/plugin.test.ts @@ -25,6 +25,7 @@ test('can set custom embeddable factory provider', async () => { setup.setCustomEmbeddableFactoryProvider(customProvider); setup.registerEmbeddableFactory('test', { type: 'test', + latestVersion: '1.0.0', create: () => Promise.resolve(undefined), getDisplayName: () => 'Test', isEditable: () => Promise.resolve(true), @@ -66,6 +67,7 @@ test('custom embeddable factory provider test for intercepting embeddable creati setup.setCustomEmbeddableFactoryProvider(customProvider); setup.registerEmbeddableFactory('test', { type: 'test', + latestVersion: '1.0.0', create: (input, parent) => Promise.resolve(new HelloWorldEmbeddable(input, parent)), getDisplayName: () => 'Test', isEditable: () => Promise.resolve(true), @@ -98,6 +100,7 @@ describe('embeddable factory', () => { extract: jest.fn().mockImplementation((state) => ({ state, references: [] })), inject: jest.fn().mockImplementation((state) => state), telemetry: jest.fn().mockResolvedValue({}), + latestVersion: '7.11.0', migrations: { '7.11.0': jest.fn().mockImplementation((state) => state) }, } as any; const embeddableState = { @@ -109,6 +112,7 @@ describe('embeddable factory', () => { const containerEmbeddableFactoryId = 'CONTAINER'; const containerEmbeddableFactory = { type: containerEmbeddableFactoryId, + latestVersion: '1.0.0', create: jest.fn(), getDisplayName: () => 'Container', isContainer: true, diff --git a/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.ts b/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.ts index 98d1f6da2918..75b8b25bf3f7 100644 --- a/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.ts +++ b/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.ts @@ -12,10 +12,11 @@ import { VersionedState, MigrateFunctionsObject } from './types'; export function migrateToLatest( migrations: MigrateFunctionsObject, - { state, version: oldVersion }: VersionedState + { state, version: oldVersion }: VersionedState, + loose?: boolean ): S { const versions = Object.keys(migrations || {}) - .filter((v) => compare(v, oldVersion) > 0) + .filter((v) => compare(v, oldVersion, loose) > 0) .sort(compare); if (!versions.length) return state as S; diff --git a/src/plugins/kibana_utils/common/persistable_state/types.ts b/src/plugins/kibana_utils/common/persistable_state/types.ts index 31234ca5c18d..c8c0a3a34473 100644 --- a/src/plugins/kibana_utils/common/persistable_state/types.ts +++ b/src/plugins/kibana_utils/common/persistable_state/types.ts @@ -78,9 +78,9 @@ export interface PersistableState

'', getDescription: () => 'Description for anomaly swimlane', isEditable: () => Promise.resolve(true), + latestVersion: '1.0.0', create: () => Promise.resolve({ id: 'swimlane_embeddable' } as IEmbeddable), grouping: [ { @@ -35,6 +36,7 @@ const testFactories: EmbeddableFactoryDefinition[] = [ getDescription: () => 'Description for anomaly chart', isEditable: () => Promise.resolve(true), create: () => Promise.resolve({ id: 'anomaly_chart_embeddable' } as IEmbeddable), + latestVersion: '1.0.0', grouping: [ { id: 'ml', @@ -48,6 +50,7 @@ const testFactories: EmbeddableFactoryDefinition[] = [ getDisplayName: () => 'Log stream', getIconType: () => '', getDescription: () => 'Description for log stream', + latestVersion: '1.0.0', isEditable: () => Promise.resolve(true), create: () => Promise.resolve({ id: 'anomaly_chart_embeddable' } as IEmbeddable), },