Use saved object references for dashboard drilldowns (#82602) (#83293)

This commit is contained in:
Anton Dosov 2020-11-12 18:05:14 +01:00 committed by GitHub
parent ae3d844872
commit aec05c49a8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 1127 additions and 306 deletions

View file

@ -1,11 +0,0 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-embeddable-server](./kibana-plugin-plugins-embeddable-server.md) &gt; [EmbeddableSetup](./kibana-plugin-plugins-embeddable-server.embeddablesetup.md) &gt; [getAttributeService](./kibana-plugin-plugins-embeddable-server.embeddablesetup.getattributeservice.md)
## EmbeddableSetup.getAttributeService property
<b>Signature:</b>
```typescript
getAttributeService: any;
```

View file

@ -7,14 +7,13 @@
<b>Signature:</b>
```typescript
export interface EmbeddableSetup
export interface EmbeddableSetup extends PersistableStateService<EmbeddableStateWithType>
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [getAttributeService](./kibana-plugin-plugins-embeddable-server.embeddablesetup.getattributeservice.md) | <code>any</code> | |
| [registerEmbeddableFactory](./kibana-plugin-plugins-embeddable-server.embeddablesetup.registerembeddablefactory.md) | <code>(factory: EmbeddableRegistryDefinition) =&gt; void</code> | |
| [registerEnhancement](./kibana-plugin-plugins-embeddable-server.embeddablesetup.registerenhancement.md) | <code>(enhancement: EnhancementRegistryDefinition) =&gt; void</code> | |

View file

@ -0,0 +1,87 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
ExtractDeps,
extractPanelsReferences,
InjectDeps,
injectPanelsReferences,
} from './embeddable_references';
import { createEmbeddablePersistableStateServiceMock } from '../../../embeddable/common/mocks';
import { SavedDashboardPanel } from '../types';
import { EmbeddableStateWithType } from '../../../embeddable/common';
const embeddablePersistableStateService = createEmbeddablePersistableStateServiceMock();
const deps: InjectDeps & ExtractDeps = {
embeddablePersistableStateService,
};
test('inject/extract panel references', () => {
embeddablePersistableStateService.extract.mockImplementationOnce((state) => {
const { HARDCODED_ID, ...restOfState } = (state as unknown) as Record<string, unknown>;
return {
state: restOfState as EmbeddableStateWithType,
references: [{ id: HARDCODED_ID as string, name: 'refName', type: 'type' }],
};
});
embeddablePersistableStateService.inject.mockImplementationOnce((state, references) => {
const ref = references.find((r) => r.name === 'refName');
return {
...state,
HARDCODED_ID: ref!.id,
};
});
const savedDashboardPanel: SavedDashboardPanel = {
type: 'search',
embeddableConfig: {
HARDCODED_ID: 'IMPORTANT_HARDCODED_ID',
},
id: 'savedObjectId',
panelIndex: '123',
gridData: {
x: 0,
y: 0,
h: 15,
w: 15,
i: '123',
},
version: '7.0.0',
};
const [{ panel: extractedPanel, references }] = extractPanelsReferences(
[savedDashboardPanel],
deps
);
expect(extractedPanel.embeddableConfig).toEqual({});
expect(references).toMatchInlineSnapshot(`
Array [
Object {
"id": "IMPORTANT_HARDCODED_ID",
"name": "refName",
"type": "type",
},
]
`);
const [injectedPanel] = injectPanelsReferences([extractedPanel], references, deps);
expect(injectedPanel).toEqual(savedDashboardPanel);
});

View file

@ -0,0 +1,82 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { omit } from 'lodash';
import {
convertSavedDashboardPanelToPanelState,
convertPanelStateToSavedDashboardPanel,
} from './embeddable_saved_object_converters';
import { SavedDashboardPanel } from '../types';
import { SavedObjectReference } from '../../../../core/types';
import { EmbeddablePersistableStateService } from '../../../embeddable/common/types';
export interface InjectDeps {
embeddablePersistableStateService: EmbeddablePersistableStateService;
}
export function injectPanelsReferences(
panels: SavedDashboardPanel[],
references: SavedObjectReference[],
deps: InjectDeps
): SavedDashboardPanel[] {
const result: SavedDashboardPanel[] = [];
for (const panel of panels) {
const embeddableState = convertSavedDashboardPanelToPanelState(panel);
embeddableState.explicitInput = omit(
deps.embeddablePersistableStateService.inject(
{ ...embeddableState.explicitInput, type: panel.type },
references
),
'type'
);
result.push(convertPanelStateToSavedDashboardPanel(embeddableState, panel.version));
}
return result;
}
export interface ExtractDeps {
embeddablePersistableStateService: EmbeddablePersistableStateService;
}
export function extractPanelsReferences(
panels: SavedDashboardPanel[],
deps: ExtractDeps
): Array<{ panel: SavedDashboardPanel; references: SavedObjectReference[] }> {
const result: Array<{ panel: SavedDashboardPanel; references: SavedObjectReference[] }> = [];
for (const panel of panels) {
const embeddable = convertSavedDashboardPanelToPanelState(panel);
const {
state: embeddableInputWithExtractedReferences,
references,
} = deps.embeddablePersistableStateService.extract({
...embeddable.explicitInput,
type: embeddable.type,
});
embeddable.explicitInput = omit(embeddableInputWithExtractedReferences, 'type');
const newPanel = convertPanelStateToSavedDashboardPanel(embeddable, panel.version);
result.push({
panel: newPanel,
references,
});
}
return result;
}

View file

@ -21,9 +21,8 @@ import {
convertSavedDashboardPanelToPanelState,
convertPanelStateToSavedDashboardPanel,
} from './embeddable_saved_object_converters';
import { SavedDashboardPanel } from '../../types';
import { DashboardPanelState } from '../embeddable';
import { EmbeddableInput } from '../../../../embeddable/public';
import { SavedDashboardPanel, DashboardPanelState } from '../types';
import { EmbeddableInput } from '../../../embeddable/common/types';
test('convertSavedDashboardPanelToPanelState', () => {
const savedDashboardPanel: SavedDashboardPanel = {
@ -135,3 +134,24 @@ test('convertPanelStateToSavedDashboardPanel will not add an undefined id when n
const converted = convertPanelStateToSavedDashboardPanel(dashboardPanel, '8.0.0');
expect(converted.hasOwnProperty('id')).toBe(false);
});
test('convertPanelStateToSavedDashboardPanel will not leave title as part of embeddable config', () => {
const dashboardPanel: DashboardPanelState = {
gridData: {
x: 0,
y: 0,
h: 15,
w: 15,
i: '123',
},
explicitInput: {
id: '123',
title: 'title',
} as EmbeddableInput,
type: 'search',
};
const converted = convertPanelStateToSavedDashboardPanel(dashboardPanel, '8.0.0');
expect(converted.embeddableConfig.hasOwnProperty('title')).toBe(false);
expect(converted.title).toBe('title');
});

View file

@ -17,9 +17,8 @@
* under the License.
*/
import { omit } from 'lodash';
import { SavedDashboardPanel } from '../../types';
import { DashboardPanelState } from '../embeddable';
import { SavedObjectEmbeddableInput } from '../../embeddable_plugin';
import { DashboardPanelState, SavedDashboardPanel } from '../types';
import { SavedObjectEmbeddableInput } from '../../../embeddable/common/';
export function convertSavedDashboardPanelToPanelState(
savedDashboardPanel: SavedDashboardPanel
@ -49,7 +48,7 @@ export function convertPanelStateToSavedDashboardPanel(
type: panelState.type,
gridData: panelState.gridData,
panelIndex: panelState.explicitInput.id,
embeddableConfig: omit(panelState.explicitInput, ['id', 'savedObjectId']),
embeddableConfig: omit(panelState.explicitInput, ['id', 'savedObjectId', 'title']),
...(customTitle && { title: customTitle }),
...(savedObjectId !== undefined && { id: savedObjectId }),
};

View file

@ -17,8 +17,18 @@
* under the License.
*/
import { extractReferences, injectReferences } from './saved_dashboard_references';
import { SavedObjectDashboard } from './saved_dashboard';
import {
extractReferences,
injectReferences,
InjectDeps,
ExtractDeps,
} from './saved_dashboard_references';
import { createEmbeddablePersistableStateServiceMock } from '../../embeddable/common/mocks';
const embeddablePersistableStateServiceMock = createEmbeddablePersistableStateServiceMock();
const deps: InjectDeps & ExtractDeps = {
embeddablePersistableStateService: embeddablePersistableStateServiceMock,
};
describe('extractReferences', () => {
test('extracts references from panelsJSON', () => {
@ -41,28 +51,28 @@ describe('extractReferences', () => {
},
references: [],
};
const updatedDoc = extractReferences(doc);
const updatedDoc = extractReferences(doc, deps);
expect(updatedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"foo": true,
"panelsJSON": "[{\\"title\\":\\"Title 1\\",\\"panelRefName\\":\\"panel_0\\"},{\\"title\\":\\"Title 2\\",\\"panelRefName\\":\\"panel_1\\"}]",
},
"references": Array [
Object {
"id": "1",
"name": "panel_0",
"type": "visualization",
},
Object {
"id": "2",
"name": "panel_1",
"type": "visualization",
},
],
}
`);
Object {
"attributes": Object {
"foo": true,
"panelsJSON": "[{\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\",\\"panelRefName\\":\\"panel_0\\"},{\\"embeddableConfig\\":{},\\"title\\":\\"Title 2\\",\\"panelRefName\\":\\"panel_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', () => {
@ -79,7 +89,7 @@ Object {
},
references: [],
};
expect(() => extractReferences(doc)).toThrowErrorMatchingInlineSnapshot(
expect(() => extractReferences(doc, deps)).toThrowErrorMatchingInlineSnapshot(
`"\\"type\\" attribute is missing from panel \\"0\\""`
);
});
@ -98,21 +108,21 @@ Object {
},
references: [],
};
expect(extractReferences(doc)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"foo": true,
"panelsJSON": "[{\\"type\\":\\"visualization\\",\\"title\\":\\"Title 1\\"}]",
},
"references": Array [],
}
`);
expect(extractReferences(doc, deps)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"foo": true,
"panelsJSON": "[{\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\"}]",
},
"references": Array [],
}
`);
});
});
describe('injectReferences', () => {
test('injects references into context', () => {
const context = {
test('returns injected attributes', () => {
const attributes = {
id: '1',
title: 'test',
panelsJSON: JSON.stringify([
@ -125,7 +135,7 @@ describe('injectReferences', () => {
title: 'Title 2',
},
]),
} as SavedObjectDashboard;
};
const references = [
{
name: 'panel_0',
@ -138,49 +148,49 @@ describe('injectReferences', () => {
id: '2',
},
];
injectReferences(context, references);
const newAttributes = injectReferences({ attributes, references }, deps);
expect(context).toMatchInlineSnapshot(`
Object {
"id": "1",
"panelsJSON": "[{\\"title\\":\\"Title 1\\",\\"id\\":\\"1\\",\\"type\\":\\"visualization\\"},{\\"title\\":\\"Title 2\\",\\"id\\":\\"2\\",\\"type\\":\\"visualization\\"}]",
"title": "test",
}
`);
expect(newAttributes).toMatchInlineSnapshot(`
Object {
"id": "1",
"panelsJSON": "[{\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\",\\"id\\":\\"1\\"},{\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 2\\",\\"id\\":\\"2\\"}]",
"title": "test",
}
`);
});
test('skips when panelsJSON is missing', () => {
const context = {
const attributes = {
id: '1',
title: 'test',
} as SavedObjectDashboard;
injectReferences(context, []);
expect(context).toMatchInlineSnapshot(`
Object {
"id": "1",
"title": "test",
}
`);
};
const newAttributes = injectReferences({ attributes, references: [] }, deps);
expect(newAttributes).toMatchInlineSnapshot(`
Object {
"id": "1",
"title": "test",
}
`);
});
test('skips when panelsJSON is not an array', () => {
const context = {
const attributes = {
id: '1',
panelsJSON: '{}',
title: 'test',
} as SavedObjectDashboard;
injectReferences(context, []);
expect(context).toMatchInlineSnapshot(`
Object {
"id": "1",
"panelsJSON": "{}",
"title": "test",
}
`);
};
const newAttributes = injectReferences({ attributes, references: [] }, deps);
expect(newAttributes).toMatchInlineSnapshot(`
Object {
"id": "1",
"panelsJSON": "{}",
"title": "test",
}
`);
});
test('skips a panel when panelRefName is missing', () => {
const context = {
const attributes = {
id: '1',
title: 'test',
panelsJSON: JSON.stringify([
@ -192,7 +202,7 @@ Object {
title: 'Title 2',
},
]),
} as SavedObjectDashboard;
};
const references = [
{
name: 'panel_0',
@ -200,18 +210,18 @@ Object {
id: '1',
},
];
injectReferences(context, references);
expect(context).toMatchInlineSnapshot(`
Object {
"id": "1",
"panelsJSON": "[{\\"title\\":\\"Title 1\\",\\"id\\":\\"1\\",\\"type\\":\\"visualization\\"},{\\"title\\":\\"Title 2\\"}]",
"title": "test",
}
`);
const newAttributes = injectReferences({ attributes, references }, deps);
expect(newAttributes).toMatchInlineSnapshot(`
Object {
"id": "1",
"panelsJSON": "[{\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\",\\"id\\":\\"1\\"},{\\"embeddableConfig\\":{},\\"title\\":\\"Title 2\\"}]",
"title": "test",
}
`);
});
test(`fails when it can't find the reference in the array`, () => {
const context = {
const attributes = {
id: '1',
title: 'test',
panelsJSON: JSON.stringify([
@ -220,9 +230,9 @@ Object {
title: 'Title 1',
},
]),
} as SavedObjectDashboard;
expect(() => injectReferences(context, [])).toThrowErrorMatchingInlineSnapshot(
`"Could not find reference \\"panel_0\\""`
);
};
expect(() =>
injectReferences({ attributes, references: [] }, deps)
).toThrowErrorMatchingInlineSnapshot(`"Could not find reference \\"panel_0\\""`);
});
});

View file

@ -17,18 +17,47 @@
* under the License.
*/
import { SavedObjectAttributes, SavedObjectReference } from 'kibana/public';
import { SavedObjectDashboard } from './saved_dashboard';
import { SavedObjectAttributes, SavedObjectReference } from '../../../core/types';
import {
extractPanelsReferences,
injectPanelsReferences,
} from './embeddable/embeddable_references';
import { SavedDashboardPanel730ToLatest } from './types';
import { EmbeddablePersistableStateService } from '../../embeddable/common/types';
export function extractReferences({
attributes,
references = [],
}: {
export interface ExtractDeps {
embeddablePersistableStateService: EmbeddablePersistableStateService;
}
export interface SavedObjectAttributesAndReferences {
attributes: SavedObjectAttributes;
references: SavedObjectReference[];
}) {
}
export function extractReferences(
{ attributes, references = [] }: SavedObjectAttributesAndReferences,
deps: ExtractDeps
): SavedObjectAttributesAndReferences {
if (typeof attributes.panelsJSON !== 'string') {
return { attributes, references };
}
const panelReferences: SavedObjectReference[] = [];
const panels: Array<Record<string, string>> = JSON.parse(String(attributes.panelsJSON));
let panels: Array<Record<string, string>> = JSON.parse(String(attributes.panelsJSON));
const extractedReferencesResult = extractPanelsReferences(
(panels as unknown) as SavedDashboardPanel730ToLatest[],
deps
);
panels = (extractedReferencesResult.map((res) => res.panel) as unknown) as Array<
Record<string, string>
>;
extractedReferencesResult.forEach((res) => {
panelReferences.push(...res.references);
});
// TODO: This extraction should be done by EmbeddablePersistableStateService
// https://github.com/elastic/kibana/issues/82830
panels.forEach((panel, i) => {
if (!panel.type) {
throw new Error(`"type" attribute is missing from panel "${i}"`);
@ -46,6 +75,7 @@ export function extractReferences({
delete panel.type;
delete panel.id;
});
return {
references: [...references, ...panelReferences],
attributes: {
@ -55,21 +85,28 @@ export function extractReferences({
};
}
export interface InjectDeps {
embeddablePersistableStateService: EmbeddablePersistableStateService;
}
export function injectReferences(
savedObject: SavedObjectDashboard,
references: SavedObjectReference[]
) {
{ attributes, references = [] }: SavedObjectAttributesAndReferences,
deps: InjectDeps
): SavedObjectAttributes {
// Skip if panelsJSON is missing otherwise this will cause saved object import to fail when
// importing objects without panelsJSON. At development time of this, there is no guarantee each saved
// object has panelsJSON in all previous versions of kibana.
if (typeof savedObject.panelsJSON !== 'string') {
return;
if (typeof attributes.panelsJSON !== 'string') {
return attributes;
}
const panels = JSON.parse(savedObject.panelsJSON);
let panels = JSON.parse(attributes.panelsJSON);
// Same here, prevent failing saved object import if ever panels aren't an array.
if (!Array.isArray(panels)) {
return;
return attributes;
}
// TODO: This injection should be done by EmbeddablePersistableStateService
// https://github.com/elastic/kibana/issues/82830
panels.forEach((panel) => {
if (!panel.panelRefName) {
return;
@ -84,5 +121,11 @@ export function injectReferences(
panel.type = reference.type;
delete panel.panelRefName;
});
savedObject.panelsJSON = JSON.stringify(panels);
panels = injectPanelsReferences(panels, references, deps);
return {
...attributes,
panelsJSON: JSON.stringify(panels),
};
}

View file

@ -17,6 +17,8 @@
* under the License.
*/
import { EmbeddableInput, PanelState } from '../../../../src/plugins/embeddable/common/types';
import { SavedObjectEmbeddableInput } from '../../../../src/plugins/embeddable/common/lib/saved_object_embeddable';
import {
RawSavedDashboardPanelTo60,
RawSavedDashboardPanel610,
@ -26,6 +28,21 @@ import {
RawSavedDashboardPanel730ToLatest,
} from './bwc/types';
import { GridData } from './embeddable/types';
export type PanelId = string;
export type SavedObjectId = string;
export interface DashboardPanelState<
TEmbeddableInput extends EmbeddableInput | SavedObjectEmbeddableInput = SavedObjectEmbeddableInput
> extends PanelState<TEmbeddableInput> {
readonly gridData: GridData;
}
/**
* This should always represent the latest dashboard panel shape, after all possible migrations.
*/
export type SavedDashboardPanel = SavedDashboardPanel730ToLatest;
export type SavedDashboardPanel640To720 = Pick<
RawSavedDashboardPanel640To720,
Exclude<keyof RawSavedDashboardPanel640To720, 'name'>

View file

@ -81,7 +81,6 @@ import { getTopNavConfig } from './top_nav/get_top_nav_config';
import { TopNavIds } from './top_nav/top_nav_ids';
import { getDashboardTitle } from './dashboard_strings';
import { DashboardAppScope } from './dashboard_app';
import { convertSavedDashboardPanelToPanelState } from './lib/embeddable_saved_object_converters';
import { RenderDeps } from './application';
import {
IKbnUrlStateStorage,
@ -97,6 +96,7 @@ import {
subscribeWithScope,
} from '../../../kibana_legacy/public';
import { migrateLegacyQuery } from './lib/migrate_legacy_query';
import { convertSavedDashboardPanelToPanelState } from '../../common/embeddable/embeddable_saved_object_converters';
export interface DashboardAppControllerDependencies extends RenderDeps {
$scope: DashboardAppScope;

View file

@ -30,7 +30,6 @@ import { migrateLegacyQuery } from './lib/migrate_legacy_query';
import { ViewMode } from '../embeddable_plugin';
import { getAppStateDefaults, migrateAppState, getDashboardIdFromUrl } from './lib';
import { convertPanelStateToSavedDashboardPanel } from './lib/embeddable_saved_object_converters';
import { FilterUtils } from './lib/filter_utils';
import {
DashboardAppState,
@ -48,6 +47,7 @@ import {
} from '../../../kibana_utils/public';
import { SavedObjectDashboard } from '../saved_dashboards';
import { DashboardContainer } from './embeddable';
import { convertPanelStateToSavedDashboardPanel } from '../../common/embeddable/embeddable_saved_object_converters';
/**
* Dashboard state manager handles connecting angular and redux state between the angular and react portions of the

View file

@ -16,14 +16,4 @@
* specific language governing permissions and limitations
* under the License.
*/
import { SavedObjectEmbeddableInput } from 'src/plugins/embeddable/public';
import { GridData } from '../../../common';
import { PanelState, EmbeddableInput } from '../../embeddable_plugin';
export type PanelId = string;
export type SavedObjectId = string;
export interface DashboardPanelState<
TEmbeddableInput extends EmbeddableInput | SavedObjectEmbeddableInput = SavedObjectEmbeddableInput
> extends PanelState<TEmbeddableInput> {
readonly gridData: GridData;
}
export * from '../../../common/types';

View file

@ -450,6 +450,7 @@ export class DashboardPlugin
const savedDashboardLoader = createSavedDashboardLoader({
savedObjectsClient: core.savedObjects.client,
savedObjects: plugins.savedObjects,
embeddableStart: plugins.embeddable,
});
const dashboardContainerFactory = plugins.embeddable.getEmbeddableFactory(
DASHBOARD_CONTAINER_TYPE

View file

@ -16,6 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
export * from './saved_dashboard_references';
export * from '../../common/saved_dashboard_references';
export * from './saved_dashboard';
export * from './saved_dashboards';

View file

@ -17,10 +17,12 @@
* under the License.
*/
import { SavedObject, SavedObjectsStart } from '../../../../plugins/saved_objects/public';
import { extractReferences, injectReferences } from './saved_dashboard_references';
import { Filter, ISearchSource, Query, RefreshInterval } from '../../../../plugins/data/public';
import { createDashboardEditUrl } from '../dashboard_constants';
import { EmbeddableStart } from '../../../embeddable/public';
import { SavedObjectAttributes, SavedObjectReference } from '../../../../core/types';
import { extractReferences, injectReferences } from '../../common/saved_dashboard_references';
export interface SavedObjectDashboard extends SavedObject {
id?: string;
@ -41,7 +43,8 @@ export interface SavedObjectDashboard extends SavedObject {
// Used only by the savedDashboards service, usually no reason to change this
export function createSavedDashboardClass(
savedObjectStart: SavedObjectsStart
savedObjectStart: SavedObjectsStart,
embeddableStart: EmbeddableStart
): new (id: string) => SavedObjectDashboard {
class SavedDashboard extends savedObjectStart.SavedObjectClass {
// save these objects with the 'dashboard' type
@ -77,8 +80,19 @@ export function createSavedDashboardClass(
type: SavedDashboard.type,
mapping: SavedDashboard.mapping,
searchSource: SavedDashboard.searchSource,
extractReferences,
injectReferences,
extractReferences: (opts: {
attributes: SavedObjectAttributes;
references: SavedObjectReference[];
}) => extractReferences(opts, { embeddablePersistableStateService: embeddableStart }),
injectReferences: (so: SavedObjectDashboard, references: SavedObjectReference[]) => {
const newAttributes = injectReferences(
{ attributes: so._serialize().attributes, references },
{
embeddablePersistableStateService: embeddableStart,
}
);
Object.assign(so, newAttributes);
},
// if this is null/undefined then the SavedObject will be assigned the defaults
id,

View file

@ -20,16 +20,22 @@
import { SavedObjectsClientContract } from 'kibana/public';
import { SavedObjectLoader, SavedObjectsStart } from '../../../../plugins/saved_objects/public';
import { createSavedDashboardClass } from './saved_dashboard';
import { EmbeddableStart } from '../../../embeddable/public';
interface Services {
savedObjectsClient: SavedObjectsClientContract;
savedObjects: SavedObjectsStart;
embeddableStart: EmbeddableStart;
}
/**
* @param services
*/
export function createSavedDashboardLoader({ savedObjects, savedObjectsClient }: Services) {
const SavedDashboard = createSavedDashboardClass(savedObjects);
export function createSavedDashboardLoader({
savedObjects,
savedObjectsClient,
embeddableStart,
}: Services) {
const SavedDashboard = createSavedDashboardClass(savedObjects, embeddableStart);
return new SavedObjectLoader(SavedDashboard, savedObjectsClient);
}

View file

@ -19,9 +19,12 @@
import { Query, Filter } from 'src/plugins/data/public';
import { SavedObject as SavedObjectType, SavedObjectAttributes } from 'src/core/public';
import { SavedDashboardPanel730ToLatest } from '../common';
import { ViewMode } from './embeddable_plugin';
import { SavedDashboardPanel } from '../common/types';
export { SavedDashboardPanel };
export interface DashboardCapabilities {
showWriteControls: boolean;
createNew: boolean;
@ -71,11 +74,6 @@ export interface Field {
export type NavAction = (anchorElement?: any) => void;
/**
* This should always represent the latest dashboard panel shape, after all possible migrations.
*/
export type SavedDashboardPanel = SavedDashboardPanel730ToLatest;
export interface DashboardAppState {
panels: SavedDashboardPanel[];
fullScreenMode: boolean;

View file

@ -25,22 +25,34 @@ import {
Logger,
} from '../../../core/server';
import { dashboardSavedObjectType } from './saved_objects';
import { createDashboardSavedObjectType } from './saved_objects';
import { capabilitiesProvider } from './capabilities_provider';
import { DashboardPluginSetup, DashboardPluginStart } from './types';
import { EmbeddableSetup } from '../../embeddable/server';
export class DashboardPlugin implements Plugin<DashboardPluginSetup, DashboardPluginStart> {
interface SetupDeps {
embeddable: EmbeddableSetup;
}
export class DashboardPlugin
implements Plugin<DashboardPluginSetup, DashboardPluginStart, SetupDeps> {
private readonly logger: Logger;
constructor(initializerContext: PluginInitializerContext) {
this.logger = initializerContext.logger.get();
}
public setup(core: CoreSetup) {
public setup(core: CoreSetup, plugins: SetupDeps) {
this.logger.debug('dashboard: Setup');
core.savedObjects.registerType(dashboardSavedObjectType);
core.savedObjects.registerType(
createDashboardSavedObjectType({
migrationDeps: {
embeddable: plugins.embeddable,
},
})
);
core.capabilities.registerProvider(capabilitiesProvider);
return {};

View file

@ -18,9 +18,16 @@
*/
import { SavedObjectsType } from 'kibana/server';
import { dashboardSavedObjectTypeMigrations } from './dashboard_migrations';
import {
createDashboardSavedObjectTypeMigrations,
DashboardSavedObjectTypeMigrationsDeps,
} from './dashboard_migrations';
export const dashboardSavedObjectType: SavedObjectsType = {
export const createDashboardSavedObjectType = ({
migrationDeps,
}: {
migrationDeps: DashboardSavedObjectTypeMigrationsDeps;
}): SavedObjectsType => ({
name: 'dashboard',
hidden: false,
namespaceType: 'single',
@ -65,5 +72,5 @@ export const dashboardSavedObjectType: SavedObjectsType = {
version: { type: 'integer' },
},
},
migrations: dashboardSavedObjectTypeMigrations,
};
migrations: createDashboardSavedObjectTypeMigrations(migrationDeps),
});

View file

@ -19,7 +19,14 @@
import { SavedObjectUnsanitizedDoc } from 'kibana/server';
import { savedObjectsServiceMock } from '../../../../core/server/mocks';
import { dashboardSavedObjectTypeMigrations as migrations } from './dashboard_migrations';
import { createEmbeddableSetupMock } from '../../../embeddable/server/mocks';
import { createDashboardSavedObjectTypeMigrations } from './dashboard_migrations';
import { DashboardDoc730ToLatest } from '../../common';
const embeddableSetupMock = createEmbeddableSetupMock();
const migrations = createDashboardSavedObjectTypeMigrations({
embeddable: embeddableSetupMock,
});
const contextMock = savedObjectsServiceMock.createMigrationContext();
@ -448,4 +455,50 @@ Object {
`);
});
});
describe('7.11.0 - embeddable persistable state extraction', () => {
const migration = migrations['7.11.0'];
const doc: DashboardDoc730ToLatest = {
attributes: {
description: '',
kibanaSavedObjectMeta: {
searchSourceJSON:
'{"query":{"language":"kuery","query":""},"filter":[{"query":{"match_phrase":{"machine.os.keyword":"osx"}},"$state":{"store":"appState"},"meta":{"type":"phrase","key":"machine.os.keyword","params":{"query":"osx"},"disabled":false,"negate":false,"alias":null,"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index"}}]}',
},
optionsJSON: '{"useMargins":true,"hidePanelTitles":false}',
panelsJSON:
'[{"version":"7.9.3","gridData":{"x":0,"y":0,"w":24,"h":15,"i":"82fa0882-9f9e-476a-bbb9-03555e5ced91"},"panelIndex":"82fa0882-9f9e-476a-bbb9-03555e5ced91","embeddableConfig":{"enhancements":{"dynamicActions":{"events":[]}}},"panelRefName":"panel_0"}]',
timeRestore: false,
title: 'Dashboard A',
version: 1,
},
id: '376e6260-1f5e-11eb-91aa-7b6d5f8a61d6',
references: [
{
id: '90943e30-9a47-11e8-b64d-95841ca0b247',
name: 'kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index',
type: 'index-pattern',
},
{ id: '14e2e710-4258-11e8-b3aa-73fdaf54bfc9', name: 'panel_0', type: 'visualization' },
],
type: 'dashboard',
};
test('should migrate 7.3.0 doc without embeddable state to extract', () => {
const newDoc = migration(doc, contextMock);
expect(newDoc).toEqual(doc);
});
test('should migrate 7.3.0 doc and extract embeddable state', () => {
embeddableSetupMock.extract.mockImplementationOnce((state) => ({
state: { ...state, __extracted: true },
references: [{ id: '__new', name: '__newRefName', type: '__newType' }],
}));
const newDoc = migration(doc, contextMock);
expect(newDoc).not.toEqual(doc);
expect(newDoc.references).toHaveLength(doc.references.length + 1);
expect(JSON.parse(newDoc.attributes.panelsJSON)[0].embeddableConfig.__extracted).toBe(true);
});
});
});

View file

@ -18,11 +18,12 @@
*/
import { get, flow } from 'lodash';
import { SavedObjectMigrationFn } from 'kibana/server';
import { SavedObjectAttributes, SavedObjectMigrationFn } from 'kibana/server';
import { migrations730 } from './migrations_730';
import { migrateMatchAllQuery } from './migrate_match_all_query';
import { DashboardDoc700To720 } from '../../common';
import { DashboardDoc700To720, DashboardDoc730ToLatest } from '../../common';
import { EmbeddableSetup } from '../../../embeddable/server';
import { injectReferences, extractReferences } from '../../common/saved_dashboard_references';
function migrateIndexPattern(doc: DashboardDoc700To720) {
const searchSourceJSON = get(doc, 'attributes.kibanaSavedObjectMeta.searchSourceJSON');
@ -100,7 +101,57 @@ const migrations700: SavedObjectMigrationFn<any, any> = (doc): DashboardDoc700To
return doc as DashboardDoc700To720;
};
export const dashboardSavedObjectTypeMigrations = {
/**
* In 7.8.0 we introduced dashboard drilldowns which are stored inside dashboard saved object as part of embeddable state
* In 7.11.0 we created an embeddable references/migrations system that allows to properly extract embeddable persistable state
* https://github.com/elastic/kibana/issues/71409
* The idea of this migration is to inject all the embeddable panel references and then run the extraction again.
* As the result of the extraction:
* 1. In addition to regular `panel_` we will get new references which are extracted by `embeddablePersistableStateService` (dashboard drilldown references)
* 2. `panel_` references will be regenerated
* All other references like index-patterns are forwarded non touched
* @param deps
*/
function createExtractPanelReferencesMigration(
deps: DashboardSavedObjectTypeMigrationsDeps
): SavedObjectMigrationFn<DashboardDoc730ToLatest['attributes']> {
return (doc) => {
const references = doc.references ?? [];
/**
* Remembering this because dashboard's extractReferences won't return those
* All other references like `panel_` will be overwritten
*/
const oldNonPanelReferences = references.filter((ref) => !ref.name.startsWith('panel_'));
const injectedAttributes = injectReferences(
{
attributes: (doc.attributes as unknown) as SavedObjectAttributes,
references,
},
{ embeddablePersistableStateService: deps.embeddable }
);
const { attributes, references: newPanelReferences } = extractReferences(
{ attributes: injectedAttributes, references: [] },
{ embeddablePersistableStateService: deps.embeddable }
);
return {
...doc,
references: [...oldNonPanelReferences, ...newPanelReferences],
attributes,
};
};
}
export interface DashboardSavedObjectTypeMigrationsDeps {
embeddable: EmbeddableSetup;
}
export const createDashboardSavedObjectTypeMigrations = (
deps: DashboardSavedObjectTypeMigrationsDeps
) => ({
/**
* We need to have this migration twice, once with a version prior to 7.0.0 once with a version
* after it. The reason for that is, that this migration has been introduced once 7.0.0 was already
@ -115,4 +166,5 @@ export const dashboardSavedObjectTypeMigrations = {
'7.0.0': flow(migrations700),
'7.3.0': flow(migrations730),
'7.9.3': flow(migrateMatchAllQuery),
};
'7.11.0': flow(createExtractPanelReferencesMigration(deps)),
});

View file

@ -17,4 +17,4 @@
* under the License.
*/
export { dashboardSavedObjectType } from './dashboard';
export { createDashboardSavedObjectType } from './dashboard';

View file

@ -18,12 +18,16 @@
*/
import { savedObjectsServiceMock } from '../../../../core/server/mocks';
import { dashboardSavedObjectTypeMigrations as migrations } from './dashboard_migrations';
import { createDashboardSavedObjectTypeMigrations } from './dashboard_migrations';
import { migrations730 } from './migrations_730';
import { DashboardDoc700To720, DashboardDoc730ToLatest, DashboardDocPre700 } from '../../common';
import { RawSavedDashboardPanel730ToLatest } from '../../common';
import { createEmbeddableSetupMock } from '../../../embeddable/server/mocks';
const mockContext = savedObjectsServiceMock.createMigrationContext();
const migrations = createDashboardSavedObjectTypeMigrations({
embeddable: createEmbeddableSetupMock(),
});
test('dashboard migration 7.3.0 migrates filters to query on search source', () => {
const doc: DashboardDoc700To720 = {

View file

@ -0,0 +1,21 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export * from './types';
export * from './lib';

View file

@ -22,3 +22,4 @@ export * from './inject';
export * from './migrate';
export * from './migrate_base_input';
export * from './telemetry';
export * from './saved_object_embeddable';

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { EmbeddableInput } from '..';
import { EmbeddableInput } from '../types';
export interface SavedObjectEmbeddableInput extends EmbeddableInput {
savedObjectId: string;

View file

@ -0,0 +1,31 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { EmbeddablePersistableStateService } from './types';
export const createEmbeddablePersistableStateServiceMock = (): jest.Mocked<
EmbeddablePersistableStateService
> => {
return {
inject: jest.fn((state, references) => state),
extract: jest.fn((state) => ({ state, references: [] })),
migrate: jest.fn((state, version) => state),
telemetry: jest.fn((state, collector) => ({})),
};
};

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { SerializableState } from '../../kibana_utils/common';
import { PersistableStateService, SerializableState } from '../../kibana_utils/common';
import { Query, TimeRange } from '../../data/common/query';
import { Filter } from '../../data/common/es_query/filters';
@ -74,8 +74,21 @@ export type EmbeddableInput = {
searchSessionId?: string;
};
export interface PanelState<E extends EmbeddableInput & { id: string } = { id: string }> {
// The type of embeddable in this panel. Will be used to find the factory in which to
// load the embeddable.
type: string;
// Stores input for this embeddable that is specific to this embeddable. Other parts of embeddable input
// will be derived from the container's input. **Any state in here will override any state derived from
// the container.**
explicitInput: Partial<E> & { id: string };
}
export type EmbeddableStateWithType = EmbeddableInput & { type: string };
export type EmbeddablePersistableStateService = PersistableStateService<EmbeddableStateWithType>;
export interface CommonEmbeddableStartContract {
getEmbeddableFactory: (embeddableFactoryId: string) => any;
getEnhancement: (enhancementId: string) => any;

View file

@ -31,7 +31,7 @@ import {
import { IContainer, ContainerInput, ContainerOutput, PanelState } from './i_container';
import { PanelNotFoundError, EmbeddableFactoryNotFoundError } from '../errors';
import { EmbeddableStart } from '../../plugin';
import { isSavedObjectEmbeddableInput } from '../embeddables/saved_object_embeddable';
import { isSavedObjectEmbeddableInput } from '../../../common/lib/saved_object_embeddable';
const getKeys = <T extends {}>(o: T): Array<keyof T> => Object.keys(o) as Array<keyof T>;

View file

@ -24,17 +24,9 @@ import {
ErrorEmbeddable,
IEmbeddable,
} from '../embeddables';
import { PanelState } from '../../../common/types';
export interface PanelState<E extends EmbeddableInput & { id: string } = { id: string }> {
// The type of embeddable in this panel. Will be used to find the factory in which to
// load the embeddable.
type: string;
// Stores input for this embeddable that is specific to this embeddable. Other parts of embeddable input
// will be derived from the container's input. **Any state in here will override any state derived from
// the container.**
explicitInput: Partial<E> & { id: string };
}
export { PanelState };
export interface ContainerOutput extends EmbeddableOutput {
embeddableLoaded: { [key: string]: boolean };

View file

@ -24,5 +24,5 @@ export * from './default_embeddable_factory_provider';
export { ErrorEmbeddable, isErrorEmbeddable } from './error_embeddable';
export { withEmbeddableSubscription } from './with_subscription';
export { EmbeddableRoot } from './embeddable_root';
export * from './saved_object_embeddable';
export * from '../../../common/lib/saved_object_embeddable';
export { EmbeddableRenderer, EmbeddableRendererProps } from './embeddable_renderer';

View file

@ -0,0 +1,30 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createEmbeddablePersistableStateServiceMock } from '../common/mocks';
import { EmbeddableSetup, EmbeddableStart } from './plugin';
export const createEmbeddableSetupMock = (): jest.Mocked<EmbeddableSetup> => ({
...createEmbeddablePersistableStateServiceMock(),
registerEmbeddableFactory: jest.fn(),
registerEnhancement: jest.fn(),
});
export const createEmbeddableStartMock = (): jest.Mocked<EmbeddableStart> =>
createEmbeddablePersistableStateServiceMock();

View file

@ -32,23 +32,32 @@ import {
getMigrateFunction,
getTelemetryFunction,
} from '../common/lib';
import { SerializableState } from '../../kibana_utils/common';
import { PersistableStateService, SerializableState } from '../../kibana_utils/common';
import { EmbeddableStateWithType } from '../common/types';
export interface EmbeddableSetup {
getAttributeService: any;
export interface EmbeddableSetup extends PersistableStateService<EmbeddableStateWithType> {
registerEmbeddableFactory: (factory: EmbeddableRegistryDefinition) => void;
registerEnhancement: (enhancement: EnhancementRegistryDefinition) => void;
}
export class EmbeddableServerPlugin implements Plugin<object, object> {
export type EmbeddableStart = PersistableStateService<EmbeddableStateWithType>;
export class EmbeddableServerPlugin implements Plugin<EmbeddableSetup, EmbeddableStart> {
private readonly embeddableFactories: EmbeddableFactoryRegistry = new Map();
private readonly enhancements: EnhancementsRegistry = new Map();
public setup(core: CoreSetup) {
const commonContract = {
getEmbeddableFactory: this.getEmbeddableFactory,
getEnhancement: this.getEnhancement,
};
return {
registerEmbeddableFactory: this.registerEmbeddableFactory,
registerEnhancement: this.registerEnhancement,
telemetry: getTelemetryFunction(commonContract),
extract: getExtractFunction(commonContract),
inject: getInjectFunction(commonContract),
migrate: getMigrateFunction(commonContract),
};
}

View file

@ -18,12 +18,11 @@ export interface EmbeddableRegistryDefinition<P extends EmbeddableStateWithType
id: string;
}
// Warning: (ae-forgotten-export) The symbol "PersistableStateService" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "EmbeddableSetup" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export interface EmbeddableSetup {
// (undocumented)
getAttributeService: any;
export interface EmbeddableSetup extends PersistableStateService<EmbeddableStateWithType> {
// (undocumented)
registerEmbeddableFactory: (factory: EmbeddableRegistryDefinition) => void;
// (undocumented)

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/**
* NOTE: DO NOT CHANGE THIS STRING WITHOUT CAREFUL CONSIDERATOIN, BECAUSE IT IS
* STORED IN SAVED OBJECTS.
*
* Also temporary dashboard drilldown migration code inside embeddable plugin relies on it
* x-pack/plugins/embeddable_enhanced/public/embeddables/embeddable_action_storage.ts
*/
export const EMBEDDABLE_TO_DASHBOARD_DRILLDOWN = 'DASHBOARD_TO_DASHBOARD_DRILLDOWN';

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { createExtract, createInject } from './dashboard_drilldown_persistable_state';
import { SerializedEvent } from '../../../../ui_actions_enhanced/common';
const drilldownId = 'test_id';
const extract = createExtract({ drilldownId });
const inject = createInject({ drilldownId });
const state: SerializedEvent = {
eventId: 'event_id',
triggers: [],
action: {
factoryId: drilldownId,
name: 'name',
config: {
dashboardId: 'dashboardId_1',
},
},
};
test('should extract and injected dashboard reference', () => {
const { state: extractedState, references } = extract(state);
expect(extractedState).not.toEqual(state);
expect(extractedState.action.config.dashboardId).toBeUndefined();
expect(references).toMatchInlineSnapshot(`
Array [
Object {
"id": "dashboardId_1",
"name": "drilldown:test_id:event_id:dashboardId",
"type": "dashboard",
},
]
`);
let injectedState = inject(extractedState, references);
expect(injectedState).toEqual(state);
references[0].id = 'dashboardId_2';
injectedState = inject(extractedState, references);
expect(injectedState).not.toEqual(extractedState);
expect(injectedState.action.config.dashboardId).toBe('dashboardId_2');
});

View file

@ -0,0 +1,75 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { SavedObjectReference } from '../../../../../../src/core/types';
import { PersistableStateService } from '../../../../../../src/plugins/kibana_utils/common';
import { SerializedAction, SerializedEvent } from '../../../../ui_actions_enhanced/common';
import { DrilldownConfig } from './types';
type DashboardDrilldownPersistableState = PersistableStateService<SerializedEvent>;
const generateRefName = (state: SerializedEvent, id: string) =>
`drilldown:${id}:${state.eventId}:dashboardId`;
const injectDashboardId = (state: SerializedEvent, dashboardId: string): SerializedEvent => {
return {
...state,
action: {
...state.action,
config: {
...state.action.config,
dashboardId,
},
},
};
};
export const createInject = ({
drilldownId,
}: {
drilldownId: string;
}): DashboardDrilldownPersistableState['inject'] => {
return (state: SerializedEvent, references: SavedObjectReference[]) => {
const action = state.action as SerializedAction<DrilldownConfig>;
const refName = generateRefName(state, drilldownId);
const ref = references.find((r) => r.name === refName);
if (!ref) return state;
if (ref.id && ref.id === action.config.dashboardId) return state;
return injectDashboardId(state, ref.id);
};
};
export const createExtract = ({
drilldownId,
}: {
drilldownId: string;
}): DashboardDrilldownPersistableState['extract'] => {
return (state: SerializedEvent) => {
const action = state.action as SerializedAction<DrilldownConfig>;
const references: SavedObjectReference[] = action.config.dashboardId
? [
{
name: generateRefName(state, drilldownId),
type: 'dashboard',
id: action.config.dashboardId,
},
]
: [];
const { dashboardId, ...restOfConfig } = action.config;
return {
state: {
...state,
action: ({
...state.action,
config: restOfConfig,
} as unknown) as SerializedAction,
},
references,
};
};
};

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { createExtract, createInject } from './dashboard_drilldown_persistable_state';
export { EMBEDDABLE_TO_DASHBOARD_DRILLDOWN } from './constants';
export { DrilldownConfig } from './types';

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type DrilldownConfig = {
dashboardId?: string;
useCurrentFilters: boolean;
useCurrentDateRange: boolean;
};

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './dashboard_drilldown';

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './drilldowns';

View file

@ -1,7 +1,7 @@
{
"id": "dashboardEnhanced",
"version": "kibana",
"server": false,
"server": true,
"ui": true,
"requiredPlugins": ["data", "uiActionsEnhanced", "embeddable", "dashboard", "share"],
"configPath": ["xpack", "dashboardEnhanced"],

View file

@ -9,19 +9,19 @@ import { DataPublicPluginStart } from 'src/plugins/data/public';
import { DashboardStart } from 'src/plugins/dashboard/public';
import { reactToUiComponent } from '../../../../../../../src/plugins/kibana_react/public';
import {
TriggerId,
TriggerContextMapping,
TriggerId,
} from '../../../../../../../src/plugins/ui_actions/public';
import { CollectConfigContainer } from './components';
import {
UiActionsEnhancedDrilldownDefinition as Drilldown,
UiActionsEnhancedBaseActionFactoryContext as BaseActionFactoryContext,
AdvancedUiActionsStart,
UiActionsEnhancedBaseActionFactoryContext as BaseActionFactoryContext,
UiActionsEnhancedDrilldownDefinition as Drilldown,
} from '../../../../../ui_actions_enhanced/public';
import { txtGoToDashboard } from './i18n';
import {
StartServicesGetter,
CollectConfigProps,
StartServicesGetter,
} from '../../../../../../../src/plugins/kibana_utils/public';
import { KibanaURL } from '../../../../../../../src/plugins/share/public';
import { Config } from './types';

View file

@ -6,12 +6,8 @@
import { UiActionsEnhancedBaseActionFactoryContext } from '../../../../../ui_actions_enhanced/public';
import { APPLY_FILTER_TRIGGER } from '../../../../../../../src/plugins/ui_actions/public';
import { DrilldownConfig } from '../../../../common';
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type Config = {
dashboardId?: string;
useCurrentFilters: boolean;
useCurrentDateRange: boolean;
};
export type Config = DrilldownConfig;
export type FactoryContext = UiActionsEnhancedBaseActionFactoryContext<typeof APPLY_FILTER_TRIGGER>;

View file

@ -65,6 +65,12 @@ test('getHref is defined', () => {
expect(drilldown.getHref).toBeDefined();
});
test('inject/extract are defined', () => {
const drilldown = new EmbeddableToDashboardDrilldown({} as any);
expect(drilldown.extract).toBeDefined();
expect(drilldown.inject).toBeDefined();
});
describe('.execute() & getHref', () => {
/**
* A convenience test setup helper

View file

@ -22,6 +22,7 @@ import {
} from '../abstract_dashboard_drilldown';
import { KibanaURL } from '../../../../../../../src/plugins/share/public';
import { EMBEDDABLE_TO_DASHBOARD_DRILLDOWN } from './constants';
import { createExtract, createInject } from '../../../../common';
type Trigger = typeof APPLY_FILTER_TRIGGER;
type Context = TriggerContextMapping[Trigger];
@ -80,4 +81,8 @@ export class EmbeddableToDashboardDrilldown extends AbstractDashboardDrilldown<T
return url;
}
public readonly inject = createInject({ drilldownId: this.id });
public readonly extract = createExtract({ drilldownId: this.id });
}

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { PluginInitializerContext } from 'src/core/server';
import { DashboardEnhancedPlugin } from './plugin';
export {
SetupContract as DashboardEnhancedSetupContract,
SetupDependencies as DashboardEnhancedSetupDependencies,
StartContract as DashboardEnhancedStartContract,
StartDependencies as DashboardEnhancedStartDependencies,
} from './plugin';
export function plugin(context: PluginInitializerContext) {
return new DashboardEnhancedPlugin(context);
}

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/server';
import { AdvancedUiActionsSetup, AdvancedUiActionsStart } from '../../ui_actions_enhanced/server';
import { EMBEDDABLE_TO_DASHBOARD_DRILLDOWN, createExtract, createInject } from '../common';
export interface SetupDependencies {
uiActionsEnhanced: AdvancedUiActionsSetup;
}
export interface StartDependencies {
uiActionsEnhanced: AdvancedUiActionsStart;
}
// eslint-disable-next-line
export interface SetupContract {}
// eslint-disable-next-line
export interface StartContract {}
export class DashboardEnhancedPlugin
implements Plugin<SetupContract, StartContract, SetupDependencies, StartDependencies> {
constructor(protected readonly context: PluginInitializerContext) {}
public setup(core: CoreSetup<StartDependencies>, plugins: SetupDependencies): SetupContract {
plugins.uiActionsEnhanced.registerActionFactory({
id: EMBEDDABLE_TO_DASHBOARD_DRILLDOWN,
inject: createInject({ drilldownId: EMBEDDABLE_TO_DASHBOARD_DRILLDOWN }),
extract: createExtract({ drilldownId: EMBEDDABLE_TO_DASHBOARD_DRILLDOWN }),
});
return {};
}
public start(core: CoreStart, plugins: StartDependencies): StartContract {
return {};
}
public stop() {}
}

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './types';

View file

@ -7,11 +7,11 @@
import { EnhancementRegistryDefinition } from '../../../../src/plugins/embeddable/server';
import { SavedObjectReference } from '../../../../src/core/types';
import { DynamicActionsState, SerializedEvent } from './types';
import { AdvancedUiActionsPublicPlugin } from './plugin';
import { AdvancedUiActionsServerPlugin } from './plugin';
import { SerializableState } from '../../../../src/plugins/kibana_utils/common';
export const dynamicActionEnhancement = (
uiActionsEnhanced: AdvancedUiActionsPublicPlugin
uiActionsEnhanced: AdvancedUiActionsServerPlugin
): EnhancementRegistryDefinition => {
return {
id: 'dynamicActions',

View file

@ -4,13 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { AdvancedUiActionsPublicPlugin } from './plugin';
import { AdvancedUiActionsServerPlugin } from './plugin';
export function plugin() {
return new AdvancedUiActionsPublicPlugin();
return new AdvancedUiActionsServerPlugin();
}
export { AdvancedUiActionsPublicPlugin as Plugin };
export { AdvancedUiActionsServerPlugin as Plugin };
export {
SetupContract as AdvancedUiActionsSetup,
StartContract as AdvancedUiActionsStart,

View file

@ -16,7 +16,7 @@ import {
} from './types';
export interface SetupContract {
registerActionFactory: any;
registerActionFactory: (definition: ActionFactoryDefinition) => void;
}
export type StartContract = void;
@ -25,7 +25,7 @@ interface SetupDependencies {
embeddable: EmbeddableSetup; // Embeddable are needed because they register basic triggers/actions.
}
export class AdvancedUiActionsPublicPlugin
export class AdvancedUiActionsServerPlugin
implements Plugin<SetupContract, StartContract, SetupDependencies> {
protected readonly actionFactories: ActionFactoryRegistry = new Map();

View file

@ -14,7 +14,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const dashboardPanelActions = getService('dashboardPanelActions');
const dashboardDrilldownPanelActions = getService('dashboardDrilldownPanelActions');
const dashboardDrilldownsManage = getService('dashboardDrilldownsManage');
const PageObjects = getPageObjects(['dashboard', 'common', 'header', 'timePicker']);
const PageObjects = getPageObjects([
'dashboard',
'common',
'header',
'timePicker',
'settings',
'copySavedObjectsToSpace',
]);
const pieChart = getService('pieChart');
const log = getService('log');
const browser = getService('browser');
@ -22,120 +29,188 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const filterBar = getService('filterBar');
const security = getService('security');
const spaces = getService('spaces');
describe('Dashboard to dashboard drilldown', function () {
before(async () => {
log.debug('Dashboard Drilldowns:initTests');
await security.testUser.setRoles(['test_logstash_reader', 'global_dashboard_all']);
await PageObjects.common.navigateToApp('dashboard');
await PageObjects.dashboard.preserveCrossAppState();
describe('Create & use drilldowns', () => {
before(async () => {
log.debug('Dashboard Drilldowns:initTests');
await security.testUser.setRoles(['test_logstash_reader', 'global_dashboard_all']);
await PageObjects.common.navigateToApp('dashboard');
await PageObjects.dashboard.preserveCrossAppState();
});
after(async () => {
await security.testUser.restoreDefaults();
});
it('should create dashboard to dashboard drilldown, use it, and then delete it', async () => {
await PageObjects.dashboard.gotoDashboardEditMode(
dashboardDrilldownsManage.DASHBOARD_WITH_PIE_CHART_NAME
);
// create drilldown
await dashboardPanelActions.openContextMenu();
await dashboardDrilldownPanelActions.expectExistsCreateDrilldownAction();
await dashboardDrilldownPanelActions.clickCreateDrilldown();
await dashboardDrilldownsManage.expectsCreateDrilldownFlyoutOpen();
await dashboardDrilldownsManage.fillInDashboardToDashboardDrilldownWizard({
drilldownName: DRILLDOWN_TO_AREA_CHART_NAME,
destinationDashboardTitle: dashboardDrilldownsManage.DASHBOARD_WITH_AREA_CHART_NAME,
});
await dashboardDrilldownsManage.saveChanges();
await dashboardDrilldownsManage.expectsCreateDrilldownFlyoutClose();
// check that drilldown notification badge is shown
expect(await PageObjects.dashboard.getPanelDrilldownCount()).to.be(1);
// save dashboard, navigate to view mode
await PageObjects.dashboard.saveDashboard(
dashboardDrilldownsManage.DASHBOARD_WITH_PIE_CHART_NAME,
{
saveAsNew: false,
waitDialogIsClosed: true,
}
);
// trigger drilldown action by clicking on a pie and picking drilldown action by it's name
await pieChart.clickOnPieSlice('40,000');
await dashboardDrilldownPanelActions.expectMultipleActionsMenuOpened();
const href = await dashboardDrilldownPanelActions.getActionHrefByText(
DRILLDOWN_TO_AREA_CHART_NAME
);
expect(typeof href).to.be('string'); // checking that action has a href
const dashboardIdFromHref = PageObjects.dashboard.getDashboardIdFromUrl(href);
await navigateWithinDashboard(async () => {
await dashboardDrilldownPanelActions.clickActionByText(DRILLDOWN_TO_AREA_CHART_NAME);
});
// checking that href is at least pointing to the same dashboard that we are navigated to by regular click
expect(dashboardIdFromHref).to.be(
await PageObjects.dashboard.getDashboardIdFromCurrentUrl()
);
// check that we drilled-down with filter from pie chart
expect(await filterBar.getFilterCount()).to.be(1);
const originalTimeRangeDurationHours = await PageObjects.timePicker.getTimeDurationInHours();
// brush area chart and drilldown back to pie chat dashboard
await brushAreaChart();
await dashboardDrilldownPanelActions.expectMultipleActionsMenuOpened();
await navigateWithinDashboard(async () => {
await dashboardDrilldownPanelActions.clickActionByText(DRILLDOWN_TO_PIE_CHART_NAME);
});
// because filters are preserved during navigation, we expect that only one slice is displayed (filter is still applied)
expect(await filterBar.getFilterCount()).to.be(1);
await pieChart.expectPieSliceCount(1);
// check that new time range duration was applied
const newTimeRangeDurationHours = await PageObjects.timePicker.getTimeDurationInHours();
expect(newTimeRangeDurationHours).to.be.lessThan(originalTimeRangeDurationHours);
// delete drilldown
await PageObjects.dashboard.switchToEditMode();
await dashboardPanelActions.openContextMenu();
await dashboardDrilldownPanelActions.expectExistsManageDrilldownsAction();
await dashboardDrilldownPanelActions.clickManageDrilldowns();
await dashboardDrilldownsManage.expectsManageDrilldownsFlyoutOpen();
await dashboardDrilldownsManage.deleteDrilldownsByTitles([DRILLDOWN_TO_AREA_CHART_NAME]);
await dashboardDrilldownsManage.closeFlyout();
// check that drilldown notification badge is shown
expect(await PageObjects.dashboard.getPanelDrilldownCount()).to.be(0);
});
it('browser back/forward navigation works after drilldown navigation', async () => {
await PageObjects.dashboard.loadSavedDashboard(
dashboardDrilldownsManage.DASHBOARD_WITH_AREA_CHART_NAME
);
const originalTimeRangeDurationHours = await PageObjects.timePicker.getTimeDurationInHours();
await brushAreaChart();
await dashboardDrilldownPanelActions.expectMultipleActionsMenuOpened();
await navigateWithinDashboard(async () => {
await dashboardDrilldownPanelActions.clickActionByText(DRILLDOWN_TO_PIE_CHART_NAME);
});
// check that new time range duration was applied
const newTimeRangeDurationHours = await PageObjects.timePicker.getTimeDurationInHours();
expect(newTimeRangeDurationHours).to.be.lessThan(originalTimeRangeDurationHours);
await navigateWithinDashboard(async () => {
await browser.goBack();
});
expect(await PageObjects.timePicker.getTimeDurationInHours()).to.be(
originalTimeRangeDurationHours
);
});
});
after(async () => {
await security.testUser.restoreDefaults();
});
it('should create dashboard to dashboard drilldown, use it, and then delete it', async () => {
await PageObjects.dashboard.gotoDashboardEditMode(
dashboardDrilldownsManage.DASHBOARD_WITH_PIE_CHART_NAME
);
// create drilldown
await dashboardPanelActions.openContextMenu();
await dashboardDrilldownPanelActions.expectExistsCreateDrilldownAction();
await dashboardDrilldownPanelActions.clickCreateDrilldown();
await dashboardDrilldownsManage.expectsCreateDrilldownFlyoutOpen();
await dashboardDrilldownsManage.fillInDashboardToDashboardDrilldownWizard({
drilldownName: DRILLDOWN_TO_AREA_CHART_NAME,
destinationDashboardTitle: dashboardDrilldownsManage.DASHBOARD_WITH_AREA_CHART_NAME,
});
await dashboardDrilldownsManage.saveChanges();
await dashboardDrilldownsManage.expectsCreateDrilldownFlyoutClose();
// check that drilldown notification badge is shown
expect(await PageObjects.dashboard.getPanelDrilldownCount()).to.be(1);
// save dashboard, navigate to view mode
await PageObjects.dashboard.saveDashboard(
dashboardDrilldownsManage.DASHBOARD_WITH_PIE_CHART_NAME,
{
saveAsNew: false,
waitDialogIsClosed: true,
}
);
// trigger drilldown action by clicking on a pie and picking drilldown action by it's name
await pieChart.clickOnPieSlice('40,000');
await dashboardDrilldownPanelActions.expectMultipleActionsMenuOpened();
const href = await dashboardDrilldownPanelActions.getActionHrefByText(
DRILLDOWN_TO_AREA_CHART_NAME
);
expect(typeof href).to.be('string'); // checking that action has a href
const dashboardIdFromHref = PageObjects.dashboard.getDashboardIdFromUrl(href);
await navigateWithinDashboard(async () => {
await dashboardDrilldownPanelActions.clickActionByText(DRILLDOWN_TO_AREA_CHART_NAME);
});
// checking that href is at least pointing to the same dashboard that we are navigated to by regular click
expect(dashboardIdFromHref).to.be(await PageObjects.dashboard.getDashboardIdFromCurrentUrl());
// check that we drilled-down with filter from pie chart
expect(await filterBar.getFilterCount()).to.be(1);
const originalTimeRangeDurationHours = await PageObjects.timePicker.getTimeDurationInHours();
// brush area chart and drilldown back to pie chat dashboard
await brushAreaChart();
await dashboardDrilldownPanelActions.expectMultipleActionsMenuOpened();
await navigateWithinDashboard(async () => {
await dashboardDrilldownPanelActions.clickActionByText(DRILLDOWN_TO_PIE_CHART_NAME);
describe('Copy to space', () => {
const destinationSpaceId = 'custom_space';
before(async () => {
await spaces.create({
id: destinationSpaceId,
name: 'custom_space',
disabledFeatures: [],
});
await PageObjects.settings.navigateTo();
await PageObjects.settings.clickKibanaSavedObjects();
});
// because filters are preserved during navigation, we expect that only one slice is displayed (filter is still applied)
expect(await filterBar.getFilterCount()).to.be(1);
await pieChart.expectPieSliceCount(1);
// check that new time range duration was applied
const newTimeRangeDurationHours = await PageObjects.timePicker.getTimeDurationInHours();
expect(newTimeRangeDurationHours).to.be.lessThan(originalTimeRangeDurationHours);
// delete drilldown
await PageObjects.dashboard.switchToEditMode();
await dashboardPanelActions.openContextMenu();
await dashboardDrilldownPanelActions.expectExistsManageDrilldownsAction();
await dashboardDrilldownPanelActions.clickManageDrilldowns();
await dashboardDrilldownsManage.expectsManageDrilldownsFlyoutOpen();
await dashboardDrilldownsManage.deleteDrilldownsByTitles([DRILLDOWN_TO_AREA_CHART_NAME]);
await dashboardDrilldownsManage.closeFlyout();
// check that drilldown notification badge is shown
expect(await PageObjects.dashboard.getPanelDrilldownCount()).to.be(0);
});
it('browser back/forward navigation works after drilldown navigation', async () => {
await PageObjects.dashboard.loadSavedDashboard(
dashboardDrilldownsManage.DASHBOARD_WITH_AREA_CHART_NAME
);
const originalTimeRangeDurationHours = await PageObjects.timePicker.getTimeDurationInHours();
await brushAreaChart();
await dashboardDrilldownPanelActions.expectMultipleActionsMenuOpened();
await navigateWithinDashboard(async () => {
await dashboardDrilldownPanelActions.clickActionByText(DRILLDOWN_TO_PIE_CHART_NAME);
});
// check that new time range duration was applied
const newTimeRangeDurationHours = await PageObjects.timePicker.getTimeDurationInHours();
expect(newTimeRangeDurationHours).to.be.lessThan(originalTimeRangeDurationHours);
await navigateWithinDashboard(async () => {
await browser.goBack();
after(async () => {
await spaces.delete(destinationSpaceId);
});
expect(await PageObjects.timePicker.getTimeDurationInHours()).to.be(
originalTimeRangeDurationHours
);
it('Dashboards linked by a drilldown are both copied to a space', async () => {
await PageObjects.copySavedObjectsToSpace.openCopyToSpaceFlyoutForObject(
dashboardDrilldownsManage.DASHBOARD_WITH_AREA_CHART_NAME
);
await PageObjects.copySavedObjectsToSpace.setupForm({
destinationSpaceId,
});
await PageObjects.copySavedObjectsToSpace.startCopy();
// Wait for successful copy
await testSubjects.waitForDeleted(`cts-summary-indicator-loading-${destinationSpaceId}`);
await testSubjects.existOrFail(`cts-summary-indicator-success-${destinationSpaceId}`);
const summaryCounts = await PageObjects.copySavedObjectsToSpace.getSummaryCounts();
expect(summaryCounts).to.eql({
success: 5, // 2 dashboards (linked by a drilldown) + 2 visualizations + 1 index pattern
pending: 0,
skipped: 0,
errors: 0,
});
await PageObjects.copySavedObjectsToSpace.finishCopy();
// Actually use copied dashboards in a new space:
await PageObjects.common.navigateToApp('dashboard', {
basePath: `/s/${destinationSpaceId}`,
});
await PageObjects.dashboard.preserveCrossAppState();
await PageObjects.dashboard.loadSavedDashboard(
dashboardDrilldownsManage.DASHBOARD_WITH_AREA_CHART_NAME
);
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.dashboard.waitForRenderComplete();
// brush area chart and drilldown back to pie chat dashboard
await brushAreaChart();
await dashboardDrilldownPanelActions.expectMultipleActionsMenuOpened();
await navigateWithinDashboard(async () => {
await dashboardDrilldownPanelActions.clickActionByText(DRILLDOWN_TO_PIE_CHART_NAME);
});
await pieChart.expectPieSliceCount(10);
});
});
});

View file

@ -126,7 +126,7 @@
"title": "Dashboard Foo",
"hits": 0,
"description": "",
"panelsJSON": "[{}]",
"panelsJSON": "[]",
"optionsJSON": "{}",
"version": 1,
"timeRestore": false,
@ -156,7 +156,7 @@
"title": "Dashboard Bar",
"hits": 0,
"description": "",
"panelsJSON": "[{}]",
"panelsJSON": "[]",
"optionsJSON": "{}",
"version": 1,
"timeRestore": false,

View file

@ -11,6 +11,12 @@
"title": "[Logs Sample] Overview ECS",
"version": 1
},
"references": [
{ "id": "sample_visualization", "name": "panel_0", "type": "visualization" },
{ "id": "sample_search", "name": "panel_1", "type": "search" },
{ "id": "sample_search", "name": "panel_2", "type": "search" },
{ "id": "sample_visualization", "name": "panel_3", "type": "visualization" }
],
"id": "sample_dashboard",
"type": "dashboard"
}
}

View file

@ -11,6 +11,12 @@
"title": "[Logs Sample2] Overview ECS",
"version": 1
},
"references": [
{ "id": "sample_visualization", "name": "panel_0", "type": "visualization" },
{ "id": "sample_search", "name": "panel_1", "type": "search" },
{ "id": "sample_search", "name": "panel_2", "type": "search" },
{ "id": "sample_visualization", "name": "panel_3", "type": "visualization" }
],
"id": "sample_dashboard2",
"type": "dashboard"
}
}

View file

@ -11,6 +11,12 @@
"title": "[Logs Sample] Overview ECS",
"version": 1
},
"references": [
{ "id": "sample_visualization", "name": "panel_0", "type": "visualization" },
{ "id": "sample_search2", "name": "panel_1", "type": "search" },
{ "id": "sample_search2", "name": "panel_2", "type": "search" },
{ "id": "sample_visualization", "name": "panel_3", "type": "visualization" }
],
"id": "sample_dashboard",
"type": "dashboard"
}
}