[Canvas] Expression metrisVis workpad arguments. (#114808)

* Added element.

* Added metric_vis model.

* Removed translations.

* Added translations to the ui element.

* added metricVis name.

* Added font ui argument.

* Added all arguments except palette.

* Added palette.

* Added stops_palette.

* Fixed bug with label.

* Removed unused labels.

* Fixed mistake with table and input.

* added first_datatable type.

* Changed first_datatable to lens_multitable.

* Fixed the mistake in the name.

* One more fix.

* Adde the ability to extend the default palette list to add custom after change the color.

* Added small refactor of the palette to intergrate stops_palette with the ability to scale the solution.

* Code reorganization at palette.

* Added export.

* Added color stops with functionality of update.

* Added optimizations for rerendering of sidebar at canvas.

* Added workpad.

* Fixed bug.

* Added direct onChange listener.

* Experiment on updating props.

* Updated the behaviour of color stop rerendering flow.

* Fixed some bugs.

* Added fixes for unexpected behavior.

* Telemetry tests refactored.

* Regrouped.

* Added removable option and fixed bug with colors on palette change.

* Added validation for input.

* Fixed behaviour with rangeMin and rangeMax.

* Added fix and comment,

* Fixed a bug with percentage mode.

* Changed from 60 pt to 80 px.

* Fixed bug with continuity.

* Added one more fix.

* Added metricVis element translations.

* Fixed types

* Reverted fontSize to 60.

* Added fontUnit to the font expression and added pt option to the metricVis expression.

* Added comment to the metricVis expression at to_ast of vis_types/metric.

* Fixed tests.

* Fixed i18n mistake.

* Added translations to labels.

* Small refactor of palette picker.

* updated snapshot.

* Added support of changing continuity and range.

* Added fix for the datasource form.

* UpdatePropsRef generic type added.

* Simplified the code.

* Added padding between color stops.

* Updated behaviour of the metricVis when adding bucket.

* Remove ref on unmount to avoid leaks.

* Added `Labels` as default.

* Commented out metricVis translation for now.

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Yaroslav Kuznietsov 2021-11-11 16:05:23 +02:00 committed by GitHub
parent 22492bea71
commit 3f73eb5ec3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 1354 additions and 502 deletions

View file

@ -32,6 +32,7 @@ import { verticalBarChart } from './vert_bar_chart';
import { verticalProgressBar } from './vertical_progress_bar';
import { verticalProgressPill } from './vertical_progress_pill';
import { tagCloud } from './tag_cloud';
import { metricVis } from './metric_vis';
import { SetupInitializer } from '../plugin';
import { ElementFactory } from '../../types';
@ -73,3 +74,9 @@ export const initializeElements: SetupInitializer<ElementFactory[]> = (core, plu
];
return applyElementStrings(specs);
};
// For testing purpose. Will be removed after exposing `metricVis` element.
export const initializeElementsSpec: SetupInitializer<ElementFactory[]> = (core, plugins) => {
const specs = initializeElements(core, plugins);
return [...applyElementStrings([metricVis]), ...specs];
};

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ElementFactory } from '../../../types';
export const metricVis: ElementFactory = () => ({
name: 'metricVis',
displayName: '(New) Metric Vis',
type: 'chart',
help: 'Metric visualization',
icon: 'visMetric',
expression: `filters
| demodata
| head 1
| metricVis metric={visdimension "percent_uptime"} colorMode="Labels"
| render`,
});

View file

@ -29,6 +29,7 @@ storiesOf('arguments/Palette', module).add('default', () => (
}}
onValueChange={action('onValueChange')}
renderError={action('renderError')}
typeInstance={{}}
/>
</div>
));

View file

@ -16,7 +16,7 @@ import { imageUpload } from './image_upload';
// @ts-expect-error untyped local
import { number } from './number';
import { numberFormatInitializer } from './number_format';
import { palette } from './palette';
import { palette, stopsPalette } from './palette';
// @ts-expect-error untyped local
import { percentage } from './percentage';
// @ts-expect-error untyped local
@ -42,6 +42,7 @@ export const args = [
imageUpload,
number,
palette,
stopsPalette,
percentage,
range,
select,

View file

@ -1,105 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC } from 'react';
import PropTypes from 'prop-types';
import { get } from 'lodash';
import { getType } from '@kbn/interpreter/common';
import { ExpressionAstFunction, ExpressionAstExpression } from 'src/plugins/expressions';
import { PalettePicker } from '../../../public/components/palette_picker';
import { templateFromReactComponent } from '../../../public/lib/template_from_react_component';
import { ArgumentStrings } from '../../../i18n';
import { identifyPalette, ColorPalette } from '../../../common/lib';
const { Palette: strings } = ArgumentStrings;
interface Props {
onValueChange: (value: ExpressionAstExpression) => void;
argValue: ExpressionAstExpression;
renderError: () => void;
argId?: string;
}
export const PaletteArgInput: FC<Props> = ({ onValueChange, argId, argValue, renderError }) => {
// TODO: This is weird, its basically a reimplementation of what the interpretter would return.
// Probably a better way todo this, and maybe a better way to handle template type objects in general?
const astToPalette = ({ chain }: { chain: ExpressionAstFunction[] }): ColorPalette | null => {
if (chain.length !== 1 || chain[0].function !== 'palette') {
renderError();
return null;
}
try {
const colors = chain[0].arguments._.map((astObj) => {
if (getType(astObj) !== 'string') {
renderError();
}
return astObj;
}) as string[];
const gradient = get(chain[0].arguments.gradient, '[0]') as boolean;
const palette = identifyPalette({ colors, gradient });
if (palette) {
return palette;
}
return {
id: 'custom',
label: strings.getCustomPaletteLabel(),
colors,
gradient,
} as any as ColorPalette;
} catch (e) {
renderError();
}
return null;
};
const handleChange = (palette: ColorPalette): void => {
const astObj: ExpressionAstExpression = {
type: 'expression',
chain: [
{
type: 'function',
function: 'palette',
arguments: {
_: palette.colors,
gradient: [palette.gradient],
},
},
],
};
onValueChange(astObj);
};
const palette = astToPalette(argValue);
if (!palette) {
renderError();
return null;
}
return <PalettePicker id={argId} palette={palette} onChange={handleChange} />;
};
PaletteArgInput.propTypes = {
argId: PropTypes.string,
onValueChange: PropTypes.func.isRequired,
argValue: PropTypes.any.isRequired,
renderError: PropTypes.func,
};
export const palette = () => ({
name: 'palette',
displayName: strings.getDisplayName(),
help: strings.getHelp(),
default:
'{palette #882E72 #B178A6 #D6C1DE #1965B0 #5289C7 #7BAFDE #4EB265 #90C987 #CAE0AB #F7EE55 #F6C141 #F1932D #E8601C #DC050C}',
simpleTemplate: templateFromReactComponent(PaletteArgInput),
});

View file

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

View file

@ -0,0 +1,121 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC } from 'react';
import PropTypes from 'prop-types';
import { ExpressionAstExpression } from 'src/plugins/expressions';
import { templateFromReactComponent } from '../../../../public/lib/template_from_react_component';
import { ArgumentStrings } from '../../../../i18n';
import { ColorPalette } from '../../../../common/lib';
import { astToPalette } from './utils';
import { ColorPaletteName, getPaletteType } from './palette_types';
import { CustomColorPalette } from '../../../../public/components/palette_picker';
const { Palette: strings, StopsPalette: stopsPaletteStrings } = ArgumentStrings;
interface Props {
onValueChange: (value: ExpressionAstExpression) => void;
argValue: ExpressionAstExpression;
renderError: () => void;
argId?: string;
typeInstance: {
options?: {
type?: ColorPaletteName;
};
};
}
export const PaletteArgInput: FC<Props> = ({
onValueChange,
argId,
argValue,
renderError,
typeInstance,
}) => {
const handleChange = (palette: ColorPalette | CustomColorPalette): void => {
let colorStopsPaletteConfig = {};
if (palette.stops?.length) {
colorStopsPaletteConfig = {
stop: palette.stops,
...(palette.range ? { range: [palette.range] } : {}),
...(palette.continuity ? { continuity: [palette.continuity] } : {}),
};
}
const astObj: ExpressionAstExpression = {
type: 'expression',
chain: [
{
type: 'function',
function: 'palette',
arguments: {
_: palette.colors,
gradient: [palette.gradient],
...colorStopsPaletteConfig,
},
},
],
};
onValueChange(astObj);
};
const palette = astToPalette(argValue, renderError);
if (!palette) {
renderError();
return null;
}
const PalettePicker = getPaletteType(typeInstance.options?.type);
return <PalettePicker id={argId} palette={palette} onChange={handleChange} />;
};
export const SimplePaletteArgInput: FC<Props> = (props) => {
const { typeInstance } = props;
const { type, ...restOptions } = typeInstance.options ?? {};
return (
<PaletteArgInput {...props} typeInstance={{ ...props.typeInstance, options: restOptions }} />
);
};
export const StopsPaletteArgInput: FC<Props> = (props) => (
<PaletteArgInput
{...props}
typeInstance={{
...props.typeInstance,
options: { ...(props.typeInstance.options ?? {}), type: 'stops' },
}}
/>
);
PaletteArgInput.propTypes = {
argId: PropTypes.string,
onValueChange: PropTypes.func.isRequired,
argValue: PropTypes.any.isRequired,
renderError: PropTypes.func,
};
const defaultPaletteOptions = {
default:
'{palette #882E72 #B178A6 #D6C1DE #1965B0 #5289C7 #7BAFDE #4EB265 #90C987 #CAE0AB #F7EE55 #F6C141 #F1932D #E8601C #DC050C}',
};
export const palette = () => ({
name: 'palette',
displayName: strings.getDisplayName(),
help: strings.getHelp(),
simpleTemplate: templateFromReactComponent(SimplePaletteArgInput),
...defaultPaletteOptions,
});
export const stopsPalette = () => ({
name: 'stops_palette',
help: stopsPaletteStrings.getHelp(),
displayName: stopsPaletteStrings.getDisplayName(),
template: templateFromReactComponent(StopsPaletteArgInput),
...defaultPaletteOptions,
});

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { PalettePicker, StopsPalettePicker } from '../../../../public/components/palette_picker';
const DEFAULT_PALETTE = 'default';
const STOPS_PALETTE = 'stops';
export type ColorPaletteName = typeof DEFAULT_PALETTE | typeof STOPS_PALETTE;
const paletteTypes = {
[DEFAULT_PALETTE]: PalettePicker,
[STOPS_PALETTE]: StopsPalettePicker,
};
export const getPaletteType = (type: ColorPaletteName = DEFAULT_PALETTE) =>
paletteTypes[type] ?? paletteTypes[DEFAULT_PALETTE];

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getType } from '@kbn/interpreter/common';
import { ExpressionAstArgument, ExpressionAstFunction } from 'src/plugins/expressions';
import { identifyPalette, ColorPalette, identifyPartialPalette } from '../../../../common/lib';
import { ArgumentStrings } from '../../../../i18n';
const { Palette: strings } = ArgumentStrings;
export const CUSTOM_PALETTE = 'custom';
interface PaletteParams {
colors: string[];
gradient: boolean;
stops?: number[];
}
export const createCustomPalette = (
paletteParams: PaletteParams
): ColorPalette<typeof CUSTOM_PALETTE> => ({
id: CUSTOM_PALETTE,
label: strings.getCustomPaletteLabel(),
...paletteParams,
});
type UnboxArray<T> = T extends Array<infer U> ? U : T;
function reduceElementsWithType<T extends unknown[]>(
arr: any[],
arg: ExpressionAstArgument,
type: string,
onError: () => void
) {
if (getType(arg) !== type) {
onError();
}
return [...arr, arg as UnboxArray<T>];
}
// TODO: This is weird, its basically a reimplementation of what the interpretter would return.
// Probably a better way todo this, and maybe a better way to handle template type objects in general?
export const astToPalette = (
{ chain }: { chain: ExpressionAstFunction[] },
onError: () => void
): ColorPalette | ColorPalette<typeof CUSTOM_PALETTE> | null => {
if (chain.length !== 1 || chain[0].function !== 'palette') {
onError();
return null;
}
const { _, stop: argStops, gradient: argGradient, ...restArgs } = chain[0].arguments ?? {};
try {
const colors =
_?.reduce<string[]>((args, arg) => {
return reduceElementsWithType(args, arg, 'string', onError);
}, []) ?? [];
const stops =
argStops?.reduce<number[]>((args, arg) => {
return reduceElementsWithType(args, arg, 'number', onError);
}, []) ?? [];
const gradient = !!argGradient?.[0];
const palette = (stops.length ? identifyPartialPalette : identifyPalette)({ colors, gradient });
const restPreparedArgs = Object.keys(restArgs).reduce<
Record<string, ExpressionAstArgument | ExpressionAstArgument[]>
>((acc, argName) => {
acc[argName] = restArgs[argName]?.length > 1 ? restArgs[argName] : restArgs[argName]?.[0];
return acc;
}, {});
if (palette) {
return {
...palette,
...restPreparedArgs,
stops,
};
}
return createCustomPalette({ colors, gradient, stops, ...restPreparedArgs });
} catch (e) {
onError();
}
return null;
};

View file

@ -31,9 +31,6 @@ const VisDimensionArgInput: React.FC<VisDimensionArgInputProps> = ({
onValueChange,
argId,
columns,
}: {
// @todo define types
[key: string]: any;
}) => {
const [value, setValue] = useState(argValue);
const confirm = typeInstance?.options?.confirm;

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import React, { useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import {
EuiFormRow,
@ -27,13 +27,16 @@ import { DataSourceStrings, LUCENE_QUERY_URL } from '../../../i18n';
const { ESDocs: strings } = DataSourceStrings;
const EsdocsDatasource = ({ args, updateArgs, defaultIndex }) => {
const setArg = (name, value) => {
updateArgs &&
updateArgs({
...args,
...setSimpleArg(name, value),
});
};
const setArg = useCallback(
(name, value) => {
updateArgs &&
updateArgs({
...args,
...setSimpleArg(name, value),
});
},
[args, updateArgs]
);
// TODO: This is a terrible way of doing defaults. We need to find a way to read the defaults for the function
// and set them for the data source UI.
@ -73,6 +76,12 @@ const EsdocsDatasource = ({ args, updateArgs, defaultIndex }) => {
const index = getIndex();
useEffect(() => {
if (getSimpleArg('index', args)[0] !== index) {
setArg('index', index);
}
}, [args, index, setArg]);
const sortOptions = [
{ value: 'asc', text: strings.getAscendingOption() },
{ value: 'desc', text: strings.getDescendingOption() },

View file

@ -8,5 +8,6 @@
import { pointseries } from './point_series';
import { math } from './math';
import { tagcloud } from './tagcloud';
import { metricVis } from './metric_vis';
export const modelSpecs = [pointseries, math, tagcloud];
export const modelSpecs = [pointseries, math, tagcloud, metricVis];

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { get } from 'lodash';
import { ViewStrings } from '../../../i18n';
import { getState, getValue } from '../../../public/lib/resolved_arg';
const { MetricVis: strings } = ViewStrings;
export const metricVis = () => ({
name: 'metricVis',
displayName: strings.getDisplayName(),
args: [
{
name: 'metric',
displayName: strings.getMetricColumnDisplayName(),
help: strings.getMetricColumnHelp(),
argType: 'vis_dimension',
multi: true,
default: `{visdimension}`,
},
{
name: 'bucket',
displayName: strings.getBucketColumnDisplayName(),
help: strings.getBucketColumnHelp(),
argType: 'vis_dimension',
default: `{visdimension}`,
},
{
name: 'palette',
argType: 'stops_palette',
},
{
name: 'font',
displayName: strings.getFontColumnDisplayName(),
help: strings.getFontColumnHelp(),
argType: 'font',
default: `{font size=60 align="center"}`,
},
{
name: 'colorMode',
displayName: strings.getColorModeColumnDisplayName(),
help: strings.getColorModeColumnHelp(),
argType: 'select',
default: 'Labels',
options: {
choices: [
{ value: 'None', name: strings.getColorModeNoneOption() },
{ value: 'Labels', name: strings.getColorModeLabelOption() },
{ value: 'Background', name: strings.getColorModeBackgroundOption() },
],
},
},
{
name: 'showLabels',
displayName: strings.getShowLabelsColumnDisplayName(),
help: strings.getShowLabelsColumnHelp(),
argType: 'toggle',
default: true,
},
{
name: 'percentageMode',
displayName: strings.getPercentageModeColumnDisplayName(),
help: strings.getPercentageModeColumnHelp(),
argType: 'toggle',
},
],
resolve({ context }: any) {
if (getState(context) !== 'ready') {
return { columns: [] };
}
return { columns: get(getValue(context), 'columns', []) };
},
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { isEqual } from 'lodash';
import { difference, isEqual } from 'lodash';
import { LibStrings } from '../../i18n';
const { Palettes: strings } = LibStrings;
@ -19,11 +19,14 @@ export type PaletteID = typeof palettes[number]['id'];
* An interface representing a color palette in Canvas, with a textual label and a set of
* hex values.
*/
export interface ColorPalette {
id: PaletteID;
export interface ColorPalette<AdditionalPaletteID extends string = PaletteID> {
id: PaletteID | AdditionalPaletteID;
label: string;
colors: string[];
gradient: boolean;
stops?: number[];
range?: 'number' | 'percent';
continuity?: 'above' | 'below' | 'all' | 'none';
}
// This function allows one to create a strongly-typed palette for inclusion in
@ -52,6 +55,15 @@ export const identifyPalette = (
});
};
export const identifyPartialPalette = (
input: Pick<ColorPalette, 'colors' | 'gradient'>
): ColorPalette | undefined => {
return palettes.find((palette) => {
const { colors, gradient } = palette;
return gradient === input.gradient && difference(input.colors, colors).length === 0;
});
};
export const paulTor14 = createPalette({
id: 'paul_tor_14',
label: 'Paul Tor 14',

View file

@ -6,10 +6,10 @@
*/
import { getElementStrings } from './element_strings';
import { initializeElements } from '../../canvas_plugin_src/elements';
import { initializeElementsSpec } from '../../canvas_plugin_src/elements';
import { coreMock } from '../../../../../src/core/public/mocks';
const elementSpecs = initializeElements(coreMock.createSetup() as any, {} as any);
const elementSpecs = initializeElementsSpec(coreMock.createSetup() as any, {} as any);
describe('ElementStrings', () => {
const elementStrings = getElementStrings();

View file

@ -230,4 +230,12 @@ export const getElementStrings = (): ElementStringDict => ({
defaultMessage: 'Tagcloud visualization',
}),
},
metricVis: {
displayName: i18n.translate('xpack.canvas.elements.metricVisDisplayName', {
defaultMessage: '(New) Metric Vis',
}),
help: i18n.translate('xpack.canvas.elements.metricVisHelpText', {
defaultMessage: 'Metric visualization',
}),
},
});

View file

@ -328,6 +328,16 @@ export const ArgumentStrings = {
defaultMessage: 'Select column',
}),
},
StopsPalette: {
getDisplayName: () =>
i18n.translate('xpack.canvas.uis.arguments.stopsPaletteTitle', {
defaultMessage: 'Palette picker with bounds',
}),
getHelp: () =>
i18n.translate('xpack.canvas.uis.arguments.stopsPaletteLabel', {
defaultMessage: 'Provides colors for the values, based on the bounds',
}),
},
};
export const DataSourceStrings = {
@ -1273,4 +1283,70 @@ export const ViewStrings = {
defaultMessage: 'Bucket dimension configuration',
}),
},
MetricVis: {
getDisplayName: () =>
i18n.translate('xpack.canvas.uis.views.metricVisTitle', {
defaultMessage: 'Metric Vis',
}),
getMetricColumnDisplayName: () =>
i18n.translate('xpack.canvas.uis.views.metricVis.args.metricDisplayName', {
defaultMessage: 'Metric',
}),
getMetricColumnHelp: () =>
i18n.translate('xpack.canvas.uis.views.metricVis.args.metricHelp', {
defaultMessage: 'Metric dimension configuration',
}),
getBucketColumnDisplayName: () =>
i18n.translate('xpack.canvas.uis.views.metricVis.args.bucketDisplayName', {
defaultMessage: 'Bucket',
}),
getBucketColumnHelp: () =>
i18n.translate('xpack.canvas.uis.views.metricVis.args.bucketHelp', {
defaultMessage: 'Bucket dimension configuration',
}),
getFontColumnDisplayName: () =>
i18n.translate('xpack.canvas.uis.views.metricVis.args.fontDisplayName', {
defaultMessage: 'Font',
}),
getFontColumnHelp: () =>
i18n.translate('xpack.canvas.uis.views.metricVis.args.fontHelp', {
defaultMessage: 'Metric font configuration',
}),
getPercentageModeColumnDisplayName: () =>
i18n.translate('xpack.canvas.uis.views.metricVis.args.percentageModeDisplayName', {
defaultMessage: 'Enable percentage mode',
}),
getPercentageModeColumnHelp: () =>
i18n.translate('xpack.canvas.uis.views.metricVis.args.percentageModeHelp', {
defaultMessage: 'Shows metric in percentage mode.',
}),
getShowLabelsColumnDisplayName: () =>
i18n.translate('xpack.canvas.uis.views.metricVis.args.showLabelsDisplayName', {
defaultMessage: 'Show metric labels',
}),
getShowLabelsColumnHelp: () =>
i18n.translate('xpack.canvas.uis.views.metricVis.args.showLabelsHelp', {
defaultMessage: 'Shows labels under the metric values.',
}),
getColorModeColumnDisplayName: () =>
i18n.translate('xpack.canvas.uis.views.metricVis.args.colorModeDisplayName', {
defaultMessage: 'Metric color mode',
}),
getColorModeColumnHelp: () =>
i18n.translate('xpack.canvas.uis.views.metricVis.args.colorModeHelp', {
defaultMessage: 'Which part of metric to fill with color.',
}),
getColorModeNoneOption: () =>
i18n.translate('xpack.canvas.uis.views.metricVis.args.colorMode.noneOption', {
defaultMessage: 'None',
}),
getColorModeLabelOption: () =>
i18n.translate('xpack.canvas.uis.views.metricVis.args.colorMode.labelsOption', {
defaultMessage: 'Labels',
}),
getColorModeBackgroundOption: () =>
i18n.translate('xpack.canvas.uis.views.metricVis.args.colorMode.backgroundOption', {
defaultMessage: 'Background',
}),
},
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useRef, useEffect } from 'react';
import React, { useRef, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { ErrorBoundary } from '../enhance/error_boundary';
import { ArgSimpleForm } from './arg_simple_form';
@ -39,11 +39,9 @@ export const ArgForm = (props) => {
onValueRemove,
workpad,
assets,
renderError,
setRenderError,
resolvedArgValue,
} = props;
const [renderError, setRenderError] = useState(false);
const isMounted = useRef();
useEffect(() => {
@ -62,21 +60,15 @@ export const ArgForm = (props) => {
{({ error, resetErrorState }) => {
const { template, simpleTemplate } = argTypeInstance.argType;
const hasError = Boolean(error) || renderError;
const argumentProps = {
...templateProps,
resolvedArgValue,
defaultValue: argTypeInstance.default,
renderError: () => {
// TODO: don't do this
// It's an ugly hack to avoid React's render cycle and ensure the error happens on the next tick
// This is important; Otherwise we end up updating state in the middle of a render cycle
Promise.resolve().then(() => {
// Provide templates with a renderError method, and wrap the error in a known error type
// to stop Kibana's window.error from being called
isMounted.current && setRenderError(true);
});
// Provide templates with a renderError method, and wrap the error in a known error type
// to stop Kibana's window.error from being called
isMounted.current && setRenderError(true);
},
error: hasError,
setLabel: (label) => isMounted.current && setLabel(label),
@ -154,7 +146,5 @@ ArgForm.propTypes = {
expand: PropTypes.bool,
setExpand: PropTypes.func,
onValueRemove: PropTypes.func,
renderError: PropTypes.bool.isRequired,
setRenderError: PropTypes.func.isRequired,
resolvedArgValue: PropTypes.any,
};

View file

@ -7,14 +7,17 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import usePrevious from 'react-use/lib/usePrevious';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import { RenderToDom } from '../render_to_dom';
import { ExpressionFormHandlers } from '../../../common/lib/expression_form_handlers';
import { UpdatePropsRef } from '../../../types/arguments';
interface ArgTemplateFormProps {
template?: (
domNode: HTMLElement,
config: ArgTemplateFormProps['argumentProps'],
handlers: ArgTemplateFormProps['handlers']
handlers: ArgTemplateFormProps['handlers'],
onMount?: (ref: UpdatePropsRef<ArgTemplateFormProps['argumentProps']> | null) => void
) => void;
argumentProps: {
valueMissing?: boolean;
@ -42,11 +45,27 @@ export const ArgTemplateForm: React.FunctionComponent<ArgTemplateFormProps> = ({
errorTemplate,
}) => {
const [updatedHandlers, setHandlers] = useState(mergeWithFormHandlers(handlers));
const previousError = usePrevious(error);
const [mounted, setMounted] = useState(false);
const prevError = usePrevious(error);
const prevMounted = usePrevious(mounted);
const mountedArgumentRef = useRef<UpdatePropsRef<ArgTemplateFormProps['argumentProps']>>();
const domNodeRef = useRef<HTMLElement>();
useEffectOnce(() => () => {
mountedArgumentRef.current = undefined;
});
const renderTemplate = useCallback(
(domNode) => template && template(domNode, argumentProps, updatedHandlers),
[template, argumentProps, updatedHandlers]
(domNode) =>
template &&
template(domNode, argumentProps, updatedHandlers, (ref) => {
if (!mountedArgumentRef.current && ref) {
mountedArgumentRef.current = ref;
setMounted(true);
}
}),
[argumentProps, template, updatedHandlers]
);
const renderErrorTemplate = useCallback(
@ -59,22 +78,30 @@ export const ArgTemplateForm: React.FunctionComponent<ArgTemplateFormProps> = ({
}, [handlers]);
useEffect(() => {
if (previousError !== error) {
if (!prevError && error) {
updatedHandlers.destroy();
}
}, [previousError, error, updatedHandlers]);
}, [prevError, error, updatedHandlers]);
useEffect(() => {
if (!error) {
if ((!error && prevError && mounted) || (mounted && !prevMounted && !error)) {
renderTemplate(domNodeRef.current);
}
}, [error, renderTemplate, domNodeRef]);
}, [error, mounted, prevError, prevMounted, renderTemplate]);
useEffect(() => {
if (mountedArgumentRef.current) {
mountedArgumentRef.current?.updateProps(argumentProps);
}
}, [argumentProps]);
if (error) {
mountedArgumentRef.current = undefined;
return renderErrorTemplate();
}
if (!template) {
mountedArgumentRef.current = undefined;
return null;
}
@ -82,7 +109,7 @@ export const ArgTemplateForm: React.FunctionComponent<ArgTemplateFormProps> = ({
<RenderToDom
render={(domNode) => {
domNodeRef.current = domNode;
renderTemplate(domNode);
setMounted(true);
}}
/>
);

View file

@ -19,12 +19,10 @@ export const ArgForm = (props) => {
const { argTypeInstance, label: labelFromProps, templateProps } = props;
const [label, setLabel] = useState(getLabel(labelFromProps, argTypeInstance));
const [resolvedArgValue, setResolvedArgValue] = useState(null);
const [renderError, setRenderError] = useState(false);
const workpad = useSelector(getWorkpadInfo);
const assets = useSelector(getAssets);
useEffect(() => {
setRenderError(false);
setResolvedArgValue();
}, [templateProps?.argValue]);
@ -37,8 +35,6 @@ export const ArgForm = (props) => {
setLabel={setLabel}
resolvedArgValue={resolvedArgValue}
setResolvedArgValue={setResolvedArgValue}
renderError={renderError}
setRenderError={setRenderError}
/>
);
};

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { FunctionFormComponent } from './function_form_component';
import { FunctionFormComponent as Component } from './function_form_component';
import { FunctionUnknown } from './function_unknown';
import { FunctionFormContextPending } from './function_form_context_pending';
import { FunctionFormContextError } from './function_form_context_error';
@ -56,5 +56,5 @@ export const FunctionForm: React.FunctionComponent<FunctionFormProps> = (props)
);
}
return <FunctionFormComponent {...props} />;
return <Component {...props} />;
};

View file

@ -6,8 +6,9 @@
*/
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { Ast } from '@kbn/interpreter/common';
import deepEqual from 'react-fast-compare';
import {
ExpressionAstExpression,
ExpressionValue,
@ -49,15 +50,21 @@ interface FunctionFormProps {
export const FunctionForm: React.FunctionComponent<FunctionFormProps> = (props) => {
const { expressionIndex, argType, nextArgType } = props;
const dispatch = useDispatch();
const context = useSelector<State, ExpressionContext>((state) =>
getContextForIndex(state, expressionIndex)
const context = useSelector<State, ExpressionContext>(
(state) => getContextForIndex(state, expressionIndex),
deepEqual
);
const element = useSelector<State, CanvasElement | undefined>((state) =>
getSelectedElement(state)
const element = useSelector<State, CanvasElement | undefined>(
(state) => getSelectedElement(state),
deepEqual
);
const pageId = useSelector<State, string>((state) => getSelectedPage(state));
const assets = useSelector<State, State['assets']>((state) => getAssets(state));
const filterGroups = useSelector<State, string[]>((state) => getGlobalFilterGroups(state));
const pageId = useSelector<State, string>((state) => getSelectedPage(state), shallowEqual);
const assets = useSelector<State, State['assets']>((state) => getAssets(state), shallowEqual);
const filterGroups = useSelector<State, string[]>(
(state) => getGlobalFilterGroups(state),
shallowEqual
);
const addArgument = useCallback(
(argName: string, argValue: string | Ast | null) => () => {
dispatch(
@ -131,7 +138,6 @@ export const FunctionForm: React.FunctionComponent<FunctionFormProps> = (props)
},
[assets, onAssetAddDispatch]
);
return (
<Component
{...props}

View file

@ -6,3 +6,5 @@
*/
export { PalettePicker } from './palette_picker';
export { StopsPalettePicker } from './stops_palette_picker';
export type { PalettePickerProps, CustomColorPalette, StopsPalettePickerProps } from './types';

View file

@ -1,117 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC } from 'react';
import PropTypes from 'prop-types';
import { isEqual } from 'lodash';
import { EuiColorPalettePicker, EuiColorPalettePickerPaletteProps } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { palettes, ColorPalette } from '../../../common/lib/palettes';
const strings = {
getEmptyPaletteLabel: () =>
i18n.translate('xpack.canvas.palettePicker.emptyPaletteLabel', {
defaultMessage: 'None',
}),
getNoPaletteFoundErrorTitle: () =>
i18n.translate('xpack.canvas.palettePicker.noPaletteFoundErrorTitle', {
defaultMessage: 'Color palette not found',
}),
};
interface RequiredProps {
id?: string;
onChange?: (palette: ColorPalette) => void;
palette: ColorPalette;
clearable?: false;
}
interface ClearableProps {
id?: string;
onChange?: (palette: ColorPalette | null) => void;
palette: ColorPalette | null;
clearable: true;
}
type Props = RequiredProps | ClearableProps;
const findPalette = (colorPalette: ColorPalette | null, colorPalettes: ColorPalette[] = []) => {
const palette = colorPalettes.filter((cp) => cp.id === colorPalette?.id)[0] ?? null;
if (palette === null) {
return colorPalettes.filter((cp) => isEqual(cp.colors, colorPalette?.colors))[0] ?? null;
}
return palette;
};
export const PalettePicker: FC<Props> = (props) => {
const colorPalettes: EuiColorPalettePickerPaletteProps[] = palettes.map((item) => ({
value: item.id,
title: item.label,
type: item.gradient ? 'gradient' : 'fixed',
palette: item.colors,
}));
if (props.clearable) {
const { palette, onChange = () => {} } = props;
colorPalettes.unshift({
value: 'clear',
title: strings.getEmptyPaletteLabel(),
type: 'text',
});
const onPickerChange = (value: string) => {
const canvasPalette = palettes.find((item) => item.id === value);
onChange(canvasPalette || null);
};
const foundPalette = findPalette(palette, palettes);
return (
<EuiColorPalettePicker
id={props.id}
compressed={true}
palettes={colorPalettes}
onChange={onPickerChange}
valueOfSelected={foundPalette?.id ?? 'clear'}
/>
);
}
const { palette, onChange = () => {} } = props;
const onPickerChange = (value: string) => {
const canvasPalette = palettes.find((item) => item.id === value);
if (!canvasPalette) {
throw new Error(strings.getNoPaletteFoundErrorTitle());
}
onChange(canvasPalette);
};
const foundPalette = findPalette(palette, palettes);
return (
<EuiColorPalettePicker
id={props.id}
compressed={true}
palettes={colorPalettes}
onChange={onPickerChange}
valueOfSelected={foundPalette?.id}
/>
);
};
PalettePicker.propTypes = {
id: PropTypes.string,
palette: PropTypes.object,
onChange: PropTypes.func,
clearable: PropTypes.bool,
};

View file

@ -8,12 +8,13 @@
import React, { FC, useState } from 'react';
import { action } from '@storybook/addon-actions';
import { storiesOf } from '@storybook/react';
import { PalettePicker } from '../palette_picker';
import { PalettePicker } from '../../palette_picker';
import { paulTor14, ColorPalette } from '../../../../common/lib/palettes';
import { paulTor14, ColorPalette } from '../../../../../common/lib/palettes';
import { CustomColorPalette } from '../../types';
const Interactive: FC = () => {
const [palette, setPalette] = useState<ColorPalette | null>(paulTor14);
const [palette, setPalette] = useState<ColorPalette | CustomColorPalette | null>(paulTor14);
return <PalettePicker palette={palette} onChange={setPalette} clearable={true} />;
};

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiColorPalettePicker } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { FC } from 'react';
import { ClearableComponentProps } from '../types';
import { findPalette, prepareColorPalette } from '../utils';
const strings = {
getEmptyPaletteLabel: () =>
i18n.translate('xpack.canvas.palettePicker.emptyPaletteLabel', {
defaultMessage: 'None',
}),
};
export const ClearablePalettePicker: FC<ClearableComponentProps> = (props) => {
const { palette, palettes, onChange = () => {} } = props;
const colorPalettes = palettes.map(prepareColorPalette);
const onPickerChange = (value: string) => {
const canvasPalette = palettes.find((item) => item.id === value);
onChange(canvasPalette || null);
};
const foundPalette = findPalette(palette ?? null, palettes);
return (
<EuiColorPalettePicker
id={props.id}
compressed={true}
palettes={[
{
value: 'clear',
title: strings.getEmptyPaletteLabel(),
type: 'text',
},
...colorPalettes,
]}
onChange={onPickerChange}
valueOfSelected={foundPalette?.id ?? 'clear'}
/>
);
};

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiColorPalettePicker } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { FC } from 'react';
import { RequiredComponentProps } from '../types';
import { findPalette, prepareColorPalette } from '../utils';
const strings = {
getNoPaletteFoundErrorTitle: () =>
i18n.translate('xpack.canvas.palettePicker.noPaletteFoundErrorTitle', {
defaultMessage: 'Color palette not found',
}),
};
export const DefaultPalettePicker: FC<RequiredComponentProps> = (props) => {
const { palette, palettes, onChange = () => {} } = props;
const colorPalettes = palettes.map(prepareColorPalette);
const onPickerChange = (value: string) => {
const canvasPalette = palettes.find((item) => item.id === value);
if (!canvasPalette) {
throw new Error(strings.getNoPaletteFoundErrorTitle());
}
onChange(canvasPalette);
};
const foundPalette = findPalette(palette ?? null, palettes);
return (
<EuiColorPalettePicker
id={props.id}
compressed={true}
palettes={colorPalettes}
onChange={onPickerChange}
valueOfSelected={foundPalette?.id}
/>
);
};

View file

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

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC } from 'react';
import PropTypes from 'prop-types';
import { ClearablePalettePicker } from './clearable_palette_picker';
import { palettes as defaultPalettes } from '../../../../common/lib/palettes';
import { PalettePickerProps } from '../types';
import { DefaultPalettePicker } from './default_palette_picker';
export const PalettePicker: FC<PalettePickerProps> = (props) => {
const { additionalPalettes = [] } = props;
const palettes = [...defaultPalettes, ...additionalPalettes];
if (props.clearable) {
return (
<ClearablePalettePicker
palettes={palettes}
palette={props.palette}
onChange={props.onChange}
/>
);
}
return (
<DefaultPalettePicker palettes={palettes} palette={props.palette} onChange={props.onChange} />
);
};
PalettePicker.propTypes = {
id: PropTypes.string,
palette: PropTypes.object,
onChange: PropTypes.func,
clearable: PropTypes.bool,
};

View file

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

View file

@ -0,0 +1,133 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiButtonIcon,
EuiColorPicker,
EuiFieldNumber,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { FC, useEffect, useState } from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import { ColorStop } from '../types';
interface Props {
removable?: boolean;
stop?: number;
color?: string;
onDelete: () => void;
onChange: (colorStop: ColorStop) => void;
}
interface ValidationResult {
color: boolean;
stop: boolean;
}
const strings = {
getDeleteStopColorLabel: () =>
i18n.translate('xpack.canvas.stopsColorPicker.deleteColorStopLabel', {
defaultMessage: 'Delete',
}),
};
const isValidColorStop = (colorStop: ColorStop): ValidationResult & { valid: boolean } => {
const valid = !isNaN(colorStop.stop);
return {
valid,
stop: valid,
color: true,
};
};
export const StopColorPicker: FC<Props> = (props) => {
const { stop, color, onDelete, onChange, removable = true } = props;
const [colorStop, setColorStop] = useState<ColorStop>({ stop: stop ?? 0, color: color ?? '' });
const [areValidFields, setAreValidFields] = useState<ValidationResult>({
stop: true,
color: true,
});
const onChangeInput = (updatedColorStop: ColorStop) => {
setColorStop(updatedColorStop);
};
const [, cancel] = useDebounce(
() => {
if (color === colorStop.color && stop === colorStop.stop) {
return;
}
const { valid, ...validationResult } = isValidColorStop(colorStop);
if (!valid) {
setAreValidFields(validationResult);
return;
}
onChange(colorStop);
},
150,
[colorStop]
);
useEffect(() => {
const newColorStop = { stop: stop ?? 0, color: color ?? '' };
setColorStop(newColorStop);
const { valid, ...validationResult } = isValidColorStop(newColorStop);
setAreValidFields(validationResult);
}, [color, stop]);
useEffect(() => {
return () => {
cancel();
};
}, [cancel]);
return (
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem>
<EuiFieldNumber
compressed
value={colorStop.stop}
min={-Infinity}
onChange={({ target: { valueAsNumber } }) =>
onChangeInput({ ...colorStop, stop: valueAsNumber })
}
isInvalid={!areValidFields.stop}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiColorPicker
secondaryInputDisplay="top"
color={colorStop.color}
showAlpha
compressed
onChange={(newColor) => {
onChangeInput({ ...colorStop, color: newColor });
}}
isInvalid={!areValidFields.color}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="trash"
color="danger"
title={strings.getDeleteStopColorLabel()}
onClick={onDelete}
isDisabled={!removable}
aria-label={strings.getDeleteStopColorLabel()}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,147 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useCallback, useMemo } from 'react';
import { flowRight, identity } from 'lodash';
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSpacer } from '@elastic/eui';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import { i18n } from '@kbn/i18n';
import { ColorStop, CustomColorPalette, StopsPalettePickerProps } from '../types';
import { PalettePicker } from '../palette_picker';
import { StopColorPicker } from './stop_color_picker';
import { Palette } from './types';
import {
reduceColorsByStopsSize,
transformPaletteToColorStops,
mergeColorStopsWithPalette,
deleteColorStop,
updateColorStop,
addNewColorStop,
getOverridenPaletteOptions,
} from './utils';
import { ColorPalette } from '../../../../common/lib/palettes';
const strings = {
getAddColorStopLabel: () =>
i18n.translate('xpack.canvas.stopsPalettePicker.addColorStopLabel', {
defaultMessage: 'Add color stop',
}),
getColorStopsLabel: () =>
i18n.translate('xpack.canvas.stopsPalettePicker.colorStopsLabel', {
defaultMessage: 'Color stops',
}),
};
const defaultStops = [0, 1];
const MIN_STOPS = 2;
export const StopsPalettePicker: FC<StopsPalettePickerProps> = (props) => {
const { palette, onChange } = props;
const stops = useMemo(
() => (!palette?.stops || !palette.stops.length ? defaultStops : palette.stops),
[palette?.stops]
);
const colors = useMemo(
() => reduceColorsByStopsSize(palette?.colors, stops.length),
[palette?.colors, stops.length]
);
const onChangePalette = useCallback(
(newPalette: ColorPalette | CustomColorPalette | null) => {
if (newPalette) {
const newColors = reduceColorsByStopsSize(newPalette?.colors, stops.length);
props.onChange?.({
...palette,
...newPalette,
colors: newColors,
stops,
});
}
},
[palette, props, stops]
);
useEffectOnce(() => {
onChangePalette({ ...getOverridenPaletteOptions(), ...palette });
});
const paletteColorStops = useMemo(
() => transformPaletteToColorStops({ stops, colors }),
[colors, stops]
);
const updatePalette = useCallback(
(fn: (colorStops: ColorStop[]) => ColorStop[]) =>
flowRight<ColorStop[][], ColorStop[], Palette, void>(
onChange ?? identity,
mergeColorStopsWithPalette(palette),
fn
),
[onChange, palette]
);
const deleteColorStopAndApply = useCallback(
(index: number) => updatePalette(deleteColorStop(index))(paletteColorStops),
[paletteColorStops, updatePalette]
);
const updateColorStopAndApply = useCallback(
(index: number, colorStop: ColorStop) =>
updatePalette(updateColorStop(index, colorStop))(paletteColorStops),
[paletteColorStops, updatePalette]
);
const addColorStopAndApply = useCallback(
() => updatePalette(addNewColorStop(palette))(paletteColorStops),
[palette, paletteColorStops, updatePalette]
);
const stopColorPickers = paletteColorStops.map(({ id, ...rest }, index) => (
<EuiFlexItem>
<StopColorPicker
{...rest}
key={index}
removable={index >= MIN_STOPS}
onDelete={() => deleteColorStopAndApply(index)}
onChange={(cp: ColorStop) => updateColorStopAndApply(index, cp)}
/>
</EuiFlexItem>
));
return (
<>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<PalettePicker
additionalPalettes={palette?.id === 'custom' ? [palette] : []}
palette={props.palette ?? undefined}
onChange={onChangePalette}
clearable={false}
/>
</EuiFlexItem>
<EuiFormRow label={strings.getColorStopsLabel()}>
<EuiFlexGroup gutterSize="s" direction="column">
{stopColorPickers}
</EuiFlexGroup>
</EuiFormRow>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiButtonEmpty
iconType="plusInCircle"
color="primary"
aria-label={strings.getAddColorStopLabel()}
size="xs"
flush="left"
onClick={addColorStopAndApply}
>
{strings.getAddColorStopLabel()}
</EuiButtonEmpty>
</>
);
};

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { CustomColorPalette } from '../types';
import { ColorPalette } from '../../../../common/lib/palettes';
export type Palette = ColorPalette | CustomColorPalette;
export type PaletteColorStops = Pick<Palette, 'stops' | 'colors'>;

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { zip, take } from 'lodash';
import { htmlIdGenerator } from '@elastic/eui';
import { ColorPalette } from '../../../../common/lib';
import { ColorStop } from '../types';
import { Palette, PaletteColorStops } from './types';
const id = htmlIdGenerator();
export const getOverridenPaletteOptions = (): Pick<ColorPalette, 'range' | 'continuity'> => ({
range: 'number',
continuity: 'below',
});
export const createColorStop = (stop: number = 0, color: string = '') => ({
stop,
color,
id: id(),
});
export const transformPaletteToColorStops = ({ stops = [], colors }: PaletteColorStops) =>
zip(stops, colors).map(([stop, color]) => createColorStop(stop, color));
export const mergeColorStopsWithPalette =
(palette: Palette) =>
(colorStops: ColorStop[]): Palette => {
const stopsWithColors = colorStops.reduce<{ colors: string[]; stops: number[] }>(
(acc, { color, stop }) => {
acc.colors.push(color ?? '');
acc.stops.push(stop);
return acc;
},
{ colors: [], stops: [] }
);
return { ...palette, ...stopsWithColors };
};
export const updateColorStop =
(index: number, colorStop: ColorStop) => (colorStops: ColorStop[]) => {
colorStops.splice(index, 1, colorStop);
return colorStops;
};
export const deleteColorStop = (index: number) => (colorStops: ColorStop[]) => {
colorStops.splice(index, 1);
return colorStops;
};
export const addNewColorStop = (palette: Palette) => (colorStops: ColorStop[]) => {
const lastColorStopIndex = colorStops.length - 1;
const lastStop = lastColorStopIndex >= 0 ? colorStops[lastColorStopIndex].stop + 1 : 0;
const newIndex = lastColorStopIndex + 1;
return [
...colorStops,
{
stop: lastStop,
color:
palette.colors.length > newIndex + 1
? palette.colors[newIndex]
: palette.colors[palette.colors.length - 1],
},
];
};
export const reduceColorsByStopsSize = (colors: string[] = [], stopsSize: number) => {
const reducedColors = take(colors, stopsSize);
const colorsLength = reducedColors.length;
if (colorsLength === stopsSize) {
return reducedColors;
}
return [
...reducedColors,
...Array(stopsSize - colorsLength).fill(reducedColors[colorsLength - 1] ?? ''),
];
};

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ColorPalette } from '../../../common/lib/palettes';
export type CustomColorPalette = ColorPalette<'custom'>;
export interface RequiredProps {
id?: string;
onChange?: (palette: ColorPalette | CustomColorPalette) => void;
palette: ColorPalette | CustomColorPalette;
clearable?: false;
additionalPalettes?: Array<ColorPalette | CustomColorPalette>;
}
export interface ClearableProps {
id?: string;
onChange?: (palette: ColorPalette | CustomColorPalette | null) => void;
palette: ColorPalette | CustomColorPalette | null;
clearable: true;
additionalPalettes?: Array<ColorPalette | CustomColorPalette>;
}
export type PalettePickerProps = RequiredProps | ClearableProps;
export type StopsPalettePickerProps = RequiredProps;
export type ClearableComponentProps = {
palettes: Array<ColorPalette | CustomColorPalette>;
} & Partial<Pick<ClearableProps, 'onChange' | 'palette'>> &
Pick<ClearableProps, 'id'>;
export type RequiredComponentProps = {
palettes: Array<ColorPalette | CustomColorPalette>;
} & Partial<Pick<RequiredProps, 'onChange' | 'palette' | 'id'>>;
export interface ColorStop {
color: string;
stop: number;
}

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiColorPalettePickerPaletteProps } from '@elastic/eui';
import { isEqual } from 'lodash';
import { ColorPalette } from '../../../common/lib/palettes';
import { CustomColorPalette } from './types';
export const findPalette = (
colorPalette: ColorPalette | CustomColorPalette | null,
colorPalettes: Array<ColorPalette | CustomColorPalette> = []
) => {
const palette = colorPalettes.filter((cp) => cp.id === colorPalette?.id)[0] ?? null;
if (palette === null) {
return colorPalettes.filter((cp) => isEqual(cp.colors, colorPalette?.colors))[0] ?? null;
}
return palette;
};
export const prepareColorPalette = ({
id,
label,
gradient,
colors,
}: ColorPalette | CustomColorPalette): EuiColorPalettePickerPaletteProps => ({
value: id,
title: label,
type: gradient ? 'gradient' : 'fixed',
palette: colors,
});

View file

@ -61,7 +61,6 @@ export const ElementSettings: FunctionComponent<Props> = ({ element }) => {
),
},
];
return <EuiTabbedContent tabs={tabs} initialSelectedTab={tabs[0]} size="s" />;
};

View file

@ -6,31 +6,24 @@
*/
import React from 'react';
import { connect } from 'react-redux';
import deepEqual from 'react-fast-compare';
import { useSelector } from 'react-redux';
import { getElementById, getSelectedPage } from '../../../state/selectors/workpad';
import { ElementSettings as Component } from './element_settings.component';
import { State, PositionedElement } from '../../../../types';
import { State } from '../../../../types';
interface Props {
selectedElementId: string | null;
}
const mapStateToProps = (state: State, { selectedElementId }: Props): StateProps => ({
element: getElementById(state, selectedElementId, getSelectedPage(state)),
});
export const ElementSettings: React.FC<Props> = ({ selectedElementId }) => {
const element = useSelector((state: State) => {
return getElementById(state, selectedElementId, getSelectedPage(state));
}, deepEqual);
interface StateProps {
element: PositionedElement | undefined;
}
const renderIfElement: React.FunctionComponent<StateProps> = (props) => {
if (props.element) {
return <Component element={props.element} />;
if (element) {
return <Component element={element} />;
}
return null;
};
export const ElementSettings = connect<StateProps, {}, Props, State>(mapStateToProps)(
renderIfElement
);

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { useSelector } from 'react-redux';
import { shallowEqual, useSelector } from 'react-redux';
import { getSelectedToplevelNodes, getSelectedElementId } from '../../../state/selectors/workpad';
import { State } from '../../../../types';
import { SidebarContent as Component } from './sidebar_content.component';
@ -16,12 +16,14 @@ interface SidebarContentProps {
}
export const SidebarContent: React.FC<SidebarContentProps> = ({ commit }) => {
const selectedToplevelNodes = useSelector<State, string[]>((state) =>
getSelectedToplevelNodes(state)
const selectedToplevelNodes = useSelector<State, string[]>(
(state) => getSelectedToplevelNodes(state),
shallowEqual
);
const selectedElementId = useSelector<State, string | null>((state) =>
getSelectedElementId(state)
const selectedElementId = useSelector<State, string | null>(
(state) => getSelectedElementId(state),
shallowEqual
);
return (

View file

@ -16,7 +16,7 @@ import { CommitFn } from '../../../types';
export const WORKPAD_CONTAINER_ID = 'canvasWorkpadContainer';
interface Props {
export interface Props {
deselectElement?: MouseEventHandler;
isWriteable: boolean;
}

View file

@ -6,8 +6,8 @@
*/
import { MouseEventHandler } from 'react';
import { Dispatch } from 'redux';
import { connect } from 'react-redux';
import { Dispatch } from 'redux';
// @ts-expect-error untyped local
import { selectToplevelNodes } from '../../state/actions/transient';
import { canUserWrite } from '../../state/selectors/app';
@ -18,6 +18,8 @@ import { State } from '../../../types';
export { WORKPAD_CONTAINER_ID } from './workpad_app.component';
const WorkpadAppComponent = withElementsLoadedTelemetry(Component);
const mapDispatchToProps = (dispatch: Dispatch): { deselectElement: MouseEventHandler } => ({
deselectElement: (ev) => {
ev.stopPropagation();
@ -31,4 +33,4 @@ export const WorkpadApp = connect(
workpad: getWorkpad(state),
}),
mapDispatchToProps
)(withElementsLoadedTelemetry(Component));
)(WorkpadAppComponent);

View file

@ -6,6 +6,7 @@
*/
import React from 'react';
import { useSelector } from 'react-redux';
import { render } from '@testing-library/react';
import {
withUnconnectedElementsLoadedTelemetry,
@ -13,148 +14,137 @@ import {
WorkpadLoadedWithErrorsMetric,
} from './workpad_telemetry';
import { METRIC_TYPE } from '../../lib/ui_metric';
import { ResolvedArgType } from '../../../types';
import { ExpressionContext, ResolvedArgType } from '../../../types';
jest.mock('react-redux', () => {
const originalModule = jest.requireActual('react-redux');
return {
...originalModule,
useSelector: jest.fn(),
};
});
const trackMetric = jest.fn();
const useSelectorMock = useSelector as jest.Mock;
const Component = withUnconnectedElementsLoadedTelemetry(() => <div />, trackMetric);
const mockWorkpad = {
id: 'workpadid',
pages: [
{
elements: [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }],
elements: [{ id: '0' }, { id: '1' }, { id: '2' }, { id: '3' }],
},
{
elements: [{ id: '5' }],
elements: [{ id: '4' }],
},
],
};
const resolvedArgsMatchWorkpad = {
'1': {} as ResolvedArgType,
'2': {} as ResolvedArgType,
'3': {} as ResolvedArgType,
'4': {} as ResolvedArgType,
'5': {} as ResolvedArgType,
};
const getMockState = (resolvedArgs: Record<string, ResolvedArgType>) => ({
transient: { resolvedArgs },
});
const resolvedArgsNotMatchWorkpad = {
'non-matching-id': {} as ResolvedArgType,
};
const getResolveArgWithState = (state: 'pending' | 'ready' | 'error') =>
({
expressionRenderable: { value: { as: state, type: 'render' }, state, error: null },
expressionContext: {} as ExpressionContext,
} as ResolvedArgType);
const pendingCounts = {
pending: 5,
error: 0,
ready: 0,
};
const arrayToObject = (array: ResolvedArgType[]) =>
array.reduce<Record<number, ResolvedArgType>>((acc, el, index) => {
acc[index] = el;
return acc;
}, {});
const readyCounts = {
pending: 0,
error: 0,
ready: 5,
};
const pendingMockState = getMockState(
arrayToObject(Array(5).fill(getResolveArgWithState('pending')))
);
const errorCounts = {
pending: 0,
error: 1,
ready: 4,
};
const readyMockState = getMockState(arrayToObject(Array(5).fill(getResolveArgWithState('ready'))));
const errorMockState = getMockState(
arrayToObject([
...Array(4).fill(getResolveArgWithState('ready')),
...Array(1).fill(getResolveArgWithState('error')),
])
);
const emptyElementsMockState = getMockState({});
const notMatchedMockState = getMockState({
'non-matching-id': getResolveArgWithState('ready'),
});
describe('Elements Loaded Telemetry', () => {
beforeEach(() => {
trackMetric.mockReset();
});
it('tracks when all resolvedArgs are completed', () => {
const { rerender } = render(
<Component
telemetryElementCounts={pendingCounts}
telemetryResolvedArgs={resolvedArgsMatchWorkpad}
workpad={mockWorkpad}
/>
);
afterEach(() => {
useSelectorMock.mockClear();
});
it('tracks when all resolvedArgs are completed', () => {
useSelectorMock.mockImplementation((callback) => {
return callback(pendingMockState);
});
const { rerender } = render(<Component workpad={mockWorkpad} />);
expect(trackMetric).not.toBeCalled();
rerender(
<Component
telemetryElementCounts={readyCounts}
telemetryResolvedArgs={resolvedArgsMatchWorkpad}
workpad={mockWorkpad}
/>
);
useSelectorMock.mockClear();
useSelectorMock.mockImplementation((callback) => {
return callback(readyMockState);
});
rerender(<Component workpad={mockWorkpad} />);
expect(trackMetric).toBeCalledWith(METRIC_TYPE.LOADED, WorkpadLoadedMetric);
});
it('only tracks loaded once', () => {
const { rerender } = render(
<Component
telemetryElementCounts={pendingCounts}
telemetryResolvedArgs={resolvedArgsMatchWorkpad}
workpad={mockWorkpad}
/>
);
useSelectorMock.mockImplementation((callback) => {
return callback(pendingMockState);
});
const { rerender } = render(<Component workpad={mockWorkpad} />);
expect(trackMetric).not.toBeCalled();
rerender(
<Component
telemetryElementCounts={readyCounts}
telemetryResolvedArgs={resolvedArgsMatchWorkpad}
workpad={mockWorkpad}
/>
);
rerender(
<Component
telemetryElementCounts={readyCounts}
telemetryResolvedArgs={resolvedArgsMatchWorkpad}
workpad={mockWorkpad}
/>
);
useSelectorMock.mockClear();
useSelectorMock.mockImplementation((callback) => {
return callback(readyMockState);
});
rerender(<Component workpad={mockWorkpad} />);
rerender(<Component workpad={mockWorkpad} />);
expect(trackMetric).toBeCalledTimes(1);
});
it('does not track if resolvedArgs are never pending', () => {
const { rerender } = render(
<Component
telemetryElementCounts={readyCounts}
telemetryResolvedArgs={resolvedArgsMatchWorkpad}
workpad={mockWorkpad}
/>
);
rerender(
<Component
telemetryElementCounts={readyCounts}
telemetryResolvedArgs={resolvedArgsMatchWorkpad}
workpad={mockWorkpad}
/>
);
useSelectorMock.mockImplementation((callback) => {
return callback(readyMockState);
});
const { rerender } = render(<Component workpad={mockWorkpad} />);
rerender(<Component workpad={mockWorkpad} />);
expect(trackMetric).not.toBeCalled();
});
it('tracks if elements are in error state after load', () => {
const { rerender } = render(
<Component
telemetryElementCounts={pendingCounts}
telemetryResolvedArgs={resolvedArgsMatchWorkpad}
workpad={mockWorkpad}
/>
);
useSelectorMock.mockImplementation((callback) => {
return callback(pendingMockState);
});
const { rerender } = render(<Component workpad={mockWorkpad} />);
expect(trackMetric).not.toBeCalled();
rerender(
<Component
telemetryElementCounts={errorCounts}
telemetryResolvedArgs={resolvedArgsMatchWorkpad}
workpad={mockWorkpad}
/>
);
useSelectorMock.mockClear();
useSelectorMock.mockImplementation((callback) => {
return callback(errorMockState);
});
rerender(<Component workpad={mockWorkpad} />);
expect(trackMetric).toBeCalledWith(METRIC_TYPE.LOADED, [
WorkpadLoadedMetric,
WorkpadLoadedWithErrorsMetric,
@ -166,42 +156,30 @@ describe('Elements Loaded Telemetry', () => {
id: 'otherworkpad',
pages: [
{
elements: [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }],
elements: [{ id: '0' }, { id: '1' }, { id: '2' }, { id: '3' }],
},
{
elements: [{ id: '5' }],
elements: [{ id: '4' }],
},
],
};
const { rerender } = render(
<Component
telemetryElementCounts={readyCounts}
telemetryResolvedArgs={resolvedArgsNotMatchWorkpad}
workpad={otherWorkpad}
/>
);
useSelectorMock.mockImplementation((callback) => {
return callback(notMatchedMockState);
});
const { rerender } = render(<Component workpad={otherWorkpad} />);
expect(trackMetric).not.toBeCalled();
rerender(
<Component
telemetryElementCounts={readyCounts}
telemetryResolvedArgs={resolvedArgsNotMatchWorkpad}
workpad={mockWorkpad}
/>
);
rerender(<Component workpad={mockWorkpad} />);
expect(trackMetric).not.toBeCalled();
rerender(
<Component
telemetryElementCounts={readyCounts}
telemetryResolvedArgs={resolvedArgsMatchWorkpad}
workpad={mockWorkpad}
/>
);
useSelectorMock.mockClear();
useSelectorMock.mockImplementation((callback) => {
return callback(readyMockState);
});
rerender(<Component workpad={mockWorkpad} />);
expect(trackMetric).toBeCalledWith(METRIC_TYPE.LOADED, WorkpadLoadedMetric);
});
@ -211,24 +189,12 @@ describe('Elements Loaded Telemetry', () => {
pages: [],
};
const resolvedArgs = {};
const { rerender } = render(
<Component
telemetryElementCounts={readyCounts}
telemetryResolvedArgs={resolvedArgs}
workpad={otherWorkpad}
/>
);
rerender(
<Component
telemetryElementCounts={readyCounts}
telemetryResolvedArgs={resolvedArgs}
workpad={otherWorkpad}
/>
);
useSelectorMock.mockImplementation((callback) => {
return callback(emptyElementsMockState);
});
const { rerender } = render(<Component workpad={otherWorkpad} />);
rerender(<Component workpad={otherWorkpad} />);
expect(trackMetric).not.toBeCalled();
});
});

View file

@ -6,21 +6,18 @@
*/
import React, { useState, useEffect } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { shallowEqual, useSelector } from 'react-redux';
import deepEqual from 'react-fast-compare';
import { trackCanvasUiMetric, METRIC_TYPE } from '../../lib/ui_metric';
import { getElementCounts } from '../../state/selectors/workpad';
import { getArgs } from '../../state/selectors/resolved_args';
import { State } from '../../../types';
const WorkpadLoadedMetric = 'workpad-loaded';
const WorkpadLoadedWithErrorsMetric = 'workpad-loaded-with-errors';
export { WorkpadLoadedMetric, WorkpadLoadedWithErrorsMetric };
const mapStateToProps = (state: any) => ({
telemetryElementCounts: getElementCounts(state),
telemetryResolvedArgs: getArgs(state),
});
// TODO: Build out full workpad types
/**
Individual Page of a Workpad
@ -47,7 +44,7 @@ interface ResolvedArgs {
[keys: string]: any;
}
export interface ElementsLoadedTelemetryProps extends PropsFromRedux {
export interface ElementsLoadedTelemetryProps {
workpad: Workpad;
}
@ -65,33 +62,32 @@ export const withUnconnectedElementsLoadedTelemetry = <P extends {}>(
Component: React.ComponentType<P>,
trackMetric = trackCanvasUiMetric
) =>
function ElementsLoadedTelemetry(props: ElementsLoadedTelemetryProps) {
const { telemetryElementCounts, workpad, telemetryResolvedArgs, ...other } = props;
const { error, pending } = telemetryElementCounts;
function ElementsLoadedTelemetry(props: P & ElementsLoadedTelemetryProps) {
const { workpad } = props;
const [currentWorkpadId, setWorkpadId] = useState<string | undefined>(undefined);
const [hasReported, setHasReported] = useState(false);
const telemetryElementCounts = useSelector(
(state: State) => getElementCounts(state),
shallowEqual
);
const telemetryResolvedArgs = useSelector((state: State) => getArgs(state), deepEqual);
const resolvedArgsAreForWorkpad = areAllElementsInResolvedArgs(workpad, telemetryResolvedArgs);
const { error, pending } = telemetryElementCounts;
const resolved = resolvedArgsAreForWorkpad && pending === 0;
useEffect(() => {
const resolvedArgsAreForWorkpad = areAllElementsInResolvedArgs(
workpad,
telemetryResolvedArgs
);
if (workpad.id !== currentWorkpadId) {
setWorkpadId(workpad.id);
const workpadElementCount = workpad.pages.reduce(
(reduction, page) => reduction + page.elements.length,
0
);
if (workpadElementCount === 0 || (resolvedArgsAreForWorkpad && pending === 0)) {
setHasReported(true);
} else {
setHasReported(false);
}
} else if (!hasReported && pending === 0 && resolvedArgsAreForWorkpad) {
setWorkpadId(workpad.id);
setHasReported(workpadElementCount === 0 || resolved);
} else if (!hasReported && resolved) {
if (error > 0) {
trackMetric(METRIC_TYPE.LOADED, [WorkpadLoadedMetric, WorkpadLoadedWithErrorsMetric]);
} else {
@ -99,16 +95,9 @@ export const withUnconnectedElementsLoadedTelemetry = <P extends {}>(
}
setHasReported(true);
}
}, [currentWorkpadId, hasReported, error, pending, telemetryResolvedArgs, workpad]);
return <Component {...(other as P)} workpad={workpad} />;
}, [currentWorkpadId, hasReported, error, workpad.id, resolved, workpad.pages]);
return <Component {...props} />;
};
const connector = connect(mapStateToProps, {});
type PropsFromRedux = ConnectedProps<typeof connector>;
export const withElementsLoadedTelemetry = <P extends {}>(Component: React.ComponentType<P>) => {
const telemetry = withUnconnectedElementsLoadedTelemetry(Component);
return connector(telemetry);
};
export const withElementsLoadedTelemetry = <P extends {}>(Component: React.ComponentType<P>) =>
withUnconnectedElementsLoadedTelemetry(Component);

View file

@ -12,6 +12,7 @@ import { RenderToDom } from '../components/render_to_dom';
import { BaseForm, BaseFormProps } from './base_form';
import { ExpressionFormHandlers } from '../../common/lib';
import { ExpressionFunction } from '../../types';
import { UpdatePropsRef } from '../../types/arguments';
const defaultTemplate = () => (
<div>
@ -22,7 +23,8 @@ const defaultTemplate = () => (
type TemplateFn = (
domNode: HTMLElement,
config: DatasourceRenderProps,
handlers: ExpressionFormHandlers
handlers: ExpressionFormHandlers,
onMount?: (ref: UpdatePropsRef<DatasourceRenderProps> | null) => void
) => void;
export type DatasourceProps = {
@ -49,6 +51,8 @@ interface DatasourceWrapperProps {
const DatasourceWrapper: React.FunctionComponent<DatasourceWrapperProps> = (props) => {
const domNodeRef = useRef<HTMLElement>();
const datasourceRef = useRef<UpdatePropsRef<DatasourceRenderProps>>();
const { spec, datasourceProps, handlers } = props;
const callRenderFn = useCallback(() => {
@ -58,14 +62,23 @@ const DatasourceWrapper: React.FunctionComponent<DatasourceWrapperProps> = (prop
return;
}
template(domNodeRef.current, datasourceProps, handlers);
template(domNodeRef.current, datasourceProps, handlers, (ref) => {
datasourceRef.current = ref ?? undefined;
});
}, [datasourceProps, handlers, spec]);
useEffect(() => {
callRenderFn();
}, [callRenderFn, props]);
}, [callRenderFn]);
useEffect(() => {
if (datasourceRef.current) {
datasourceRef.current.updateProps(datasourceProps);
}
}, [datasourceProps]);
useEffectOnce(() => () => {
datasourceRef.current = undefined;
handlers.destroy();
});

View file

@ -140,7 +140,6 @@ export class FunctionForm extends BaseForm {
// Don't instaniate these until render time, to give the registries a chance to populate.
const argInstances = this.args.map((argSpec) => new Arg(argSpec));
if (args === null || !isPlainObject(args)) {
throw new Error(`Form "${this.name}" expects "args" object`);
}
@ -153,7 +152,6 @@ export class FunctionForm extends BaseForm {
// otherwise, leave the value alone (including if the arg is not defined)
const isMulti = arg && arg.multi;
const argValues = args[argName] && !isMulti ? [last(args[argName]) ?? null] : args[argName];
return { arg, argValues };
});

View file

@ -5,42 +5,72 @@
* 2.0.
*/
import React, { ComponentType, FC } from 'react';
import React, {
ComponentType,
forwardRef,
ForwardRefRenderFunction,
useImperativeHandle,
useState,
} from 'react';
import { unmountComponentAtNode, render } from 'react-dom';
import PropTypes from 'prop-types';
import { I18nProvider } from '@kbn/i18n/react';
import { ErrorBoundary } from '../components/enhance/error_boundary';
import { ArgumentHandlers } from '../../types/arguments';
import { ArgumentHandlers, UpdatePropsRef } from '../../types/arguments';
export interface Props {
renderError: Function;
}
export const templateFromReactComponent = (Component: ComponentType<any>) => {
const WrappedComponent: FC<Props> = (props) => (
<ErrorBoundary>
{({ error }) => {
if (error) {
props.renderError();
return null;
}
const WrappedComponent: ForwardRefRenderFunction<UpdatePropsRef<Props>, Props> = (props, ref) => {
const [updatedProps, setUpdatedProps] = useState<Props>(props);
return (
<I18nProvider>
<Component {...props} />
</I18nProvider>
);
}}
</ErrorBoundary>
);
useImperativeHandle(ref, () => ({
updateProps: (newProps: Props) => {
setUpdatedProps(newProps);
},
}));
WrappedComponent.propTypes = {
return (
<ErrorBoundary>
{({ error }) => {
if (error) {
props.renderError();
return null;
}
return (
<I18nProvider>
<Component {...updatedProps} />
</I18nProvider>
);
}}
</ErrorBoundary>
);
};
const ForwardRefWrappedComponent = forwardRef(WrappedComponent);
ForwardRefWrappedComponent.propTypes = {
renderError: PropTypes.func,
};
return (domNode: HTMLElement, config: Props, handlers: ArgumentHandlers) => {
return (
domNode: HTMLElement,
config: Props,
handlers: ArgumentHandlers,
onMount?: (ref: UpdatePropsRef<Props> | null) => void
) => {
try {
const el = React.createElement(WrappedComponent, config);
const el = (
<ForwardRefWrappedComponent
{...config}
ref={(ref) => {
onMount?.(ref);
}}
/>
);
render(el, domNode, () => {
handlers.done();
});

View file

@ -19,6 +19,10 @@ export interface ArgumentHandlers {
onDestroy: GenericCallback;
}
export interface UpdatePropsRef<Props extends {} = {}> {
updateProps: (newProps: Props) => void;
}
export interface ArgumentSpec<ArgumentConfig = {}> {
/** The argument type */
name: string;
@ -33,13 +37,19 @@ export interface ArgumentSpec<ArgumentConfig = {}> {
simpleTemplate?: (
domNode: HTMLElement,
config: ArgumentConfig,
handlers: ArgumentHandlers
handlers: ArgumentHandlers,
onMount: (ref: UpdatePropsRef<ArgumentConfig> | null) => void
) => void;
/**
* A function that renders a complex/large argument
* This is nested in an accordian so it can be expanded/collapsed
*/
template?: (domNode: HTMLElement, config: ArgumentConfig, handlers: ArgumentHandlers) => void;
template?: (
domNode: HTMLElement,
config: ArgumentConfig,
handlers: ArgumentHandlers,
onMount: (ref: UpdatePropsRef<ArgumentConfig> | null) => void
) => void;
}
export type ArgumentFactory<ArgumentConfig = {}> = () => ArgumentSpec<ArgumentConfig>;