mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 10:40:07 -04:00
[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:
parent
10ab42692d
commit
26389e5014
44 changed files with 540 additions and 332 deletions
|
@ -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;
|
||||
};
|
|
@ -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) {
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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']),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -91,6 +91,7 @@ export const createDashboard = async (
|
|||
reduxEmbeddablePackage,
|
||||
searchSessionId,
|
||||
savedObjectResult?.dashboardInput,
|
||||
savedObjectResult.anyMigrationRun,
|
||||
dashboardCreationStartTime,
|
||||
undefined,
|
||||
creationOptions,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
@ -31,6 +31,7 @@ export type DashboardStateFromSettingsFlyout = DashboardStateFromSaveModal & Das
|
|||
|
||||
export interface DashboardPublicState {
|
||||
lastSavedInput: DashboardContainerInput;
|
||||
hasRunClientsideMigrations?: boolean;
|
||||
animatePanelTransforms?: boolean;
|
||||
isEmbeddedExternally?: boolean;
|
||||
hasUnsavedChanges?: boolean;
|
||||
|
|
|
@ -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`;
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -66,6 +66,7 @@ export interface LoadDashboardReturn {
|
|||
dashboardId?: string;
|
||||
resolveMeta?: DashboardResolveMeta;
|
||||
dashboardInput: DashboardContainerInput;
|
||||
anyMigrationRun?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -46,6 +46,7 @@ export class DashboardStorage extends SOContentStorage<DashboardCrudTypes> {
|
|||
'optionsJSON',
|
||||
'panelsJSON',
|
||||
'timeFrom',
|
||||
'version',
|
||||
'timeTo',
|
||||
'title',
|
||||
],
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -37,19 +37,16 @@ export const migrateExplicitlyHiddenTitles: SavedObjectMigrationFn<any, any> = (
|
|||
// Convert each panel into the dashboard panel state
|
||||
const originalPanelState = convertSavedDashboardPanelToPanelState<EmbeddableInput>(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 {
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -80,6 +80,7 @@ export {
|
|||
shouldRefreshFilterCompareOptions,
|
||||
PANEL_HOVER_TRIGGER,
|
||||
panelHoverTrigger,
|
||||
runEmbeddableFactoryMigrations,
|
||||
} from './lib';
|
||||
|
||||
export { EmbeddablePanel } from './embeddable_panel';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -94,7 +94,6 @@ export abstract class Embeddable<
|
|||
this.onResetInput(newInput);
|
||||
});
|
||||
}
|
||||
|
||||
this.getOutput$()
|
||||
.pipe(
|
||||
map(({ title }) => title || ''),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue