Timelion visualization renderer (#78540)

* Update styles

* Implement toExpressionAst fn

* Implement renderer

* Update unit tests

* Add unit tests

* Update types

* Remove unused vars

* Fix types

* Update types

* Show error message when no data

* Update ExpressionRenderDefinition api

* Update renderer when there is no data

* Make options component lazy

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Daniil Suleiman 2020-10-01 17:08:12 +03:00 committed by GitHub
parent 9f5033354d
commit b692c374a2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 258 additions and 181 deletions

View file

@ -9,5 +9,5 @@ A user friendly name of the renderer as will be displayed to user in UI.
<b>Signature:</b>
```typescript
displayName: string;
displayName?: string;
```

View file

@ -9,5 +9,5 @@ A user friendly name of the renderer as will be displayed to user in UI.
<b>Signature:</b>
```typescript
displayName: string;
displayName?: string;
```

View file

@ -28,7 +28,7 @@ export interface ExpressionRenderDefinition<Config = unknown> {
/**
* A user friendly name of the renderer as will be displayed to user in UI.
*/
displayName: string;
displayName?: string;
/**
* Help text as will be displayed to user. A sentence or few about what this

View file

@ -429,7 +429,7 @@ export interface ExpressionImage {
//
// @public (undocumented)
export interface ExpressionRenderDefinition<Config = unknown> {
displayName: string;
displayName?: string;
help?: string;
name: string;
render: (domNode: HTMLElement, config: Config, handlers: IInterpreterRenderHandlers) => void | Promise<void>;

View file

@ -401,7 +401,7 @@ export interface ExpressionImage {
//
// @public (undocumented)
export interface ExpressionRenderDefinition<Config = unknown> {
displayName: string;
displayName?: string;
help?: string;
name: string;
render: (domNode: HTMLElement, config: Config, handlers: IInterpreterRenderHandlers) => void | Promise<void>;

View file

@ -10,3 +10,9 @@
@import './app';
@import './base';
@import './directives/index';
// these styles is needed to be loaded here explicitly if the timelion visualization was not opened in browser
// styles for timelion visualization are lazy loaded only while a vis is opened
// this will duplicate styles only if both Timelion app and timelion visualization are loaded
// could be left here as it is since the Timelion app is deprecated
@import '../../vis_type_timelion/public/components/index.scss';

View file

@ -25,7 +25,6 @@ import { ExpressionRenderDefinition } from '../../expressions/common/expression_
import { TagCloudVisDependencies } from './plugin';
import { TagCloudVisRenderValue } from './tag_cloud_fn';
// @ts-ignore
const TagCloudChart = lazy(() => import('./components/tag_cloud_chart'));
export const getTagCloudVisRenderer: (

View file

@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`timelion vis toExpressionAst function should match basic snapshot 1`] = `
Object {
"chain": Array [
Object {
"arguments": Object {
"expression": Array [
".es(*)",
],
"interval": Array [
"auto",
],
},
"function": "timelion_vis",
"type": "function",
},
],
"type": "expression",
}
`;

View file

@ -1,15 +0,0 @@
.visEditor--timelion {
vis-options-react-wrapper,
.visEditorSidebar__options,
.visEditorSidebar__timelionOptions {
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.visEditor__sidebar {
@include euiBreakpoint('xs', 's', 'm') {
width: 100%;
}
}
}

View file

@ -1,12 +0,0 @@
.timVis {
min-width: 100%;
display: flex;
flex-direction: column;
.timChart {
min-width: 100%;
flex: 1;
display: flex;
flex-direction: column;
}
}

View file

@ -58,3 +58,11 @@
white-space: nowrap;
font-weight: $euiFontWeightBold;
}
.visEditor--timelion {
.visEditorSidebar__timelionOptions {
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
}

View file

@ -1,2 +1,2 @@
@import 'panel';
@import 'timelion_vis';
@import 'timelion_expression_input';

View file

@ -19,4 +19,3 @@
export * from './timelion_expression_input';
export * from './timelion_interval';
export * from './timelion_vis';

View file

@ -1,50 +0,0 @@
/*
* 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 React from 'react';
import { IUiSettingsClient } from 'kibana/public';
import { ChartComponent } from './chart';
import { VisParams } from '../timelion_vis_fn';
import { TimelionSuccessResponse } from '../helpers/timelion_request_handler';
import { ExprVis } from '../../../visualizations/public';
export interface TimelionVisComponentProp {
config: IUiSettingsClient;
renderComplete(): void;
updateStatus: object;
vis: ExprVis;
visData: TimelionSuccessResponse;
visParams: VisParams;
}
function TimelionVisComponent(props: TimelionVisComponentProp) {
return (
<div className="timVis">
<ChartComponent
applyFilter={props.vis.API.events.applyFilter}
seriesList={props.visData.sheet[0]}
renderComplete={props.renderComplete}
interval={props.vis.getState().params.interval}
/>
</div>
);
}
export { TimelionVisComponent };

View file

@ -21,7 +21,9 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react';
import $ from 'jquery';
import moment from 'moment-timezone';
import { debounce, compact, get, each, cloneDeep, last, map } from 'lodash';
import { useResizeObserver } from '@elastic/eui';
import { IInterpreterRenderHandlers } from 'src/plugins/expressions';
import { useKibana } from '../../../kibana_react/public';
import '../flot';
import { DEFAULT_TIME_FORMAT } from '../../common/lib';
@ -38,18 +40,19 @@ import { Series, Sheet } from '../helpers/timelion_request_handler';
import { tickFormatters } from '../helpers/tick_formatters';
import { generateTicksProvider } from '../helpers/tick_generator';
import { TimelionVisDependencies } from '../plugin';
import { ExprVisAPIEvents } from '../../../visualizations/public';
import './index.scss';
interface CrosshairPlot extends jquery.flot.plot {
setCrosshair: (pos: Position) => void;
clearCrosshair: () => void;
}
interface PanelProps {
applyFilter: ExprVisAPIEvents['applyFilter'];
interface TimelionVisComponentProps {
fireEvent: IInterpreterRenderHandlers['event'];
interval: string;
seriesList: Sheet;
renderComplete(): void;
renderComplete: IInterpreterRenderHandlers['done'];
}
interface Position {
@ -75,11 +78,16 @@ const DEBOUNCE_DELAY = 50;
// ensure legend is the same height with or without a caption so legend items do not move around
const emptyCaption = '<br>';
function Panel({ interval, seriesList, renderComplete, applyFilter }: PanelProps) {
function TimelionVisComponent({
interval,
seriesList,
renderComplete,
fireEvent,
}: TimelionVisComponentProps) {
const kibana = useKibana<TimelionVisDependencies>();
const [chart, setChart] = useState(() => cloneDeep(seriesList.list));
const [canvasElem, setCanvasElem] = useState<HTMLDivElement>();
const [chartElem, setChartElem] = useState<HTMLDivElement>();
const [chartElem, setChartElem] = useState<HTMLDivElement | null>(null);
const [originalColorMap, setOriginalColorMap] = useState(() => new Map<Series, string>());
@ -191,7 +199,7 @@ function Panel({ interval, seriesList, renderComplete, applyFilter }: PanelProps
interval,
kibana.services.timefilter,
kibana.services.uiSettings,
chartElem && chartElem.clientWidth,
chartElem?.clientWidth,
grid
);
const updatedSeries = buildSeriesData(chartValue, options);
@ -216,12 +224,14 @@ function Panel({ interval, seriesList, renderComplete, applyFilter }: PanelProps
updateCaption(newPlot.getData());
}
},
[canvasElem, chartElem, renderComplete, kibana.services, interval, updateCaption]
[canvasElem, chartElem?.clientWidth, renderComplete, kibana.services, interval, updateCaption]
);
const dimensions = useResizeObserver(chartElem);
useEffect(() => {
updatePlot(chart, seriesList.render && seriesList.render.grid);
}, [chart, updatePlot, seriesList.render]);
}, [chart, updatePlot, seriesList.render, dimensions]);
useEffect(() => {
const colorsSet: Array<[Series, string]> = [];
@ -349,21 +359,24 @@ function Panel({ interval, seriesList, renderComplete, applyFilter }: PanelProps
const plotSelectedHandler = useCallback(
(event: JQuery.TriggeredEvent, ranges: Ranges) => {
applyFilter({
timeFieldName: '*',
filters: [
{
range: {
'*': {
gte: ranges.xaxis.from,
lte: ranges.xaxis.to,
fireEvent({
name: 'applyFilter',
data: {
timeFieldName: '*',
filters: [
{
range: {
'*': {
gte: ranges.xaxis.from,
lte: ranges.xaxis.to,
},
},
},
},
],
],
},
});
},
[applyFilter]
[fireEvent]
);
useEffect(() => {
@ -396,4 +409,6 @@ function Panel({ interval, seriesList, renderComplete, applyFilter }: PanelProps
);
}
export { Panel };
// default export required for React.Lazy
// eslint-disable-next-line import/no-default-export
export { TimelionVisComponent as default };

View file

@ -19,10 +19,10 @@
import { i18n } from '@kbn/i18n';
import { KIBANA_CONTEXT_NAME } from 'src/plugins/expressions/public';
import { VisParams } from '../../../visualizations/public';
import { TimeRange, Filter, esQuery, Query } from '../../../data/public';
import { TimelionVisDependencies } from '../plugin';
import { getTimezone } from './get_timezone';
import { TimelionVisParams } from '../timelion_vis_fn';
interface Stats {
cacheCount: number;
@ -77,7 +77,7 @@ export function getTimelionRequestHandler({
timeRange: TimeRange;
filters: Filter[];
query: Query;
visParams: VisParams;
visParams: TimelionVisParams;
}): Promise<TimelionSuccessResponse> {
const expression = visParams.expression;

View file

@ -1,3 +0,0 @@
@import './timelion_vis';
@import './timelion_editor';
@import './components/index';

View file

@ -39,8 +39,8 @@ import { getTimelionVisDefinition } from './timelion_vis_type';
import { setIndexPatterns, setSavedObjectsClient } from './helpers/plugin_services';
import { ConfigSchema } from '../config';
import './index.scss';
import { getArgValueSuggestions } from './helpers/arg_value_suggestions';
import { getTimelionVisRenderer } from './timelion_vis_renderer';
/** @internal */
export interface TimelionVisDependencies extends Partial<CoreStart> {
@ -93,7 +93,8 @@ export class TimelionVisPlugin
};
expressions.registerFunction(() => getTimelionVisualizationConfig(dependencies));
visualizations.createReactVisualization(getTimelionVisDefinition(dependencies));
expressions.registerRenderer(getTimelionVisRenderer(dependencies));
visualizations.createBaseVisualization(getTimelionVisDefinition(dependencies));
return {
isUiEnabled: this.initializerContext.config.get().ui.enabled,

View file

@ -21,30 +21,45 @@ import React, { useCallback } from 'react';
import { EuiPanel } from '@elastic/eui';
import { VisOptionsProps } from 'src/plugins/vis_default_editor/public';
import { VisParams } from './timelion_vis_fn';
import { KibanaContextProvider } from '../../kibana_react/public';
import { TimelionVisParams } from './timelion_vis_fn';
import { TimelionInterval, TimelionExpressionInput } from './components';
import { TimelionVisDependencies } from './plugin';
export type TimelionOptionsProps = VisOptionsProps<VisParams>;
export type TimelionOptionsProps = VisOptionsProps<TimelionVisParams>;
function TimelionOptions({ stateParams, setValue, setValidity }: TimelionOptionsProps) {
const setInterval = useCallback((value: VisParams['interval']) => setValue('interval', value), [
setValue,
]);
function TimelionOptions({
services,
stateParams,
setValue,
setValidity,
}: TimelionOptionsProps & {
services: TimelionVisDependencies;
}) {
const setInterval = useCallback(
(value: TimelionVisParams['interval']) => setValue('interval', value),
[setValue]
);
const setExpressionInput = useCallback(
(value: VisParams['expression']) => setValue('expression', value),
(value: TimelionVisParams['expression']) => setValue('expression', value),
[setValue]
);
return (
<EuiPanel className="visEditorSidebar__timelionOptions" paddingSize="s">
<TimelionInterval
value={stateParams.interval}
setValue={setInterval}
setValidity={setValidity}
/>
<TimelionExpressionInput value={stateParams.expression} setValue={setExpressionInput} />
</EuiPanel>
<KibanaContextProvider services={services}>
<EuiPanel className="visEditorSidebar__timelionOptions" paddingSize="s">
<TimelionInterval
value={stateParams.interval}
setValue={setInterval}
setValidity={setValidity}
/>
<TimelionExpressionInput value={stateParams.expression} setValue={setExpressionInput} />
</EuiPanel>
</KibanaContextProvider>
);
}
export { TimelionOptions };
// default export required for React.Lazy
// eslint-disable-next-line import/no-default-export
export { TimelionOptions as default };

View file

@ -24,29 +24,39 @@ import {
KibanaContext,
Render,
} from 'src/plugins/expressions/public';
import { getTimelionRequestHandler } from './helpers/timelion_request_handler';
import {
getTimelionRequestHandler,
TimelionSuccessResponse,
} from './helpers/timelion_request_handler';
import { TIMELION_VIS_NAME } from './timelion_vis_type';
import { TimelionVisDependencies } from './plugin';
import { Filter, Query, TimeRange } from '../../data/common';
type Input = KibanaContext | null;
type Output = Promise<Render<RenderValue>>;
type Output = Promise<Render<TimelionRenderValue>>;
interface Arguments {
expression: string;
interval: string;
}
interface RenderValue {
visData: Input;
export interface TimelionRenderValue {
visData: TimelionSuccessResponse;
visType: 'timelion';
visParams: VisParams;
visParams: TimelionVisParams;
}
export type VisParams = Arguments;
export type TimelionVisParams = Arguments;
export type TimelionExpressionFunctionDefinition = ExpressionFunctionDefinition<
'timelion_vis',
Input,
Arguments,
Output
>;
export const getTimelionVisualizationConfig = (
dependencies: TimelionVisDependencies
): ExpressionFunctionDefinition<'timelion_vis', Input, Arguments, Output> => ({
): TimelionExpressionFunctionDefinition => ({
name: 'timelion_vis',
type: 'render',
inputTypes: ['kibana_context', 'null'],
@ -82,7 +92,7 @@ export const getTimelionVisualizationConfig = (
return {
type: 'render',
as: 'visualization',
as: 'timelion_vis',
value: {
visParams,
visType: TIMELION_VIS_NAME,

View file

@ -0,0 +1,65 @@
/*
* 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 React, { lazy } from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { ExpressionRenderDefinition } from 'src/plugins/expressions';
import { KibanaContextProvider } from '../../kibana_react/public';
import { VisualizationContainer } from '../../visualizations/public';
import { TimelionVisDependencies } from './plugin';
import { TimelionRenderValue } from './timelion_vis_fn';
// @ts-ignore
const TimelionVisComponent = lazy(() => import('./components/timelion_vis_component'));
export const getTimelionVisRenderer: (
deps: TimelionVisDependencies
) => ExpressionRenderDefinition<TimelionRenderValue> = (deps) => ({
name: 'timelion_vis',
displayName: 'Timelion visualization',
reuseDomNode: true,
render: (domNode, { visData, visParams }, handlers) => {
handlers.onDestroy(() => {
unmountComponentAtNode(domNode);
});
const [seriesList] = visData.sheet;
const showNoResult = !seriesList || !seriesList.list.length;
if (showNoResult) {
// send the render complete event when there is no data to show
// to notify that a chart is updated
handlers.done();
}
render(
<VisualizationContainer showNoResult={showNoResult}>
<KibanaContextProvider services={{ ...deps }}>
<TimelionVisComponent
interval={visParams.interval}
seriesList={seriesList}
renderComplete={handlers.done}
fireEvent={handlers.event}
/>
</KibanaContextProvider>
</VisualizationContainer>,
domNode
);
},
});

View file

@ -17,18 +17,19 @@
* under the License.
*/
import React from 'react';
import React, { lazy } from 'react';
import { i18n } from '@kbn/i18n';
import { KibanaContextProvider } from '../../kibana_react/public';
import { DefaultEditorSize } from '../../vis_default_editor/public';
import { getTimelionRequestHandler } from './helpers/timelion_request_handler';
import { TimelionVisComponent, TimelionVisComponentProp } from './components';
import { TimelionOptions, TimelionOptionsProps } from './timelion_options';
import { TimelionOptionsProps } from './timelion_options';
import { TimelionVisDependencies } from './plugin';
import { toExpressionAst } from './to_ast';
import { VIS_EVENT_TO_TRIGGER } from '../../visualizations/public';
const TimelionOptions = lazy(() => import('./timelion_options'));
export const TIMELION_VIS_NAME = 'timelion';
export function getTimelionVisDefinition(dependencies: TimelionVisDependencies) {
@ -48,21 +49,15 @@ export function getTimelionVisDefinition(dependencies: TimelionVisDependencies)
expression: '.es(*)',
interval: 'auto',
},
component: (props: TimelionVisComponentProp) => (
<KibanaContextProvider services={{ ...dependencies }}>
<TimelionVisComponent {...props} />
</KibanaContextProvider>
),
},
editorConfig: {
optionsTemplate: (props: TimelionOptionsProps) => (
<KibanaContextProvider services={{ ...dependencies }}>
<TimelionOptions {...props} />
</KibanaContextProvider>
<TimelionOptions services={dependencies} {...props} />
),
defaultSize: DefaultEditorSize.MEDIUM,
},
requestHandler: timelionRequestHandler,
toExpressionAst,
responseHandler: 'none',
inspectorAdapters: {},
getSupportedTriggers: () => {

View file

@ -17,25 +17,24 @@
* under the License.
*/
import React from 'react';
import { Vis } from 'src/plugins/visualizations/public';
import { TimelionVisParams } from './timelion_vis_fn';
import { toExpressionAst } from './to_ast';
import { Sheet } from '../helpers/timelion_request_handler';
import { Panel } from './panel';
import { ExprVisAPIEvents } from '../../../visualizations/public';
describe('timelion vis toExpressionAst function', () => {
let vis: Vis<TimelionVisParams>;
interface ChartComponentProp {
applyFilter: ExprVisAPIEvents['applyFilter'];
interval: string;
renderComplete(): void;
seriesList: Sheet;
}
beforeEach(() => {
vis = {
params: {
expression: '.es(*)',
interval: 'auto',
},
} as any;
});
function ChartComponent(props: ChartComponentProp) {
if (!props.seriesList) {
return null;
}
return <Panel {...props} />;
}
export { ChartComponent };
it('should match basic snapshot', () => {
const actual = toExpressionAst(vis);
expect(actual).toMatchSnapshot();
});
});

View file

@ -0,0 +1,37 @@
/*
* 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 { buildExpression, buildExpressionFunction } from '../../expressions/public';
import { Vis } from '../../visualizations/public';
import { TimelionExpressionFunctionDefinition, TimelionVisParams } from './timelion_vis_fn';
const escapeString = (data: string): string => {
return data.replace(/\\/g, `\\\\`).replace(/'/g, `\\'`);
};
export const toExpressionAst = (vis: Vis<TimelionVisParams>) => {
const timelion = buildExpressionFunction<TimelionExpressionFunctionDefinition>('timelion_vis', {
expression: escapeString(vis.params.expression),
interval: escapeString(vis.params.interval),
});
const ast = buildExpression([timelion]);
return ast.toAst();
};

View file

@ -24,6 +24,4 @@ exports[`visualize loader pipeline helpers: build pipeline buildPipelineVisFunct
exports[`visualize loader pipeline helpers: build pipeline buildPipelineVisFunction handles tile_map function 1`] = `"tilemap visConfig='{\\"metric\\":{},\\"dimensions\\":{\\"metric\\":{\\"accessor\\":0,\\"label\\":\\"\\",\\"format\\":{},\\"params\\":{},\\"aggType\\":\\"\\"},\\"geohash\\":1,\\"geocentroid\\":3}}' "`;
exports[`visualize loader pipeline helpers: build pipeline buildPipelineVisFunction handles timelion function 1`] = `"timelion_vis expression='foo' interval='bar' "`;
exports[`visualize loader pipeline helpers: build pipeline buildPipelineVisFunction handles vega function 1`] = `"vega spec='this is a test' "`;

View file

@ -117,12 +117,6 @@ describe('visualize loader pipeline helpers: build pipeline', () => {
expect(actual).toMatchSnapshot();
});
it('handles timelion function', () => {
const params = { expression: 'foo', interval: 'bar' };
const actual = buildPipelineVisFunction.timelion(params, schemasDef, uiState);
expect(actual).toMatchSnapshot();
});
describe('handles table function', () => {
it('without splits or buckets', () => {
const params = { foo: 'bar' };

View file

@ -263,11 +263,6 @@ export const buildPipelineVisFunction: BuildPipelineVisFunction = {
const paramsArray = [paramsJson, uiStateJson].filter((param) => Boolean(param));
return `tsvb ${paramsArray.join(' ')}`;
},
timelion: (params) => {
const expression = prepareString('expression', params.expression);
const interval = prepareString('interval', params.interval);
return `timelion_vis ${expression}${interval}`;
},
table: (params, schemas) => {
const visConfig = {
...params,