[Canvas] HeatMap. (#120239)

* Added defaults to expressions.

* Fixed error on number passed as a name.

* One more fix.

* Added Heatmap element to Canvas.

* Added support of nested expressions.

* Added support of the adding models/views as arguments of the expressions.

* Added support of the name from parent configuration.

* Added support of removing nested models.

* Added heatmap legend description)

* Replaced help and displayName of legend.

* Added heatmap_grid.

* Fixed label.

* Added context of nested expressions support.

* Fixed bugs with updating of elements.

* Added color picker.

* Make color compressed

* Added usable inputs with good user experience.

* Reduced number of props, passing to the arg..

* Percentage and range args with debounce/

* Removed not used args from heatmap_grid

* fixed arg name.

* Fixed storybooks.

* Fixed one more story.

* Fixed unused args from lens.

* Added comments to the recursive function.

* Added docs to the transformNestedFunctionsToUIConfig

* Removed not used translations.

* Fixed tests.

* Added rest of arguments.

* Fixed args defaults generating.

* Fixed tests of lens.

* Changed '@kbn/interpreter/common' to '@kbn/interpreter'.

* Changed names of setArgumentAtIndex and addArgumentValueAtIndex and changed comments.

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Yaroslav Kuznietsov 2022-01-12 09:08:38 +02:00 committed by GitHub
parent 0493f00d7f
commit dd9d8461d2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 1269 additions and 387 deletions

View file

@ -17,7 +17,10 @@ import {
EXPRESSION_HEATMAP_LEGEND_NAME,
} from '../constants';
const convertToVisDimension = (columns: DatatableColumn[], accessor: string) => {
const convertToVisDimension = (
columns: DatatableColumn[],
accessor: string
): ExpressionValueVisDimension | undefined => {
const column = columns.find((c) => c.id === accessor);
if (!column) return;
return {
@ -27,7 +30,7 @@ const convertToVisDimension = (columns: DatatableColumn[], accessor: string) =>
params: { ...column.meta.params?.params },
},
type: 'vis_dimension',
} as ExpressionValueVisDimension;
};
};
const prepareHeatmapLogTable = (
@ -70,12 +73,14 @@ export const heatmapFunction = (): HeatmapExpressionFunctionDefinition => ({
help: i18n.translate('expressionHeatmap.function.legendConfig.help', {
defaultMessage: 'Configure the chart legend.',
}),
default: `{${EXPRESSION_HEATMAP_LEGEND_NAME}}`,
},
gridConfig: {
types: [EXPRESSION_HEATMAP_GRID_NAME],
help: i18n.translate('expressionHeatmap.function.gridConfig.help', {
defaultMessage: 'Configure the heatmap layout.',
}),
default: `{${EXPRESSION_HEATMAP_GRID_NAME}}`,
},
showTooltip: {
types: ['boolean'],
@ -118,6 +123,7 @@ export const heatmapFunction = (): HeatmapExpressionFunctionDefinition => ({
help: i18n.translate('expressionHeatmap.function.args.valueAccessorHelpText', {
defaultMessage: 'The id of the value column or the corresponding dimension',
}),
required: true,
},
// not supported yet, small multiples accessor
splitRowAccessor: {

View file

@ -20,7 +20,7 @@ export const heatmapGridConfig: ExpressionFunctionDefinition<
name: EXPRESSION_HEATMAP_GRID_NAME,
aliases: [],
type: EXPRESSION_HEATMAP_GRID_NAME,
help: `Configure the heatmap layout `,
help: `Configure the heatmap layout`,
inputTypes: ['null'],
args: {
// grid
@ -38,20 +38,6 @@ export const heatmapGridConfig: ExpressionFunctionDefinition<
}),
required: false,
},
cellHeight: {
types: ['number'],
help: i18n.translate('expressionHeatmap.function.args.grid.cellHeight.help', {
defaultMessage: 'Specifies the grid cell height',
}),
required: false,
},
cellWidth: {
types: ['number'],
help: i18n.translate('expressionHeatmap.function.args.grid.cellWidth.help', {
defaultMessage: 'Specifies the grid cell width',
}),
required: false,
},
// cells
isCellLabelVisible: {
types: ['boolean'],
@ -66,20 +52,6 @@ export const heatmapGridConfig: ExpressionFunctionDefinition<
defaultMessage: 'Specifies whether or not the Y-axis labels are visible.',
}),
},
yAxisLabelWidth: {
types: ['number'],
help: i18n.translate('expressionHeatmap.function.args.grid.yAxisLabelWidth.help', {
defaultMessage: 'Specifies the width of the Y-axis labels.',
}),
required: false,
},
yAxisLabelColor: {
types: ['string'],
help: i18n.translate('expressionHeatmap.function.args.grid.yAxisLabelColor.help', {
defaultMessage: 'Specifies the color of the Y-axis labels.',
}),
required: false,
},
// X-axis
isXAxisLabelVisible: {
types: ['boolean'],

View file

@ -48,14 +48,10 @@ export interface HeatmapGridConfig {
// grid
strokeWidth?: number;
strokeColor?: string;
cellHeight?: number;
cellWidth?: number;
// cells
isCellLabelVisible: boolean;
// Y-axis
isYAxisLabelVisible: boolean;
yAxisLabelWidth?: number;
yAxisLabelColor?: string;
// X-axis
isXAxisLabelVisible: boolean;
}

View file

@ -258,7 +258,7 @@ export const HeatmapComponent: FC<HeatmapRenderProps> = memo(
const percentageNumber = (Math.abs(value - min) / (max - min)) * 100;
value = parseInt(percentageNumber.toString(), 10) / 100;
}
return metricFormatter.convert(value);
return `${metricFormatter.convert(value) ?? ''}`;
};
const { colors, ranges } = computeColorRanges(
@ -415,7 +415,8 @@ export const HeatmapComponent: FC<HeatmapRenderProps> = memo(
name: yAxisColumn?.name ?? '',
...(yAxisColumn
? {
formatter: (v: number | string) => formatFactory(yAxisColumn.meta.params).convert(v),
formatter: (v: number | string) =>
`${formatFactory(yAxisColumn.meta.params).convert(v) ?? ''}`,
}
: {}),
},
@ -424,7 +425,7 @@ export const HeatmapComponent: FC<HeatmapRenderProps> = memo(
// eui color subdued
textColor: chartTheme.axes?.tickLabel?.fill ?? `#6a717d`,
padding: xAxisColumn?.name ? 8 : 0,
formatter: (v: number | string) => xValuesFormatter.convert(v),
formatter: (v: number | string) => `${xValuesFormatter.convert(v) ?? ''}`,
name: xAxisColumn?.name ?? '',
},
brushMask: {

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 heatmap: ElementFactory = () => ({
name: 'heatmap',
displayName: 'Heatmap',
type: 'chart',
help: 'Heatmap visualization',
icon: 'heatmap',
expression: `filters
| demodata
| head 10
| heatmap xAccessor={visdimension "age"} yAccessor={visdimension "project"} valueAccessor={visdimension "cost"}
| render`,
});

View file

@ -33,6 +33,7 @@ import { verticalProgressBar } from './vertical_progress_bar';
import { verticalProgressPill } from './vertical_progress_pill';
import { tagCloud } from './tag_cloud';
import { metricVis } from './metric_vis';
import { heatmap } from './heatmap';
import { SetupInitializer } from '../plugin';
import { ElementFactory } from '../../types';
@ -63,6 +64,7 @@ const elementSpecs = [
verticalProgressBar,
verticalProgressPill,
tagCloud,
heatmap,
];
const initializeElementFactories = [metricElementInitializer];

View file

@ -0,0 +1,56 @@
/*
* 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 {
EuiColorPicker,
EuiFlexGroup,
EuiFlexItem,
EuiSetColorMethod,
useColorPickerState,
} from '@elastic/eui';
import { templateFromReactComponent } from '../../../../public/lib/template_from_react_component';
import { withDebounceArg } from '../../../../public/components/with_debounce_arg';
import { ArgumentStrings } from '../../../../i18n';
const { Color: strings } = ArgumentStrings;
interface Props {
onValueChange: (value: string) => void;
argValue: string;
}
const ColorPicker: FC<Props> = ({ onValueChange, argValue }) => {
const [color, setColor, errors] = useColorPickerState(argValue);
const pickColor: EuiSetColorMethod = (value, meta) => {
setColor(value, meta);
onValueChange(value);
};
return (
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<EuiColorPicker compressed onChange={pickColor} color={color} isInvalid={!!errors} />
</EuiFlexItem>
</EuiFlexGroup>
);
};
ColorPicker.propTypes = {
argValue: PropTypes.any.isRequired,
onValueChange: PropTypes.func.isRequired,
};
export const colorPicker = () => ({
name: 'color_picker',
displayName: strings.getDisplayName(),
help: strings.getHelp(),
simpleTemplate: templateFromReactComponent(withDebounceArg(ColorPicker)),
default: '"#000"',
});

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 { colorPicker } from './color_picker';

View file

@ -39,7 +39,7 @@ const getMathValue = (argValue, columns) => {
// TODO: Garbage, we could make a much nicer math form that can handle way more.
const DatacolumnArgInput = ({
onValueChange,
columns,
resolved: { columns },
argValue,
renderError,
argId,
@ -123,7 +123,9 @@ const DatacolumnArgInput = ({
};
DatacolumnArgInput.propTypes = {
columns: PropTypes.array.isRequired,
resolved: PropTypes.shape({
columns: PropTypes.array.isRequired,
}).isRequired,
onValueChange: PropTypes.func.isRequired,
typeInstance: PropTypes.object.isRequired,
renderError: PropTypes.func.isRequired,

View file

@ -32,6 +32,7 @@ import { textarea } from './textarea';
// @ts-expect-error untyped local
import { toggle } from './toggle';
import { visdimension } from './vis_dimension';
import { colorPicker } from './color_picker';
import { SetupInitializer } from '../../plugin';
@ -51,6 +52,7 @@ export const args = [
textarea,
toggle,
visdimension,
colorPicker,
];
export const initializers = [dateFormatInitializer, numberFormatInitializer];

View file

@ -9,6 +9,7 @@ import React, { useState, useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import { EuiFieldNumber, EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { templateFromReactComponent } from '../../../public/lib/template_from_react_component';
import { withDebounceArg } from '../../../public/components/with_debounce_arg';
import { ArgumentStrings } from '../../../i18n';
const { Number: strings } = ArgumentStrings;
@ -28,8 +29,11 @@ const NumberArgInput = ({ argId, argValue, typeInstance, onValueChange }) => {
const onChange = useCallback(
(ev) => {
const onChangeFn = confirm ? setValue : onValueChange;
onChangeFn(ev.target.value);
const { value } = ev.target;
setValue(value);
if (!confirm) {
onValueChange(value);
}
},
[confirm, onValueChange]
);
@ -62,6 +66,6 @@ export const number = () => ({
name: 'number',
displayName: strings.getDisplayName(),
help: strings.getHelp(),
simpleTemplate: templateFromReactComponent(NumberArgInput),
simpleTemplate: templateFromReactComponent(withDebounceArg(NumberArgInput)),
default: '0',
});

View file

@ -5,18 +5,27 @@
* 2.0.
*/
import React from 'react';
import React, { useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import { EuiRange } from '@elastic/eui';
import { withDebounceArg } from '../../../public/components/with_debounce_arg';
import { templateFromReactComponent } from '../../../public/lib/template_from_react_component';
import { ArgumentStrings } from '../../../i18n';
const { Percentage: strings } = ArgumentStrings;
const PercentageArgInput = ({ onValueChange, argValue }) => {
const handleChange = (ev) => {
return onValueChange(ev.target.value / 100);
};
const [value, setValue] = useState(argValue);
const handleChange = useCallback(
(ev) => {
const { value } = ev.target;
const numberVal = Number(value) / 100;
setValue(numberVal);
onValueChange(numberVal);
},
[onValueChange]
);
return (
<EuiRange
@ -25,7 +34,7 @@ const PercentageArgInput = ({ onValueChange, argValue }) => {
max={100}
showLabels
showInput
value={argValue * 100}
value={value * 100}
onChange={handleChange}
/>
);
@ -41,5 +50,5 @@ export const percentage = () => ({
name: 'percentage',
displayName: strings.getDisplayName(),
help: strings.getHelp(),
simpleTemplate: templateFromReactComponent(PercentageArgInput),
simpleTemplate: templateFromReactComponent(withDebounceArg(PercentageArgInput, 50)),
});

View file

@ -5,19 +5,28 @@
* 2.0.
*/
import React from 'react';
import React, { useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import { EuiRange } from '@elastic/eui';
import { templateFromReactComponent } from '../../../public/lib/template_from_react_component';
import { withDebounceArg } from '../../../public/components/with_debounce_arg';
import { ArgumentStrings } from '../../../i18n';
const { Range: strings } = ArgumentStrings;
const RangeArgInput = ({ typeInstance, onValueChange, argValue }) => {
const { min, max, step } = typeInstance.options;
const handleChange = (ev) => {
return onValueChange(Number(ev.target.value));
};
const [value, setValue] = useState(argValue);
const handleChange = useCallback(
(ev) => {
const { value } = ev.target;
const numberVal = Number(value);
setValue(numberVal);
onValueChange(numberVal);
},
[onValueChange]
);
return (
<EuiRange
@ -27,7 +36,7 @@ const RangeArgInput = ({ typeInstance, onValueChange, argValue }) => {
step={step}
showLabels
showInput
value={argValue}
value={value}
onChange={handleChange}
/>
);
@ -50,5 +59,5 @@ export const range = () => ({
name: 'range',
displayName: strings.getDisplayName(),
help: strings.getHelp(),
simpleTemplate: templateFromReactComponent(RangeArgInput),
simpleTemplate: templateFromReactComponent(withDebounceArg(RangeArgInput, 50)),
});

View file

@ -9,6 +9,8 @@ import React, { useState, useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import { EuiFlexItem, EuiFlexGroup, EuiFieldText, EuiButton } from '@elastic/eui';
import { templateFromReactComponent } from '../../../public/lib/template_from_react_component';
import { withDebounceArg } from '../../../public/components/with_debounce_arg';
import { ArgumentStrings } from '../../../i18n';
const { String: strings } = ArgumentStrings;
@ -23,8 +25,11 @@ const StringArgInput = ({ argValue, typeInstance, onValueChange, argId }) => {
const onChange = useCallback(
(ev) => {
const onChangeFn = confirm ? setValue : onValueChange;
onChangeFn(ev.target.value);
const { value } = ev.target;
setValue(value);
if (!confirm) {
onValueChange(value);
}
},
[confirm, onValueChange]
);
@ -56,5 +61,5 @@ export const string = () => ({
name: 'string',
displayName: strings.getDisplayName(),
help: strings.getHelp(),
simpleTemplate: templateFromReactComponent(StringArgInput),
simpleTemplate: templateFromReactComponent(withDebounceArg(StringArgInput)),
});

View file

@ -9,18 +9,22 @@ import React, { useState, useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import { EuiFormRow, EuiTextArea, EuiSpacer, EuiButton } from '@elastic/eui';
import { templateFromReactComponent } from '../../../public/lib/template_from_react_component';
import { withDebounceArg } from '../../../public/components/with_debounce_arg';
import { ArgumentStrings } from '../../../i18n';
const { Textarea: strings } = ArgumentStrings;
const TextAreaArgInput = ({ argValue, typeInstance, onValueChange, renderError, argId }) => {
const confirm = typeInstance?.options?.confirm;
const [value, setValue] = useState();
const [value, setValue] = useState(argValue);
const onChange = useCallback(
(ev) => {
const onChangeFn = confirm ? setValue : onValueChange;
onChangeFn(ev.target.value);
const { value } = ev.target;
setValue(value);
if (!confirm) {
onValueChange(value);
}
},
[confirm, onValueChange]
);
@ -68,5 +72,5 @@ export const textarea = () => ({
name: 'textarea',
displayName: strings.getDisplayName(),
help: strings.getHelp(),
template: templateFromReactComponent(TextAreaArgInput),
template: templateFromReactComponent(withDebounceArg(TextAreaArgInput)),
});

View file

@ -10,27 +10,25 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSelect } from '@elastic/eui';
import { DatatableColumn, ExpressionAstExpression } from 'src/plugins/expressions';
import { templateFromReactComponent } from '../../../public/lib/template_from_react_component';
import { ArgumentStrings } from '../../../i18n';
import { ResolvedArgProps, ResolvedColumns } from '../../../public/expression_types/arg';
const { VisDimension: strings } = ArgumentStrings;
interface VisDimensionArgInputProps {
type VisDimensionArgInputProps = {
onValueChange: (value: ExpressionAstExpression) => void;
argValue: ExpressionAstExpression;
argId?: string;
columns: DatatableColumn[];
typeInstance: {
options?: {
confirm?: string;
};
};
}
} & ResolvedArgProps<ResolvedColumns>;
const VisDimensionArgInput: React.FC<VisDimensionArgInputProps> = ({
argValue,
typeInstance,
onValueChange,
argId,
columns,
resolved: { columns },
}) => {
const [value, setValue] = useState(argValue);
const confirm = typeInstance?.options?.confirm;
@ -75,7 +73,7 @@ const VisDimensionArgInput: React.FC<VisDimensionArgInputProps> = ({
return (
<EuiFlexGroup gutterSize="s" direction="column">
<EuiFlexItem>
<EuiSelect options={options} value={column} onChange={onChange} />
<EuiSelect compressed options={options} value={column} onChange={onChange} />
</EuiFlexItem>
{confirm && (
<EuiFlexItem grow={false}>

View file

@ -0,0 +1,56 @@
/*
* 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 { getState, getValue } from '../../../public/lib/resolved_arg';
import { ModelStrings } from '../../../i18n';
import { ResolvedColumns } from '../../../public/expression_types/arg';
const { HeatmapGrid: strings } = ModelStrings;
export const heatmapGrid = () => ({
name: 'heatmap_grid',
displayName: strings.getDisplayName(),
args: [
{
name: 'strokeWidth',
displayName: strings.getStrokeWidthDisplayName(),
help: strings.getStrokeWidthHelp(),
argType: 'number',
},
{
name: 'strokeColor',
displayName: strings.getStrokeColorDisplayName(),
help: strings.getStrokeColorDisplayName(),
argType: 'color_picker',
},
{
name: 'isCellLabelVisible',
displayName: strings.getIsCellLabelVisibleDisplayName(),
help: strings.getIsCellLabelVisibleHelp(),
argType: 'toggle',
},
{
name: 'isYAxisLabelVisible',
displayName: strings.getIsYAxisLabelVisibleDisplayName(),
help: strings.getIsYAxisLabelVisibleHelp(),
argType: 'toggle',
},
{
name: 'isXAxisLabelVisible',
displayName: strings.getIsXAxisLabelVisibleDisplayName(),
help: strings.getIsXAxisLabelVisibleHelp(),
argType: 'toggle',
},
],
resolve({ context }: any): ResolvedColumns {
if (getState(context) !== 'ready') {
return { columns: [] };
}
return { columns: get(getValue(context), 'columns', []) };
},
});

View file

@ -0,0 +1,61 @@
/*
* 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 { getState, getValue } from '../../../public/lib/resolved_arg';
import { ModelStrings } from '../../../i18n';
import { ResolvedColumns } from '../../../public/expression_types/arg';
const { HeatmapLegend: strings } = ModelStrings;
export const heatmapLegend = () => ({
name: 'heatmap_legend',
displayName: strings.getDisplayName(),
args: [
{
name: 'isVisible',
displayName: strings.getIsVisibleDisplayName(),
help: strings.getIsVisibleHelp(),
argType: 'toggle',
default: true,
},
{
name: 'position',
displayName: strings.getPositionDisplayName(),
help: strings.getPositionHelp(),
argType: 'select',
default: 'right',
options: {
choices: [
{ value: 'top', name: strings.getPositionTopOption() },
{ value: 'right', name: strings.getPositionRightOption() },
{ value: 'bottom', name: strings.getPositionBottomOption() },
{ value: 'left', name: strings.getPositionLeftOption() },
],
},
},
{
name: 'maxLines',
displayName: strings.getMaxLinesDisplayName(),
help: strings.getMaxLinesHelp(),
argType: 'number',
default: 10,
},
{
name: 'shouldTruncate',
displayName: strings.getShouldTruncateDisplayName(),
help: strings.getShouldTruncateHelp(),
argType: 'toggle',
},
],
resolve({ context }: any): ResolvedColumns {
if (getState(context) !== 'ready') {
return { columns: [] };
}
return { columns: get(getValue(context), 'columns', []) };
},
});

View file

@ -9,5 +9,7 @@ import { pointseries } from './point_series';
import { math } from './math';
import { tagcloud } from './tagcloud';
import { metricVis } from './metric_vis';
import { heatmapLegend } from './heatmap_legend';
import { heatmapGrid } from './heatmap_grid';
export const modelSpecs = [pointseries, math, tagcloud, metricVis];
export const modelSpecs = [pointseries, math, tagcloud, metricVis, heatmapLegend, heatmapGrid];

View file

@ -6,6 +6,7 @@
*/
import { get } from 'lodash';
import { ResolvedColumns } from '../../../public/expression_types/arg';
import { ViewStrings } from '../../../i18n';
import { getState, getValue } from '../../../public/lib/resolved_arg';
@ -70,7 +71,7 @@ export const metricVis = () => ({
argType: 'toggle',
},
],
resolve({ context }: any) {
resolve({ context }: any): ResolvedColumns {
if (getState(context) !== 'ready') {
return { columns: [] };
}

View file

@ -6,6 +6,7 @@
*/
import { get } from 'lodash';
import { ResolvedColumns } from '../../../public/expression_types/arg';
import { ViewStrings } from '../../../i18n';
import { getState, getValue } from '../../../public/lib/resolved_arg';
@ -82,7 +83,7 @@ export const tagcloud = () => ({
default: true,
},
],
resolve({ context }: any) {
resolve({ context }: any): ResolvedColumns {
if (getState(context) !== 'ready') {
return { columns: [] };
}

View file

@ -0,0 +1,100 @@
/*
* 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 { ResolvedColumns } from '../../../public/expression_types/arg';
import { ViewStrings } from '../../../i18n';
import { getState, getValue } from '../../../public/lib/resolved_arg';
const { Heatmap: strings } = ViewStrings;
export const heatmap = () => ({
name: 'heatmap',
displayName: strings.getDisplayName(),
args: [
{
name: 'xAccessor',
displayName: strings.getXAccessorDisplayName(),
help: strings.getXAccessorHelp(),
argType: 'vis_dimension',
default: `{visdimension}`,
},
{
name: 'yAccessor',
displayName: strings.getYAccessorDisplayName(),
help: strings.getYAccessorHelp(),
argType: 'vis_dimension',
default: `{visdimension}`,
},
{
name: 'valueAccessor',
displayName: strings.getValueAccessorDisplayName(),
help: strings.getValueAccessorHelp(),
argType: 'vis_dimension',
default: `{visdimension}`,
},
{
name: 'splitRowAccessor',
displayName: strings.getSplitRowAccessorDisplayName(),
help: strings.getSplitRowAccessorHelp(),
argType: 'vis_dimension',
default: `{visdimension}`,
},
{
name: 'splitColumnAccessor',
displayName: strings.getSplitColumnAccessorDisplayName(),
help: strings.getSplitColumnAccessorHelp(),
argType: 'vis_dimension',
default: `{visdimension}`,
},
{
name: 'showTooltip',
displayName: strings.getShowTooltipDisplayName(),
help: strings.getShowTooltipHelp(),
argType: 'toggle',
default: true,
},
{
name: 'highlightInHover',
displayName: strings.getHighlightInHoverDisplayName(),
help: strings.getHighlightInHoverHelp(),
argType: 'toggle',
},
{
name: 'lastRangeIsRightOpen',
displayName: strings.getLastRangeIsRightOpenDisplayName(),
help: strings.getLastRangeIsRightOpenHelp(),
argType: 'toggle',
default: true,
},
{
name: 'palette',
argType: 'stops_palette',
},
{
name: 'legend',
displayName: strings.getLegendDisplayName(),
help: strings.getLegendHelp(),
type: 'model',
argType: 'heatmap_legend',
},
{
name: 'gridConfig',
displayName: strings.getGridConfigDisplayName(),
help: strings.getGridConfigHelp(),
type: 'model',
argType: 'heatmap_grid',
},
],
resolve({ context }: any): ResolvedColumns {
if (getState(context) !== 'ready') {
return { columns: [] };
}
return { columns: get(getValue(context), 'columns', []) };
},
});

View file

@ -32,6 +32,8 @@ import { shape } from './shape';
import { table } from './table';
// @ts-expect-error untyped local
import { timefilterControl } from './timefilterControl';
import { heatmap } from './heatmap';
import { SetupInitializer } from '../../plugin';
export const viewSpecs = [
@ -48,6 +50,7 @@ export const viewSpecs = [
shape,
table,
timefilterControl,
heatmap,
];
export const viewInitializers = [metricInitializer];

View file

@ -238,4 +238,12 @@ export const getElementStrings = (): ElementStringDict => ({
defaultMessage: 'Metric visualization',
}),
},
heatmap: {
displayName: i18n.translate('xpack.canvas.elements.heatmapDisplayName', {
defaultMessage: 'Heatmap',
}),
help: i18n.translate('xpack.canvas.elements.heatmapHelpText', {
defaultMessage: 'Heatmap visualization',
}),
},
});

View file

@ -244,6 +244,16 @@ export const ArgumentStrings = {
defaultMessage: 'Custom',
}),
},
Color: {
getDisplayName: () =>
i18n.translate('xpack.canvas.uis.arguments.colorTitle', {
defaultMessage: 'Color',
}),
getHelp: () =>
i18n.translate('xpack.canvas.uis.arguments.colorLabel', {
defaultMessage: 'Color picker',
}),
},
Percentage: {
getDisplayName: () =>
i18n.translate('xpack.canvas.uis.arguments.percentageTitle', {
@ -591,6 +601,106 @@ export const ModelStrings = {
defaultMessage: 'Data along the vertical axis. Usually a number',
}),
},
HeatmapLegend: {
getDisplayName: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_legend.title', {
defaultMessage: "Configure the heatmap chart's legend",
}),
getIsVisibleDisplayName: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_legend.args.isVisibleTitle', {
defaultMessage: 'Show legend',
}),
getIsVisibleHelp: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_legend.args.isVisibleLabel', {
defaultMessage: 'Specifies whether or not the legend is visible',
}),
getPositionDisplayName: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_legend.args.positionTitle', {
defaultMessage: 'Legend Position',
}),
getPositionHelp: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_legend.args.positionLabel', {
defaultMessage: 'Specifies the legend position.',
}),
getPositionTopOption: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_legend.args.positionTopLabel', {
defaultMessage: 'Top',
}),
getPositionBottomOption: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_legend.args.positionBottomLabel', {
defaultMessage: 'Bottom',
}),
getPositionLeftOption: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_legend.args.positionLeftLabel', {
defaultMessage: 'Left',
}),
getPositionRightOption: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_legend.args.positionRightLabel', {
defaultMessage: 'Right',
}),
getMaxLinesDisplayName: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_legend.args.maxLinesTitle', {
defaultMessage: 'Legend maximum lines',
}),
getMaxLinesHelp: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_legend.args.maxLinesLabel', {
defaultMessage: 'Specifies the number of lines per legend item.',
}),
getShouldTruncateDisplayName: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_legend.args.shouldTruncateTitle', {
defaultMessage: 'Truncate label',
}),
getShouldTruncateHelp: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_legend.args.shouldTruncateLabel', {
defaultMessage: 'Specifies whether or not the legend items should be truncated',
}),
},
HeatmapGrid: {
getDisplayName: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_grid.title', {
defaultMessage: 'Configure the heatmap layout',
}),
getStrokeWidthDisplayName: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_grid.args.strokeWidthTitle', {
defaultMessage: 'Stroke width',
}),
getStrokeWidthHelp: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_grid.args.strokeWidthLabel', {
defaultMessage: 'Specifies the grid stroke width',
}),
getStrokeColorDisplayName: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_grid.args.strokeColorTitle', {
defaultMessage: 'Stroke color',
}),
getStrokeColorHelp: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_grid.args.strokeColorLabel', {
defaultMessage: 'Specifies the grid stroke color',
}),
getIsCellLabelVisibleDisplayName: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_grid.args.isCellLabelVisibleTitle', {
defaultMessage: 'Show cell label',
}),
getIsCellLabelVisibleHelp: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_grid.args.isCellLabelVisibleLabel', {
defaultMessage: 'Specifies whether or not the cell label is visible',
}),
getIsYAxisLabelVisibleDisplayName: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_grid.args.isYAxisLabelVisibleTile', {
defaultMessage: 'Show Y-axis labels',
}),
getIsYAxisLabelVisibleHelp: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_grid.args.isYAxisLabelVisibleLabel', {
defaultMessage: 'Specifies whether or not the Y-axis labels are visible',
}),
getIsXAxisLabelVisibleDisplayName: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_grid.args.isXAxisLabelVisibleTile', {
defaultMessage: 'Show X-axis labels',
}),
getIsXAxisLabelVisibleHelp: () =>
i18n.translate('xpack.canvas.uis.models.heatmap_grid.args.isXAxisLabelVisibleLabel', {
defaultMessage: 'Specifies whether or not the X-axis labels are visible',
}),
},
};
export const TransformStrings = {
@ -1349,4 +1459,91 @@ export const ViewStrings = {
defaultMessage: 'Background',
}),
},
Heatmap: {
getDisplayName: () =>
i18n.translate('xpack.canvas.uis.views.heatmapTitle', {
defaultMessage: 'Heatmap Visualization',
}),
getXAccessorDisplayName: () =>
i18n.translate('xpack.canvas.uis.views.heatmap.args.xAccessorDisplayName', {
defaultMessage: 'X-axis',
}),
getXAccessorHelp: () =>
i18n.translate('xpack.canvas.uis.views.heatmap.args.xAccessorHelp', {
defaultMessage: 'The name of the x axis column or the corresponding dimension',
}),
getYAccessorDisplayName: () =>
i18n.translate('xpack.canvas.uis.views.heatmap.args.yAccessorDisplayName', {
defaultMessage: 'Y-axis',
}),
getYAccessorHelp: () =>
i18n.translate('xpack.canvas.uis.views.heatmap.args.yAccessorHelp', {
defaultMessage: 'The name of the y axis column or the corresponding dimension',
}),
getValueAccessorDisplayName: () =>
i18n.translate('xpack.canvas.uis.views.heatmap.args.valueAccessorDisplayName', {
defaultMessage: 'Value',
}),
getValueAccessorHelp: () =>
i18n.translate('xpack.canvas.uis.views.heatmap.args.valueAccessorHelp', {
defaultMessage: 'The name of the value column or the corresponding dimension',
}),
getLegendHelp: () =>
i18n.translate('xpack.canvas.uis.views.heatmap.args.legendHelp', {
defaultMessage: "Configure the heatmap chart's legend",
}),
getLegendDisplayName: () =>
i18n.translate('xpack.canvas.uis.views.heatmap.args.legendDisplayName', {
defaultMessage: 'Heatmap legend',
}),
getGridConfigHelp: () =>
i18n.translate('xpack.canvas.uis.views.heatmap.args.gridConfigHelp', {
defaultMessage: 'Configure the heatmap layout',
}),
getGridConfigDisplayName: () =>
i18n.translate('xpack.canvas.uis.views.heatmap.args.gridConfigDisplayName', {
defaultMessage: 'Heatmap layout configuration',
}),
getSplitRowAccessorDisplayName: () =>
i18n.translate('xpack.canvas.uis.views.heatmap.args.splitRowAccessorDisplayName', {
defaultMessage: 'Split row',
}),
getSplitRowAccessorHelp: () =>
i18n.translate('xpack.canvas.uis.views.heatmap.args.plitRowAccessorHelp', {
defaultMessage: 'The id of the split row or the corresponding dimension',
}),
getSplitColumnAccessorDisplayName: () =>
i18n.translate('xpack.canvas.uis.views.heatmap.args.splitColumnAccessorDisplayName', {
defaultMessage: 'Split column',
}),
getSplitColumnAccessorHelp: () =>
i18n.translate('xpack.canvas.uis.views.heatmap.args.splitColumnAccessorHelp', {
defaultMessage: 'The id of the split column or the corresponding dimension',
}),
getShowTooltipDisplayName: () =>
i18n.translate('xpack.canvas.uis.views.heatmap.args.showTooltipDisplayName', {
defaultMessage: 'Show tooltip',
}),
getShowTooltipHelp: () =>
i18n.translate('xpack.canvas.uis.views.heatmap.args.showTooltipHelp', {
defaultMessage: 'Show tooltip on hover',
}),
getHighlightInHoverDisplayName: () =>
i18n.translate('xpack.canvas.uis.views.heatmap.args.highlightInHoverDisplayName', {
defaultMessage: 'Hightlight on hover',
}),
getHighlightInHoverHelp: () =>
i18n.translate('xpack.canvas.uis.views.heatmap.args.highlightInHoverHelp', {
defaultMessage:
'When this is enabled, it highlights the ranges of the same color on legend hover',
}),
getLastRangeIsRightOpenDisplayName: () =>
i18n.translate('xpack.canvas.uis.views.heatmap.args.lastRangeIsRightOpenDisplayName', {
defaultMessage: 'Last range is right open',
}),
getLastRangeIsRightOpenHelp: () =>
i18n.translate('xpack.canvas.uis.views.heatmap.args.lastRangeIsRightOpenHelp', {
defaultMessage: 'If is set to true, the last range value will be right open',
}),
},
};

View file

@ -11,7 +11,6 @@ import { EuiButtonIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Popover } from '../popover';
import { ArgAdd } from '../arg_add';
import type { Arg } from '../../expression_types/arg';
const strings = {
getAddAriaLabel: () =>
@ -20,8 +19,10 @@ const strings = {
}),
};
interface ArgOptions {
arg: Arg;
export interface ArgOptions {
name?: string;
displayName?: string;
help?: string;
onValueAdd: () => void;
}
@ -49,9 +50,9 @@ export const ArgAddPopover: FC<Props> = ({ options }) => {
{({ closePopover }) =>
options.map((opt) => (
<ArgAdd
key={`${opt.arg.name}-add`}
displayName={opt.arg.displayName ?? ''}
help={opt.arg.help ?? ''}
key={`${opt.name}-add`}
displayName={opt.displayName ?? ''}
help={opt.help ?? ''}
onValueAdd={() => {
opt.onValueAdd();
closePopover();

View file

@ -6,3 +6,4 @@
*/
export { ArgAddPopover } from './arg_add_popover';
export type { ArgOptions } from './arg_add_popover';

View file

@ -9,10 +9,11 @@ import React, { useState, useEffect, useCallback, useRef, memo, ReactPortal } fr
import deepEqual from 'react-fast-compare';
import usePrevious from 'react-use/lib/usePrevious';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import { ExpressionAstExpression, ExpressionValue } from 'src/plugins/expressions';
import { ExpressionFormHandlers } from '../../../common/lib/expression_form_handlers';
import { UpdatePropsRef } from '../../../types/arguments';
interface ArgTemplateFormProps {
export interface ArgTemplateFormProps {
template?: (
domNode: HTMLElement,
config: ArgTemplateFormProps['argumentProps'],
@ -24,10 +25,13 @@ interface ArgTemplateFormProps {
label?: string;
setLabel: (label: string) => void;
expand?: boolean;
argValue: any;
setExpand?: (expand: boolean) => void;
onValueRemove?: (argName: string, argIndex: string) => void;
onValueRemove?: () => void;
onValueChange: (value: any) => void;
resetErrorState: () => void;
renderError: () => void;
argResolver: (ast: ExpressionAstExpression) => Promise<ExpressionValue>;
};
handlers?: { [key: string]: (...args: any[]) => any };
error?: unknown;

View file

@ -39,6 +39,7 @@ const strings = {
defaultMessage: 'Save',
}),
};
export class DatasourceComponent extends PureComponent {
static propTypes = {
args: PropTypes.object.isRequired,

View file

@ -12,7 +12,7 @@ import { get } from 'lodash';
import { datasourceRegistry } from '../../expression_types';
import { getServerFunctions } from '../../state/selectors/app';
import { getSelectedElement, getSelectedPage } from '../../state/selectors/workpad';
import { setArgumentAtIndex, setAstAtIndex, flushContext } from '../../state/actions/elements';
import { setAstAtIndex, flushContext } from '../../state/actions/elements';
import { Datasource as Component } from './datasource';
const DatasourceComponent = (props) => {
@ -52,7 +52,6 @@ const mapStateToProps = (state) => ({
});
const mapDispatchToProps = (dispatch) => ({
dispatchArgumentAtIndex: (props) => (arg) => dispatch(setArgumentAtIndex({ ...props, arg })),
dispatchAstAtIndex:
({ index, element, pageId }) =>
(ast) => {
@ -63,7 +62,7 @@ const mapDispatchToProps = (dispatch) => ({
const mergeProps = (stateProps, dispatchProps, ownProps) => {
const { element, pageId, functionDefinitions } = stateProps;
const { dispatchArgumentAtIndex, dispatchAstAtIndex } = dispatchProps;
const { dispatchAstAtIndex } = dispatchProps;
const getDataTableFunctionsByName = (name) =>
functionDefinitions.find((fn) => fn.name === name && fn.type === 'datatable');
@ -106,11 +105,6 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => {
element,
index: datasourceAst && datasourceAst.expressionIndex,
}),
setDatasourceArgs: dispatchArgumentAtIndex({
pageId,
element,
index: datasourceAst && datasourceAst.expressionIndex,
}),
};
};

View file

@ -13,13 +13,15 @@ type FunctionFormComponentProps = RenderArgData;
export const FunctionFormComponent: FunctionComponent<FunctionFormComponentProps> = (props) => {
const passedProps = {
name: props.name,
removable: props.removable,
argResolver: props.argResolver,
args: props.args,
id: props.id,
nestedFunctionsArgs: props.nestedFunctionsArgs,
argType: props.argType,
argTypeDef: props.argTypeDef,
filterGroups: props.filterGroups,
context: props.context,
expressionIndex: props.expressionIndex,
expressionType: props.expressionType,
nextArgType: props.nextArgType,
nextExpressionType: props.nextExpressionType,
@ -27,6 +29,7 @@ export const FunctionFormComponent: FunctionComponent<FunctionFormComponentProps
onValueAdd: props.onValueAdd,
onValueChange: props.onValueChange,
onValueRemove: props.onValueRemove,
onContainerRemove: props.onContainerRemove,
updateContext: props.updateContext,
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useCallback, useEffect } from 'react';
import React, { FC, useCallback, useEffect } from 'react';
import usePrevious from 'react-use/lib/usePrevious';
import { Loading } from '../loading';
import { CanvasElement, ExpressionContext } from '../../../types';
@ -18,9 +18,7 @@ interface FunctionFormContextPendingProps {
updateContext: (element?: CanvasElement) => void;
}
export const FunctionFormContextPending: React.FunctionComponent<
FunctionFormContextPendingProps
> = (props) => {
export const FunctionFormContextPending: FC<FunctionFormContextPendingProps> = (props) => {
const { contextExpression, expressionType, context, updateContext } = props;
const prevContextExpression = usePrevious(contextExpression);
const fetchContext = useCallback(

View file

@ -19,8 +19,8 @@ import { getId } from '../../lib/get_id';
import { createAsset } from '../../state/actions/assets';
import {
fetchContext,
setArgumentAtIndex,
addArgumentValueAtIndex,
setArgument as setArgumentValue,
addArgumentValue,
deleteArgumentAtIndex,
// @ts-expect-error untyped local
} from '../../state/actions/elements';
@ -34,24 +34,29 @@ import { getAssets } from '../../state/selectors/assets';
// @ts-expect-error unconverted lib
import { findExistingAsset } from '../../lib/find_existing_asset';
import { FunctionForm as Component } from './function_form';
import { ArgType, ArgTypeDef } from '../../expression_types/types';
import { Args, ArgType, ArgTypeDef } from '../../expression_types/types';
import { State, ExpressionContext, CanvasElement, AssetType } from '../../../types';
interface FunctionFormProps {
name: string;
argResolver: (ast: ExpressionAstExpression) => Promise<ExpressionValue>;
args: Record<string, Array<string | Ast>> | null;
args: Args;
nestedFunctionsArgs: Args;
argType: ArgType;
argTypeDef: ArgTypeDef;
expressionIndex: number;
nextArgType?: ArgType;
path: string;
parentPath: string;
removable?: boolean;
}
export const FunctionForm: React.FunctionComponent<FunctionFormProps> = (props) => {
const { expressionIndex, argType, nextArgType } = props;
const { expressionIndex, ...restProps } = props;
const { nextArgType, path, parentPath, argType } = restProps;
const dispatch = useDispatch();
const context = useSelector<State, ExpressionContext>(
(state) => getContextForIndex(state, expressionIndex),
(state) => getContextForIndex(state, parentPath, expressionIndex),
deepEqual
);
const element = useSelector<State, CanvasElement | undefined>(
@ -67,55 +72,33 @@ export const FunctionForm: React.FunctionComponent<FunctionFormProps> = (props)
const addArgument = useCallback(
(argName: string, argValue: string | Ast | null) => () => {
dispatch(
addArgumentValueAtIndex({
index: expressionIndex,
element,
pageId,
argName,
value: argValue,
})
);
dispatch(addArgumentValue({ element, pageId, argName, value: argValue, path }));
},
[dispatch, element, expressionIndex, pageId]
[dispatch, element, pageId, path]
);
const updateContext = useCallback(
() => dispatch(fetchContext(expressionIndex, element)),
[dispatch, element, expressionIndex]
);
const updateContext = useCallback(() => {
return dispatch(fetchContext(expressionIndex, element, false, parentPath));
}, [dispatch, element, expressionIndex, parentPath]);
const setArgument = useCallback(
(argName: string, valueIndex: number) => (value: string | Ast | null) => {
dispatch(
setArgumentAtIndex({
index: expressionIndex,
element,
pageId,
argName,
value,
valueIndex,
})
);
dispatch(setArgumentValue({ element, pageId, argName, value, valueIndex, path }));
},
[dispatch, element, expressionIndex, pageId]
[dispatch, element, pageId, path]
);
const deleteArgument = useCallback(
(argName: string, argIndex: number) => () => {
dispatch(
deleteArgumentAtIndex({
index: expressionIndex,
element,
pageId,
argName,
argIndex,
})
);
dispatch(deleteArgumentAtIndex({ element, pageId, argName, argIndex, path }));
},
[dispatch, element, expressionIndex, pageId]
[dispatch, element, pageId, path]
);
const deleteParentArgument = useCallback(() => {
dispatch(deleteArgumentAtIndex({ element, pageId, path: parentPath }));
}, [dispatch, element, pageId, parentPath]);
const onAssetAddDispatch = useCallback(
(type: AssetType['type'], content: AssetType['value']) => {
// make the ID here and pass it into the action
@ -138,9 +121,11 @@ export const FunctionForm: React.FunctionComponent<FunctionFormProps> = (props)
},
[assets, onAssetAddDispatch]
);
return (
<Component
{...props}
{...restProps}
id={path}
context={context}
filterGroups={filterGroups}
expressionType={findExpressionType(argType)}
@ -149,6 +134,7 @@ export const FunctionForm: React.FunctionComponent<FunctionFormProps> = (props)
updateContext={updateContext}
onValueChange={setArgument}
onValueRemove={deleteArgument}
onContainerRemove={deleteParentArgument}
onAssetAdd={onAssetAdd}
/>
);

View file

@ -9,62 +9,185 @@ import { compose, withProps } from 'recompose';
import { get } from 'lodash';
import { toExpression } from '@kbn/interpreter';
import { interpretAst } from '../../lib/run_interpreter';
import { modelRegistry, viewRegistry, transformRegistry } from '../../expression_types';
import { getArgTypeDef } from '../../lib/args';
import { FunctionFormList as Component } from './function_form_list';
function normalizeContext(chain) {
if (!Array.isArray(chain) || !chain.length) {
return null;
}
return {
type: 'expression',
chain,
};
return { type: 'expression', chain };
}
function getExpression(ast) {
return ast != null && ast.type === 'expression' ? toExpression(ast) : ast;
}
function getArgTypeDef(fn) {
return modelRegistry.get(fn) || viewRegistry.get(fn) || transformRegistry.get(fn);
const isPureArgumentType = (arg) => !arg.type || arg.type === 'argument';
const reduceArgsByCondition = (argsObject, isMatchingCondition) =>
Object.keys(argsObject).reduce((acc, argName) => {
if (isMatchingCondition(argName)) {
return { ...acc, [argName]: argsObject[argName] };
}
return acc;
}, {});
const createComponentsWithContext = () => ({ mapped: [], context: [] });
const getPureArgs = (argTypeDef, args) => {
const pureArgumentsView = argTypeDef.args.filter((arg) => isPureArgumentType(arg));
const pureArgumentsNames = pureArgumentsView.map((arg) => arg.name);
const pureArgs = reduceArgsByCondition(args, (argName) => pureArgumentsNames.includes(argName));
return { args: pureArgs, argumentsView: pureArgumentsView };
};
const getComplexArgs = (argTypeDef, args) => {
const complexArgumentsView = argTypeDef.args.filter((arg) => !isPureArgumentType(arg));
const complexArgumentsNames = complexArgumentsView.map((arg) => arg.name);
const complexArgs = reduceArgsByCondition(args, (argName) =>
complexArgumentsNames.includes(argName)
);
return { args: complexArgs, argumentsView: complexArgumentsView };
};
const mergeComponentsAndContexts = (
{ context = [], mapped = [] },
{ context: nextContext = [], mapped: nextMapped = [] }
) => ({
mapped: [...mapped, ...nextMapped],
context: [...context, ...nextContext],
});
const buildPath = (prevPath = '', argName, index, removable = false) => {
const newPath = index === undefined ? argName : `${argName}.${index}`;
return { path: prevPath.length ? `${prevPath}.${newPath}` : newPath, removable };
};
const componentFactory = ({
args,
argsWithExprFunctions,
argType,
argTypeDef,
argumentsView,
argUiConfig,
prevContext,
expressionIndex,
nextArg,
path,
parentPath,
removable,
}) => ({
args,
nestedFunctionsArgs: argsWithExprFunctions,
argType: argType.function,
argTypeDef: Object.assign(argTypeDef, {
args: argumentsView,
name: argUiConfig?.name ?? argTypeDef.name,
displayName: argUiConfig?.displayName ?? argTypeDef.displayName,
help: argUiConfig?.help ?? argTypeDef.name,
}),
argResolver: (argAst) => interpretAst(argAst, prevContext),
contextExpression: getExpression(prevContext),
expressionIndex, // preserve the index in the AST
nextArgType: nextArg && nextArg.function,
path,
parentPath,
removable,
});
/**
* Converts expression functions at the arguments for the expression, to the array of UI component configurations.
* @param {Ast['chain'][number]['arguments']} complexArgs - expression's arguments, which are expression functions.
* @param {object[]} complexArgumentsViews - argument UI views/models/tranforms.
* @param {string} argumentPath - path at the AST to the current expression.
* @returns flatten array of the arguments UI configurations.
*/
const transformNestedFunctionsToUIConfig = (complexArgs, complexArgumentsViews, argumentPath) =>
Object.keys(complexArgs).reduce((current, argName) => {
const next = complexArgs[argName]
.map(({ chain }, index) =>
transformFunctionsToUIConfig(
chain,
buildPath(argumentPath, argName, index, true),
complexArgumentsViews?.find((argView) => argView.name === argName)
)
)
.reduce(
(current, next) => mergeComponentsAndContexts(current, next),
createComponentsWithContext()
);
return mergeComponentsAndContexts(current, next);
}, createComponentsWithContext());
/**
* Converts chain of expressions to the array of UI component configurations.
* Recursively loops through the AST, detects expression functions inside
* the expression chain of the top and nested levels, finds view/model/transform definition
* for the found expression functions, splits arguments of the expression for two categories: simple and expression functions.
* After, recursively loops through the nested expression functions, creates UI component configurations and flatten them to the array.
*
* @param {Ast['chain']} functionsChain - chain of expression functions.
* @param {{ path: string, removable: boolean }} functionMeta - saves the path to the current expressions chain at the original AST
* and saves the information about that it can be removed (is an argument of the other expression).
* @param {object} argUiConfig - Argument UI configuration of the element, which contains current expressions chain. It can be view, model, transform or argument.
* @returns UI component configurations of expressions, found at AST.
*/
function transformFunctionsToUIConfig(functionsChain, { path, removable }, argUiConfig) {
const parentPath = path;
const argumentsPath = path ? `${path}.chain` : `chain`;
return functionsChain.reduce((current, argType, i) => {
const argumentPath = `${argumentsPath}.${i}.arguments`;
const argTypeDef = getArgTypeDef(argType.function);
current.context = current.context.concat(argType);
// filter out argTypes that shouldn't be in the sidebar
if (!argTypeDef) {
return current;
}
const { argumentsView, args } = getPureArgs(argTypeDef, argType.arguments);
const { argumentsView: exprFunctionsViews, args: argsWithExprFunctions } = getComplexArgs(
argTypeDef,
argType.arguments
);
// wrap each part of the chain in ArgType, passing in the previous context
const component = componentFactory({
args,
argsWithExprFunctions,
argType,
argTypeDef,
argumentsView,
argUiConfig,
prevContext: normalizeContext(current.context),
expressionIndex: i, // preserve the index in the AST
nextArg: functionsChain[i + 1] || null,
path: argumentPath,
parentPath,
removable,
});
const components = transformNestedFunctionsToUIConfig(
argsWithExprFunctions,
exprFunctionsViews,
argumentPath
);
return mergeComponentsAndContexts(current, {
...components,
mapped: [component, ...components.mapped],
});
}, createComponentsWithContext());
}
const functionFormItems = withProps((props) => {
const selectedElement = props.element;
const FunctionFormChain = get(selectedElement, 'ast.chain', []);
const functionsChain = get(selectedElement, 'ast.chain', []);
// map argTypes from AST, attaching nextArgType if one exists
const FunctionFormListItems = FunctionFormChain.reduce(
(acc, argType, i) => {
const argTypeDef = getArgTypeDef(argType.function);
const prevContext = normalizeContext(acc.context);
const nextArg = FunctionFormChain[i + 1] || null;
// filter out argTypes that shouldn't be in the sidebar
if (argTypeDef) {
// wrap each part of the chain in ArgType, passing in the previous context
const component = {
args: argType.arguments,
argType: argType.function,
argTypeDef: argTypeDef,
argResolver: (argAst) => interpretAst(argAst, prevContext),
contextExpression: getExpression(prevContext),
expressionIndex: i, // preserve the index in the AST
nextArgType: nextArg && nextArg.function,
};
acc.mapped.push(component);
}
acc.context = acc.context.concat(argType);
return acc;
},
{ mapped: [], context: [] }
);
const functionsListItems = transformFunctionsToUIConfig(functionsChain, buildPath('', 'ast'));
return {
functionFormItems: FunctionFormListItems.mapped,
functionFormItems: functionsListItems.mapped,
};
});

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 { withDebounceArg } from './with_debounce_arg';

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, useState, useEffect } from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import deepEqual from 'react-fast-compare';
import { ArgTemplateFormProps } from '../arg_form/arg_template_form';
type Props = ArgTemplateFormProps['argumentProps'];
export const withDebounceArg =
(Arg: FC<Props>, debouncePeriod: number = 150): FC<Props> =>
({ argValue, onValueChange, ...restProps }) => {
const [localArgValue, setArgValue] = useState(argValue);
const [, cancel] = useDebounce(
() => {
if (localArgValue === argValue || deepEqual(localArgValue, argValue)) {
return;
}
onValueChange(localArgValue);
},
debouncePeriod,
[localArgValue]
);
useEffect(() => {
return () => {
cancel();
};
}, [cancel]);
return <Arg {...{ ...restProps, argValue: localArgValue, onValueChange: setArgValue }} />;
};

View file

@ -11,13 +11,14 @@ import { Ast } from '@kbn/interpreter';
// @ts-expect-error unconverted components
import { ArgForm } from '../components/arg_form';
import { argTypeRegistry } from './arg_type_registry';
import type { ArgType, ArgTypeDef, ExpressionType } from './types';
import type { Args, ArgType, ArgTypeDef, ArgValue, ExpressionType } from './types';
import {
AssetType,
CanvasElement,
ExpressionAstExpression,
ExpressionValue,
ExpressionContext,
DatatableColumn,
} from '../../types';
import { BaseFormProps } from './base_form';
@ -26,6 +27,7 @@ interface ArtOwnProps {
multi?: boolean;
required?: boolean;
types?: string[];
type?: 'model' | 'argument';
default?: string | null;
resolve?: (...args: any[]) => any;
options?: {
@ -38,10 +40,25 @@ interface ArtOwnProps {
shapes?: string[];
};
}
export type ArgProps = ArtOwnProps & BaseFormProps;
export type ArgUiConfig = ArtOwnProps & BaseFormProps;
export interface ResolvedColumns {
columns: DatatableColumn[];
}
export interface ResolvedLabels {
labels: string[];
}
export interface ResolvedDataurl {
dataurl: string;
}
export interface ResolvedArgProps<T = ResolvedColumns | ResolvedLabels | ResolvedDataurl | {}> {
resolved: T;
}
export interface DataArg {
argValue?: string | Ast | null;
argValue?: ArgValue | null;
skipRender?: boolean;
label?: string;
valueIndex: number;
@ -50,16 +67,15 @@ export interface DataArg {
contextExpression?: string;
name: string;
argResolver: (ast: ExpressionAstExpression) => Promise<ExpressionValue>;
args: Record<string, Array<string | Ast>> | null;
args: Args;
argType: ArgType;
argTypeDef?: ArgTypeDef;
filterGroups: string[];
context?: ExpressionContext;
expressionIndex: number;
expressionType: ExpressionType;
nextArgType?: ArgType;
nextExpressionType?: ExpressionType;
onValueAdd: (argName: string, argValue: string | Ast | null) => () => void;
onValueAdd: (argName: string, argValue: ArgValue | null) => () => void;
onAssetAdd: (type: AssetType['type'], content: AssetType['value']) => string;
onValueChange: (value: Ast | string) => void;
onValueRemove: () => void;
@ -81,7 +97,7 @@ export class Arg {
displayName?: string;
help?: string;
constructor(props: ArgProps) {
constructor(props: ArgUiConfig) {
const argType = argTypeRegistry.get(props.argType);
if (!argType) {
throw new Error(`Invalid arg type: ${props.argType}`);
@ -117,26 +133,32 @@ export class Arg {
}
// TODO: Document what these otherProps are. Maybe make them named arguments?
render(data: DataArg) {
const { onValueChange, onValueRemove, argValue, key, label, ...otherProps } = data;
render(data: DataArg & ResolvedArgProps) {
const { onValueChange, onValueRemove, key, label, ...otherProps } = data;
const resolvedProps = this.resolve?.(otherProps);
const { argValue, onAssetAdd, resolved, filterGroups, argResolver } = otherProps;
const argId = key;
// This is everything the arg_type template needs to render
const templateProps = {
...otherProps,
...this.resolve?.(otherProps),
onValueChange,
argValue,
argId,
onAssetAdd,
onValueChange,
typeInstance: this,
resolved: { ...resolved, ...resolvedProps },
argResolver,
filterGroups,
};
const formProps = {
key,
argTypeInstance: this,
valueMissing: this.required && argValue == null,
valueMissing: this.required && data.argValue == null,
label,
onValueChange,
onValueRemove,
templateProps,
argId: key,
argId,
options: this.options,
};

View file

@ -49,7 +49,7 @@ class Interactive extends React.Component<{}, { argValue: ExpressionAstExpressio
action('onValueChange')(argValue);
this.setState({ argValue });
}}
labels={array('Series Labels', ['label1', 'label2'])}
resolved={{ labels: array('Series Labels', ['label1', 'label2']) }}
typeInstance={{
name: radios('Type Instance', { default: 'defaultStyle', custom: 'custom' }, 'custom'),
options: {
@ -74,7 +74,7 @@ storiesOf('arguments/SeriesStyle/components', module)
.add('extended: defaults', () => (
<ExtendedTemplate
argValue={defaultExpression}
labels={[]}
resolved={{ labels: [] }}
onValueChange={action('onValueChange')}
typeInstance={{
name: 'defaultStyle',

View file

@ -35,6 +35,7 @@ class Interactive extends React.Component<{}, { argValue: ExpressionAstExpressio
public render() {
return (
<SimpleTemplate
resolved={{ labels: [] }}
argValue={this.state.argValue}
onValueChange={(argValue) => {
action('onValueChange')(argValue);
@ -64,6 +65,7 @@ storiesOf('arguments/SeriesStyle/components', module)
argValue={defaultExpression}
onValueChange={action('onValueChange')}
workpad={getDefaultWorkpad()}
resolved={{ labels: [] }}
typeInstance={{
name: 'defaultStyle',
}}
@ -72,7 +74,7 @@ storiesOf('arguments/SeriesStyle/components', module)
.add('simple: defaults', () => (
<SimpleTemplate
argValue={defaultExpression}
labels={['label1', 'label2']}
resolved={{ labels: ['label1', 'label2'] }}
onValueChange={action('onValueChange')}
workpad={getDefaultWorkpad()}
typeInstance={{
@ -83,6 +85,7 @@ storiesOf('arguments/SeriesStyle/components', module)
.add('simple: no series', () => (
<SimpleTemplate
argValue={defaultExpression}
resolved={{ labels: [] }}
onValueChange={action('onValueChange')}
workpad={getDefaultWorkpad()}
typeInstance={{
@ -94,7 +97,7 @@ storiesOf('arguments/SeriesStyle/components', module)
<SimpleTemplate
argValue={defaultExpression}
onValueChange={action('onValueChange')}
labels={['label1', 'label2']}
resolved={{ labels: ['label1', 'label2'] }}
workpad={getDefaultWorkpad()}
typeInstance={{
name: 'unknown',

View file

@ -10,6 +10,7 @@ import PropTypes from 'prop-types';
import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect, EuiSpacer } from '@elastic/eui';
import immutable from 'object-path-immutable';
import { get } from 'lodash';
import { ResolvedArgProps, ResolvedLabels } from '../../arg';
import { ExpressionAstExpression } from '../../../../types';
import { ArgTypesStrings } from '../../../../i18n';
@ -24,9 +25,8 @@ export interface Arguments {
}
export type Argument = keyof Arguments;
export interface Props {
export type Props = {
argValue: ExpressionAstExpression;
labels: string[];
onValueChange: (argValue: ExpressionAstExpression) => void;
typeInstance?: {
name: string;
@ -34,10 +34,15 @@ export interface Props {
include: string[];
};
};
}
} & ResolvedArgProps<ResolvedLabels>;
export const ExtendedTemplate: FunctionComponent<Props> = (props) => {
const { typeInstance, onValueChange, labels, argValue } = props;
const {
typeInstance,
onValueChange,
resolved: { labels },
argValue,
} = props;
const chain = get(argValue, 'chain.0', {});
const chainArgs = get(chain, 'arguments', {});
const selectedSeries = get(chainArgs, 'label.0', '');
@ -141,5 +146,7 @@ ExtendedTemplate.propTypes = {
onValueChange: PropTypes.func.isRequired,
argValue: PropTypes.any.isRequired,
typeInstance: PropTypes.object,
labels: PropTypes.array.isRequired,
resolved: PropTypes.shape({
labels: PropTypes.array.isRequired,
}).isRequired,
};

View file

@ -10,6 +10,7 @@ import PropTypes from 'prop-types';
import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiButtonIcon, EuiText } from '@elastic/eui';
import immutable from 'object-path-immutable';
import { get } from 'lodash';
import { ResolvedArgProps, ResolvedLabels } from '../../arg';
import { ColorPickerPopover } from '../../../components/color_picker_popover';
import { TooltipIcon, IconType } from '../../../components/tooltip_icon';
import { ExpressionAstExpression, CanvasWorkpad } from '../../../../types';
@ -23,18 +24,23 @@ interface Arguments {
}
type Argument = keyof Arguments;
interface Props {
type Props = {
argValue: ExpressionAstExpression;
labels?: string[];
onValueChange: (argValue: ExpressionAstExpression) => void;
typeInstance: {
name: string;
};
workpad: CanvasWorkpad;
}
} & ResolvedArgProps<ResolvedLabels>;
export const SimpleTemplate: FunctionComponent<Props> = (props) => {
const { typeInstance, argValue, onValueChange, labels, workpad } = props;
const {
typeInstance,
argValue,
onValueChange,
resolved: { labels },
workpad,
} = props;
const { name } = typeInstance;
const chain = get(argValue, 'chain.0', {});
const chainArgs = get(chain, 'arguments', {});
@ -107,7 +113,9 @@ SimpleTemplate.displayName = 'SeriesStyleArgSimpleInput';
SimpleTemplate.propTypes = {
argValue: PropTypes.any.isRequired,
labels: PropTypes.array,
resolved: PropTypes.shape({
labels: PropTypes.array.isRequired,
}).isRequired,
onValueChange: PropTypes.func.isRequired,
workpad: PropTypes.shape({
colors: PropTypes.array.isRequired,

View file

@ -6,65 +6,71 @@
*/
import React, { ReactElement } from 'react';
import { EuiCallOut } from '@elastic/eui';
import { EuiButtonIcon, EuiCallOut, EuiFlexGroup, EuiFormRow, EuiToolTip } from '@elastic/eui';
import { isPlainObject, uniq, last, compact } from 'lodash';
import { Ast, fromExpression } from '@kbn/interpreter';
import { ArgAddPopover } from '../components/arg_add_popover';
import { ArgAddPopover, ArgOptions } from '../components/arg_add_popover';
// @ts-expect-error unconverted components
import { SidebarSection } from '../components/sidebar/sidebar_section';
// @ts-expect-error unconverted components
import { SidebarSectionTitle } from '../components/sidebar/sidebar_section_title';
import { BaseForm, BaseFormProps } from './base_form';
import { Arg, ArgProps } from './arg';
import { ArgType, ArgTypeDef, ExpressionType } from './types';
import { Arg, ArgUiConfig, ResolvedArgProps } from './arg';
import { ArgDisplayType, Args, ArgType, ArgTypeDef, ArgValue, ExpressionType } from './types';
import { Model, Transform, View } from '../expression_types';
import {
AssetType,
CanvasElement,
DatatableColumn,
ExpressionAstExpression,
ExpressionContext,
ExpressionValue,
} from '../../types';
import { buildDefaultArgExpr, getArgTypeDef } from '../lib/args';
export interface DataArg {
export interface ArgWithValues {
arg: Arg | undefined;
argValues?: Array<string | Ast | null>;
skipRender?: boolean;
label?: 'string';
argValues?: Array<ArgValue | null>;
}
export type RenderArgData = BaseFormProps & {
argType: ArgType;
removable?: boolean;
type?: ArgDisplayType;
argTypeDef?: ArgTypeDef;
args: Record<string, Array<Ast | string>> | null;
args: Args;
id: string;
nestedFunctionsArgs: Args;
argResolver: (ast: ExpressionAstExpression) => Promise<ExpressionValue>;
context?: ExpressionContext;
contextExpression?: string;
expressionIndex: number;
expressionType: ExpressionType;
filterGroups: string[];
nextArgType?: ArgType;
nextExpressionType?: ExpressionType;
onValueAdd: (argName: string, argValue: string | Ast | null) => () => void;
onValueAdd: (argName: string, argValue: ArgValue | null) => () => void;
onValueChange: (argName: string, argIndex: number) => (value: string | Ast) => void;
onValueRemove: (argName: string, argIndex: number) => () => void;
onContainerRemove: () => void;
onAssetAdd: (type: AssetType['type'], content: AssetType['value']) => string;
updateContext: (element?: CanvasElement) => void;
typeInstance?: ExpressionType;
columns?: DatatableColumn[];
};
export type RenderArgProps = {
typeInstance: FunctionForm;
} & RenderArgData;
} & RenderArgData &
ResolvedArgProps;
export type FunctionFormProps = {
args?: ArgProps[];
args?: ArgUiConfig[];
resolve?: (...args: any[]) => any;
} & BaseFormProps;
export class FunctionForm extends BaseForm {
args: ArgProps[];
/**
* UI arguments config
*/
args: ArgUiConfig[];
resolve: (...args: any[]) => any;
constructor(props: FunctionFormProps) {
@ -74,23 +80,21 @@ export class FunctionForm extends BaseForm {
this.resolve = props.resolve || (() => ({}));
}
renderArg(props: RenderArgProps, dataArg: DataArg) {
const { onValueRemove, onValueChange, ...passedProps } = props;
const { arg, argValues, skipRender, label } = dataArg;
const { argType, expressionIndex } = passedProps;
renderArg(argWithValues: ArgWithValues, props: RenderArgProps) {
const { onValueRemove, onValueChange, onContainerRemove, id, ...passedProps } = props;
const { arg, argValues } = argWithValues;
// TODO: show some information to the user than an argument was skipped
if (!arg || skipRender) {
if (!arg) {
return null;
}
const renderArgWithProps = (
argValue: string | Ast | null,
valueIndex: number
): ReactElement<any, any> | null =>
arg.render({
key: `${argType}-${expressionIndex}-${arg.name}-${valueIndex}`,
key: `${id}.${arg.name}.${valueIndex}`,
...passedProps,
label,
valueIndex,
onValueChange: onValueChange(arg.name, valueIndex),
onValueRemove: onValueRemove(arg.name, valueIndex),
@ -107,21 +111,107 @@ export class FunctionForm extends BaseForm {
return argValues && argValues.map(renderArgWithProps);
}
// TODO: Argument adding isn't very good, we should improve this UI
getAddableArg(props: RenderArgProps, dataArg: DataArg) {
const { onValueAdd } = props;
const { arg, argValues, skipRender } = dataArg;
getArgDescription({ name, displayName, help }: Arg | ArgTypeDef, argUiConfig: ArgUiConfig) {
return {
name: argUiConfig.name ?? name ?? '',
displayName: argUiConfig.displayName ?? displayName,
help: argUiConfig.help ?? help,
};
}
// skip arguments that aren't defined in the expression type schema
if (!arg || arg.required || skipRender) {
getAddableArgComplex(
argUiConfig: ArgUiConfig,
argValues: Array<ArgValue | null>,
onValueAdd: RenderArgProps['onValueAdd']
) {
if (argValues && !argUiConfig.multi) {
return null;
}
const argExpression = buildDefaultArgExpr(argUiConfig);
const arg = getArgTypeDef(argUiConfig.argType);
if (!arg || argExpression === undefined) {
return null;
}
const value = argExpression === null ? null : fromExpression(argExpression, 'argument');
return {
...this.getArgDescription(arg, argUiConfig),
onValueAdd: onValueAdd(argUiConfig.name, value),
};
}
getAddableArgSimple(
argUiConfig: ArgUiConfig,
argValues: Array<ArgValue | null>,
onValueAdd: RenderArgProps['onValueAdd']
) {
const arg = new Arg(argUiConfig);
// skip arguments that aren't defined in the expression type schema
if (!arg || arg.required) {
return null;
}
if (argValues && !arg.multi) {
return null;
}
const value = arg.default == null ? null : fromExpression(arg.default, 'argument');
return { arg, onValueAdd: onValueAdd(arg.name, value) };
const value =
arg.default === null || arg.default === undefined
? null
: fromExpression(arg.default, 'argument');
return { ...this.getArgDescription(arg, argUiConfig), onValueAdd: onValueAdd(arg.name, value) };
}
getAddableArgs(
simpleFunctionArgs: RenderArgData['args'] = {},
nestedFunctionsArgs: RenderArgData['nestedFunctionsArgs'] = {},
onValueAdd: RenderArgData['onValueAdd']
) {
const simpleArgs = simpleFunctionArgs === null ? {} : simpleFunctionArgs;
const complexArgs = nestedFunctionsArgs === null ? {} : nestedFunctionsArgs;
const addableArgs = this.args.reduce<ArgOptions[]>((addable, arg) => {
if (!arg.type || arg.type === 'argument') {
const addableArg = this.getAddableArgSimple(arg, simpleArgs[arg.name], onValueAdd);
return addableArg ? [...addable, addableArg] : addable;
}
const addableArg = this.getAddableArgComplex(arg, complexArgs[arg.name], onValueAdd);
return addableArg ? [...addable, addableArg] : addable;
}, []);
return addableArgs;
}
getArgsWithValues(args: RenderArgData['args'], argTypeDef: RenderArgData['argTypeDef']) {
let argInstances: Arg[] = [];
if (this.isExpressionFunctionForm(argTypeDef)) {
const argNames = argTypeDef.args.map(({ name }) => name);
argInstances = this.args
.filter((arg) => argNames.includes(arg.name))
.map((argSpec) => new Arg(argSpec));
} else {
argInstances = this.args.map((argSpec) => new Arg(argSpec));
}
if (args === null || !isPlainObject(args)) {
throw new Error(`Form "${this.name}" expects "args" object`);
}
// get a mapping of arg values from the expression and from the renderable's schema
const argNames = uniq(this.args.map((arg) => arg.name).concat(Object.keys(args)));
return argNames.map((argName) => {
const arg = argInstances.find((argument) => argument.name === argName);
// if arg is not multi, only preserve the last value found
// 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 };
});
}
resolveArg(...args: unknown[]) {
@ -129,53 +219,58 @@ export class FunctionForm extends BaseForm {
return {};
}
render(data: RenderArgData) {
if (!data) {
data = {
args: null,
argTypeDef: undefined,
} as RenderArgData;
}
const { args, argTypeDef } = data;
private isExpressionFunctionForm(
argTypeDef?: ArgTypeDef
): argTypeDef is View | Model | Transform {
return (
!!argTypeDef &&
(argTypeDef instanceof View || argTypeDef instanceof Model || argTypeDef instanceof Transform)
);
}
// 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`);
}
// get a mapping of arg values from the expression and from the renderable's schema
const argNames = uniq(argInstances.map((arg) => arg.name).concat(Object.keys(args)));
const dataArgs = argNames.map((argName) => {
const arg = argInstances.find((argument) => argument.name === argName);
// if arg is not multi, only preserve the last value found
// 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 };
});
// props are passed to resolve and the returned object is mixed into the template props
const props = { ...data, ...this.resolve(data), typeInstance: this };
render(data: RenderArgData = { args: null, argTypeDef: undefined } as RenderArgData) {
const { args, argTypeDef, nestedFunctionsArgs = {}, removable } = data;
const argsWithValues = this.getArgsWithValues(args, argTypeDef);
try {
// props are passed to resolve and the returned object is mixed into the template props
const props: RenderArgProps = { ...data, resolved: this.resolve(data), typeInstance: this };
// allow a hook to override the data args
const resolvedDataArgs = dataArgs.map((d) => ({ ...d, ...this.resolveArg(d, props) }));
const resolvedArgsWithValues = argsWithValues.map((argWithValues) => ({
...argWithValues,
...this.resolveArg(argWithValues, props),
}));
const argumentForms = compact(
resolvedDataArgs.map((dataArg) => this.renderArg(props, dataArg))
);
const addableArgs = compact(
resolvedDataArgs.map((dataArg) => this.getAddableArg(props, dataArg))
resolvedArgsWithValues.map((argWithValues) => this.renderArg(argWithValues, props))
);
const addableArgs = this.getAddableArgs(args, nestedFunctionsArgs, props.onValueAdd);
if (!addableArgs.length && !argumentForms.length) {
return null;
}
return (
<SidebarSection>
<SidebarSectionTitle title={argTypeDef?.displayName} tip={argTypeDef?.help}>
{addableArgs.length === 0 ? null : <ArgAddPopover options={addableArgs} />}
<EuiFormRow>
<EuiFlexGroup direction="row" gutterSize="s">
{removable && (
<EuiToolTip position="top" content={'Remove'}>
<EuiButtonIcon
color="text"
onClick={() => {
props.onContainerRemove();
}}
iconType="cross"
iconSize="s"
aria-label={'Remove'}
className="canvasArg__remove"
/>
</EuiToolTip>
)}
{addableArgs.length === 0 ? null : <ArgAddPopover options={addableArgs} />}
</EuiFlexGroup>
</EuiFormRow>
</SidebarSectionTitle>
{argumentForms}
</SidebarSection>

View file

@ -5,12 +5,14 @@
* 2.0.
*/
import { Ast } from '@kbn/interpreter';
import type { Transform } from './transform';
import type { View } from './view';
import type { Datasource } from './datasource';
import type { Model } from './model';
export type ArgType = string;
export type ArgDisplayType = 'model' | 'argument';
export type ArgTypeDef = View | Model | Transform | Datasource;
@ -20,3 +22,6 @@ export type { Arg } from './arg';
export type ExpressionType = View | Model | Transform;
export type { RenderArgData } from './function_form';
export type ArgValue = string | Ast;
export type Args = Record<string, Array<ArgValue | null>> | null;

View file

@ -0,0 +1,71 @@
/*
* 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 { fromExpression, toExpression } from '@kbn/interpreter';
import {
modelRegistry,
viewRegistry,
transformRegistry,
Model,
View,
Transform,
} from '../expression_types';
import { ArgUiConfig } from '../expression_types/arg';
type ArgType = Model | View | Transform;
export function getArgTypeDef(fn: string): ArgType {
return modelRegistry.get(fn) || viewRegistry.get(fn) || transformRegistry.get(fn);
}
const buildArg = (arg: ArgUiConfig, expr: string) => `${arg.name}=${formatExpr(expr)}`;
const filterValidArguments = (args: Array<string | undefined>) =>
args.filter((arg) => arg !== undefined);
const formatExpr = (expr: string) => {
if (isWithBrackets(expr)) {
const exprWithoutBrackets = removeFigureBrackets(expr);
return toExpression(fromExpression(exprWithoutBrackets));
}
return expr;
};
const removeFigureBrackets = (expr: string) => {
if (isWithBrackets(expr)) {
return expr.substring(1, expr.length - 1);
}
return expr;
};
const isWithBrackets = (expr: string) => expr[0] === '{' && expr[expr.length - 1] === '}';
export function buildDefaultArgExpr(argUiConfig: ArgUiConfig): string | undefined {
const argConfig = getArgTypeDef(argUiConfig.argType);
if (argUiConfig.default) {
return buildArg(argUiConfig, argUiConfig.default);
}
if (!argConfig) {
return undefined;
}
const defaultArgs = argConfig.args.map((arg) => {
const argConf = getArgTypeDef(arg.argType);
if (arg.default && argConf && Array.isArray(argConf.args)) {
return buildArg(arg, arg.default);
}
return buildDefaultArgExpr(arg);
});
const validArgs = filterValidArguments(defaultArgs);
const defExpr = validArgs.length
? `{${argUiConfig.argType} ${validArgs.join(' ')}}`
: `{${argUiConfig.argType}}`;
return defExpr;
}

View file

@ -7,7 +7,7 @@
import { createAction } from 'redux-actions';
import immutable from 'object-path-immutable';
import { get, pick, cloneDeep, without } from 'lodash';
import { get, pick, cloneDeep, without, last } from 'lodash';
import { toExpression, safeElementFromExpression } from '@kbn/interpreter';
import { createThunk } from '../../lib/create_thunk';
import {
@ -30,8 +30,8 @@ const { actionsElements: strings } = ErrorStrings;
const { set, del } = immutable;
export function getSiblingContext(state, elementId, checkIndex) {
const prevContextPath = [elementId, 'expressionContext', checkIndex];
export function getSiblingContext(state, elementId, checkIndex, path = ['ast.chain']) {
const prevContextPath = [elementId, 'expressionContext', ...path, checkIndex];
const prevContextValue = getResolvedArgsValue(state, prevContextPath);
// if a value is found, return it, along with the index it was found at
@ -49,7 +49,7 @@ export function getSiblingContext(state, elementId, checkIndex) {
}
// walk back up to find the closest cached context available
return getSiblingContext(state, elementId, prevContextIndex);
return getSiblingContext(state, elementId, prevContextIndex, path);
}
function getBareElement(el, includeId = false) {
@ -71,8 +71,9 @@ export const flushContextAfterIndex = createAction('flushContextAfterIndex');
export const fetchContext = createThunk(
'fetchContext',
({ dispatch, getState }, index, element, fullRefresh = false) => {
const chain = get(element, 'ast.chain');
({ dispatch, getState }, index, element, fullRefresh = false, path) => {
const pathToTarget = [...path.split('.'), 'chain'];
const chain = get(element, pathToTarget);
const invalidIndex = chain ? index >= chain.length : true;
if (!element || !chain || invalidIndex) {
@ -81,22 +82,18 @@ export const fetchContext = createThunk(
// cache context as the previous index
const contextIndex = index - 1;
const contextPath = [element.id, 'expressionContext', contextIndex];
const contextPath = [element.id, 'expressionContext', path, contextIndex];
// set context state to loading
dispatch(
args.setLoading({
path: contextPath,
})
);
dispatch(args.setLoading({ path: contextPath }));
// function to walk back up to find the closest context available
const getContext = () => getSiblingContext(getState(), element.id, contextIndex - 1);
const getContext = () => getSiblingContext(getState(), element.id, contextIndex - 1, [path]);
const { index: prevContextIndex, context: prevContextValue } =
fullRefresh !== true ? getContext() : {};
// modify the ast chain passed to the interpreter
const astChain = element.ast.chain.filter((exp, i) => {
const astChain = chain.filter((exp, i) => {
if (prevContextValue != null) {
return i > prevContextIndex && i < index;
}
@ -104,22 +101,10 @@ export const fetchContext = createThunk(
});
const variables = getWorkpadVariablesAsObject(getState());
const elementWithNewAst = set(element, pathToTarget, astChain);
// get context data from a partial AST
return interpretAst(
{
...element.ast,
chain: astChain,
},
variables,
prevContextValue
).then((value) => {
dispatch(
args.setValue({
path: contextPath,
value,
})
);
return interpretAst(elementWithNewAst.ast, variables, prevContextValue).then((value) => {
dispatch(args.setValue({ path: contextPath, value }));
});
}
);
@ -359,57 +344,71 @@ export const setAstAtIndex = createThunk(
}
);
// index here is the top-level argument in the expression. for example in the expression
// demodata().pointseries().plot(), demodata is 0, pointseries is 1, and plot is 2
// argIndex is the index in multi-value arguments, and is optional. excluding it will cause
// the entire argument from be set to the passed value
export const setArgumentAtIndex = createThunk('setArgumentAtIndex', ({ dispatch }, args) => {
const { index, argName, value, valueIndex, element, pageId } = args;
let selector = `ast.chain.${index}.arguments.${argName}`;
/**
* Updating the value of the given argument of the element's expression.
* @param {string} args.path - the path to the argument at the AST. Example: "ast.chain.0.arguments.some_arg.chain.1.arguments".
* @param {string} args.argName - the argument name at the AST.
* @param {number} args.valueIndex - the index of the value in the array of argument's values.
* @param {any} args.value - the value to be set to the AST.
* @param {any} args.element - the element, which contains the expression.
* @param {any} args.pageId - the workpad's page, where element is located.
*/
export const setArgument = createThunk('setArgument', ({ dispatch }, args) => {
const { argName, value, valueIndex, element, pageId, path } = args;
let selector = `${path}.${argName}`;
if (valueIndex != null) {
selector += '.' + valueIndex;
}
const newElement = set(element, selector, value);
const newAst = get(newElement, ['ast', 'chain', index]);
dispatch(setAstAtIndex(index, newAst, element, pageId));
const pathTerms = path.split('.');
const argumentChainPath = pathTerms.slice(0, 3);
const argumnentChainIndex = last(argumentChainPath);
const newAst = get(newElement, argumentChainPath);
dispatch(setAstAtIndex(argumnentChainIndex, newAst, element, pageId));
});
// index here is the top-level argument in the expression. for example in the expression
// demodata().pointseries().plot(), demodata is 0, pointseries is 1, and plot is 2
export const addArgumentValueAtIndex = createThunk(
'addArgumentValueAtIndex',
({ dispatch }, args) => {
const { index, argName, value, element } = args;
/**
* Adding the value to the given argument of the element's expression.
* @param {string} args.path - the path to the argument at the AST. Example: "ast.chain.0.arguments.some_arg.chain.1.arguments".
* @param {string} args.argName - the argument name at the given path of the AST.
* @param {any} args.value - the value to be added to the array of argument's values at the AST.
* @param {any} args.element - the element, which contains the expression.
* @param {any} args.pageId - the workpad's page, where element is located.
*/
export const addArgumentValue = createThunk('addArgumentValue', ({ dispatch }, args) => {
const { argName, value, element, path } = args;
const values = get(element, [...path.split('.'), argName], []);
const newValue = values.concat(value);
dispatch(
setArgument({
...args,
value: newValue,
})
);
});
const values = get(element, ['ast', 'chain', index, 'arguments', argName], []);
const newValue = values.concat(value);
dispatch(
setArgumentAtIndex({
...args,
value: newValue,
})
);
}
);
// index here is the top-level argument in the expression. for example in the expression
// demodata().pointseries().plot(), demodata is 0, pointseries is 1, and plot is 2
// argIndex is the index in multi-value arguments, and is optional. excluding it will remove
// the entire argument from the expresion
export const deleteArgumentAtIndex = createThunk('deleteArgumentAtIndex', ({ dispatch }, args) => {
const { index, element, pageId, argName, argIndex } = args;
const curVal = get(element, ['ast', 'chain', index, 'arguments', argName]);
const newElement =
const { element, pageId, argName, argIndex, path } = args;
const pathTerms = path.split('.');
const argumentChainPath = pathTerms.slice(0, 3);
const argumnentChainIndex = last(argumentChainPath);
const curVal = get(element, [...pathTerms, argName]);
let newElement =
argIndex != null && curVal.length > 1
? // if more than one val, remove the specified val
del(element, `ast.chain.${index}.arguments.${argName}.${argIndex}`)
del(element, `${path}.${argName}.${argIndex}`)
: // otherwise, remove the entire key
del(element, `ast.chain.${index}.arguments.${argName}`);
del(element, argName ? `${path}.${argName}` : path);
dispatch(setAstAtIndex(index, get(newElement, ['ast', 'chain', index]), element, pageId));
const parentPath = pathTerms.slice(0, pathTerms.length - 1);
const updatedArgument = get(newElement, parentPath);
if (Array.isArray(updatedArgument) && !updatedArgument.length) {
newElement = del(element, parentPath);
}
dispatch(setAstAtIndex(argumnentChainIndex, get(newElement, argumentChainPath), element, pageId));
});
/*

View file

@ -67,41 +67,48 @@ describe('getSiblingContext', () => {
},
};
const stateWithDefaultPath = {
transient: {
resolvedArgs: {
'element-foo': {
expressionContext: {
'ast.chain': state.transient.resolvedArgs['element-foo'].expressionContext,
},
},
},
},
};
const expectedElement = {
index: 2,
context: {
type: 'pointseries',
columns: {
x: { type: 'string', role: 'dimension', expression: 'cost' },
y: { type: 'string', role: 'dimension', expression: 'project' },
color: { type: 'string', role: 'dimension', expression: 'project' },
},
rows: [
{ x: '200', y: 'tigers', color: 'tigers' },
{ x: '500', y: 'pandas', color: 'pandas' },
],
},
};
it('should find context when a previous context value is found', () => {
// pointseries map
expect(getSiblingContext(state, 'element-foo', 2)).toEqual({
index: 2,
context: {
type: 'pointseries',
columns: {
x: { type: 'string', role: 'dimension', expression: 'cost' },
y: { type: 'string', role: 'dimension', expression: 'project' },
color: { type: 'string', role: 'dimension', expression: 'project' },
},
rows: [
{ x: '200', y: 'tigers', color: 'tigers' },
{ x: '500', y: 'pandas', color: 'pandas' },
],
},
});
expect(getSiblingContext(state, 'element-foo', 2, [])).toEqual(expectedElement);
expect(getSiblingContext(stateWithDefaultPath, 'element-foo', 2)).toEqual(expectedElement);
expect(getSiblingContext(stateWithDefaultPath, 'element-foo', 2, ['ast.chain'])).toEqual(
expectedElement
);
});
it('should find context when a previous context value is not found', () => {
// pointseries map
expect(getSiblingContext(state, 'element-foo', 1000)).toEqual({
index: 2,
context: {
type: 'pointseries',
columns: {
x: { type: 'string', role: 'dimension', expression: 'cost' },
y: { type: 'string', role: 'dimension', expression: 'project' },
color: { type: 'string', role: 'dimension', expression: 'project' },
},
rows: [
{ x: '200', y: 'tigers', color: 'tigers' },
{ x: '500', y: 'pandas', color: 'pandas' },
],
},
});
expect(getSiblingContext(state, 'element-foo', 1000, [])).toEqual(expectedElement);
expect(getSiblingContext(stateWithDefaultPath, 'element-foo', 1000)).toEqual(expectedElement);
expect(getSiblingContext(stateWithDefaultPath, 'element-foo', 1000, ['ast.chain'])).toEqual(
expectedElement
);
});
});

View file

@ -456,7 +456,7 @@ export function getResolvedArgs(state: State, elementId: string, path: any): any
return args;
}
export function getSelectedResolvedArgs(state: State, path: any): any {
export function getSelectedResolvedArgs(state: State, path: Array<string | number>): any {
const elementId = getSelectedElementId(state);
if (elementId) {
@ -464,8 +464,12 @@ export function getSelectedResolvedArgs(state: State, path: any): any {
}
}
export function getContextForIndex(state: State, index: number): ExpressionContext {
return getSelectedResolvedArgs(state, ['expressionContext', index - 1]);
export function getContextForIndex(
state: State,
parentPath: string,
index: number
): ExpressionContext {
return getSelectedResolvedArgs(state, ['expressionContext', parentPath, index - 1]);
}
export function getRefreshInterval(state: State): number {

View file

@ -432,14 +432,10 @@ describe('heatmap', () => {
// grid
strokeWidth: [],
strokeColor: [],
cellHeight: [],
cellWidth: [],
// cells
isCellLabelVisible: [false],
// Y-axis
isYAxisLabelVisible: [true],
yAxisLabelWidth: [],
yAxisLabelColor: [],
// X-axis
isXAxisLabelVisible: [true],
},

View file

@ -356,18 +356,10 @@ export const getHeatmapVisualization = ({
strokeColor: state.gridConfig.strokeColor
? [state.gridConfig.strokeColor]
: [],
cellHeight: state.gridConfig.cellHeight ? [state.gridConfig.cellHeight] : [],
cellWidth: state.gridConfig.cellWidth ? [state.gridConfig.cellWidth] : [],
// cells
isCellLabelVisible: [state.gridConfig.isCellLabelVisible],
// Y-axis
isYAxisLabelVisible: [state.gridConfig.isYAxisLabelVisible],
yAxisLabelWidth: state.gridConfig.yAxisLabelWidth
? [state.gridConfig.yAxisLabelWidth]
: [],
yAxisLabelColor: state.gridConfig.yAxisLabelColor
? [state.gridConfig.yAxisLabelColor]
: [],
// X-axis
isXAxisLabelVisible: state.gridConfig.isXAxisLabelVisible
? [state.gridConfig.isXAxisLabelVisible]

View file

@ -1956,15 +1956,11 @@
"expressionMetricVis.function.metric.help": "メトリックディメンションの構成です。",
"expressionMetricVis.function.percentageMode.help": "百分率モードでメトリックを表示します。colorRange を設定する必要があります。",
"expressionMetricVis.function.showLabels.help": "メトリック値の下にラベルを表示します。",
"expressionHeatmap.function.args.grid.cellHeight.help": "指定网格单元格高度",
"expressionHeatmap.function.args.grid.cellWidth.help": "指定网格单元格宽度",
"expressionHeatmap.function.args.grid.isCellLabelVisible.help": "指定单元格标签是否可见。",
"expressionHeatmap.function.args.grid.isXAxisLabelVisible.help": "指定 X 轴标签是否可见。",
"expressionHeatmap.function.args.grid.isYAxisLabelVisible.help": "指定 Y 轴标签是否可见。",
"expressionHeatmap.function.args.grid.strokeColor.help": "指定网格笔画颜色",
"expressionHeatmap.function.args.grid.strokeWidth.help": "指定网格笔画宽度",
"expressionHeatmap.function.args.grid.yAxisLabelColor.help": "指定 Y 轴标签的颜色。",
"expressionHeatmap.function.args.grid.yAxisLabelWidth.help": "指定 Y 轴标签的宽度。",
"expressionHeatmap.function.args.legend.isVisible.help": "指定图例是否可见。",
"expressionHeatmap.function.args.legend.maxLines.help": "指定每个图例项的行数。",
"expressionHeatmap.function.args.legend.position.help": "指定图例位置。",

View file

@ -1964,15 +1964,11 @@
"expressionMetric.functions.metricHelpText": "在标签上显示数字。",
"expressionMetric.renderer.metric.displayName": "指标",
"expressionMetric.renderer.metric.helpDescription": "在标签上呈现数字",
"expressionHeatmap.function.args.grid.cellHeight.help": "指定网格单元格高度",
"expressionHeatmap.function.args.grid.cellWidth.help": "指定网格单元格宽度",
"expressionHeatmap.function.args.grid.isCellLabelVisible.help": "指定单元格标签是否可见。",
"expressionHeatmap.function.args.grid.isXAxisLabelVisible.help": "指定 X 轴标签是否可见。",
"expressionHeatmap.function.args.grid.isYAxisLabelVisible.help": "指定 Y 轴标签是否可见。",
"expressionHeatmap.function.args.grid.strokeColor.help": "指定网格笔画颜色",
"expressionHeatmap.function.args.grid.strokeWidth.help": "指定网格笔画宽度",
"expressionHeatmap.function.args.grid.yAxisLabelColor.help": "指定 Y 轴标签的颜色。",
"expressionHeatmap.function.args.grid.yAxisLabelWidth.help": "指定 Y 轴标签的宽度。",
"expressionHeatmap.function.args.legend.isVisible.help": "指定图例是否可见。",
"expressionHeatmap.function.args.legend.maxLines.help": "指定每个图例项的行数。",
"expressionHeatmap.function.args.legend.position.help": "指定图例位置。",