[Embeddable] Clientside migration system (#162986)

Changes the versioning scheme used by Dashboard Panels and by value Embeddables, and introduces a new clientside system that can migrate Embeddable Inputs to their latest versions.
This commit is contained in:
Devon Thomson 2023-08-22 15:08:27 -04:00 committed by GitHub
parent 10ab42692d
commit 26389e5014
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 540 additions and 332 deletions

View file

@ -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<SimpleEmbeddableInputV1, 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) => {
export const migrateToVersion2: SimpleEmbeddable730MigrateFn = (state) => {
const newState: SimpleEmbeddableInput = { ...state, title: defaultTitle, value: state.number };
return newState;
};

View file

@ -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<SimpleEmbeddableInput>
{
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) {

View file

@ -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 */

View file

@ -29,6 +29,13 @@ export interface DashboardPanelState<
> extends PanelState<TEmbeddableInput> {
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;

View file

@ -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 = {

View file

@ -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<string, string>): 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<Record<string, string>>).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<Record<string, string>> = 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),
},
};
}

View file

@ -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();
});

View file

@ -17,7 +17,6 @@ export function convertSavedDashboardPanelToPanelState<
>(savedDashboardPanel: SavedDashboardPanel): DashboardPanelState<TEmbeddableInput> {
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)
);
};

View file

@ -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 = {

View file

@ -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 (
<div className="dashboardTopNav">
<h1
@ -243,10 +278,11 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr
<TopNavMenu
{...visibilityProps}
query={query}
badges={badges}
screenTitle={title}
useDefaultBehaviors={true}
indexPatterns={allDataViews}
savedQueryId={savedQueryId}
indexPatterns={allDataViews}
showSaveQuery={showSaveQuery}
appName={LEGACY_DASHBOARD_APP_ID}
visible={viewMode !== ViewMode.PRINT}
@ -259,22 +295,6 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr
: viewModeTopNavConfig
: undefined
}
badges={
hasUnsavedChanges && viewMode === ViewMode.EDIT
? [
{
'data-test-subj': 'dashboardUnsavedChangesBadge',
badgeText: unsavedChangesBadgeStrings.getUnsavedChangedBadgeText(),
title: '',
color: 'warning',
toolTipProps: {
content: unsavedChangesBadgeStrings.getUnsavedChangedBadgeToolTipContent(),
position: 'bottom',
} as EuiToolTipProps,
},
]
: undefined
}
onQuerySubmit={(_payload, isUpdate) => {
if (isUpdate === false) {
dashboard.forceRefresh();

View file

@ -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(

View file

@ -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,

View file

@ -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,

View file

@ -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']),
};
}

View file

@ -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;
};

View file

@ -91,6 +91,7 @@ export const createDashboard = async (
reduxEmbeddablePackage,
searchSessionId,
savedObjectResult?.dashboardInput,
savedObjectResult.anyMigrationRun,
dashboardCreationStartTime,
undefined,
creationOptions,

View file

@ -127,6 +127,7 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
reduxToolsPackage: ReduxToolsPackage,
initialSessionId?: string,
initialLastSavedInput?: DashboardContainerInput,
anyMigrationRun?: boolean,
dashboardCreationStartTime?: number,
parent?: Container,
creationOptions?: DashboardCreationOptions,
@ -174,6 +175,7 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
...DEFAULT_DASHBOARD_INPUT,
id: initialInput.id,
},
hasRunClientsideMigrations: anyMigrationRun,
isEmbeddedExternally: creationOptions?.isEmbeddedExternally,
animatePanelTransforms: false, // set panel transforms to false initially to avoid panels animating on initial render.
hasUnsavedChanges: false, // if there is initial unsaved changes, the initial diff will catch them.

View file

@ -108,7 +108,9 @@ export const DashboardRenderer = forwardRef<AwaitingDashboardAPI, DashboardRende
const dashboardFactory = embeddable.getEmbeddableFactory(
DASHBOARD_CONTAINER_TYPE
) as DashboardContainerFactory & { create: DashboardContainerFactoryDefinition['create'] };
) as DashboardContainerFactory & {
create: DashboardContainerFactoryDefinition['create'];
};
const container = await dashboardFactory?.create(
{ id } as unknown as DashboardContainerInput, // Input from creationOptions is used instead.
undefined,

View file

@ -6,8 +6,12 @@
* Side Public License, v 1.
*/
import { convertNumberToDashboardVersion } from '../services/dashboard_content_management/lib/dashboard_versioning';
export const DASHBOARD_CONTAINER_TYPE = 'dashboard';
export const LATEST_DASHBOARD_CONTAINER_VERSION = convertNumberToDashboardVersion(1);
export type { DashboardContainer } from './embeddable/dashboard_container';
export {
type DashboardContainerFactory,

View file

@ -118,6 +118,10 @@ export const dashboardContainerReducers = {
action: PayloadAction<DashboardPublicState['lastSavedInput']>
) => {
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;
},
/**

View file

@ -31,6 +31,7 @@ export type DashboardStateFromSettingsFlyout = DashboardStateFromSaveModal & Das
export interface DashboardPublicState {
lastSavedInput: DashboardContainerInput;
hasRunClientsideMigrations?: boolean;
animatePanelTransforms?: boolean;
isEmbeddedExternally?: boolean;
hasUnsavedChanges?: boolean;

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 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`;

View file

@ -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,
};
};

View file

@ -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');
});
});

View file

@ -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 };
};

View file

@ -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<SaveDashboardReturn> => {
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;

View file

@ -66,6 +66,7 @@ export interface LoadDashboardReturn {
dashboardId?: string;
resolveMeta?: DashboardResolveMeta;
dashboardInput: DashboardContainerInput;
anyMigrationRun?: boolean;
}
/**

View file

@ -46,6 +46,7 @@ export class DashboardStorage extends SOContentStorage<DashboardCrudTypes> {
'optionsJSON',
'panelsJSON',
'timeFrom',
'version',
'timeTo',
'title',
],

View file

@ -74,15 +74,13 @@ export const migrateByValueDashboardPanels =
type: originalPanelState.type,
});
// Convert the embeddable state back into the panel shape
newPanels.push(
convertPanelStateToSavedDashboardPanel(
{
newPanels.push({
...convertPanelStateToSavedDashboardPanel({
...originalPanelState,
explicitInput: { ...migratedInput, id: migratedInput.id as string },
},
version
)
);
}),
version,
});
} else {
newPanels.push(panel);
}

View file

@ -37,8 +37,7 @@ export const migrateExplicitlyHiddenTitles: SavedObjectMigrationFn<any, any> = (
// Convert each panel into the dashboard panel state
const originalPanelState = convertSavedDashboardPanelToPanelState<EmbeddableInput>(panel);
newPanels.push(
convertPanelStateToSavedDashboardPanel(
{
convertPanelStateToSavedDashboardPanel({
...originalPanelState,
explicitInput: {
...originalPanelState.explicitInput,
@ -47,9 +46,7 @@ export const migrateExplicitlyHiddenTitles: SavedObjectMigrationFn<any, any> = (
? { hidePanelTitles: true }
: {}),
},
},
panel.version
)
})
);
});
return {

View file

@ -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<E extends EmbeddableInput & { id: string } = { id: string }> {
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<E extends EmbeddableInput & { id: string } = { id: s
// Stores input for this embeddable that is specific to this embeddable. Other parts of embeddable input
// will be derived from the container's input. **State in here will override state derived from the container.**
explicitInput: Partial<E> & { id: string };
// allows individual embeddable panels to maintain versioning information separate from the main Kibana version
version?: string;
}
export type EmbeddableStateWithType = EmbeddableInput & { type: string };

View file

@ -80,6 +80,7 @@ export {
shouldRefreshFilterCompareOptions,
PANEL_HOVER_TRIGGER,
panelHoverTrigger,
runEmbeddableFactoryMigrations,
} from './lib';
export { EmbeddablePanel } from './embeddable_panel';

View file

@ -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 = <T extends {}>(o: T): Array<keyof T> => Object.keys(o) as Array<keyof T>;
@ -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;
}

View file

@ -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<I, O, E, T>
): EmbeddableFactory<I, O, E, T> => {
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<I, O, E, T> = {
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<I>, 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),

View file

@ -94,7 +94,6 @@ export abstract class Embeddable<
this.onResetInput(newInput);
});
}
this.getOutput$()
.pipe(
map(({ title }) => title || ''),

View file

@ -36,6 +36,14 @@ export interface EmbeddableFactory<
>,
TSavedObjectAttributes = unknown
> extends PersistableState<EmbeddableStateWithType> {
/**
* 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<TEmbeddable | ErrorEmbeddable>;
/**
* 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,

View file

@ -17,7 +17,10 @@ export type EmbeddableFactoryDefinition<
T = unknown
> =
// Required parameters
Pick<EmbeddableFactory<I, O, E, T>, 'create' | 'type' | 'isEditable' | 'getDisplayName'> &
Pick<
EmbeddableFactory<I, O, E, T>,
'create' | 'type' | 'latestVersion' | 'isEditable' | 'getDisplayName'
> &
// Optional parameters
Partial<
Pick<

View file

@ -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<TestInputTypeVersion100>(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<TestInputTypeVersion100>(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');
});
});

View file

@ -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 = <ToType extends EmbeddableInput>(
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 };
};

View file

@ -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';

View file

@ -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,

View file

@ -12,10 +12,11 @@ import { VersionedState, MigrateFunctionsObject } from './types';
export function migrateToLatest<S extends SerializableRecord>(
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;

View file

@ -78,9 +78,9 @@ export interface PersistableState<P extends SerializableRecord = SerializableRec
/**
* A list of migration functions, which migrate the persistable state
* serializable object to the next version. Migration functions should are
* keyed by the Kibana version using semver, where the version indicates to
* which version the state will be migrated to.
* serializable object to the next version. Migration functions should be
* keyed using semver, where the version indicates which version the state
* will be migrated to.
*/
migrations: MigrateFunctionsObject | GetMigrationFunctionObjectFn;
}

View file

@ -19,6 +19,7 @@ const testFactories: EmbeddableFactoryDefinition[] = [
getIconType: () => '',
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),
},