[Lens] Implement deep linking and embedding (#84416) (#87978)

This commit is contained in:
Joe Reuter 2021-01-12 13:18:03 +01:00 committed by GitHub
parent 91436b387e
commit 5ce8a5d4f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 506 additions and 15 deletions

View file

@ -0,0 +1,5 @@
{
"rules": {
"@typescript-eslint/consistent-type-definitions": 0
}
}

View file

@ -0,0 +1,13 @@
# Embedded Lens examples
To run this example plugin, use the command `yarn start --run-examples`.
This example shows how to embed Lens into other applications. Using the `EmbeddableComponent` of the `lens` start plugin,
you can pass in a valid Lens configuration which will get rendered the same way Lens dashboard panels work. Updating the
configuration will reload the embedded visualization.
## Link to editor
It is possible to use the same configuration and the `navigateToPrefilledEditor` method to navigate the current user to a
prefilled Lens editor so they can manipulate the configuration on their own and even save the results to a dashboard.
Make sure to always check permissions using `canUseEditor` whether the current user has permissions to access Lens.

View file

@ -0,0 +1,15 @@
{
"id": "embeddedLensExample",
"version": "0.0.1",
"kibanaVersion": "kibana",
"configPath": ["embedded_lens_example"],
"server": false,
"ui": true,
"requiredPlugins": [
"lens",
"data",
"developerExamples"
],
"optionalPlugins": [],
"requiredBundles": []
}

View file

@ -0,0 +1,14 @@
{
"name": "embedded_lens_example",
"version": "1.0.0",
"main": "target/examples/embedded_lens_example",
"kibana": {
"version": "kibana",
"templateVersion": "1.0.0"
},
"license": "Apache-2.0",
"scripts": {
"kbn": "node ../../../scripts/kbn.js",
"build": "rm -rf './target' && ../../../node_modules/.bin/tsc"
}
}

View file

@ -0,0 +1,180 @@
/*
* 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 React, { useState } from 'react';
import {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiPage,
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiPageHeader,
EuiPageHeaderSection,
EuiTitle,
} from '@elastic/eui';
import { IndexPattern } from 'src/plugins/data/public';
import { CoreStart } from 'kibana/public';
import { TypedLensByValueInput } from '../../../plugins/lens/public';
import { StartDependencies } from './plugin';
// Generate a Lens state based on some app-specific input parameters.
// `TypedLensByValueInput` can be used for type-safety - it uses the same interfaces as Lens-internal code.
function getLensAttributes(
defaultIndexPattern: IndexPattern,
color: string
): TypedLensByValueInput['attributes'] {
return {
visualizationType: 'lnsXY',
title: 'Prefilled from example app',
references: [
{
id: defaultIndexPattern.id!,
name: 'indexpattern-datasource-current-indexpattern',
type: 'index-pattern',
},
{
id: defaultIndexPattern.id!,
name: 'indexpattern-datasource-layer-layer1',
type: 'index-pattern',
},
],
state: {
datasourceStates: {
indexpattern: {
layers: {
layer1: {
columnOrder: ['col1', 'col2'],
columns: {
col2: {
dataType: 'number',
isBucketed: false,
label: 'Count of records',
operationType: 'count',
scale: 'ratio',
sourceField: 'Records',
},
col1: {
dataType: 'date',
isBucketed: true,
label: '@timestamp',
operationType: 'date_histogram',
params: { interval: 'auto' },
scale: 'interval',
sourceField: defaultIndexPattern.timeFieldName!,
},
},
},
},
},
},
filters: [],
query: { language: 'kuery', query: '' },
visualization: {
axisTitlesVisibilitySettings: { x: true, yLeft: true, yRight: true },
fittingFunction: 'None',
gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true },
layers: [
{
accessors: ['col2'],
layerId: 'layer1',
seriesType: 'bar_stacked',
xAccessor: 'col1',
yConfig: [{ forAccessor: 'col2', color }],
},
],
legend: { isVisible: true, position: 'right' },
preferredSeriesType: 'bar_stacked',
tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true },
valueLabels: 'hide',
},
},
};
}
export const App = (props: {
core: CoreStart;
plugins: StartDependencies;
defaultIndexPattern: IndexPattern | null;
}) => {
const [color, setColor] = useState('green');
const LensComponent = props.plugins.lens.EmbeddableComponent;
return (
<EuiPage>
<EuiPageBody style={{ maxWidth: 1200, margin: '0 auto' }}>
<EuiPageHeader>
<EuiPageHeaderSection>
<EuiTitle size="l">
<h1>Embedded Lens vis</h1>
</EuiTitle>
</EuiPageHeaderSection>
</EuiPageHeader>
<EuiPageContent>
<EuiPageContentBody style={{ maxWidth: 800, margin: '0 auto' }}>
<p>
This app embeds a Lens visualization by specifying the configuration. Data fetching
and rendering is completely managed by Lens itself.
</p>
<p>
The Change color button will update the configuration by picking a new random color of
the series which causes Lens to re-render. The Edit button will take the current
configuration and navigate to a prefilled editor.
</p>
{props.defaultIndexPattern && props.defaultIndexPattern.isTimeBased() ? (
<>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton
onClick={() => {
// eslint-disable-next-line no-bitwise
const newColor = '#' + ((Math.random() * 0xffffff) << 0).toString(16);
setColor(newColor);
}}
>
Change color
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
isDisabled={!props.plugins.lens.canUseEditor()}
onClick={() => {
props.plugins.lens.navigateToPrefilledEditor({
id: '',
timeRange: {
from: 'now-5d',
to: 'now',
},
attributes: getLensAttributes(props.defaultIndexPattern!, color),
});
// eslint-disable-next-line no-bitwise
const newColor = '#' + ((Math.random() * 0xffffff) << 0).toString(16);
setColor(newColor);
}}
>
Edit
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
<LensComponent
id=""
style={{ height: 500 }}
timeRange={{
from: 'now-5d',
to: 'now',
}}
attributes={getLensAttributes(props.defaultIndexPattern, color)}
/>
</>
) : (
<p>This demo only works if your default index pattern is set and time based</p>
)}
</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
</EuiPage>
);
};

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.
*/
import { EmbeddedLensExamplePlugin } from './plugin';
export const plugin = () => new EmbeddedLensExamplePlugin();

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { CoreSetup, AppMountParameters } from 'kibana/public';
import { StartDependencies } from './plugin';
export const mount = (coreSetup: CoreSetup<StartDependencies>) => async ({
element,
}: AppMountParameters) => {
const [core, plugins] = await coreSetup.getStartServices();
const { App } = await import('./app');
const deps = {
core,
plugins,
};
const defaultIndexPattern = await plugins.data.indexPatterns.getDefault();
const reactElement = <App {...deps} defaultIndexPattern={defaultIndexPattern} />;
render(reactElement, element);
return () => unmountComponentAtNode(element);
};

View file

@ -0,0 +1,53 @@
/*
* 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 { Plugin, CoreSetup, AppNavLinkStatus } from '../../../../src/core/public';
import { DataPublicPluginStart } from '../../../../src/plugins/data/public';
import { LensPublicStart } from '../../../plugins/lens/public';
import { DeveloperExamplesSetup } from '../../../../examples/developer_examples/public';
import { mount } from './mount';
export interface SetupDependencies {
developerExamples: DeveloperExamplesSetup;
}
export interface StartDependencies {
data: DataPublicPluginStart;
lens: LensPublicStart;
}
export class EmbeddedLensExamplePlugin
implements Plugin<void, void, SetupDependencies, StartDependencies> {
public setup(core: CoreSetup<StartDependencies>, { developerExamples }: SetupDependencies) {
core.application.register({
id: 'embedded_lens_example',
title: 'Embedded Lens example',
navLinkStatus: AppNavLinkStatus.hidden,
mount: mount(core),
});
developerExamples.register({
appId: 'embedded_lens_example',
title: 'Embedded Lens',
description:
'Embed Lens visualizations into other applications and link to a pre-configured Lens editor to allow users to use visualizations in your app as starting points for further explorations.',
links: [
{
label: 'README',
href:
'https://github.com/elastic/kibana/tree/master/x-pack/examples/embedded_lens_example',
iconType: 'logoGithub',
size: 's',
target: '_blank',
},
],
});
}
public start() {}
public stop() {}
}

View file

@ -0,0 +1,22 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./target",
"skipLibCheck": true
},
"include": [
"index.ts",
"public/**/*.ts",
"public/**/*.tsx",
"server/**/*.ts",
"../../typings/**/*"
],
"exclude": [],
"references": [
{ "path": "../../../src/core/tsconfig.json" },
{ "path": "../../../src/plugins/kibana_utils/tsconfig.json" },
{ "path": "../../../src/plugins/kibana_react/tsconfig.json" },
{ "path": "../../../src/plugins/share/tsconfig.json" },
{ "path": "../../../src/plugins/data/tsconfig.json" }
]
}

View file

@ -38,7 +38,7 @@ import {
EmbeddableStateTransfer,
} from '../../../../../src/plugins/embeddable/public';
import { TableInspectorAdapter } from '../editor_frame_service/types';
import { EditorFrameInstance } from '..';
import { EditorFrameInstance } from '../types';
export interface LensAppState {
isLoading: boolean;

View file

@ -214,8 +214,6 @@ describe('embeddable', () => {
searchSessionId: 'searchSessionId',
});
expect(expressionRenderer).toHaveBeenCalledTimes(1);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(expressionRenderer).toHaveBeenCalledTimes(2);

View file

@ -57,6 +57,10 @@ interface LensBaseEmbeddableInput extends EmbeddableInput {
filters?: Filter[];
query?: Query;
timeRange?: TimeRange;
palette?: PaletteOutput;
renderMode?: RenderMode;
style?: React.CSSProperties;
className?: string;
}
export type LensByValueInput = {
@ -64,10 +68,7 @@ export type LensByValueInput = {
} & LensBaseEmbeddableInput;
export type LensByReferenceInput = SavedObjectEmbeddableInput & LensBaseEmbeddableInput;
export type LensEmbeddableInput = (LensByValueInput | LensByReferenceInput) & {
palette?: PaletteOutput;
renderMode?: RenderMode;
};
export type LensEmbeddableInput = LensByValueInput | LensByReferenceInput;
export interface LensEmbeddableOutput extends EmbeddableOutput {
indexPatterns?: IIndexPattern[];
@ -159,6 +160,37 @@ export class Embeddable
.subscribe((input) => {
this.reload();
});
// Re-initialize the visualization if either the attributes or the saved object id changes
input$
.pipe(
distinctUntilChanged((a, b) =>
isEqual(
['attributes' in a && a.attributes, 'savedObjectId' in a && a.savedObjectId],
['attributes' in b && b.attributes, 'savedObjectId' in b && b.savedObjectId]
)
),
skip(1)
)
.subscribe(async (input) => {
await this.initializeSavedVis(input);
this.reload();
});
// Update search context and reload on changes related to search
input$
.pipe(
distinctUntilChanged((a, b) =>
isEqual(
[a.filters, a.query, a.timeRange, a.searchSessionId],
[b.filters, b.query, b.timeRange, b.searchSessionId]
)
),
skip(1)
)
.subscribe(async (input) => {
this.onContainerStateChanged(input);
});
}
public supportedTriggers() {
@ -262,6 +294,8 @@ export class Embeddable
renderMode={input.renderMode}
syncColors={input.syncColors}
hasCompatibleActions={this.hasCompatibleActions}
className={input.className}
style={input.style}
/>,
domNode
);

View file

@ -0,0 +1,51 @@
/*
* 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 React from 'react';
import {
EmbeddableRenderer,
EmbeddableStart,
} from '../../../../../../src/plugins/embeddable/public';
import type { LensByReferenceInput, LensByValueInput } from './embeddable';
import type { Document } from '../../persistence';
import type { IndexPatternPersistedState } from '../../indexpattern_datasource/types';
import type { XYState } from '../../xy_visualization/types';
import type { PieVisualizationState } from '../../pie_visualization/types';
import type { DatatableVisualizationState } from '../../datatable_visualization/visualization';
import type { State as MetricState } from '../../metric_visualization/types';
type LensAttributes<TVisType, TVisState> = Omit<
Document,
'savedObjectId' | 'type' | 'state' | 'visualizationType'
> & {
visualizationType: TVisType;
state: Omit<Document['state'], 'datasourceStates' | 'visualization'> & {
datasourceStates: {
indexpattern: IndexPatternPersistedState;
};
visualization: TVisState;
};
};
/**
* Type-safe variant of by value embeddable input for Lens.
* This can be used to hardcode certain Lens chart configurations within another app.
*/
export type TypedLensByValueInput = Omit<LensByValueInput, 'attributes'> & {
attributes:
| LensAttributes<'lnsXY', XYState>
| LensAttributes<'lnsPie', PieVisualizationState>
| LensAttributes<'lnsDatatable', DatatableVisualizationState>
| LensAttributes<'lnsMetric', MetricState>;
};
export type EmbeddableComponentProps = TypedLensByValueInput | LensByReferenceInput;
export function getEmbeddableComponent(embeddableStart: EmbeddableStart) {
return (props: EmbeddableComponentProps) => {
const factory = embeddableStart.getEmbeddableFactory('lens')!;
return <EmbeddableRenderer factory={factory} input={props} />;
};
}

View file

@ -15,6 +15,7 @@ import {
} from 'src/plugins/expressions/public';
import { ExecutionContextSearch } from 'src/plugins/data/public';
import { DefaultInspectorAdapters, RenderMode } from 'src/plugins/expressions';
import classNames from 'classnames';
import { getOriginalRequestErrorMessage } from '../error_helper';
export interface ExpressionWrapperProps {
@ -31,6 +32,8 @@ export interface ExpressionWrapperProps {
renderMode?: RenderMode;
syncColors?: boolean;
hasCompatibleActions?: ReactExpressionRendererProps['hasCompatibleActions'];
style?: React.CSSProperties;
className?: string;
}
export function ExpressionWrapper({
@ -44,6 +47,8 @@ export function ExpressionWrapper({
renderMode,
syncColors,
hasCompatibleActions,
style,
className,
}: ExpressionWrapperProps) {
return (
<I18nProvider>
@ -62,7 +67,7 @@ export function ExpressionWrapper({
</EuiFlexItem>
</EuiFlexGroup>
) : (
<div className="lnsExpressionRenderer">
<div className={classNames('lnsExpressionRenderer', className)} style={style}>
<ExpressionRendererComponent
className="lnsExpressionRenderer__component"
padding="s"

View file

@ -6,6 +6,15 @@
import { LensPlugin } from './plugin';
export * from './types';
export {
EmbeddableComponentProps,
TypedLensByValueInput,
} from './editor_frame_service/embeddable/embeddable_component';
export type { XYState } from './xy_visualization/types';
export type { PieVisualizationState } from './pie_visualization/types';
export type { DatatableVisualizationState } from './datatable_visualization/visualization';
export type { State as MetricState } from './metric_visualization/types';
export type { IndexPatternPersistedState } from './indexpattern_datasource/types';
export { LensPublicStart } from './plugin';
export const plugin = () => new LensPlugin();

View file

@ -47,7 +47,7 @@ import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/p
import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
import { VisualizeFieldContext } from '../../../../../src/plugins/ui_actions/public';
import { mergeLayer } from './state_helpers';
import { Datasource, StateSetter } from '../index';
import { Datasource, StateSetter } from '../types';
import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public';
import { deleteColumn, isReferenced } from './operations';
import { Dragging } from '../drag_drop/providers';

View file

@ -5,7 +5,7 @@
*/
import { getSuggestions } from './metric_suggestions';
import { TableSuggestionColumn, TableSuggestion } from '../index';
import { TableSuggestionColumn, TableSuggestion } from '../types';
describe('metric_suggestions', () => {
function numCol(columnId: string): TableSuggestionColumn {

View file

@ -17,6 +17,7 @@ import { NavigationPublicPluginStart } from '../../../../src/plugins/navigation/
import { UrlForwardingSetup } from '../../../../src/plugins/url_forwarding/public';
import { GlobalSearchPluginSetup } from '../../global_search/public';
import { ChartsPluginSetup, ChartsPluginStart } from '../../../../src/plugins/charts/public';
import { EmbeddableStateTransfer } from '../../../../src/plugins/embeddable/public';
import { EditorFrameService } from './editor_frame_service';
import {
IndexPatternDatasource,
@ -37,7 +38,7 @@ import {
ACTION_VISUALIZE_FIELD,
VISUALIZE_FIELD_TRIGGER,
} from '../../../../src/plugins/ui_actions/public';
import { NOT_INTERNATIONALIZED_PRODUCT_NAME } from '../common';
import { getEditPath, NOT_INTERNATIONALIZED_PRODUCT_NAME } from '../common';
import { PLUGIN_ID_OSS } from '../../../../src/plugins/lens_oss/common/constants';
import { EditorFrameStart } from './types';
import { getLensAliasConfig } from './vis_type_alias';
@ -45,6 +46,11 @@ import { visualizeFieldAction } from './trigger_actions/visualize_field_actions'
import { getSearchProvider } from './search_provider';
import { LensAttributeService } from './lens_attribute_service';
import { LensEmbeddableInput } from './editor_frame_service/embeddable';
import {
EmbeddableComponentProps,
getEmbeddableComponent,
} from './editor_frame_service/embeddable/embeddable_component';
export interface LensPluginSetupDependencies {
urlForwarding: UrlForwardingSetup;
@ -68,6 +74,31 @@ export interface LensPluginStartDependencies {
savedObjectsTagging?: SavedObjectTaggingPluginStart;
}
export interface LensPublicStart {
/**
* React component which can be used to embed a Lens visualization into another application.
* See `x-pack/examples/embedded_lens_example` for exemplary usage.
*
* This API might undergo breaking changes even in minor versions.
*
* @experimental
*/
EmbeddableComponent: React.ComponentType<EmbeddableComponentProps>;
/**
* Method which navigates to the Lens editor, loading the state specified by the `input` parameter.
* See `x-pack/examples/embedded_lens_example` for exemplary usage.
*
* This API might undergo breaking changes even in minor versions.
*
* @experimental
*/
navigateToPrefilledEditor: (input: LensEmbeddableInput) => void;
/**
* Method which returns true if the user has permission to use Lens as defined by application capabilities.
*/
canUseEditor: () => boolean;
}
export class LensPlugin {
private datatableVisualization: DatatableVisualization;
private editorFrameService: EditorFrameService;
@ -174,8 +205,9 @@ export class LensPlugin {
urlForwarding.forwardApp('lens', 'lens');
}
start(core: CoreStart, startDependencies: LensPluginStartDependencies) {
this.createEditorFrame = this.editorFrameService.start(core, startDependencies).createInstance;
start(core: CoreStart, startDependencies: LensPluginStartDependencies): LensPublicStart {
const frameStart = this.editorFrameService.start(core, startDependencies);
this.createEditorFrame = frameStart.createInstance;
// unregisters the OSS alias
startDependencies.visualizations.unRegisterAlias(PLUGIN_ID_OSS);
// unregisters the Visualize action and registers the lens one
@ -186,6 +218,29 @@ export class LensPlugin {
VISUALIZE_FIELD_TRIGGER,
visualizeFieldAction(core.application)
);
return {
EmbeddableComponent: getEmbeddableComponent(startDependencies.embeddable),
navigateToPrefilledEditor: (input: LensEmbeddableInput) => {
if (input.timeRange) {
startDependencies.data.query.timefilter.timefilter.setTime(input.timeRange);
}
const transfer = new EmbeddableStateTransfer(
core.application.navigateToApp,
core.application.currentAppId$
);
transfer.navigateToEditor('lens', {
path: getEditPath(undefined),
state: {
originatingApp: '',
valueInput: input,
},
});
},
canUseEditor: () => {
return Boolean(core.application.capabilities.visualize?.show);
},
};
}
stop() {

View file

@ -19,7 +19,7 @@ import { LensIconChartBarHorizontalStacked } from '../assets/chart_bar_horizonta
import { LensIconChartBarHorizontalPercentage } from '../assets/chart_bar_horizontal_percentage';
import { LensIconChartLine } from '../assets/chart_line';
import { VisualizationType } from '../index';
import { VisualizationType } from '../types';
import { FittingFunction } from './fitting_functions';
export interface LegendConfig {