[Lens] Thresholds added (#108342)

Co-authored-by: Marta Bondyra <marta.bondyra@gmail.com>
Co-authored-by: dej611 <dej611@gmail.com>
Co-authored-by: Marco Liberati <dej611@users.noreply.github.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Marta Bondyra 2021-09-22 11:14:40 +02:00 committed by GitHub
parent 9b410ce544
commit 0cbdf3f259
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 4007 additions and 761 deletions

View file

@ -27,12 +27,18 @@ interface AxisConfig {
hide?: boolean;
}
export type YAxisMode = 'auto' | 'left' | 'right';
export type YAxisMode = 'auto' | 'left' | 'right' | 'bottom';
export type LineStyle = 'solid' | 'dashed' | 'dotted';
export type FillStyle = 'none' | 'above' | 'below';
export interface YConfig {
forAccessor: string;
axisMode?: YAxisMode;
color?: string;
icon?: string;
lineWidth?: number;
lineStyle?: LineStyle;
fill?: FillStyle;
}
export type AxisTitlesVisibilityConfigResult = AxesSettingsConfig & {
@ -161,6 +167,24 @@ export const yAxisConfig: ExpressionFunctionDefinition<
types: ['string'],
help: 'The color of the series',
},
lineStyle: {
types: ['string'],
options: ['solid', 'dotted', 'dashed'],
help: 'The style of the threshold line',
},
lineWidth: {
types: ['number'],
help: 'The width of the threshold line',
},
icon: {
types: ['string'],
help: 'An optional icon used for threshold lines',
},
fill: {
types: ['string'],
options: ['none', 'above', 'below'],
help: '',
},
},
fn: function fn(input: unknown, args: YConfig) {
return {

View file

@ -0,0 +1,40 @@
/*
* 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 from 'react';
import { EuiIconProps } from '@elastic/eui';
export const LensIconChartBarThreshold = ({
title,
titleId,
...props
}: Omit<EuiIconProps, 'type'>) => (
<svg
viewBox="0 0 16 12"
width={30}
height={22}
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-labelledby={titleId}
{...props}
>
{title ? <title id={titleId}>{title}</title> : null}
<g>
<path
className="lensChartIcon__subdued"
d="M3.2 4.79997C3.2 4.50542 2.96122 4.26663 2.66667 4.26663H0.533333C0.238784 4.26663 0 4.50542 0 4.79997V6.39997H3.2V4.79997ZM3.2 9.59997H0V13.3333C0 13.6279 0.238784 13.8666 0.533333 13.8666H2.66667C2.96122 13.8666 3.2 13.6279 3.2 13.3333V9.59997ZM8.53333 9.59997H11.7333V13.3333C11.7333 13.6279 11.4946 13.8666 11.2 13.8666H9.06667C8.77211 13.8666 8.53333 13.6279 8.53333 13.3333V9.59997ZM11.7333 6.39997H8.53333V2.66663C8.53333 2.37208 8.77211 2.1333 9.06667 2.1333H11.2C11.4946 2.1333 11.7333 2.37208 11.7333 2.66663V6.39997ZM12.8 9.59997V13.3333C12.8 13.6279 13.0388 13.8666 13.3333 13.8666H15.4667C15.7612 13.8666 16 13.6279 16 13.3333V9.59997H12.8ZM16 6.39997V5.86663C16 5.57208 15.7612 5.3333 15.4667 5.3333H13.3333C13.0388 5.3333 12.8 5.57208 12.8 5.86663V6.39997H16ZM7.46667 11.2C7.46667 10.9054 7.22789 10.6666 6.93333 10.6666H4.8C4.50544 10.6666 4.26667 10.9054 4.26667 11.2V13.3333C4.26667 13.6279 4.50544 13.8666 4.8 13.8666H6.93333C7.22789 13.8666 7.46667 13.6279 7.46667 13.3333V11.2Z"
/>
<rect
y="7.4668"
width="16"
height="1.06667"
rx="0.533334"
className="lensChartIcon__accent"
/>
</g>
</svg>
);

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import {
EuiToolTip,
EuiButton,
@ -38,12 +38,17 @@ export function AddLayerButton({
}: AddLayerButtonProps) {
const [showLayersChoice, toggleLayersChoice] = useState(false);
const hasMultipleLayers = Boolean(visualization.appendLayer && visualizationState);
if (!hasMultipleLayers) {
const supportedLayers = useMemo(() => {
if (!visualization.appendLayer || !visualizationState) {
return null;
}
return visualization.getSupportedLayers?.(visualizationState, layersMeta);
}, [visualization, visualizationState, layersMeta]);
if (supportedLayers == null) {
return null;
}
const supportedLayers = visualization.getSupportedLayers?.(visualizationState, layersMeta);
if (supportedLayers?.length === 1) {
if (supportedLayers.length === 1) {
return (
<EuiToolTip
display="block"

View file

@ -19,9 +19,13 @@ import { LayerPanel } from './layer_panel';
import { coreMock } from 'src/core/public/mocks';
import { generateId } from '../../../id_generator';
import { mountWithProvider } from '../../../mocks';
import { layerTypes } from '../../../../common';
import { ReactWrapper } from 'enzyme';
jest.mock('../../../id_generator');
const waitMs = (time: number) => new Promise((r) => setTimeout(r, time));
let container: HTMLDivElement | undefined;
beforeEach(() => {
@ -137,7 +141,7 @@ describe('ConfigPanel', () => {
const updater = () => 'updated';
updateDatasource('mockindexpattern', updater);
await new Promise((r) => setTimeout(r, 0));
await waitMs(0);
expect(lensStore.dispatch).toHaveBeenCalledTimes(1);
expect(
(lensStore.dispatch as jest.Mock).mock.calls[0][0].payload.updater(
@ -147,7 +151,7 @@ describe('ConfigPanel', () => {
updateAll('mockindexpattern', updater, props.visualizationState);
// wait for one tick so async updater has a chance to trigger
await new Promise((r) => setTimeout(r, 0));
await waitMs(0);
expect(lensStore.dispatch).toHaveBeenCalledTimes(2);
expect(
(lensStore.dispatch as jest.Mock).mock.calls[0][0].payload.updater(
@ -293,4 +297,164 @@ describe('ConfigPanel', () => {
expect(focusedEl?.children[0].getAttribute('data-test-subj')).toEqual('lns-layerPanel-1');
});
});
describe('initial default value', () => {
function prepareAndMountComponent(props: ReturnType<typeof getDefaultProps>) {
(generateId as jest.Mock).mockReturnValue(`newId`);
return mountWithProvider(
<LayerPanels {...props} />,
{
preloadedState: {
datasourceStates: {
mockindexpattern: {
isLoading: false,
state: 'state',
},
},
activeDatasourceId: 'mockindexpattern',
},
},
{
attachTo: container,
}
);
}
function clickToAddLayer(instance: ReactWrapper) {
act(() => {
instance.find('[data-test-subj="lnsLayerAddButton"]').first().simulate('click');
});
instance.update();
act(() => {
instance
.find(`[data-test-subj="lnsLayerAddButton-${layerTypes.THRESHOLD}"]`)
.first()
.simulate('click');
});
instance.update();
return waitMs(0);
}
function clickToAddDimension(instance: ReactWrapper) {
act(() => {
instance.find('[data-test-subj="lns-empty-dimension"]').last().simulate('click');
});
return waitMs(0);
}
it('should not add an initial dimension when not specified', async () => {
const props = getDefaultProps();
props.activeVisualization.getSupportedLayers = jest.fn(() => [
{ type: layerTypes.DATA, label: 'Data Layer' },
{
type: layerTypes.THRESHOLD,
label: 'Threshold layer',
},
]);
mockDatasource.initializeDimension = jest.fn();
const { instance, lensStore } = await prepareAndMountComponent(props);
await clickToAddLayer(instance);
expect(lensStore.dispatch).toHaveBeenCalledTimes(1);
expect(mockDatasource.initializeDimension).not.toHaveBeenCalled();
});
it('should not add an initial dimension when initialDimensions are not available for the given layer type', async () => {
const props = getDefaultProps();
props.activeVisualization.getSupportedLayers = jest.fn(() => [
{
type: layerTypes.DATA,
label: 'Data Layer',
initialDimensions: [
{
groupId: 'testGroup',
columnId: 'myColumn',
dataType: 'number',
label: 'Initial value',
staticValue: 100,
},
],
},
{
type: layerTypes.THRESHOLD,
label: 'Threshold layer',
},
]);
mockDatasource.initializeDimension = jest.fn();
const { instance, lensStore } = await prepareAndMountComponent(props);
await clickToAddLayer(instance);
expect(lensStore.dispatch).toHaveBeenCalledTimes(1);
expect(mockDatasource.initializeDimension).not.toHaveBeenCalled();
});
it('should use group initial dimension value when adding a new layer if available', async () => {
const props = getDefaultProps();
props.activeVisualization.getSupportedLayers = jest.fn(() => [
{ type: layerTypes.DATA, label: 'Data Layer' },
{
type: layerTypes.THRESHOLD,
label: 'Threshold layer',
initialDimensions: [
{
groupId: 'testGroup',
columnId: 'myColumn',
dataType: 'number',
label: 'Initial value',
staticValue: 100,
},
],
},
]);
mockDatasource.initializeDimension = jest.fn();
const { instance, lensStore } = await prepareAndMountComponent(props);
await clickToAddLayer(instance);
expect(lensStore.dispatch).toHaveBeenCalledTimes(1);
expect(mockDatasource.initializeDimension).toHaveBeenCalledWith(undefined, 'newId', {
columnId: 'myColumn',
dataType: 'number',
groupId: 'testGroup',
label: 'Initial value',
staticValue: 100,
});
});
it('should add an initial dimension value when clicking on the empty dimension button', async () => {
const props = getDefaultProps();
props.activeVisualization.getSupportedLayers = jest.fn(() => [
{
type: layerTypes.DATA,
label: 'Data Layer',
initialDimensions: [
{
groupId: 'a',
columnId: 'newId',
dataType: 'number',
label: 'Initial value',
staticValue: 100,
},
],
},
]);
mockDatasource.initializeDimension = jest.fn();
const { instance, lensStore } = await prepareAndMountComponent(props);
await clickToAddDimension(instance);
expect(lensStore.dispatch).toHaveBeenCalledTimes(1);
expect(mockDatasource.initializeDimension).toHaveBeenCalledWith('state', 'first', {
groupId: 'a',
columnId: 'newId',
dataType: 'number',
label: 'Initial value',
staticValue: 100,
});
});
});
});

View file

@ -26,8 +26,9 @@ import {
useLensSelector,
selectVisualization,
VisualizationState,
LensAppState,
} from '../../../state_management';
import { AddLayerButton } from './add_layer';
import { AddLayerButton, getLayerType } from './add_layer';
export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: ConfigPanelWrapperProps) {
const visualization = useLensSelector(selectVisualization);
@ -177,6 +178,33 @@ export function LayerPanels(
layerIds.length
) === 'clear'
}
onEmptyDimensionAdd={(columnId, { groupId }) => {
// avoid state update if the datasource does not support initializeDimension
if (
activeDatasourceId != null &&
datasourceMap[activeDatasourceId]?.initializeDimension
) {
dispatchLens(
updateState({
subType: 'LAYER_DEFAULT_DIMENSION',
updater: (state) =>
addInitialValueIfAvailable({
...props,
state,
activeDatasourceId,
layerId,
layerType: getLayerType(
activeVisualization,
state.visualization.state,
layerId
),
columnId,
groupId,
}),
})
);
}
}}
onRemoveLayer={() => {
dispatchLens(
updateState({
@ -232,21 +260,92 @@ export function LayerPanels(
dispatchLens(
updateState({
subType: 'ADD_LAYER',
updater: (state) =>
appendLayer({
updater: (state) => {
const newState = appendLayer({
activeVisualization,
generateId: () => id,
trackUiEvent,
activeDatasource: datasourceMap[activeDatasourceId!],
state,
layerType,
}),
});
return addInitialValueIfAvailable({
...props,
activeDatasourceId: activeDatasourceId!,
state: newState,
layerId: id,
layerType,
});
},
})
);
setNextFocusedLayerId(id);
}}
/>
</EuiForm>
);
}
function addInitialValueIfAvailable({
state,
activeVisualization,
framePublicAPI,
layerType,
activeDatasourceId,
datasourceMap,
layerId,
columnId,
groupId,
}: ConfigPanelWrapperProps & {
state: LensAppState;
activeDatasourceId: string;
activeVisualization: Visualization;
layerId: string;
layerType: string;
columnId?: string;
groupId?: string;
}) {
const layerInfo = activeVisualization
.getSupportedLayers(state.visualization.state, framePublicAPI)
.find(({ type }) => type === layerType);
const activeDatasource = datasourceMap[activeDatasourceId];
if (layerInfo?.initialDimensions && activeDatasource?.initializeDimension) {
const info = groupId
? layerInfo.initialDimensions.find(({ groupId: id }) => id === groupId)
: // pick the first available one if not passed
layerInfo.initialDimensions[0];
if (info) {
return {
...state,
datasourceStates: {
...state.datasourceStates,
[activeDatasourceId]: {
...state.datasourceStates[activeDatasourceId],
state: activeDatasource.initializeDimension(
state.datasourceStates[activeDatasourceId].state,
layerId,
{
...info,
columnId: columnId || info.columnId,
}
),
},
},
visualization: {
...state.visualization,
state: activeVisualization.setDimension({
groupId: info.groupId,
layerId,
columnId: columnId || info.columnId,
prevState: state.visualization.state,
frame: framePublicAPI,
}),
},
};
}
}
return state;
}

View file

@ -83,6 +83,7 @@ describe('LayerPanel', () => {
registerNewLayerRef: jest.fn(),
isFullscreen: false,
toggleFullscreen: jest.fn(),
onEmptyDimensionAdd: jest.fn(),
};
}
@ -920,4 +921,33 @@ describe('LayerPanel', () => {
expect(updateVisualization).toHaveBeenCalledTimes(1);
});
});
describe('add a new dimension', () => {
it('should call onEmptyDimensionAdd callback on new dimension creation', async () => {
mockVisualization.getConfiguration.mockReturnValue({
groups: [
{
groupLabel: 'A',
groupId: 'a',
accessors: [],
filterOperations: () => true,
supportsMoreColumns: true,
dataTestSubj: 'lnsGroup',
},
],
});
const props = getDefaultProps();
const { instance } = await mountWithProvider(<LayerPanel {...props} />);
act(() => {
instance.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click');
});
instance.update();
expect(props.onEmptyDimensionAdd).toHaveBeenCalledWith(
'newid',
expect.objectContaining({ groupId: 'a' })
);
});
});
});

View file

@ -57,6 +57,7 @@ export function LayerPanel(
onRemoveLayer: () => void;
registerNewLayerRef: (layerId: string, instance: HTMLDivElement | null) => void;
toggleFullscreen: () => void;
onEmptyDimensionAdd: (columnId: string, group: { groupId: string }) => void;
}
) {
const [activeDimension, setActiveDimension] = useState<ActiveDimensionState>(
@ -124,7 +125,11 @@ export function LayerPanel(
dateRange,
};
const { groups, supportStaticValue } = useMemo(
const {
groups,
supportStaticValue,
supportFieldFormat = true,
} = useMemo(
() => activeVisualization.getConfiguration(layerVisualizationConfigProps),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
@ -227,13 +232,25 @@ export function LayerPanel(
const isDimensionPanelOpen = Boolean(activeId);
const updateDataLayerState = useCallback(
(newState: unknown, { isDimensionComplete = true }: { isDimensionComplete?: boolean } = {}) => {
(
newState: unknown,
{
isDimensionComplete = true,
// this flag is a hack to force a sync render where it was planned an async/setTimeout state update
// TODO: revisit this once we get rid of updateDatasourceAsync upstream
forceRender = false,
}: { isDimensionComplete?: boolean; forceRender?: boolean } = {}
) => {
if (!activeGroup || !activeId) {
return;
}
if (allAccessors.includes(activeId)) {
if (isDimensionComplete) {
updateDatasourceAsync(datasourceId, newState);
if (forceRender) {
updateDatasource(datasourceId, newState);
} else {
updateDatasourceAsync(datasourceId, newState);
}
} else {
// The datasource can indicate that the previously-valid column is no longer
// complete, which clears the visualization. This keeps the flyout open and reuses
@ -263,7 +280,11 @@ export function LayerPanel(
);
setActiveDimension({ ...activeDimension, isNew: false });
} else {
updateDatasourceAsync(datasourceId, newState);
if (forceRender) {
updateDatasource(datasourceId, newState);
} else {
updateDatasourceAsync(datasourceId, newState);
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -295,11 +316,10 @@ export function LayerPanel(
hasBorder
hasShadow
>
<section className="lnsLayerPanel__layerHeader">
<header className="lnsLayerPanel__layerHeader">
<EuiFlexGroup gutterSize="s" responsive={false} alignItems="center">
<EuiFlexItem grow className="lnsLayerPanel__layerSettingsWrapper">
<LayerSettings
layerId={layerId}
layerConfigProps={{
...layerVisualizationConfigProps,
setState: props.updateVisualization,
@ -354,7 +374,7 @@ export function LayerPanel(
}}
/>
)}
</section>
</header>
{groups.map((group, groupIndex) => {
const isMissing = !isEmptyLayer && group.required && group.accessors.length === 0;
@ -460,6 +480,8 @@ export function LayerPanel(
columnId: accessorConfig.columnId,
groupId: group.groupId,
filterOperations: group.filterOperations,
invalid: group.invalid,
invalidMessage: group.invalidMessage,
}}
/>
</DimensionButton>
@ -478,6 +500,7 @@ export function LayerPanel(
layerDatasource={layerDatasource}
layerDatasourceDropProps={layerDatasourceDropProps}
onClick={(id) => {
props.onEmptyDimensionAdd(id, group);
setActiveDimension({
activeGroup: group,
activeId: id,
@ -538,6 +561,8 @@ export function LayerPanel(
toggleFullscreen,
isFullscreen,
setState: updateDataLayerState,
supportStaticValue: Boolean(supportStaticValue),
supportFieldFormat: Boolean(supportFieldFormat),
layerType: activeVisualization.getLayerType(layerId, visualizationState),
}}
/>

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 React from 'react';
import {
createMockFramePublicAPI,
createMockVisualization,
mountWithProvider,
} from '../../../mocks';
import { Visualization } from '../../../types';
import { LayerSettings } from './layer_settings';
describe('LayerSettings', () => {
let mockVisualization: jest.Mocked<Visualization>;
const frame = createMockFramePublicAPI();
function getDefaultProps() {
return {
activeVisualization: mockVisualization,
layerConfigProps: {
layerId: 'myLayer',
state: {},
frame,
dateRange: { fromDate: 'now-7d', toDate: 'now' },
activeData: frame.activeData,
setState: jest.fn(),
},
};
}
beforeEach(() => {
mockVisualization = {
...createMockVisualization(),
id: 'testVis',
visualizationTypes: [
{
icon: 'empty',
id: 'testVis',
label: 'TEST1',
groupLabel: 'testVisGroup',
},
],
};
});
it('should render nothing with no custom renderer nor description', async () => {
// @ts-expect-error
mockVisualization.getDescription.mockReturnValue(undefined);
const { instance } = await mountWithProvider(<LayerSettings {...getDefaultProps()} />);
expect(instance.html()).toBe(null);
});
it('should render a static header if visualization has only a description value', async () => {
mockVisualization.getDescription.mockReturnValue({
icon: 'myIcon',
label: 'myVisualizationType',
});
const { instance } = await mountWithProvider(<LayerSettings {...getDefaultProps()} />);
expect(instance.find('StaticHeader').first().prop('label')).toBe('myVisualizationType');
});
it('should call the custom renderer if available', async () => {
mockVisualization.renderLayerHeader = jest.fn();
await mountWithProvider(<LayerSettings {...getDefaultProps()} />);
expect(mockVisualization.renderLayerHeader).toHaveBeenCalled();
});
});

View file

@ -6,44 +6,23 @@
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiTitle } from '@elastic/eui';
import { NativeRenderer } from '../../../native_renderer';
import { Visualization, VisualizationLayerWidgetProps } from '../../../types';
import { StaticHeader } from '../../../shared_components';
export function LayerSettings({
layerId,
activeVisualization,
layerConfigProps,
}: {
layerId: string;
activeVisualization: Visualization;
layerConfigProps: VisualizationLayerWidgetProps;
}) {
const description = activeVisualization.getDescription(layerConfigProps.state);
if (!activeVisualization.renderLayerHeader) {
const description = activeVisualization.getDescription(layerConfigProps.state);
if (!description) {
return null;
}
return (
<EuiFlexGroup
gutterSize="s"
alignItems="center"
responsive={false}
className={'lnsLayerPanel__settingsStaticHeader'}
>
{description.icon && (
<EuiFlexItem grow={false}>
<EuiIcon type={description.icon} />{' '}
</EuiFlexItem>
)}
<EuiFlexItem grow>
<EuiTitle size="xxs">
<h5>{description.label}</h5>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
);
return <StaticHeader label={description.label} icon={description.icon} />;
}
return (

View file

@ -45,21 +45,22 @@ describe('suggestion helpers', () => {
generateSuggestion(),
]);
const suggestedState = {};
const suggestions = getSuggestions({
visualizationMap: {
vis1: {
...mockVisualization,
getSuggestions: () => [
{
score: 0.5,
title: 'Test',
state: suggestedState,
previewIcon: 'empty',
},
],
},
const visualizationMap = {
vis1: {
...mockVisualization,
getSuggestions: () => [
{
score: 0.5,
title: 'Test',
state: suggestedState,
previewIcon: 'empty',
},
],
},
activeVisualizationId: 'vis1',
};
const suggestions = getSuggestions({
visualizationMap,
activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap,
datasourceStates,
@ -74,38 +75,39 @@ describe('suggestion helpers', () => {
datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([
generateSuggestion(),
]);
const suggestions = getSuggestions({
visualizationMap: {
vis1: {
...mockVisualization1,
getSuggestions: () => [
{
score: 0.5,
title: 'Test',
state: {},
previewIcon: 'empty',
},
{
score: 0.5,
title: 'Test2',
state: {},
previewIcon: 'empty',
},
],
},
vis2: {
...mockVisualization2,
getSuggestions: () => [
{
score: 0.5,
title: 'Test3',
state: {},
previewIcon: 'empty',
},
],
},
const visualizationMap = {
vis1: {
...mockVisualization1,
getSuggestions: () => [
{
score: 0.5,
title: 'Test',
state: {},
previewIcon: 'empty',
},
{
score: 0.5,
title: 'Test2',
state: {},
previewIcon: 'empty',
},
],
},
activeVisualizationId: 'vis1',
vis2: {
...mockVisualization2,
getSuggestions: () => [
{
score: 0.5,
title: 'Test3',
state: {},
previewIcon: 'empty',
},
],
},
};
const suggestions = getSuggestions({
visualizationMap,
activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap,
datasourceStates,
@ -116,11 +118,12 @@ describe('suggestion helpers', () => {
it('should call getDatasourceSuggestionsForField when a field is passed', () => {
datasourceMap.mock.getDatasourceSuggestionsForField.mockReturnValue([generateSuggestion()]);
const droppedField = {};
const visualizationMap = {
vis1: createMockVisualization(),
};
getSuggestions({
visualizationMap: {
vis1: createMockVisualization(),
},
activeVisualizationId: 'vis1',
visualizationMap,
activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap,
datasourceStates,
@ -128,7 +131,8 @@ describe('suggestion helpers', () => {
});
expect(datasourceMap.mock.getDatasourceSuggestionsForField).toHaveBeenCalledWith(
datasourceStates.mock.state,
droppedField
droppedField,
expect.any(Function)
);
});
@ -148,12 +152,13 @@ describe('suggestion helpers', () => {
mock2: createMockDatasource('a'),
mock3: createMockDatasource('a'),
};
const visualizationMap = {
vis1: createMockVisualization(),
};
const droppedField = {};
getSuggestions({
visualizationMap: {
vis1: createMockVisualization(),
},
activeVisualizationId: 'vis1',
visualizationMap,
activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap: multiDatasourceMap,
datasourceStates: multiDatasourceStates,
@ -161,11 +166,13 @@ describe('suggestion helpers', () => {
});
expect(multiDatasourceMap.mock.getDatasourceSuggestionsForField).toHaveBeenCalledWith(
multiDatasourceStates.mock.state,
droppedField
droppedField,
expect.any(Function)
);
expect(multiDatasourceMap.mock2.getDatasourceSuggestionsForField).toHaveBeenCalledWith(
multiDatasourceStates.mock2.state,
droppedField
droppedField,
expect.any(Function)
);
expect(multiDatasourceMap.mock3.getDatasourceSuggestionsForField).not.toHaveBeenCalled();
});
@ -174,11 +181,14 @@ describe('suggestion helpers', () => {
datasourceMap.mock.getDatasourceSuggestionsForVisualizeField.mockReturnValue([
generateSuggestion(),
]);
const visualizationMap = {
vis1: createMockVisualization(),
};
getSuggestions({
visualizationMap: {
vis1: createMockVisualization(),
},
activeVisualizationId: 'vis1',
visualizationMap,
activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap,
datasourceStates,
@ -214,11 +224,13 @@ describe('suggestion helpers', () => {
indexPatternId: '1',
fieldName: 'test',
};
const visualizationMap = {
vis1: createMockVisualization(),
};
getSuggestions({
visualizationMap: {
vis1: createMockVisualization(),
},
activeVisualizationId: 'vis1',
visualizationMap,
activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap: multiDatasourceMap,
datasourceStates: multiDatasourceStates,
@ -245,38 +257,39 @@ describe('suggestion helpers', () => {
datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([
generateSuggestion(),
]);
const suggestions = getSuggestions({
visualizationMap: {
vis1: {
...mockVisualization1,
getSuggestions: () => [
{
score: 0.2,
title: 'Test',
state: {},
previewIcon: 'empty',
},
{
score: 0.8,
title: 'Test2',
state: {},
previewIcon: 'empty',
},
],
},
vis2: {
...mockVisualization2,
getSuggestions: () => [
{
score: 0.6,
title: 'Test3',
state: {},
previewIcon: 'empty',
},
],
},
const visualizationMap = {
vis1: {
...mockVisualization1,
getSuggestions: () => [
{
score: 0.2,
title: 'Test',
state: {},
previewIcon: 'empty',
},
{
score: 0.8,
title: 'Test2',
state: {},
previewIcon: 'empty',
},
],
},
activeVisualizationId: 'vis1',
vis2: {
...mockVisualization2,
getSuggestions: () => [
{
score: 0.6,
title: 'Test3',
state: {},
previewIcon: 'empty',
},
],
},
};
const suggestions = getSuggestions({
visualizationMap,
activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap,
datasourceStates,
@ -305,12 +318,13 @@ describe('suggestion helpers', () => {
{ state: {}, table: table1, keptLayerIds: ['first'] },
{ state: {}, table: table2, keptLayerIds: ['first'] },
]);
const visualizationMap = {
vis1: mockVisualization1,
vis2: mockVisualization2,
};
getSuggestions({
visualizationMap: {
vis1: mockVisualization1,
vis2: mockVisualization2,
},
activeVisualizationId: 'vis1',
visualizationMap,
activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap,
datasourceStates,
@ -357,18 +371,20 @@ describe('suggestion helpers', () => {
previewIcon: 'empty',
},
]);
const suggestions = getSuggestions({
visualizationMap: {
vis1: {
...mockVisualization1,
getSuggestions: vis1Suggestions,
},
vis2: {
...mockVisualization2,
getSuggestions: vis2Suggestions,
},
const visualizationMap = {
vis1: {
...mockVisualization1,
getSuggestions: vis1Suggestions,
},
activeVisualizationId: 'vis1',
vis2: {
...mockVisualization2,
getSuggestions: vis2Suggestions,
},
};
const suggestions = getSuggestions({
visualizationMap,
activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap,
datasourceStates,
@ -389,12 +405,15 @@ describe('suggestion helpers', () => {
generateSuggestion(0),
generateSuggestion(1),
]);
const visualizationMap = {
vis1: mockVisualization1,
vis2: mockVisualization2,
};
getSuggestions({
visualizationMap: {
vis1: mockVisualization1,
vis2: mockVisualization2,
},
activeVisualizationId: 'vis1',
visualizationMap,
activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap,
datasourceStates,
@ -419,12 +438,13 @@ describe('suggestion helpers', () => {
generateSuggestion(0),
generateSuggestion(1),
]);
const visualizationMap = {
vis1: mockVisualization1,
vis2: mockVisualization2,
};
getSuggestions({
visualizationMap: {
vis1: mockVisualization1,
vis2: mockVisualization2,
},
activeVisualizationId: 'vis1',
visualizationMap,
activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap,
datasourceStates,
@ -451,12 +471,14 @@ describe('suggestion helpers', () => {
generateSuggestion(0),
generateSuggestion(1),
]);
const visualizationMap = {
vis1: mockVisualization1,
vis2: mockVisualization2,
};
getSuggestions({
visualizationMap: {
vis1: mockVisualization1,
vis2: mockVisualization2,
},
activeVisualizationId: 'vis1',
visualizationMap,
activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap,
datasourceStates,
@ -538,7 +560,8 @@ describe('suggestion helpers', () => {
humanData: {
label: 'myfieldLabel',
},
}
},
expect.any(Function)
);
});

View file

@ -58,7 +58,7 @@ export function getSuggestions({
datasourceMap,
datasourceStates,
visualizationMap,
activeVisualizationId,
activeVisualization,
subVisualizationId,
visualizationState,
field,
@ -69,7 +69,7 @@ export function getSuggestions({
datasourceMap: DatasourceMap;
datasourceStates: DatasourceStates;
visualizationMap: VisualizationMap;
activeVisualizationId: string | null;
activeVisualization?: Visualization;
subVisualizationId?: string;
visualizationState: unknown;
field?: unknown;
@ -83,16 +83,12 @@ export function getSuggestions({
const layerTypesMap = datasources.reduce((memo, [datasourceId, datasource]) => {
const datasourceState = datasourceStates[datasourceId].state;
if (!activeVisualizationId || !datasourceState || !visualizationMap[activeVisualizationId]) {
if (!activeVisualization || !datasourceState) {
return memo;
}
const layers = datasource.getLayers(datasourceState);
for (const layerId of layers) {
const type = getLayerType(
visualizationMap[activeVisualizationId],
visualizationState,
layerId
);
const type = getLayerType(activeVisualization, visualizationState, layerId);
memo[layerId] = type;
}
return memo;
@ -112,7 +108,11 @@ export function getSuggestions({
visualizeTriggerFieldContext.fieldName
);
} else if (field) {
dataSourceSuggestions = datasource.getDatasourceSuggestionsForField(datasourceState, field);
dataSourceSuggestions = datasource.getDatasourceSuggestionsForField(
datasourceState,
field,
(layerId) => isLayerSupportedByVisualization(layerId, [layerTypes.DATA]) // a field dragged to workspace should added to data layer
);
} else {
dataSourceSuggestions = datasource.getDatasourceSuggestionsFromCurrentState(
datasourceState,
@ -121,7 +121,6 @@ export function getSuggestions({
}
return dataSourceSuggestions.map((suggestion) => ({ ...suggestion, datasourceId }));
});
// Pass all table suggestions to all visualization extensions to get visualization suggestions
// and rank them by score
return Object.entries(visualizationMap)
@ -139,12 +138,8 @@ export function getSuggestions({
.flatMap((datasourceSuggestion) => {
const table = datasourceSuggestion.table;
const currentVisualizationState =
visualizationId === activeVisualizationId ? visualizationState : undefined;
const palette =
mainPalette ||
(activeVisualizationId && visualizationMap[activeVisualizationId]?.getMainPalette
? visualizationMap[activeVisualizationId].getMainPalette?.(visualizationState)
: undefined);
visualizationId === activeVisualization?.id ? visualizationState : undefined;
const palette = mainPalette || activeVisualization?.getMainPalette?.(visualizationState);
return getVisualizationSuggestions(
visualization,
@ -169,14 +164,14 @@ export function getVisualizeFieldSuggestions({
datasourceMap,
datasourceStates,
visualizationMap,
activeVisualizationId,
activeVisualization,
visualizationState,
visualizeTriggerFieldContext,
}: {
datasourceMap: DatasourceMap;
datasourceStates: DatasourceStates;
visualizationMap: VisualizationMap;
activeVisualizationId: string | null;
activeVisualization: Visualization;
subVisualizationId?: string;
visualizationState: unknown;
visualizeTriggerFieldContext?: VisualizeFieldContext;
@ -185,12 +180,12 @@ export function getVisualizeFieldSuggestions({
datasourceMap,
datasourceStates,
visualizationMap,
activeVisualizationId,
activeVisualization,
visualizationState,
visualizeTriggerFieldContext,
});
if (suggestions.length) {
return suggestions.find((s) => s.visualizationId === activeVisualizationId) || suggestions[0];
return suggestions.find((s) => s.visualizationId === activeVisualization?.id) || suggestions[0];
}
}
@ -263,18 +258,19 @@ export function getTopSuggestionForField(
(datasourceLayer) => datasourceLayer.getTableSpec().length > 0
);
const mainPalette =
visualization.activeId && visualizationMap[visualization.activeId]?.getMainPalette
? visualizationMap[visualization.activeId].getMainPalette?.(visualization.state)
: undefined;
const activeVisualization = visualization.activeId
? visualizationMap[visualization.activeId]
: undefined;
const mainPalette = activeVisualization?.getMainPalette?.(visualization.state);
const suggestions = getSuggestions({
datasourceMap: { [datasource.id]: datasource },
datasourceStates,
visualizationMap:
hasData && visualization.activeId
? { [visualization.activeId]: visualizationMap[visualization.activeId] }
? { [visualization.activeId]: activeVisualization! }
: visualizationMap,
activeVisualizationId: visualization.activeId,
activeVisualization,
visualizationState: visualization.state,
field,
mainPalette,

View file

@ -201,7 +201,9 @@ export function SuggestionPanel({
datasourceMap,
datasourceStates: currentDatasourceStates,
visualizationMap,
activeVisualizationId: currentVisualization.activeId,
activeVisualization: currentVisualization.activeId
? visualizationMap[currentVisualization.activeId]
: undefined,
visualizationState: currentVisualization.state,
activeData,
})

View file

@ -515,11 +515,14 @@ function getTopSuggestion(
props.visualizationMap[visualization.activeId].getMainPalette
? props.visualizationMap[visualization.activeId].getMainPalette!(visualization.state)
: undefined;
const unfilteredSuggestions = getSuggestions({
datasourceMap: props.datasourceMap,
datasourceStates,
visualizationMap: { [visualizationId]: newVisualization },
activeVisualizationId: visualization.activeId,
activeVisualization: visualization.activeId
? props.visualizationMap[visualization.activeId]
: undefined,
visualizationState: visualization.state,
subVisualizationId,
activeData: props.framePublicAPI.activeData,

View file

@ -11,15 +11,11 @@ import { i18n } from '@kbn/i18n';
import {
EuiListGroup,
EuiFormRow,
EuiFieldText,
EuiSpacer,
EuiListGroupItemProps,
EuiFormLabel,
EuiToolTip,
EuiText,
EuiTabs,
EuiTab,
EuiCallOut,
} from '@elastic/eui';
import { IndexPatternDimensionEditorProps } from './dimension_panel';
import { OperationSupportMatrix } from './operation_support';
@ -47,41 +43,29 @@ import { setTimeScaling, TimeScaling } from './time_scaling';
import { defaultFilter, Filtering, setFilter } from './filtering';
import { AdvancedOptions } from './advanced_options';
import { setTimeShift, TimeShift } from './time_shift';
import { useDebouncedValue } from '../../shared_components';
import { LayerType } from '../../../common';
import {
quickFunctionsName,
staticValueOperationName,
isQuickFunction,
getParamEditor,
formulaOperationName,
DimensionEditorTabs,
CalloutWarning,
LabelInput,
getErrorMessage,
} from './dimensions_editor_helpers';
import type { TemporaryState } from './dimensions_editor_helpers';
const operationPanels = getOperationDisplay();
export interface DimensionEditorProps extends IndexPatternDimensionEditorProps {
selectedColumn?: IndexPatternColumn;
layerType: LayerType;
operationSupportMatrix: OperationSupportMatrix;
currentIndexPattern: IndexPattern;
}
const LabelInput = ({ value, onChange }: { value: string; onChange: (value: string) => void }) => {
const { inputValue, handleInputChange, initialValue } = useDebouncedValue({ onChange, value });
return (
<EuiFormRow
label={i18n.translate('xpack.lens.indexPattern.columnLabel', {
defaultMessage: 'Display name',
description: 'Display name of a column of data',
})}
display="columnCompressed"
fullWidth
>
<EuiFieldText
compressed
data-test-subj="indexPattern-label-edit"
value={inputValue}
onChange={(e) => {
handleInputChange(e.target.value);
}}
placeholder={initialValue}
/>
</EuiFormRow>
);
};
export function DimensionEditor(props: DimensionEditorProps) {
const {
selectedColumn,
@ -96,6 +80,8 @@ export function DimensionEditor(props: DimensionEditorProps) {
dimensionGroups,
toggleFullscreen,
isFullscreen,
supportStaticValue,
supportFieldFormat = true,
layerType,
} = props;
const services = {
@ -110,6 +96,11 @@ export function DimensionEditor(props: DimensionEditorProps) {
const selectedOperationDefinition =
selectedColumn && operationDefinitionMap[selectedColumn.operationType];
const [temporaryState, setTemporaryState] = useState<TemporaryState>('none');
const temporaryQuickFunction = Boolean(temporaryState === quickFunctionsName);
const temporaryStaticValue = Boolean(temporaryState === staticValueOperationName);
const updateLayer = useCallback(
(newLayer) => setState((prevState) => mergeLayer({ state: prevState, layerId, newLayer })),
[layerId, setState]
@ -141,9 +132,64 @@ export function DimensionEditor(props: DimensionEditorProps) {
...incompleteParams
} = incompleteInfo || {};
const ParamEditor = selectedOperationDefinition?.paramEditor;
const isQuickFunctionSelected = Boolean(
supportStaticValue
? selectedOperationDefinition && isQuickFunction(selectedOperationDefinition.type)
: !selectedOperationDefinition || isQuickFunction(selectedOperationDefinition.type)
);
const showQuickFunctions = temporaryQuickFunction || isQuickFunctionSelected;
const [temporaryQuickFunction, setQuickFunction] = useState(false);
const showStaticValueFunction =
temporaryStaticValue ||
(temporaryState === 'none' &&
supportStaticValue &&
(!selectedColumn || selectedColumn?.operationType === staticValueOperationName));
const addStaticValueColumn = (prevLayer = props.state.layers[props.layerId]) => {
if (selectedColumn?.operationType !== staticValueOperationName) {
trackUiEvent(`indexpattern_dimension_operation_static_value`);
return insertOrReplaceColumn({
layer: prevLayer,
indexPattern: currentIndexPattern,
columnId,
op: staticValueOperationName,
visualizationGroups: dimensionGroups,
});
}
return prevLayer;
};
// this function intercepts the state update for static value function
// and. if in temporary state, it merges the "add new static value column" state with the incoming
// changes from the static value operation (which has to be a function)
// Note: it forced a rerender at this point to avoid UI glitches in async updates (another hack upstream)
// TODO: revisit this once we get rid of updateDatasourceAsync upstream
const moveDefinetelyToStaticValueAndUpdate = (
setter: IndexPatternLayer | ((prevLayer: IndexPatternLayer) => IndexPatternLayer)
) => {
if (temporaryStaticValue) {
setTemporaryState('none');
if (typeof setter === 'function') {
return setState(
(prevState) => {
const layer = setter(addStaticValueColumn(prevState.layers[layerId]));
return mergeLayer({ state: prevState, layerId, newLayer: layer });
},
{
isDimensionComplete: true,
forceRender: true,
}
);
}
}
return setStateWrapper(setter);
};
const ParamEditor = getParamEditor(
temporaryStaticValue,
selectedOperationDefinition,
supportStaticValue && !showQuickFunctions
);
const possibleOperations = useMemo(() => {
return Object.values(operationDefinitionMap)
@ -245,9 +291,9 @@ export function DimensionEditor(props: DimensionEditorProps) {
[`aria-pressed`]: isActive,
onClick() {
if (
operationDefinitionMap[operationType].input === 'none' ||
operationDefinitionMap[operationType].input === 'managedReference' ||
operationDefinitionMap[operationType].input === 'fullReference'
['none', 'fullReference', 'managedReference'].includes(
operationDefinitionMap[operationType].input
)
) {
// Clear invalid state because we are reseting to a valid column
if (selectedColumn?.operationType === operationType) {
@ -264,9 +310,12 @@ export function DimensionEditor(props: DimensionEditorProps) {
visualizationGroups: dimensionGroups,
targetGroup: props.groupId,
});
if (temporaryQuickFunction && newLayer.columns[columnId].operationType !== 'formula') {
if (
temporaryQuickFunction &&
isQuickFunction(newLayer.columns[columnId].operationType)
) {
// Only switch the tab once the formula is fully removed
setQuickFunction(false);
setTemporaryState('none');
}
setStateWrapper(newLayer);
trackUiEvent(`indexpattern_dimension_operation_${operationType}`);
@ -297,9 +346,12 @@ export function DimensionEditor(props: DimensionEditorProps) {
});
// );
}
if (temporaryQuickFunction && newLayer.columns[columnId].operationType !== 'formula') {
if (
temporaryQuickFunction &&
isQuickFunction(newLayer.columns[columnId].operationType)
) {
// Only switch the tab once the formula is fully removed
setQuickFunction(false);
setTemporaryState('none');
}
setStateWrapper(newLayer);
trackUiEvent(`indexpattern_dimension_operation_${operationType}`);
@ -314,7 +366,7 @@ export function DimensionEditor(props: DimensionEditorProps) {
}
if (temporaryQuickFunction) {
setQuickFunction(false);
setTemporaryState('none');
}
const newLayer = replaceColumn({
layer: props.state.layers[props.layerId],
@ -348,29 +400,10 @@ export function DimensionEditor(props: DimensionEditorProps) {
!currentFieldIsInvalid &&
!incompleteInfo &&
selectedColumn &&
selectedColumn.operationType !== 'formula';
isQuickFunction(selectedColumn.operationType);
const quickFunctions = (
<>
{temporaryQuickFunction && selectedColumn?.operationType === 'formula' && (
<>
<EuiCallOut
className="lnsIndexPatternDimensionEditor__warning"
size="s"
title={i18n.translate('xpack.lens.indexPattern.formulaWarning', {
defaultMessage: 'Formula currently applied',
})}
iconType="alert"
color="warning"
>
<p>
{i18n.translate('xpack.lens.indexPattern.formulaWarningText', {
defaultMessage: 'To overwrite your formula, select a quick function',
})}
</p>
</EuiCallOut>
</>
)}
<div className="lnsIndexPatternDimensionEditor__section lnsIndexPatternDimensionEditor__section--padded lnsIndexPatternDimensionEditor__section--shaded">
<EuiFormLabel>
{i18n.translate('xpack.lens.indexPattern.functionsLabel', {
@ -608,24 +641,28 @@ export function DimensionEditor(props: DimensionEditorProps) {
</>
);
const formulaTab = ParamEditor ? (
<ParamEditor
layer={state.layers[layerId]}
layerId={layerId}
activeData={props.activeData}
updateLayer={setStateWrapper}
columnId={columnId}
currentColumn={state.layers[layerId].columns[columnId]}
dateRange={dateRange}
indexPattern={currentIndexPattern}
operationDefinitionMap={operationDefinitionMap}
toggleFullscreen={toggleFullscreen}
isFullscreen={isFullscreen}
setIsCloseable={setIsCloseable}
{...services}
/>
const customParamEditor = ParamEditor ? (
<>
<ParamEditor
layer={state.layers[layerId]}
layerId={layerId}
activeData={props.activeData}
updateLayer={temporaryStaticValue ? moveDefinetelyToStaticValueAndUpdate : setStateWrapper}
columnId={columnId}
currentColumn={state.layers[layerId].columns[columnId]}
dateRange={dateRange}
indexPattern={currentIndexPattern}
operationDefinitionMap={operationDefinitionMap}
toggleFullscreen={toggleFullscreen}
isFullscreen={isFullscreen}
setIsCloseable={setIsCloseable}
{...services}
/>
</>
) : null;
const TabContent = showQuickFunctions ? quickFunctions : customParamEditor;
const onFormatChange = useCallback(
(newFormat) => {
updateLayer(
@ -640,58 +677,69 @@ export function DimensionEditor(props: DimensionEditorProps) {
[columnId, layerId, state.layers, updateLayer]
);
const hasFormula =
!isFullscreen && operationSupportMatrix.operationWithoutField.has(formulaOperationName);
const hasTabs = hasFormula || supportStaticValue;
return (
<div id={columnId}>
{!isFullscreen && operationSupportMatrix.operationWithoutField.has('formula') ? (
<EuiTabs size="s" className="lnsIndexPatternDimensionEditor__header">
<EuiTab
isSelected={temporaryQuickFunction || selectedColumn?.operationType !== 'formula'}
data-test-subj="lens-dimensionTabs-quickFunctions"
onClick={() => {
if (selectedColumn?.operationType === 'formula') {
setQuickFunction(true);
{hasTabs ? (
<DimensionEditorTabs
tabsEnabled={{
static_value: supportStaticValue,
formula: hasFormula,
quickFunctions: true,
}}
tabsState={{
static_value: showStaticValueFunction,
quickFunctions: showQuickFunctions,
formula:
temporaryState === 'none' && selectedColumn?.operationType === formulaOperationName,
}}
onClick={(tabClicked) => {
if (tabClicked === 'quickFunctions') {
if (selectedColumn && !isQuickFunction(selectedColumn.operationType)) {
setTemporaryState(quickFunctionsName);
return;
}
}}
>
{i18n.translate('xpack.lens.indexPattern.quickFunctionsLabel', {
defaultMessage: 'Quick functions',
})}
</EuiTab>
<EuiTab
isSelected={!temporaryQuickFunction && selectedColumn?.operationType === 'formula'}
data-test-subj="lens-dimensionTabs-formula"
onClick={() => {
if (selectedColumn?.operationType !== 'formula') {
setQuickFunction(false);
}
if (tabClicked === 'static_value') {
// when coming from a formula, set a temporary state
if (selectedColumn?.operationType === formulaOperationName) {
return setTemporaryState(staticValueOperationName);
}
setTemporaryState('none');
setStateWrapper(addStaticValueColumn());
return;
}
if (tabClicked === 'formula') {
setTemporaryState('none');
if (selectedColumn?.operationType !== formulaOperationName) {
const newLayer = insertOrReplaceColumn({
layer: props.state.layers[props.layerId],
indexPattern: currentIndexPattern,
columnId,
op: 'formula',
op: formulaOperationName,
visualizationGroups: dimensionGroups,
});
setStateWrapper(newLayer);
trackUiEvent(`indexpattern_dimension_operation_formula`);
return;
} else {
setQuickFunction(false);
}
}}
>
{i18n.translate('xpack.lens.indexPattern.formulaLabel', {
defaultMessage: 'Formula',
})}
</EuiTab>
</EuiTabs>
}
}}
/>
) : null}
{isFullscreen
? formulaTab
: selectedOperationDefinition?.type === 'formula' && !temporaryQuickFunction
? formulaTab
: quickFunctions}
<CalloutWarning
currentOperationType={selectedColumn?.operationType}
temporaryStateType={temporaryState}
/>
{TabContent}
{!isFullscreen && !currentFieldIsInvalid && !temporaryQuickFunction && (
{!isFullscreen && !currentFieldIsInvalid && temporaryState === 'none' && (
<div className="lnsIndexPatternDimensionEditor__section lnsIndexPatternDimensionEditor__section--padded">
{!incompleteInfo && selectedColumn && (
<LabelInput
@ -725,7 +773,8 @@ export function DimensionEditor(props: DimensionEditorProps) {
/>
)}
{!isFullscreen &&
{supportFieldFormat &&
!isFullscreen &&
selectedColumn &&
(selectedColumn.dataType === 'number' || selectedColumn.operationType === 'range') ? (
<FormatSelector selectedColumn={selectedColumn} onChange={onFormatChange} />
@ -735,26 +784,3 @@ export function DimensionEditor(props: DimensionEditorProps) {
</div>
);
}
function getErrorMessage(
selectedColumn: IndexPatternColumn | undefined,
incompleteOperation: boolean,
input: 'none' | 'field' | 'fullReference' | 'managedReference' | undefined,
fieldInvalid: boolean
) {
if (selectedColumn && incompleteOperation) {
if (input === 'field') {
return i18n.translate('xpack.lens.indexPattern.invalidOperationLabel', {
defaultMessage: 'This field does not work with the selected function.',
});
}
return i18n.translate('xpack.lens.indexPattern.chooseFieldLabel', {
defaultMessage: 'To use this function, select a field.',
});
}
if (fieldInvalid) {
return i18n.translate('xpack.lens.indexPattern.invalidFieldLabel', {
defaultMessage: 'Invalid field. Check your index pattern or pick another field.',
});
}
}

View file

@ -52,6 +52,13 @@ jest.mock('lodash', () => {
};
});
jest.mock('../../id_generator');
// Mock the Monaco Editor component
jest.mock('../operations/definitions/formula/editor/formula_editor', () => {
return {
WrappedFormulaEditor: () => <div />,
FormulaEditor: () => <div />,
};
});
const fields = [
{
@ -211,6 +218,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
dimensionGroups: [],
groupId: 'a',
isFullscreen: false,
supportStaticValue: false,
toggleFullscreen: jest.fn(),
};
@ -402,8 +410,9 @@ describe('IndexPatternDimensionEditorPanel', () => {
const items: EuiListGroupItemProps[] = wrapper.find(EuiListGroup).prop('listItems') || [];
expect(items.find(({ id }) => id === 'math')).toBeUndefined();
expect(items.find(({ id }) => id === 'formula')).toBeUndefined();
['math', 'formula', 'static_value'].forEach((hiddenOp) => {
expect(items.some(({ id }) => id === hiddenOp)).toBe(false);
});
});
it('should indicate that reference-based operations are not compatible when they are incomplete', () => {
@ -2217,4 +2226,130 @@ describe('IndexPatternDimensionEditorPanel', () => {
0
);
});
it('should not show tabs when formula and static_value operations are not available', () => {
const stateWithInvalidCol: IndexPatternPrivateState = getStateWithColumns({
col1: {
label: 'Average of memory',
dataType: 'number',
isBucketed: false,
// Private
operationType: 'average',
sourceField: 'memory',
params: {
format: { id: 'bytes', params: { decimals: 2 } },
},
},
});
const props = {
...defaultProps,
filterOperations: jest.fn((op) => {
// the formula operation will fall into this metadata category
return !(op.dataType === 'number' && op.scale === 'ratio');
}),
};
wrapper = mount(
<IndexPatternDimensionEditorComponent {...props} state={stateWithInvalidCol} />
);
expect(wrapper.find('[data-test-subj="lens-dimensionTabs"]').exists()).toBeFalsy();
});
it('should show the formula tab when supported', () => {
const stateWithFormulaColumn: IndexPatternPrivateState = getStateWithColumns({
col1: {
label: 'Formula',
dataType: 'number',
isBucketed: false,
operationType: 'formula',
references: ['ref1'],
params: {},
},
});
wrapper = mount(
<IndexPatternDimensionEditorComponent {...defaultProps} state={stateWithFormulaColumn} />
);
expect(
wrapper.find('[data-test-subj="lens-dimensionTabs-formula"]').first().prop('isSelected')
).toBeTruthy();
});
it('should now show the static_value tab when not supported', () => {
const stateWithFormulaColumn: IndexPatternPrivateState = getStateWithColumns({
col1: {
label: 'Formula',
dataType: 'number',
isBucketed: false,
operationType: 'formula',
references: ['ref1'],
params: {},
},
});
wrapper = mount(
<IndexPatternDimensionEditorComponent {...defaultProps} state={stateWithFormulaColumn} />
);
expect(wrapper.find('[data-test-subj="lens-dimensionTabs-static_value"]').exists()).toBeFalsy();
});
it('should show the static value tab when supported', () => {
const staticWithFormulaColumn: IndexPatternPrivateState = getStateWithColumns({
col1: {
label: 'Formula',
dataType: 'number',
isBucketed: false,
operationType: 'formula',
references: ['ref1'],
params: {},
},
});
wrapper = mount(
<IndexPatternDimensionEditorComponent
{...defaultProps}
supportStaticValue
state={staticWithFormulaColumn}
/>
);
expect(
wrapper.find('[data-test-subj="lens-dimensionTabs-static_value"]').exists()
).toBeTruthy();
});
it('should select the quick function tab by default', () => {
const stateWithNoColumn: IndexPatternPrivateState = getStateWithColumns({});
wrapper = mount(
<IndexPatternDimensionEditorComponent {...defaultProps} state={stateWithNoColumn} />
);
expect(
wrapper
.find('[data-test-subj="lens-dimensionTabs-quickFunctions"]')
.first()
.prop('isSelected')
).toBeTruthy();
});
it('should select the static value tab when supported by default', () => {
const stateWithNoColumn: IndexPatternPrivateState = getStateWithColumns({});
wrapper = mount(
<IndexPatternDimensionEditorComponent
{...defaultProps}
supportStaticValue
state={stateWithNoColumn}
/>
);
expect(
wrapper.find('[data-test-subj="lens-dimensionTabs-static_value"]').first().prop('isSelected')
).toBeTruthy();
});
});

View file

@ -16,7 +16,7 @@ import { IndexPatternColumn } from '../indexpattern';
import { isColumnInvalid } from '../utils';
import { IndexPatternPrivateState } from '../types';
import { DimensionEditor } from './dimension_editor';
import type { DateRange } from '../../../common';
import { DateRange, layerTypes } from '../../../common';
import { getOperationSupportMatrix } from './operation_support';
export type IndexPatternDimensionTriggerProps =
@ -49,11 +49,11 @@ export const IndexPatternDimensionTriggerComponent = function IndexPatternDimens
const layerId = props.layerId;
const layer = props.state.layers[layerId];
const currentIndexPattern = props.state.indexPatterns[layer.indexPatternId];
const { columnId, uniqueLabel } = props;
const { columnId, uniqueLabel, invalid, invalidMessage } = props;
const currentColumnHasErrors = useMemo(
() => isColumnInvalid(layer, columnId, currentIndexPattern),
[layer, columnId, currentIndexPattern]
() => invalid || isColumnInvalid(layer, columnId, currentIndexPattern),
[layer, columnId, currentIndexPattern, invalid]
);
const selectedColumn: IndexPatternColumn | null = layer.columns[props.columnId] ?? null;
@ -67,15 +67,17 @@ export const IndexPatternDimensionTriggerComponent = function IndexPatternDimens
return (
<EuiToolTip
content={
<p>
{i18n.translate('xpack.lens.configure.invalidConfigTooltip', {
defaultMessage: 'Invalid configuration.',
})}
<br />
{i18n.translate('xpack.lens.configure.invalidConfigTooltipClick', {
defaultMessage: 'Click for more details.',
})}
</p>
invalidMessage ?? (
<p>
{i18n.translate('xpack.lens.configure.invalidConfigTooltip', {
defaultMessage: 'Invalid configuration.',
})}
<br />
{i18n.translate('xpack.lens.configure.invalidConfigTooltipClick', {
defaultMessage: 'Click for more details.',
})}
</p>
)
}
anchorClassName="eui-displayBlock"
>
@ -127,6 +129,7 @@ export const IndexPatternDimensionEditorComponent = function IndexPatternDimensi
return (
<DimensionEditor
{...props}
layerType={props.layerType || layerTypes.DATA}
currentIndexPattern={currentIndexPattern}
selectedColumn={selectedColumn}
operationSupportMatrix={operationSupportMatrix}

View file

@ -0,0 +1,223 @@
/*
* 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.
*/
/*
* 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 './dimension_editor.scss';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFormRow, EuiFieldText, EuiTabs, EuiTab, EuiCallOut } from '@elastic/eui';
import { IndexPatternColumn, operationDefinitionMap } from '../operations';
import { useDebouncedValue } from '../../shared_components';
export const formulaOperationName = 'formula';
export const staticValueOperationName = 'static_value';
export const quickFunctionsName = 'quickFunctions';
export const nonQuickFunctions = new Set([formulaOperationName, staticValueOperationName]);
export type TemporaryState = typeof quickFunctionsName | typeof staticValueOperationName | 'none';
export function isQuickFunction(operationType: string) {
return !nonQuickFunctions.has(operationType);
}
export const LabelInput = ({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) => {
const { inputValue, handleInputChange, initialValue } = useDebouncedValue({ onChange, value });
return (
<EuiFormRow
label={i18n.translate('xpack.lens.indexPattern.columnLabel', {
defaultMessage: 'Display name',
description: 'Display name of a column of data',
})}
display="columnCompressed"
fullWidth
>
<EuiFieldText
compressed
data-test-subj="indexPattern-label-edit"
value={inputValue}
onChange={(e) => {
handleInputChange(e.target.value);
}}
placeholder={initialValue}
/>
</EuiFormRow>
);
};
export function getParamEditor(
temporaryStaticValue: boolean,
selectedOperationDefinition: typeof operationDefinitionMap[string] | undefined,
showDefaultStaticValue: boolean
) {
if (temporaryStaticValue) {
return operationDefinitionMap[staticValueOperationName].paramEditor;
}
if (selectedOperationDefinition?.paramEditor) {
return selectedOperationDefinition.paramEditor;
}
if (showDefaultStaticValue) {
return operationDefinitionMap[staticValueOperationName].paramEditor;
}
return null;
}
export const CalloutWarning = ({
currentOperationType,
temporaryStateType,
}: {
currentOperationType: keyof typeof operationDefinitionMap | undefined;
temporaryStateType: TemporaryState;
}) => {
if (
temporaryStateType === 'none' ||
(currentOperationType != null && isQuickFunction(currentOperationType))
) {
return null;
}
if (
currentOperationType === staticValueOperationName &&
temporaryStateType === 'quickFunctions'
) {
return (
<>
<EuiCallOut
className="lnsIndexPatternDimensionEditor__warning"
size="s"
title={i18n.translate('xpack.lens.indexPattern.staticValueWarning', {
defaultMessage: 'Static value currently applied',
})}
iconType="alert"
color="warning"
>
<p>
{i18n.translate('xpack.lens.indexPattern.staticValueWarningText', {
defaultMessage: 'To overwrite your static value, select a quick function',
})}
</p>
</EuiCallOut>
</>
);
}
return (
<>
<EuiCallOut
className="lnsIndexPatternDimensionEditor__warning"
size="s"
title={i18n.translate('xpack.lens.indexPattern.formulaWarning', {
defaultMessage: 'Formula currently applied',
})}
iconType="alert"
color="warning"
>
{temporaryStateType !== 'quickFunctions' ? (
<p>
{i18n.translate('xpack.lens.indexPattern.formulaWarningStaticValueText', {
defaultMessage: 'To overwrite your formula, change the value in the input field',
})}
</p>
) : (
<p>
{i18n.translate('xpack.lens.indexPattern.formulaWarningText', {
defaultMessage: 'To overwrite your formula, select a quick function',
})}
</p>
)}
</EuiCallOut>
</>
);
};
type DimensionEditorTabsType =
| typeof quickFunctionsName
| typeof staticValueOperationName
| typeof formulaOperationName;
export const DimensionEditorTabs = ({
tabsEnabled,
tabsState,
onClick,
}: {
tabsEnabled: Record<DimensionEditorTabsType, boolean>;
tabsState: Record<DimensionEditorTabsType, boolean>;
onClick: (tabClicked: DimensionEditorTabsType) => void;
}) => {
return (
<EuiTabs
size="s"
className="lnsIndexPatternDimensionEditor__header"
data-test-subj="lens-dimensionTabs"
>
{tabsEnabled.static_value ? (
<EuiTab
isSelected={tabsState.static_value}
data-test-subj="lens-dimensionTabs-static_value"
onClick={() => onClick(staticValueOperationName)}
>
{i18n.translate('xpack.lens.indexPattern.staticValueLabel', {
defaultMessage: 'Static value',
})}
</EuiTab>
) : null}
<EuiTab
isSelected={tabsState.quickFunctions}
data-test-subj="lens-dimensionTabs-quickFunctions"
onClick={() => onClick(quickFunctionsName)}
>
{i18n.translate('xpack.lens.indexPattern.quickFunctionsLabel', {
defaultMessage: 'Quick functions',
})}
</EuiTab>
{tabsEnabled.formula ? (
<EuiTab
isSelected={tabsState.formula}
data-test-subj="lens-dimensionTabs-formula"
onClick={() => onClick(formulaOperationName)}
>
{i18n.translate('xpack.lens.indexPattern.formulaLabel', {
defaultMessage: 'Formula',
})}
</EuiTab>
) : null}
</EuiTabs>
);
};
export function getErrorMessage(
selectedColumn: IndexPatternColumn | undefined,
incompleteOperation: boolean,
input: 'none' | 'field' | 'fullReference' | 'managedReference' | undefined,
fieldInvalid: boolean
) {
if (selectedColumn && incompleteOperation) {
if (input === 'field') {
return i18n.translate('xpack.lens.indexPattern.invalidOperationLabel', {
defaultMessage: 'This field does not work with the selected function.',
});
}
return i18n.translate('xpack.lens.indexPattern.chooseFieldLabel', {
defaultMessage: 'To use this function, select a field.',
});
}
if (fieldInvalid) {
return i18n.translate('xpack.lens.indexPattern.invalidFieldLabel', {
defaultMessage: 'Invalid field. Check your index pattern or pick another field.',
});
}
}

View file

@ -17,6 +17,7 @@ import { OperationMetadata, DropType } from '../../../types';
import { IndexPatternColumn, MedianIndexPatternColumn } from '../../operations';
import { getFieldByNameFactory } from '../../pure_helpers';
import { generateId } from '../../../id_generator';
import { layerTypes } from '../../../../common';
jest.mock('../../../id_generator');
@ -263,7 +264,6 @@ describe('IndexPatternDimensionEditorPanel', () => {
dateRange: { fromDate: 'now-1d', toDate: 'now' },
columnId: 'col1',
layerId: 'first',
layerType: 'data',
uniqueLabel: 'stuff',
groupId: 'group1',
filterOperations: () => true,
@ -287,6 +287,8 @@ describe('IndexPatternDimensionEditorPanel', () => {
dimensionGroups: [],
isFullscreen: false,
toggleFullscreen: () => {},
supportStaticValue: false,
layerType: layerTypes.DATA,
};
jest.clearAllMocks();

View file

@ -121,8 +121,12 @@ function onMoveCompatible(
indexPattern,
});
let updatedColumnOrder = getColumnOrder(modifiedLayer);
updatedColumnOrder = reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId);
const updatedColumnOrder = reorderByGroups(
dimensionGroups,
groupId,
getColumnOrder(modifiedLayer),
columnId
);
// Time to replace
setState(

View file

@ -1623,4 +1623,87 @@ describe('IndexPattern Data Source', () => {
expect(indexPatternDatasource.isTimeBased(state)).toEqual(false);
});
});
describe('#initializeDimension', () => {
it('should return the same state if no static value is passed', () => {
const state = enrichBaseState({
currentIndexPatternId: '1',
layers: {
first: {
indexPatternId: '1',
columnOrder: ['metric'],
columns: {
metric: {
label: 'Count of records',
dataType: 'number',
isBucketed: false,
sourceField: 'Records',
operationType: 'count',
},
},
},
},
});
expect(
indexPatternDatasource.initializeDimension!(state, 'first', {
columnId: 'newStatic',
label: 'MyNewColumn',
groupId: 'a',
dataType: 'number',
})
).toBe(state);
});
it('should add a new static value column if a static value is passed', () => {
const state = enrichBaseState({
currentIndexPatternId: '1',
layers: {
first: {
indexPatternId: '1',
columnOrder: ['metric'],
columns: {
metric: {
label: 'Count of records',
dataType: 'number',
isBucketed: false,
sourceField: 'Records',
operationType: 'count',
},
},
},
},
});
expect(
indexPatternDatasource.initializeDimension!(state, 'first', {
columnId: 'newStatic',
label: 'MyNewColumn',
groupId: 'a',
dataType: 'number',
staticValue: 0, // use a falsy value to check also this corner case
})
).toEqual({
...state,
layers: {
...state.layers,
first: {
...state.layers.first,
incompleteColumns: {},
columnOrder: ['metric', 'newStatic'],
columns: {
...state.layers.first.columns,
newStatic: {
dataType: 'number',
isBucketed: false,
label: 'Static value: 0',
operationType: 'static_value',
params: { value: 0 },
references: [],
scale: 'ratio',
},
},
},
},
});
});
});
});

View file

@ -44,7 +44,7 @@ import {
import { isDraggedField, normalizeOperationDataType } from './utils';
import { LayerPanel } from './layerpanel';
import { IndexPatternColumn, getErrorMessages } from './operations';
import { IndexPatternColumn, getErrorMessages, insertNewColumn } from './operations';
import { IndexPatternField, IndexPatternPrivateState, IndexPatternPersistedState } from './types';
import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public';
import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
@ -192,6 +192,27 @@ export function getIndexPatternDatasource({
});
},
initializeDimension(state, layerId, { columnId, groupId, label, dataType, staticValue }) {
const indexPattern = state.indexPatterns[state.layers[layerId]?.indexPatternId];
if (staticValue == null) {
return state;
}
return mergeLayer({
state,
layerId,
newLayer: insertNewColumn({
layer: state.layers[layerId],
op: 'static_value',
columnId,
field: undefined,
indexPattern,
visualizationGroups: [],
initialParams: { params: { value: staticValue } },
targetGroup: groupId,
}),
});
},
toExpression: (state, layerId) => toExpression(state, layerId, uiSettings),
renderDataPanel(
@ -404,9 +425,14 @@ export function getIndexPatternDatasource({
},
};
},
getDatasourceSuggestionsForField(state, draggedField) {
getDatasourceSuggestionsForField(state, draggedField, filterLayers) {
return isDraggedField(draggedField)
? getDatasourceSuggestionsForField(state, draggedField.indexPatternId, draggedField.field)
? getDatasourceSuggestionsForField(
state,
draggedField.indexPatternId,
draggedField.field,
filterLayers
)
: [];
},
getDatasourceSuggestionsFromCurrentState,

View file

@ -1198,6 +1198,91 @@ describe('IndexPattern Data Source suggestions', () => {
})
);
});
it('should apply layers filter if passed and model the suggestion based on that', () => {
(generateId as jest.Mock).mockReturnValue('newid');
const initialState = stateWithNonEmptyTables();
const modifiedState: IndexPatternPrivateState = {
...initialState,
layers: {
thresholdLayer: {
indexPatternId: '1',
columnOrder: ['threshold'],
columns: {
threshold: {
dataType: 'number',
isBucketed: false,
label: 'Static Value: 0',
operationType: 'static_value',
params: { value: '0' },
references: [],
scale: 'ratio',
},
},
},
currentLayer: {
indexPatternId: '1',
columnOrder: ['metric', 'ref'],
columns: {
metric: {
label: '',
customLabel: true,
dataType: 'number',
isBucketed: false,
operationType: 'average',
sourceField: 'bytes',
},
ref: {
label: '',
customLabel: true,
dataType: 'number',
isBucketed: false,
operationType: 'cumulative_sum',
references: ['metric'],
},
},
},
},
};
const suggestions = getSuggestionSubset(
getDatasourceSuggestionsForField(
modifiedState,
'1',
documentField,
(layerId) => layerId !== 'thresholdLayer'
)
);
// should ignore the threshold layer
expect(suggestions).toContainEqual(
expect.objectContaining({
table: expect.objectContaining({
changeType: 'extended',
columns: [
{
columnId: 'ref',
operation: {
dataType: 'number',
isBucketed: false,
label: '',
scale: undefined,
},
},
{
columnId: 'newid',
operation: {
dataType: 'number',
isBucketed: false,
label: 'Count of records',
scale: 'ratio',
},
},
],
}),
})
);
});
});
describe('finding the layer that is using the current index pattern', () => {

View file

@ -95,10 +95,14 @@ function buildSuggestion({
export function getDatasourceSuggestionsForField(
state: IndexPatternPrivateState,
indexPatternId: string,
field: IndexPatternField
field: IndexPatternField,
filterLayers?: (layerId: string) => boolean
): IndexPatternSuggestion[] {
const layers = Object.keys(state.layers);
const layerIds = layers.filter((id) => state.layers[id].indexPatternId === indexPatternId);
let layerIds = layers.filter((id) => state.layers[id].indexPatternId === indexPatternId);
if (filterLayers) {
layerIds = layerIds.filter(filterLayers);
}
if (layerIds.length === 0) {
// The field we're suggesting on does not match any existing layer.

View file

@ -355,6 +355,33 @@ describe('formula', () => {
references: [],
});
});
it('should move into Formula previous static_value operation', () => {
expect(
formulaOperation.buildColumn({
previousColumn: {
label: 'Static value: 0',
dataType: 'number',
isBucketed: false,
operationType: 'static_value',
references: [],
params: {
value: '0',
},
},
layer,
indexPattern,
})
).toEqual({
label: '0',
dataType: 'number',
operationType: 'formula',
isBucketed: false,
scale: 'ratio',
params: { isFormulaBroken: false, formula: '0' },
references: [],
});
});
});
describe('regenerateLayerFromAst()', () => {

View file

@ -38,6 +38,11 @@ export function generateFormula(
previousFormula: string,
operationDefinitionMap: Record<string, GenericOperationDefinition> | undefined
) {
if (previousColumn.operationType === 'static_value') {
if (previousColumn.params && 'value' in previousColumn.params) {
return String(previousColumn.params.value); // make sure it's a string
}
}
if ('references' in previousColumn) {
const metric = layer.columns[previousColumn.references[0]];
if (metric && 'sourceField' in metric && metric.dataType === 'number') {

View file

@ -7,7 +7,7 @@
import { i18n } from '@kbn/i18n';
import { IndexPatternColumn, operationDefinitionMap } from '.';
import { FieldBasedIndexPatternColumn } from './column_types';
import { FieldBasedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from './column_types';
import { IndexPattern } from '../../types';
export function getInvalidFieldMessage(
@ -81,8 +81,7 @@ export function isValidNumber(
const inputValueAsNumber = Number(inputValue);
return (
inputValue !== '' &&
inputValue !== null &&
inputValue !== undefined &&
inputValue != null &&
!Number.isNaN(inputValueAsNumber) &&
Number.isFinite(inputValueAsNumber) &&
(!integer || Number.isInteger(inputValueAsNumber)) &&
@ -91,7 +90,9 @@ export function isValidNumber(
);
}
export function getFormatFromPreviousColumn(previousColumn: IndexPatternColumn | undefined) {
export function getFormatFromPreviousColumn(
previousColumn: IndexPatternColumn | ReferenceBasedIndexPatternColumn | undefined
) {
return previousColumn?.dataType === 'number' &&
previousColumn.params &&
'format' in previousColumn.params &&

View file

@ -49,6 +49,7 @@ import {
formulaOperation,
FormulaIndexPatternColumn,
} from './formula';
import { staticValueOperation, StaticValueIndexPatternColumn } from './static_value';
import { lastValueOperation, LastValueIndexPatternColumn } from './last_value';
import { FrameDatasourceAPI, OperationMetadata } from '../../../types';
import type { BaseIndexPatternColumn, ReferenceBasedIndexPatternColumn } from './column_types';
@ -87,7 +88,8 @@ export type IndexPatternColumn =
| DerivativeIndexPatternColumn
| MovingAverageIndexPatternColumn
| MathIndexPatternColumn
| FormulaIndexPatternColumn;
| FormulaIndexPatternColumn
| StaticValueIndexPatternColumn;
export type FieldBasedIndexPatternColumn = Extract<IndexPatternColumn, { sourceField: string }>;
@ -119,6 +121,7 @@ export { CountIndexPatternColumn } from './count';
export { LastValueIndexPatternColumn } from './last_value';
export { RangeIndexPatternColumn } from './ranges';
export { FormulaIndexPatternColumn, MathIndexPatternColumn } from './formula';
export { StaticValueIndexPatternColumn } from './static_value';
// List of all operation definitions registered to this data source.
// If you want to implement a new operation, add the definition to this array and
@ -147,6 +150,7 @@ const internalOperationDefinitions = [
overallMinOperation,
overallMaxOperation,
overallAverageOperation,
staticValueOperation,
];
export { termsOperation } from './terms';
@ -168,6 +172,7 @@ export {
overallMinOperation,
} from './calculations';
export { formulaOperation } from './formula/formula';
export { staticValueOperation } from './static_value';
/**
* Properties passed to the operation-specific part of the popover editor

View file

@ -0,0 +1,404 @@
/*
* 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 from 'react';
import { shallow, mount } from 'enzyme';
import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public';
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';
import { createMockedIndexPattern } from '../../mocks';
import { staticValueOperation } from './index';
import { IndexPattern, IndexPatternLayer } from '../../types';
import { StaticValueIndexPatternColumn } from './static_value';
import { EuiFieldNumber } from '@elastic/eui';
import { act } from 'react-dom/test-utils';
jest.mock('lodash', () => {
const original = jest.requireActual('lodash');
return {
...original,
debounce: (fn: unknown) => fn,
};
});
const uiSettingsMock = {} as IUiSettingsClient;
const defaultProps = {
storage: {} as IStorageWrapper,
uiSettings: uiSettingsMock,
savedObjectsClient: {} as SavedObjectsClientContract,
dateRange: { fromDate: 'now-1d', toDate: 'now' },
data: dataPluginMock.createStartContract(),
http: {} as HttpSetup,
indexPattern: {
...createMockedIndexPattern(),
hasRestrictions: false,
} as IndexPattern,
operationDefinitionMap: {},
isFullscreen: false,
toggleFullscreen: jest.fn(),
setIsCloseable: jest.fn(),
layerId: '1',
};
describe('static_value', () => {
let layer: IndexPatternLayer;
beforeEach(() => {
layer = {
indexPatternId: '1',
columnOrder: ['col1', 'col2'],
columns: {
col1: {
label: 'Top value of category',
dataType: 'string',
isBucketed: true,
operationType: 'terms',
params: {
orderBy: { type: 'alphabetical' },
size: 3,
orderDirection: 'asc',
},
sourceField: 'category',
},
col2: {
label: 'Static value: 23',
dataType: 'number',
isBucketed: false,
operationType: 'static_value',
references: [],
params: {
value: '23',
},
},
},
};
});
function getLayerWithStaticValue(newValue: string): IndexPatternLayer {
return {
...layer,
columns: {
...layer.columns,
col2: {
...layer.columns.col2,
label: `Static value: ${newValue}`,
params: {
value: newValue,
},
} as StaticValueIndexPatternColumn,
},
};
}
describe('getDefaultLabel', () => {
it('should return the label for the given value', () => {
expect(
staticValueOperation.getDefaultLabel(
{
label: 'Static value: 23',
dataType: 'number',
isBucketed: false,
operationType: 'static_value',
references: [],
params: {
value: '23',
},
},
createMockedIndexPattern(),
layer.columns
)
).toBe('Static value: 23');
});
it('should return the default label for non valid value', () => {
expect(
staticValueOperation.getDefaultLabel(
{
label: 'Static value',
dataType: 'number',
isBucketed: false,
operationType: 'static_value',
references: [],
params: {
value: '',
},
},
createMockedIndexPattern(),
layer.columns
)
).toBe('Static value');
});
});
describe('getErrorMessage', () => {
it('should return no error for valid values', () => {
expect(
staticValueOperation.getErrorMessage!(
getLayerWithStaticValue('23'),
'col2',
createMockedIndexPattern()
)
).toBeUndefined();
// test for potential falsy value
expect(
staticValueOperation.getErrorMessage!(
getLayerWithStaticValue('0'),
'col2',
createMockedIndexPattern()
)
).toBeUndefined();
});
it('should return error for invalid values', () => {
for (const value of ['NaN', 'Infinity', 'string']) {
expect(
staticValueOperation.getErrorMessage!(
getLayerWithStaticValue(value),
'col2',
createMockedIndexPattern()
)
).toEqual(expect.arrayContaining([expect.stringMatching('is not a valid number')]));
}
});
});
describe('toExpression', () => {
it('should return a mathColumn operation with valid value', () => {
for (const value of ['23', '0', '-1']) {
expect(
staticValueOperation.toExpression(
getLayerWithStaticValue(value),
'col2',
createMockedIndexPattern()
)
).toEqual([
{
type: 'function',
function: 'mathColumn',
arguments: {
id: ['col2'],
name: [`Static value: ${value}`],
expression: [value],
},
},
]);
}
});
it('should fallback to mapColumn for invalid value', () => {
for (const value of ['NaN', '', 'Infinity']) {
expect(
staticValueOperation.toExpression(
getLayerWithStaticValue(value),
'col2',
createMockedIndexPattern()
)
).toEqual([
{
type: 'function',
function: 'mapColumn',
arguments: {
id: ['col2'],
name: [`Static value`],
expression: ['100'],
},
},
]);
}
});
});
describe('buildColumn', () => {
it('should set default static value', () => {
expect(
staticValueOperation.buildColumn({
indexPattern: createMockedIndexPattern(),
layer: { columns: {}, columnOrder: [], indexPatternId: '' },
})
).toEqual({
label: 'Static value',
dataType: 'number',
operationType: 'static_value',
isBucketed: false,
scale: 'ratio',
params: { value: '100' },
references: [],
});
});
it('should merge a previousColumn', () => {
expect(
staticValueOperation.buildColumn({
indexPattern: createMockedIndexPattern(),
layer: { columns: {}, columnOrder: [], indexPatternId: '' },
previousColumn: {
label: 'Static value',
dataType: 'number',
operationType: 'static_value',
isBucketed: false,
scale: 'ratio',
params: { value: '23' },
references: [],
},
})
).toEqual({
label: 'Static value: 23',
dataType: 'number',
operationType: 'static_value',
isBucketed: false,
scale: 'ratio',
params: { value: '23' },
references: [],
});
});
it('should create a static_value from passed arguments', () => {
expect(
staticValueOperation.buildColumn(
{
indexPattern: createMockedIndexPattern(),
layer: { columns: {}, columnOrder: [], indexPatternId: '' },
},
{ value: '23' }
)
).toEqual({
label: 'Static value: 23',
dataType: 'number',
operationType: 'static_value',
isBucketed: false,
scale: 'ratio',
params: { value: '23' },
references: [],
});
});
it('should prioritize passed arguments over previousColumn', () => {
expect(
staticValueOperation.buildColumn(
{
indexPattern: createMockedIndexPattern(),
layer: { columns: {}, columnOrder: [], indexPatternId: '' },
previousColumn: {
label: 'Static value',
dataType: 'number',
operationType: 'static_value',
isBucketed: false,
scale: 'ratio',
params: { value: '23' },
references: [],
},
},
{ value: '53' }
)
).toEqual({
label: 'Static value: 53',
dataType: 'number',
operationType: 'static_value',
isBucketed: false,
scale: 'ratio',
params: { value: '53' },
references: [],
});
});
});
describe('paramEditor', () => {
const ParamEditor = staticValueOperation.paramEditor!;
it('should render current static_value', () => {
const updateLayerSpy = jest.fn();
const instance = shallow(
<ParamEditor
{...defaultProps}
layer={layer}
updateLayer={updateLayerSpy}
columnId="col2"
currentColumn={layer.columns.col2 as StaticValueIndexPatternColumn}
/>
);
const input = instance.find('[data-test-subj="lns-indexPattern-static_value-input"]');
expect(input.prop('value')).toEqual('23');
});
it('should update state on change', async () => {
const updateLayerSpy = jest.fn();
const instance = mount(
<ParamEditor
{...defaultProps}
layer={layer}
updateLayer={updateLayerSpy}
columnId="col2"
currentColumn={layer.columns.col2 as StaticValueIndexPatternColumn}
/>
);
const input = instance
.find('[data-test-subj="lns-indexPattern-static_value-input"]')
.find(EuiFieldNumber);
await act(async () => {
input.prop('onChange')!({
currentTarget: { value: '27' },
} as React.ChangeEvent<HTMLInputElement>);
});
instance.update();
expect(updateLayerSpy.mock.calls[0]).toEqual([expect.any(Function)]);
// check that the result of the setter call is correct
expect(updateLayerSpy.mock.calls[0][0](layer)).toEqual({
...layer,
columns: {
...layer.columns,
col2: {
...layer.columns.col2,
params: {
value: '27',
},
label: 'Static value: 27',
},
},
});
});
it('should not update on invalid input, but show invalid value locally', async () => {
const updateLayerSpy = jest.fn();
const instance = mount(
<ParamEditor
{...defaultProps}
layer={layer}
updateLayer={updateLayerSpy}
columnId="col2"
currentColumn={layer.columns.col2 as StaticValueIndexPatternColumn}
/>
);
const input = instance
.find('[data-test-subj="lns-indexPattern-static_value-input"]')
.find(EuiFieldNumber);
await act(async () => {
input.prop('onChange')!({
currentTarget: { value: '' },
} as React.ChangeEvent<HTMLInputElement>);
});
instance.update();
expect(updateLayerSpy).not.toHaveBeenCalled();
expect(
instance
.find('[data-test-subj="lns-indexPattern-static_value-input"]')
.find(EuiFieldNumber)
.prop('value')
).toEqual('');
});
});
});

View file

@ -0,0 +1,222 @@
/*
* 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, { useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFieldNumber, EuiFormLabel, EuiSpacer } from '@elastic/eui';
import { OperationDefinition } from './index';
import { ReferenceBasedIndexPatternColumn } from './column_types';
import type { IndexPattern } from '../../types';
import { useDebouncedValue } from '../../../shared_components';
import { getFormatFromPreviousColumn, isValidNumber } from './helpers';
const defaultLabel = i18n.translate('xpack.lens.indexPattern.staticValueLabelDefault', {
defaultMessage: 'Static value',
});
const defaultValue = 100;
function isEmptyValue(value: number | string | undefined) {
return value == null || value === '';
}
function ofName(value: number | string | undefined) {
if (isEmptyValue(value)) {
return defaultLabel;
}
return i18n.translate('xpack.lens.indexPattern.staticValueLabelWithValue', {
defaultMessage: 'Static value: {value}',
values: { value },
});
}
export interface StaticValueIndexPatternColumn extends ReferenceBasedIndexPatternColumn {
operationType: 'static_value';
params: {
value?: string;
format?: {
id: string;
params?: {
decimals: number;
};
};
};
}
export const staticValueOperation: OperationDefinition<
StaticValueIndexPatternColumn,
'managedReference'
> = {
type: 'static_value',
displayName: defaultLabel,
getDefaultLabel: (column) => ofName(column.params.value),
input: 'managedReference',
hidden: true,
getDisabledStatus(indexPattern: IndexPattern) {
return undefined;
},
getErrorMessage(layer, columnId) {
const column = layer.columns[columnId] as StaticValueIndexPatternColumn;
return !isValidNumber(column.params.value)
? [
i18n.translate('xpack.lens.indexPattern.staticValueError', {
defaultMessage: 'The static value of {value} is not a valid number',
values: { value: column.params.value },
}),
]
: undefined;
},
getPossibleOperation() {
return {
dataType: 'number',
isBucketed: false,
scale: 'ratio',
};
},
toExpression: (layer, columnId) => {
const currentColumn = layer.columns[columnId] as StaticValueIndexPatternColumn;
const params = currentColumn.params;
// TODO: improve this logic
const useDisplayLabel = currentColumn.label !== defaultLabel;
const label = isValidNumber(params.value)
? useDisplayLabel
? currentColumn.label
: params?.value ?? defaultLabel
: defaultLabel;
return [
{
type: 'function',
function: isValidNumber(params.value) ? 'mathColumn' : 'mapColumn',
arguments: {
id: [columnId],
name: [label || defaultLabel],
expression: [isValidNumber(params.value) ? params.value! : String(defaultValue)],
},
},
];
},
buildColumn({ previousColumn, layer, indexPattern }, columnParams, operationDefinitionMap) {
const existingStaticValue =
previousColumn?.params &&
'value' in previousColumn.params &&
isValidNumber(previousColumn.params.value)
? previousColumn.params.value
: undefined;
const previousParams: StaticValueIndexPatternColumn['params'] = {
...{ value: existingStaticValue },
...getFormatFromPreviousColumn(previousColumn),
...columnParams,
};
return {
label: ofName(previousParams.value),
dataType: 'number',
operationType: 'static_value',
isBucketed: false,
scale: 'ratio',
params: { ...previousParams, value: previousParams.value ?? String(defaultValue) },
references: [],
};
},
isTransferable: (column) => {
return true;
},
createCopy(layer, sourceId, targetId, indexPattern, operationDefinitionMap) {
const currentColumn = layer.columns[sourceId] as StaticValueIndexPatternColumn;
return {
...layer,
columns: {
...layer.columns,
[targetId]: { ...currentColumn },
},
};
},
paramEditor: function StaticValueEditor({
layer,
updateLayer,
currentColumn,
columnId,
activeData,
layerId,
indexPattern,
}) {
const onChange = useCallback(
(newValue) => {
// even if debounced it's triggering for empty string with the previous valid value
if (currentColumn.params.value === newValue) {
return;
}
// Because of upstream specific UX flows, we need fresh layer state here
// so need to use the updater pattern
updateLayer((newLayer) => {
const newColumn = newLayer.columns[columnId] as StaticValueIndexPatternColumn;
return {
...newLayer,
columns: {
...newLayer.columns,
[columnId]: {
...newColumn,
label: newColumn?.customLabel ? newColumn.label : ofName(newValue),
params: {
...newColumn.params,
value: newValue,
},
},
},
};
});
},
[columnId, updateLayer, currentColumn?.params?.value]
);
// Pick the data from the current activeData (to be used when the current operation is not static_value)
const activeDataValue =
activeData &&
activeData[layerId] &&
activeData[layerId]?.rows?.length === 1 &&
activeData[layerId].rows[0][columnId];
const fallbackValue =
currentColumn?.operationType !== 'static_value' && activeDataValue != null
? activeDataValue
: String(defaultValue);
const { inputValue, handleInputChange } = useDebouncedValue<string | undefined>(
{
value: currentColumn?.params?.value || fallbackValue,
onChange,
},
{ allowFalsyValue: true }
);
const onChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.currentTarget.value;
handleInputChange(isValidNumber(value) ? value : undefined);
},
[handleInputChange]
);
return (
<div className="lnsIndexPatternDimensionEditor__section lnsIndexPatternDimensionEditor__section--padded lnsIndexPatternDimensionEditor__section--shaded">
<EuiFormLabel>
{i18n.translate('xpack.lens.indexPattern.staticValue.label', {
defaultMessage: 'Threshold value',
})}
</EuiFormLabel>
<EuiSpacer size="s" />
<EuiFieldNumber
data-test-subj="lns-indexPattern-static_value-input"
compressed
value={inputValue ?? ''}
onChange={onChangeHandler}
/>
</div>
);
},
};

View file

@ -184,6 +184,7 @@ export function insertNewColumn({
targetGroup,
shouldResetLabel,
incompleteParams,
initialParams,
}: ColumnChange): IndexPatternLayer {
const operationDefinition = operationDefinitionMap[op];
@ -197,7 +198,7 @@ export function insertNewColumn({
const baseOptions = {
indexPattern,
previousColumn: { ...incompleteParams, ...layer.columns[columnId] },
previousColumn: { ...incompleteParams, ...initialParams, ...layer.columns[columnId] },
};
if (operationDefinition.input === 'none' || operationDefinition.input === 'managedReference') {
@ -396,9 +397,17 @@ export function replaceColumn({
tempLayer = resetIncomplete(tempLayer, columnId);
if (previousDefinition.input === 'managedReference') {
if (
previousDefinition.input === 'managedReference' &&
operationDefinition.input !== previousDefinition.input
) {
// If the transition is incomplete, leave the managed state until it's finished.
tempLayer = deleteColumn({ layer: tempLayer, columnId, indexPattern });
tempLayer = removeOrphanedColumns(
previousDefinition,
previousColumn,
tempLayer,
indexPattern
);
const hypotheticalLayer = insertNewColumn({
layer: tempLayer,
@ -641,21 +650,31 @@ function removeOrphanedColumns(
previousDefinition:
| OperationDefinition<IndexPatternColumn, 'field'>
| OperationDefinition<IndexPatternColumn, 'none'>
| OperationDefinition<IndexPatternColumn, 'fullReference'>,
| OperationDefinition<IndexPatternColumn, 'fullReference'>
| OperationDefinition<IndexPatternColumn, 'managedReference'>,
previousColumn: IndexPatternColumn,
tempLayer: IndexPatternLayer,
indexPattern: IndexPattern
) {
let newLayer: IndexPatternLayer = tempLayer;
if (previousDefinition.input === 'managedReference') {
const [columnId] =
Object.entries(tempLayer.columns).find(([_, currColumn]) => currColumn === previousColumn) ||
[];
if (columnId != null) {
newLayer = deleteColumn({ layer: tempLayer, columnId, indexPattern });
}
}
if (previousDefinition.input === 'fullReference') {
(previousColumn as ReferenceBasedIndexPatternColumn).references.forEach((id: string) => {
tempLayer = deleteColumn({
newLayer = deleteColumn({
layer: tempLayer,
columnId: id,
indexPattern,
});
});
}
return tempLayer;
return newLayer;
}
export function canTransition({

View file

@ -378,6 +378,10 @@ describe('getOperationTypesForField', () => {
"operationType": "formula",
"type": "managedReference",
},
Object {
"operationType": "static_value",
"type": "managedReference",
},
],
},
Object {

View file

@ -59,9 +59,9 @@ export function mockDatasourceStates() {
};
}
export function createMockVisualization(): jest.Mocked<Visualization> {
export function createMockVisualization(id = 'vis1'): jest.Mocked<Visualization> {
return {
id: 'TEST_VIS',
id,
clearLayer: jest.fn((state, _layerId) => state),
removeLayer: jest.fn(),
getLayerIds: jest.fn((_state) => ['layer1']),
@ -70,9 +70,9 @@ export function createMockVisualization(): jest.Mocked<Visualization> {
visualizationTypes: [
{
icon: 'empty',
id: 'TEST_VIS',
id,
label: 'TEST',
groupLabel: 'TEST_VISGroup',
groupLabel: `${id}Group`,
},
],
getVisualizationTypeId: jest.fn((_state) => 'empty'),
@ -122,7 +122,7 @@ export function createMockDatasource(id: string): DatasourceMock {
return {
id: 'mockindexpattern',
clearLayer: jest.fn((state, _layerId) => state),
getDatasourceSuggestionsForField: jest.fn((_state, _item) => []),
getDatasourceSuggestionsForField: jest.fn((_state, _item, filterFn) => []),
getDatasourceSuggestionsForVisualizeField: jest.fn((_state, _indexpatternId, _fieldName) => []),
getDatasourceSuggestionsFromCurrentState: jest.fn((_state) => []),
getPersistableState: jest.fn((x) => ({

View file

@ -11,6 +11,10 @@ import { debounce } from 'lodash';
/**
* Debounces value changes and updates inputValue on root state changes if no debounced changes
* are in flight because the user is currently modifying the value.
*
* * allowFalsyValue: update upstream with all falsy values but null or undefined
*
* When testing this function mock the "debounce" function in lodash (see this module test for an example)
*/
export const useDebouncedValue = <T>(

View file

@ -14,3 +14,4 @@ export * from './coloring';
export { useDebouncedValue } from './debounced_value';
export * from './helpers';
export { LegendActionPopover } from './legend_action_popover';
export * from './static_header';

View file

@ -0,0 +1,31 @@
/*
* 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 from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiTitle, IconType } from '@elastic/eui';
export const StaticHeader = ({ label, icon }: { label: string; icon?: IconType }) => {
return (
<EuiFlexGroup
gutterSize="s"
alignItems="center"
responsive={false}
className={'lnsLayerPanel__settingsStaticHeader'}
>
{icon && (
<EuiFlexItem grow={false}>
<EuiIcon type={icon} />{' '}
</EuiFlexItem>
)}
<EuiFlexItem grow>
<EuiTitle size="xxs">
<h5>{label}</h5>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -149,7 +149,7 @@ export function loadInitial(
datasourceMap,
datasourceStates,
visualizationMap,
activeVisualizationId: Object.keys(visualizationMap)[0] || null,
activeVisualization: visualizationMap?.[Object.keys(visualizationMap)[0]] || null,
visualizationState: null,
visualizeTriggerFieldContext: initialContext,
});

View file

@ -234,7 +234,11 @@ export interface Datasource<T = unknown, P = unknown> {
toExpression: (state: T, layerId: string) => ExpressionAstExpression | string | null;
getDatasourceSuggestionsForField: (state: T, field: unknown) => Array<DatasourceSuggestion<T>>;
getDatasourceSuggestionsForField: (
state: T,
field: unknown,
filterFn: (layerId: string) => boolean
) => Array<DatasourceSuggestion<T>>;
getDatasourceSuggestionsForVisualizeField: (
state: T,
indexPatternId: string,
@ -326,6 +330,8 @@ export type DatasourceDimensionProps<T> = SharedDimensionProps & {
onRemove?: (accessor: string) => void;
state: T;
activeData?: Record<string, Datatable>;
invalid?: boolean;
invalidMessage?: string;
};
// The only way a visualization has to restrict the query building
@ -335,6 +341,7 @@ export type DatasourceDimensionEditorProps<T = unknown> = DatasourceDimensionPro
newState: Parameters<StateSetter<T>>[0],
publishToVisualization?: {
isDimensionComplete?: boolean;
forceRender?: boolean;
}
) => void;
core: Pick<CoreSetup, 'http' | 'notifications' | 'uiSettings'>;
@ -343,6 +350,8 @@ export type DatasourceDimensionEditorProps<T = unknown> = DatasourceDimensionPro
toggleFullscreen: () => void;
isFullscreen: boolean;
layerType: LayerType | undefined;
supportStaticValue: boolean;
supportFieldFormat?: boolean;
};
export type DatasourceDimensionTriggerProps<T> = DatasourceDimensionProps<T>;
@ -434,7 +443,7 @@ export interface VisualizationToolbarProps<T = unknown> {
export type VisualizationDimensionEditorProps<T = unknown> = VisualizationConfigProps<T> & {
groupId: string;
accessor: string;
setState: (newState: T) => void;
setState(newState: T | ((currState: T) => T)): void;
panelRef: MutableRefObject<HTMLDivElement | null>;
};
@ -466,13 +475,16 @@ export type VisualizationDimensionGroupConfig = SharedDimensionProps & {
// this dimension group in the hierarchy. If not specified, the position of the dimension in the array is used. specified nesting
// orders are always higher in the hierarchy than non-specified ones.
nestingOrder?: number;
// some type of layers can produce groups even if invalid. Keep this information to visually show the user that.
invalid?: boolean;
invalidMessage?: string;
};
interface VisualizationDimensionChangeProps<T> {
layerId: string;
columnId: string;
prevState: T;
frame: Pick<FramePublicAPI, 'datasourceLayers'>;
frame: Pick<FramePublicAPI, 'datasourceLayers' | 'activeData'>;
}
/**
@ -655,6 +667,7 @@ export interface Visualization<T = unknown> {
getConfiguration: (props: VisualizationConfigProps<T>) => {
groups: VisualizationDimensionGroupConfig[];
supportStaticValue?: boolean;
supportFieldFormat?: boolean;
};
/**

View file

@ -30,16 +30,17 @@ export function isFormatterCompatible(
return formatter1.id === formatter2.id;
}
export function getAxesConfiguration(
layers: XYLayerConfig[],
shouldRotate: boolean,
tables?: Record<string, Datatable>,
formatFactory?: FormatFactory
): GroupsConfiguration {
const series: { auto: FormattedMetric[]; left: FormattedMetric[]; right: FormattedMetric[] } = {
export function groupAxesByType(layers: XYLayerConfig[], tables?: Record<string, Datatable>) {
const series: {
auto: FormattedMetric[];
left: FormattedMetric[];
right: FormattedMetric[];
bottom: FormattedMetric[];
} = {
auto: [],
left: [],
right: [],
bottom: [],
};
layers?.forEach((layer) => {
@ -89,6 +90,16 @@ export function getAxesConfiguration(
series.right.push(currentSeries);
}
});
return series;
}
export function getAxesConfiguration(
layers: XYLayerConfig[],
shouldRotate: boolean,
tables?: Record<string, Datatable>,
formatFactory?: FormatFactory
): GroupsConfiguration {
const series = groupAxesByType(layers, tables);
const axisGroups: GroupsConfiguration = [];

View file

@ -59,6 +59,7 @@ import { getAxesConfiguration, GroupsConfiguration, validateExtent } from './axe
import { getColorAssignments } from './color_assignment';
import { getXDomain, XyEndzones } from './x_domain';
import { getLegendAction } from './get_legend_action';
import { ThresholdAnnotations } from './expression_thresholds';
declare global {
interface Window {
@ -251,6 +252,7 @@ export function XYChart({
const icon: IconType = layers.length > 0 ? getIconForSeriesType(layers[0].seriesType) : 'bar';
return <EmptyPlaceholder icon={icon} />;
}
const thresholdLayers = layers.filter((layer) => layer.layerType === layerTypes.THRESHOLD);
// use formatting hint of first x axis column to format ticks
const xAxisColumn = data.tables[filteredLayers[0].layerId].columns.find(
@ -832,6 +834,20 @@ export function XYChart({
}
})
)}
{thresholdLayers.length ? (
<ThresholdAnnotations
thresholdLayers={thresholdLayers}
data={data}
colorAssignments={colorAssignments}
syncColors={syncColors}
paletteService={paletteService}
formatters={{
left: yAxesConfiguration.find(({ groupId }) => groupId === 'left')?.formatter,
right: yAxesConfiguration.find(({ groupId }) => groupId === 'right')?.formatter,
bottom: xAxisFormatter,
}}
/>
) : null}
</Chart>
);
}

View file

@ -0,0 +1,193 @@
/*
* 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 from 'react';
import { groupBy } from 'lodash';
import { EuiIcon } from '@elastic/eui';
import { RectAnnotation, AnnotationDomainType, LineAnnotation } from '@elastic/charts';
import type { PaletteRegistry, SeriesLayer } from 'src/plugins/charts/public';
import type { FieldFormat } from 'src/plugins/field_formats/common';
import type { LayerArgs } from '../../common/expressions';
import type { LensMultiTable } from '../../common/types';
import type { ColorAssignments } from './color_assignment';
export const ThresholdAnnotations = ({
thresholdLayers,
data,
colorAssignments,
formatters,
paletteService,
syncColors,
}: {
thresholdLayers: LayerArgs[];
data: LensMultiTable;
colorAssignments: ColorAssignments;
formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>;
paletteService: PaletteRegistry;
syncColors: boolean;
}) => {
return (
<>
{thresholdLayers.flatMap((thresholdLayer) => {
if (!thresholdLayer.yConfig) {
return [];
}
const { columnToLabel, palette, yConfig: yConfigs, layerId } = thresholdLayer;
const columnToLabelMap: Record<string, string> = columnToLabel
? JSON.parse(columnToLabel)
: {};
const table = data.tables[layerId];
const colorAssignment = colorAssignments[palette.name];
const row = table.rows[0];
const yConfigByValue = yConfigs.sort(
({ forAccessor: idA }, { forAccessor: idB }) => row[idA] - row[idB]
);
const groupedByDirection = groupBy(yConfigByValue, 'fill');
return yConfigByValue.flatMap((yConfig, i) => {
// Find the formatter for the given axis
const groupId =
yConfig.axisMode === 'bottom'
? undefined
: yConfig.axisMode === 'right'
? 'right'
: 'left';
const formatter = formatters[groupId || 'bottom'];
const seriesLayers: SeriesLayer[] = [
{
name: columnToLabelMap[yConfig.forAccessor],
totalSeriesAtDepth: colorAssignment.totalSeriesCount,
rankAtDepth: colorAssignment.getRank(
thresholdLayer,
String(yConfig.forAccessor),
String(yConfig.forAccessor)
),
},
];
const defaultColor = paletteService.get(palette.name).getCategoricalColor(
seriesLayers,
{
maxDepth: 1,
behindText: false,
totalSeries: colorAssignment.totalSeriesCount,
syncColors,
},
palette.params
);
const props = {
groupId,
marker: yConfig.icon ? <EuiIcon type={yConfig.icon} /> : undefined,
};
const annotations = [];
const dashStyle =
yConfig.lineStyle === 'dashed'
? [(yConfig.lineWidth || 1) * 3, yConfig.lineWidth || 1]
: yConfig.lineStyle === 'dotted'
? [yConfig.lineWidth || 1, yConfig.lineWidth || 1]
: undefined;
const sharedStyle = {
strokeWidth: yConfig.lineWidth || 1,
stroke: (yConfig.color || defaultColor) ?? '#f00',
dash: dashStyle,
};
annotations.push(
<LineAnnotation
{...props}
id={`${layerId}-${yConfig.forAccessor}-line`}
key={`${layerId}-${yConfig.forAccessor}-line`}
dataValues={table.rows.map(() => ({
dataValue: row[yConfig.forAccessor],
header: columnToLabelMap[yConfig.forAccessor],
details: formatter?.convert(row[yConfig.forAccessor]) || row[yConfig.forAccessor],
}))}
domainType={
yConfig.axisMode === 'bottom'
? AnnotationDomainType.XDomain
: AnnotationDomainType.YDomain
}
style={{
line: {
...sharedStyle,
opacity: 1,
},
}}
/>
);
if (yConfig.fill && yConfig.fill !== 'none') {
const isFillAbove = yConfig.fill === 'above';
const indexFromSameType = groupedByDirection[yConfig.fill].findIndex(
({ forAccessor }) => forAccessor === yConfig.forAccessor
);
const shouldCheckNextThreshold =
indexFromSameType < groupedByDirection[yConfig.fill].length - 1;
annotations.push(
<RectAnnotation
{...props}
id={`${layerId}-${yConfig.forAccessor}-rect`}
key={`${layerId}-${yConfig.forAccessor}-rect`}
dataValues={table.rows.map(() => {
if (yConfig.axisMode === 'bottom') {
return {
coordinates: {
x0: isFillAbove ? row[yConfig.forAccessor] : undefined,
y0: undefined,
x1: isFillAbove
? shouldCheckNextThreshold
? row[
groupedByDirection[yConfig.fill!][indexFromSameType + 1].forAccessor
]
: undefined
: row[yConfig.forAccessor],
y1: undefined,
},
header: columnToLabelMap[yConfig.forAccessor],
details:
formatter?.convert(row[yConfig.forAccessor]) || row[yConfig.forAccessor],
};
}
return {
coordinates: {
x0: undefined,
y0: isFillAbove ? row[yConfig.forAccessor] : undefined,
x1: undefined,
y1: isFillAbove
? shouldCheckNextThreshold
? row[
groupedByDirection[yConfig.fill!][indexFromSameType + 1].forAccessor
]
: undefined
: row[yConfig.forAccessor],
},
header: columnToLabelMap[yConfig.forAccessor],
details:
formatter?.convert(row[yConfig.forAccessor]) || row[yConfig.forAccessor],
};
})}
style={{
...sharedStyle,
fill: (yConfig.color || defaultColor) ?? '#f00',
opacity: 0.1,
}}
/>
);
}
return annotations;
});
})}
</>
);
};

View file

@ -18,6 +18,14 @@ export function isHorizontalSeries(seriesType: SeriesType) {
);
}
export function isPercentageSeries(seriesType: SeriesType) {
return (
seriesType === 'bar_percentage_stacked' ||
seriesType === 'bar_horizontal_percentage_stacked' ||
seriesType === 'area_percentage_stacked'
);
}
export function isHorizontalChart(layers: Array<{ seriesType: SeriesType }>) {
return layers.every((l) => isHorizontalSeries(l.seriesType));
}

View file

@ -0,0 +1,165 @@
/*
* 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 { layerTypes } from '../../common';
import type { XYLayerConfig, YConfig } from '../../common/expressions';
import { Datatable } from '../../../../../src/plugins/expressions/public';
import type { DatasourcePublicAPI, FramePublicAPI } from '../types';
import { groupAxesByType } from './axes_configuration';
import { isPercentageSeries } from './state_helpers';
import type { XYState } from './types';
import { checkScaleOperation } from './visualization_helpers';
export interface ThresholdBase {
label: 'x' | 'yRight' | 'yLeft';
}
/**
* Return the threshold layers groups to show based on multiple criteria:
* * what groups are current defined in data layers
* * what existing threshold are currently defined in data thresholds
*/
export function getGroupsToShow<T extends ThresholdBase & { config?: YConfig[] }>(
thresholdLayers: T[],
state: XYState | undefined,
datasourceLayers: Record<string, DatasourcePublicAPI>,
tables: Record<string, Datatable> | undefined
): Array<T & { valid: boolean }> {
if (!state) {
return [];
}
const dataLayers = state.layers.filter(
({ layerType = layerTypes.DATA }) => layerType === layerTypes.DATA
);
const groupsAvailable = getGroupsAvailableInData(dataLayers, datasourceLayers, tables);
return thresholdLayers
.filter(({ label, config }: T) => groupsAvailable[label] || config?.length)
.map((layer) => ({ ...layer, valid: groupsAvailable[layer.label] }));
}
/**
* Returns the threshold layers groups to show based on what groups are current defined in data layers.
*/
export function getGroupsRelatedToData<T extends ThresholdBase>(
thresholdLayers: T[],
state: XYState | undefined,
datasourceLayers: Record<string, DatasourcePublicAPI>,
tables: Record<string, Datatable> | undefined
): T[] {
if (!state) {
return [];
}
const dataLayers = state.layers.filter(
({ layerType = layerTypes.DATA }) => layerType === layerTypes.DATA
);
const groupsAvailable = getGroupsAvailableInData(dataLayers, datasourceLayers, tables);
return thresholdLayers.filter(({ label }: T) => groupsAvailable[label]);
}
/**
* Returns a dictionary with the groups filled in all the data layers
*/
export function getGroupsAvailableInData(
dataLayers: XYState['layers'],
datasourceLayers: Record<string, DatasourcePublicAPI>,
tables: Record<string, Datatable> | undefined
) {
const hasNumberHistogram = dataLayers.some(
checkScaleOperation('interval', 'number', datasourceLayers)
);
const { right, left } = groupAxesByType(dataLayers, tables);
return {
x: dataLayers.some(({ xAccessor }) => xAccessor != null) && hasNumberHistogram,
yLeft: left.length > 0,
yRight: right.length > 0,
};
}
export function getStaticValue(
dataLayers: XYState['layers'],
groupId: 'x' | 'yLeft' | 'yRight',
{ activeData }: Pick<FramePublicAPI, 'activeData'>,
layerHasNumberHistogram: (layer: XYLayerConfig) => boolean
) {
const fallbackValue = 100;
if (!activeData) {
return fallbackValue;
}
// filter and organize data dimensions into threshold groups
// now pick the columnId in the active data
const { dataLayer, accessor } = getAccessorCriteriaForGroup(groupId, dataLayers, activeData);
if (groupId === 'x' && dataLayer && !layerHasNumberHistogram(dataLayer)) {
return fallbackValue;
}
return (
computeStaticValueForGroup(
dataLayer,
accessor,
activeData,
groupId !== 'x' // histogram axis should compute the min based on the current data
) || fallbackValue
);
}
function getAccessorCriteriaForGroup(
groupId: 'x' | 'yLeft' | 'yRight',
dataLayers: XYState['layers'],
activeData: FramePublicAPI['activeData']
) {
switch (groupId) {
case 'x':
const dataLayer = dataLayers.find(({ xAccessor }) => xAccessor);
return {
dataLayer,
accessor: dataLayer?.xAccessor,
};
case 'yLeft':
const { left } = groupAxesByType(dataLayers, activeData);
return {
dataLayer: dataLayers.find(({ layerId }) => layerId === left[0]?.layer),
accessor: left[0]?.accessor,
};
case 'yRight':
const { right } = groupAxesByType(dataLayers, activeData);
return {
dataLayer: dataLayers.find(({ layerId }) => layerId === right[0]?.layer),
accessor: right[0]?.accessor,
};
}
}
function computeStaticValueForGroup(
dataLayer: XYLayerConfig | undefined,
accessorId: string | undefined,
activeData: NonNullable<FramePublicAPI['activeData']>,
minZeroBased: boolean
) {
const defaultThresholdFactor = 3 / 4;
if (dataLayer && accessorId) {
if (isPercentageSeries(dataLayer?.seriesType)) {
return defaultThresholdFactor;
}
const tableId = Object.keys(activeData).find((key) =>
activeData[key].columns.some(({ id }) => id === accessorId)
);
if (tableId) {
const columnMax = activeData[tableId].rows.reduce(
(max, row) => Math.max(row[accessorId], max),
-Infinity
);
const columnMin = activeData[tableId].rows.reduce(
(max, row) => Math.min(row[accessorId], max),
Infinity
);
// Custom axis bounds can go below 0, so consider also lower values than 0
const finalMinValue = minZeroBased ? Math.min(0, columnMin) : columnMin;
const interval = columnMax - finalMinValue;
return Number((finalMinValue + interval * defaultThresholdFactor).toFixed(2));
}
}
}

View file

@ -322,6 +322,10 @@ export const buildExpression = (
forAccessor: [yConfig.forAccessor],
axisMode: yConfig.axisMode ? [yConfig.axisMode] : [],
color: yConfig.color ? [yConfig.color] : [],
lineStyle: yConfig.lineStyle ? [yConfig.lineStyle] : [],
lineWidth: yConfig.lineWidth ? [yConfig.lineWidth] : [],
fill: [yConfig.fill || 'none'],
icon: yConfig.icon ? [yConfig.icon] : [],
},
},
],

View file

@ -15,6 +15,7 @@ import { createMockDatasource, createMockFramePublicAPI } from '../mocks';
import { LensIconChartBar } from '../assets/chart_bar';
import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks';
import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks';
import { Datatable } from 'src/plugins/expressions';
function exampleState(): State {
return {
@ -216,8 +217,8 @@ describe('xy_visualization', () => {
});
describe('#getSupportedLayers', () => {
it('should return a single layer type', () => {
expect(xyVisualization.getSupportedLayers()).toHaveLength(1);
it('should return a double layer types', () => {
expect(xyVisualization.getSupportedLayers()).toHaveLength(2);
});
it('should return the icon for the visualization type', () => {
@ -317,6 +318,42 @@ describe('xy_visualization', () => {
accessors: [],
});
});
it('should add a dimension to a threshold layer', () => {
expect(
xyVisualization.setDimension({
frame,
prevState: {
...exampleState(),
layers: [
{
layerId: 'threshold',
layerType: layerTypes.THRESHOLD,
seriesType: 'line',
accessors: [],
},
],
},
layerId: 'threshold',
groupId: 'xThreshold',
columnId: 'newCol',
}).layers[0]
).toEqual({
layerId: 'threshold',
layerType: layerTypes.THRESHOLD,
seriesType: 'line',
accessors: ['newCol'],
yConfig: [
{
axisMode: 'bottom',
forAccessor: 'newCol',
icon: undefined,
lineStyle: 'solid',
lineWidth: 1,
},
],
});
});
});
describe('#removeDimension', () => {
@ -504,6 +541,300 @@ describe('xy_visualization', () => {
expect(ops.filter(filterOperations).map((x) => x.dataType)).toEqual(['number']);
});
describe('thresholds', () => {
beforeEach(() => {
frame.datasourceLayers = {
first: mockDatasource.publicAPIMock,
threshold: mockDatasource.publicAPIMock,
};
});
function getStateWithBaseThreshold(): State {
return {
...exampleState(),
layers: [
{
layerId: 'first',
layerType: layerTypes.DATA,
seriesType: 'area',
splitAccessor: undefined,
xAccessor: undefined,
accessors: ['a'],
},
{
layerId: 'threshold',
layerType: layerTypes.THRESHOLD,
seriesType: 'line',
accessors: [],
yConfig: [{ axisMode: 'left', forAccessor: 'a' }],
},
],
};
}
it('should support static value', () => {
const state = getStateWithBaseThreshold();
state.layers[0].accessors = [];
state.layers[1].yConfig = undefined;
expect(
xyVisualization.getConfiguration({
state: getStateWithBaseThreshold(),
frame,
layerId: 'threshold',
}).supportStaticValue
).toBeTruthy();
});
it('should return no threshold groups for a empty data layer', () => {
const state = getStateWithBaseThreshold();
state.layers[0].accessors = [];
state.layers[1].yConfig = undefined;
const options = xyVisualization.getConfiguration({
state,
frame,
layerId: 'threshold',
}).groups;
expect(options).toHaveLength(0);
});
it('should return a group for the vertical left axis', () => {
const options = xyVisualization.getConfiguration({
state: getStateWithBaseThreshold(),
frame,
layerId: 'threshold',
}).groups;
expect(options).toHaveLength(1);
expect(options[0].groupId).toBe('yThresholdLeft');
});
it('should return a group for the vertical right axis', () => {
const state = getStateWithBaseThreshold();
state.layers[0].yConfig = [{ axisMode: 'right', forAccessor: 'a' }];
state.layers[1].yConfig![0].axisMode = 'right';
const options = xyVisualization.getConfiguration({
state,
frame,
layerId: 'threshold',
}).groups;
expect(options).toHaveLength(1);
expect(options[0].groupId).toBe('yThresholdRight');
});
it('should compute no groups for thresholds when the only data accessor available is a date histogram', () => {
const state = getStateWithBaseThreshold();
state.layers[0].xAccessor = 'b';
state.layers[0].accessors = [];
state.layers[1].yConfig = []; // empty the configuration
// set the xAccessor as date_histogram
frame.datasourceLayers.threshold.getOperationForColumnId = jest.fn((accessor) => {
if (accessor === 'b') {
return {
dataType: 'date',
isBucketed: true,
scale: 'interval',
label: 'date_histogram',
};
}
return null;
});
const options = xyVisualization.getConfiguration({
state,
frame,
layerId: 'threshold',
}).groups;
expect(options).toHaveLength(0);
});
it('should mark horizontal group is invalid when xAccessor is changed to a date histogram', () => {
const state = getStateWithBaseThreshold();
state.layers[0].xAccessor = 'b';
state.layers[0].accessors = [];
state.layers[1].yConfig![0].axisMode = 'bottom';
// set the xAccessor as date_histogram
frame.datasourceLayers.threshold.getOperationForColumnId = jest.fn((accessor) => {
if (accessor === 'b') {
return {
dataType: 'date',
isBucketed: true,
scale: 'interval',
label: 'date_histogram',
};
}
return null;
});
const options = xyVisualization.getConfiguration({
state,
frame,
layerId: 'threshold',
}).groups;
expect(options[0]).toEqual(
expect.objectContaining({
invalid: true,
groupId: 'xThreshold',
})
);
});
it('should return groups in a specific order (left, right, bottom)', () => {
const state = getStateWithBaseThreshold();
state.layers[0].xAccessor = 'c';
state.layers[0].accessors = ['a', 'b'];
// invert them on purpose
state.layers[0].yConfig = [
{ axisMode: 'right', forAccessor: 'b' },
{ axisMode: 'left', forAccessor: 'a' },
];
state.layers[1].yConfig = [
{ forAccessor: 'c', axisMode: 'bottom' },
{ forAccessor: 'b', axisMode: 'right' },
{ forAccessor: 'a', axisMode: 'left' },
];
// set the xAccessor as number histogram
frame.datasourceLayers.threshold.getOperationForColumnId = jest.fn((accessor) => {
if (accessor === 'c') {
return {
dataType: 'number',
isBucketed: true,
scale: 'interval',
label: 'histogram',
};
}
return null;
});
const [left, right, bottom] = xyVisualization.getConfiguration({
state,
frame,
layerId: 'threshold',
}).groups;
expect(left.groupId).toBe('yThresholdLeft');
expect(right.groupId).toBe('yThresholdRight');
expect(bottom.groupId).toBe('xThreshold');
});
it('should ignore terms operation for xAccessor', () => {
const state = getStateWithBaseThreshold();
state.layers[0].xAccessor = 'b';
state.layers[0].accessors = [];
state.layers[1].yConfig = []; // empty the configuration
// set the xAccessor as top values
frame.datasourceLayers.threshold.getOperationForColumnId = jest.fn((accessor) => {
if (accessor === 'b') {
return {
dataType: 'string',
isBucketed: true,
scale: 'ordinal',
label: 'top values',
};
}
return null;
});
const options = xyVisualization.getConfiguration({
state,
frame,
layerId: 'threshold',
}).groups;
expect(options).toHaveLength(0);
});
it('should mark horizontal group is invalid when accessor is changed to a terms operation', () => {
const state = getStateWithBaseThreshold();
state.layers[0].xAccessor = 'b';
state.layers[0].accessors = [];
state.layers[1].yConfig![0].axisMode = 'bottom';
// set the xAccessor as date_histogram
frame.datasourceLayers.threshold.getOperationForColumnId = jest.fn((accessor) => {
if (accessor === 'b') {
return {
dataType: 'string',
isBucketed: true,
scale: 'ordinal',
label: 'top values',
};
}
return null;
});
const options = xyVisualization.getConfiguration({
state,
frame,
layerId: 'threshold',
}).groups;
expect(options[0]).toEqual(
expect.objectContaining({
invalid: true,
groupId: 'xThreshold',
})
);
});
it('differ vertical axis if the formatters are not compatibles between each other', () => {
const tables: Record<string, Datatable> = {
first: {
type: 'datatable',
rows: [],
columns: [
{
id: 'xAccessorId',
name: 'horizontal axis',
meta: {
type: 'date',
params: { params: { id: 'date', params: { pattern: 'HH:mm' } } },
},
},
{
id: 'yAccessorId',
name: 'left axis',
meta: {
type: 'number',
params: { id: 'number' },
},
},
{
id: 'yAccessorId2',
name: 'right axis',
meta: {
type: 'number',
params: { id: 'bytes' },
},
},
],
},
};
const state = getStateWithBaseThreshold();
state.layers[0].accessors = ['yAccessorId', 'yAccessorId2'];
state.layers[1].yConfig = []; // empty the configuration
const options = xyVisualization.getConfiguration({
state,
frame: { ...frame, activeData: tables },
layerId: 'threshold',
}).groups;
expect(options).toEqual(
expect.arrayContaining([
expect.objectContaining({ groupId: 'yThresholdLeft' }),
expect.objectContaining({ groupId: 'yThresholdRight' }),
])
);
});
});
describe('color assignment', () => {
function callConfig(layerConfigOverride: Partial<XYLayerConfig>) {
const baseState = exampleState();

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { uniq } from 'lodash';
import { groupBy, uniq } from 'lodash';
import { render } from 'react-dom';
import { Position } from '@elastic/charts';
import { FormattedMessage, I18nProvider } from '@kbn/i18n/react';
@ -14,15 +14,10 @@ import { i18n } from '@kbn/i18n';
import { PaletteRegistry } from 'src/plugins/charts/public';
import { FieldFormatsStart } from 'src/plugins/field_formats/public';
import { getSuggestions } from './xy_suggestions';
import { XyToolbar, DimensionEditor, LayerHeader } from './xy_config_panel';
import type {
Visualization,
OperationMetadata,
VisualizationType,
AccessorConfig,
DatasourcePublicAPI,
} from '../types';
import { State, visualizationTypes, XYState } from './types';
import { XyToolbar, DimensionEditor } from './xy_config_panel';
import { LayerHeader } from './xy_config_panel/layer_header';
import type { Visualization, OperationMetadata, VisualizationType, AccessorConfig } from '../types';
import { State, visualizationTypes } from './types';
import { SeriesType, XYLayerConfig } from '../../common/expressions';
import { LayerType, layerTypes } from '../../common';
import { isHorizontalChart } from './state_helpers';
@ -32,6 +27,19 @@ import { LensIconChartMixedXy } from '../assets/chart_mixed_xy';
import { LensIconChartBarHorizontal } from '../assets/chart_bar_horizontal';
import { getAccessorColorConfig, getColorAssignments } from './color_assignment';
import { getColumnToLabelMap } from './state_helpers';
import { LensIconChartBarThreshold } from '../assets/chart_bar_threshold';
import { generateId } from '../id_generator';
import {
getGroupsAvailableInData,
getGroupsRelatedToData,
getGroupsToShow,
getStaticValue,
} from './threshold_helpers';
import {
checkScaleOperation,
checkXAccessorCompatibility,
getAxisName,
} from './visualization_helpers';
const defaultIcon = LensIconChartBarStacked;
const defaultSeriesType = 'bar_stacked';
@ -186,6 +194,39 @@ export const getXyVisualization = ({
},
getSupportedLayers(state, frame) {
const thresholdGroupIds = [
{
id: 'yThresholdLeft',
label: 'yLeft' as const,
},
{
id: 'yThresholdRight',
label: 'yRight' as const,
},
{
id: 'xThreshold',
label: 'x' as const,
},
];
const dataLayers =
state?.layers.filter(({ layerType = layerTypes.DATA }) => layerType === layerTypes.DATA) ||
[];
const filledDataLayers = dataLayers.filter(
({ accessors, xAccessor }) => accessors.length || xAccessor
);
const layerHasNumberHistogram = checkScaleOperation(
'interval',
'number',
frame?.datasourceLayers || {}
);
const thresholdGroups = getGroupsRelatedToData(
thresholdGroupIds,
state,
frame?.datasourceLayers || {},
frame?.activeData
);
const layers = [
{
type: layerTypes.DATA,
@ -194,6 +235,36 @@ export const getXyVisualization = ({
}),
icon: LensIconChartMixedXy,
},
{
type: layerTypes.THRESHOLD,
label: i18n.translate('xpack.lens.xyChart.addThresholdLayerLabel', {
defaultMessage: 'Add threshold layer',
}),
icon: LensIconChartBarThreshold,
disabled:
!filledDataLayers.length ||
(!dataLayers.some(layerHasNumberHistogram) &&
dataLayers.every(({ accessors }) => !accessors.length)),
tooltipContent: filledDataLayers.length
? undefined
: i18n.translate('xpack.lens.xyChart.addThresholdLayerLabelDisabledHelp', {
defaultMessage: 'Add some data to enable threshold layer',
}),
initialDimensions: state
? thresholdGroups.map(({ id, label }) => ({
groupId: id,
columnId: generateId(),
dataType: 'number',
label: getAxisName(label, { isHorizontal: isHorizontalChart(state?.layers || []) }),
staticValue: getStaticValue(
dataLayers,
label,
{ activeData: frame?.activeData },
layerHasNumberHistogram
),
}))
: undefined,
},
];
return layers;
@ -233,8 +304,70 @@ export const getXyVisualization = ({
const isDataLayer = !layer.layerType || layer.layerType === layerTypes.DATA;
if (!isDataLayer) {
const idToIndex = sortedAccessors.reduce<Record<string, number>>((memo, id, index) => {
memo[id] = index;
return memo;
}, {});
const { bottom, left, right } = groupBy(
[...(layer.yConfig || [])].sort(
({ forAccessor: forA }, { forAccessor: forB }) => idToIndex[forA] - idToIndex[forB]
),
({ axisMode }) => {
return axisMode;
}
);
const groupsToShow = getGroupsToShow(
[
// When a threshold layer panel is added, a static threshold should automatically be included by default
// in the first available axis, in the following order: vertical left, vertical right, horizontal.
{
config: left,
id: 'yThresholdLeft',
label: 'yLeft',
dataTestSubj: 'lnsXY_yThresholdLeftPanel',
},
{
config: right,
id: 'yThresholdRight',
label: 'yRight',
dataTestSubj: 'lnsXY_yThresholdRightPanel',
},
{
config: bottom,
id: 'xThreshold',
label: 'x',
dataTestSubj: 'lnsXY_xThresholdPanel',
},
],
state,
frame.datasourceLayers,
frame?.activeData
);
return {
groups: [],
supportFieldFormat: false,
supportStaticValue: true,
// Each thresholds layer panel will have sections for each available axis
// (horizontal axis, vertical axis left, vertical axis right).
// Only axes that support numeric thresholds should be shown
groups: groupsToShow.map(({ config = [], id, label, dataTestSubj, valid }) => ({
groupId: id,
groupLabel: getAxisName(label, { isHorizontal }),
accessors: config.map(({ forAccessor, color }) => ({
columnId: forAccessor,
color: color || mappedAccessors.find(({ columnId }) => columnId === forAccessor)?.color,
triggerIcon: 'color',
})),
filterOperations: isNumericMetric,
supportsMoreColumns: true,
required: false,
enableDimensionEditor: true,
dataTestSubj,
invalid: !valid,
invalidMessage: i18n.translate('xpack.lens.configure.invalidThresholdDimension', {
defaultMessage:
'This threshold is assigned to an axis that no longer exists. You may move this threshold to another available axis or remove it.',
}),
})),
};
}
@ -305,6 +438,30 @@ export const getXyVisualization = ({
newLayer.splitAccessor = columnId;
}
if (newLayer.layerType === layerTypes.THRESHOLD) {
newLayer.accessors = [...newLayer.accessors.filter((a) => a !== columnId), columnId];
const hasYConfig = newLayer.yConfig?.some(({ forAccessor }) => forAccessor === columnId);
if (!hasYConfig) {
newLayer.yConfig = [
...(newLayer.yConfig || []),
// TODO: move this
// add a default config if none is available
{
forAccessor: columnId,
axisMode:
groupId === 'xThreshold'
? 'bottom'
: groupId === 'yThresholdRight'
? 'right'
: 'left',
icon: undefined,
lineStyle: 'solid',
lineWidth: 1,
},
];
}
}
return {
...prevState,
layers: prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)),
@ -331,7 +488,24 @@ export const getXyVisualization = ({
newLayer.yConfig = newLayer.yConfig.filter(({ forAccessor }) => forAccessor !== columnId);
}
const newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l));
let newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l));
// // check if there's any threshold layer and pull it off if all data layers have no dimensions set
const layersByType = groupBy(newLayers, ({ layerType }) => layerType);
// // check for data layers if they all still have xAccessors
const groupsAvailable = getGroupsAvailableInData(
layersByType[layerTypes.DATA],
frame.datasourceLayers,
frame?.activeData
);
if (
(Object.keys(groupsAvailable) as Array<'x' | 'yLeft' | 'yRight'>).every(
(id) => !groupsAvailable[id]
)
) {
newLayers = newLayers.filter(
({ layerType, accessors }) => layerType === layerTypes.DATA || accessors.length
);
}
return {
...prevState,
@ -510,19 +684,6 @@ function validateLayersForDimension(
};
}
function getAxisName(axis: 'x' | 'y', { isHorizontal }: { isHorizontal: boolean }) {
const vertical = i18n.translate('xpack.lens.xyChart.verticalAxisLabel', {
defaultMessage: 'Vertical axis',
});
const horizontal = i18n.translate('xpack.lens.xyChart.horizontalAxisLabel', {
defaultMessage: 'Horizontal axis',
});
if (axis === 'x') {
return isHorizontal ? vertical : horizontal;
}
return isHorizontal ? horizontal : vertical;
}
// i18n ids cannot be dynamically generated, hence the function below
function getMessageIdsForDimension(dimension: string, layers: number[], isHorizontal: boolean) {
const layersList = layers.map((i: number) => i + 1).join(', ');
@ -566,76 +727,6 @@ function newLayerState(
};
}
// min requirement for the bug:
// * 2 or more layers
// * at least one with date histogram
// * at least one with interval function
function checkXAccessorCompatibility(
state: XYState,
datasourceLayers: Record<string, DatasourcePublicAPI>
) {
const errors = [];
const hasDateHistogramSet = state.layers.some(
checkScaleOperation('interval', 'date', datasourceLayers)
);
const hasNumberHistogram = state.layers.some(
checkScaleOperation('interval', 'number', datasourceLayers)
);
const hasOrdinalAxis = state.layers.some(
checkScaleOperation('ordinal', undefined, datasourceLayers)
);
if (state.layers.length > 1 && hasDateHistogramSet && hasNumberHistogram) {
errors.push({
shortMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXShort', {
defaultMessage: `Wrong data type for {axis}.`,
values: {
axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }),
},
}),
longMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXLong', {
defaultMessage: `Data type mismatch for the {axis}. Cannot mix date and number interval types.`,
values: {
axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }),
},
}),
});
}
if (state.layers.length > 1 && (hasDateHistogramSet || hasNumberHistogram) && hasOrdinalAxis) {
errors.push({
shortMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXShort', {
defaultMessage: `Wrong data type for {axis}.`,
values: {
axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }),
},
}),
longMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXOrdinalLong', {
defaultMessage: `Data type mismatch for the {axis}, use a different function.`,
values: {
axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }),
},
}),
});
}
return errors;
}
function checkScaleOperation(
scaleType: 'ordinal' | 'interval' | 'ratio',
dataType: 'date' | 'number' | 'string' | undefined,
datasourceLayers: Record<string, DatasourcePublicAPI>
) {
return (layer: XYLayerConfig) => {
const datasourceAPI = datasourceLayers[layer.layerId];
if (!layer.xAccessor) {
return false;
}
const operation = datasourceAPI?.getOperationForColumnId(layer.xAccessor);
return Boolean(
operation && (!dataType || operation.dataType === dataType) && operation.scale === scaleType
);
};
}
function getLayersByType(state: State, byType?: string) {
return state.layers.filter(({ layerType = layerTypes.DATA }) =>
byType ? layerType === byType : true

View file

@ -0,0 +1,116 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { DatasourcePublicAPI } from '../types';
import { XYState } from './types';
import { isHorizontalChart } from './state_helpers';
import { XYLayerConfig } from '../../common/expressions';
export function getAxisName(
axis: 'x' | 'y' | 'yLeft' | 'yRight',
{ isHorizontal }: { isHorizontal: boolean }
) {
const vertical = i18n.translate('xpack.lens.xyChart.verticalAxisLabel', {
defaultMessage: 'Vertical axis',
});
const horizontal = i18n.translate('xpack.lens.xyChart.horizontalAxisLabel', {
defaultMessage: 'Horizontal axis',
});
if (axis === 'x') {
return isHorizontal ? vertical : horizontal;
}
if (axis === 'y') {
return isHorizontal ? horizontal : vertical;
}
const verticalLeft = i18n.translate('xpack.lens.xyChart.verticalLeftAxisLabel', {
defaultMessage: 'Vertical left axis',
});
const verticalRight = i18n.translate('xpack.lens.xyChart.verticalRightAxisLabel', {
defaultMessage: 'Vertical right axis',
});
const horizontalTop = i18n.translate('xpack.lens.xyChart.horizontalLeftAxisLabel', {
defaultMessage: 'Horizontal top axis',
});
const horizontalBottom = i18n.translate('xpack.lens.xyChart.horizontalRightAxisLabel', {
defaultMessage: 'Horizontal bottom axis',
});
if (axis === 'yLeft') {
return isHorizontal ? horizontalTop : verticalLeft;
}
return isHorizontal ? horizontalBottom : verticalRight;
}
// min requirement for the bug:
// * 2 or more layers
// * at least one with date histogram
// * at least one with interval function
export function checkXAccessorCompatibility(
state: XYState,
datasourceLayers: Record<string, DatasourcePublicAPI>
) {
const errors = [];
const hasDateHistogramSet = state.layers.some(
checkScaleOperation('interval', 'date', datasourceLayers)
);
const hasNumberHistogram = state.layers.some(
checkScaleOperation('interval', 'number', datasourceLayers)
);
const hasOrdinalAxis = state.layers.some(
checkScaleOperation('ordinal', undefined, datasourceLayers)
);
if (state.layers.length > 1 && hasDateHistogramSet && hasNumberHistogram) {
errors.push({
shortMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXShort', {
defaultMessage: `Wrong data type for {axis}.`,
values: {
axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }),
},
}),
longMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXLong', {
defaultMessage: `Data type mismatch for the {axis}. Cannot mix date and number interval types.`,
values: {
axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }),
},
}),
});
}
if (state.layers.length > 1 && (hasDateHistogramSet || hasNumberHistogram) && hasOrdinalAxis) {
errors.push({
shortMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXShort', {
defaultMessage: `Wrong data type for {axis}.`,
values: {
axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }),
},
}),
longMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXOrdinalLong', {
defaultMessage: `Data type mismatch for the {axis}, use a different function.`,
values: {
axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }),
},
}),
});
}
return errors;
}
export function checkScaleOperation(
scaleType: 'ordinal' | 'interval' | 'ratio',
dataType: 'date' | 'number' | 'string' | undefined,
datasourceLayers: Record<string, DatasourcePublicAPI>
) {
return (layer: XYLayerConfig) => {
const datasourceAPI = datasourceLayers[layer.layerId];
if (!layer.xAccessor) {
return false;
}
const operation = datasourceAPI?.getOperationForColumnId(layer.xAccessor);
return Boolean(
operation && (!dataType || operation.dataType === dataType) && operation.scale === scaleType
);
};
}

View file

@ -8,8 +8,8 @@
import React from 'react';
import { shallowWithIntl as shallow } from '@kbn/test/jest';
import { AxisSettingsPopover, AxisSettingsPopoverProps } from './axis_settings_popover';
import { ToolbarPopover } from '../shared_components';
import { layerTypes } from '../../common';
import { ToolbarPopover } from '../../shared_components';
import { layerTypes } from '../../../common';
describe('Axes Settings', () => {
let props: AxisSettingsPopoverProps;

View file

@ -20,15 +20,15 @@ import {
EuiFieldNumber,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { XYLayerConfig, AxesSettingsConfig, AxisExtentConfig } from '../../common/expressions';
import { ToolbarPopover, useDebouncedValue } from '../shared_components';
import { isHorizontalChart } from './state_helpers';
import { EuiIconAxisBottom } from '../assets/axis_bottom';
import { EuiIconAxisLeft } from '../assets/axis_left';
import { EuiIconAxisRight } from '../assets/axis_right';
import { EuiIconAxisTop } from '../assets/axis_top';
import { ToolbarButtonProps } from '../../../../../src/plugins/kibana_react/public';
import { validateExtent } from './axes_configuration';
import { XYLayerConfig, AxesSettingsConfig, AxisExtentConfig } from '../../../common/expressions';
import { ToolbarPopover, useDebouncedValue } from '../../shared_components';
import { isHorizontalChart } from '../state_helpers';
import { EuiIconAxisBottom } from '../../assets/axis_bottom';
import { EuiIconAxisLeft } from '../../assets/axis_left';
import { EuiIconAxisRight } from '../../assets/axis_right';
import { EuiIconAxisTop } from '../../assets/axis_top';
import { ToolbarButtonProps } from '../../../../../../src/plugins/kibana_react/public';
import { validateExtent } from '../axes_configuration';
type AxesSettingsConfigKeys = keyof AxesSettingsConfig;

View file

@ -0,0 +1,175 @@
/*
* 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 './xy_config_panel.scss';
import React, { useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { debounce } from 'lodash';
import { EuiFormRow, EuiColorPicker, EuiColorPickerProps, EuiToolTip, EuiIcon } from '@elastic/eui';
import type { PaletteRegistry } from 'src/plugins/charts/public';
import type { VisualizationDimensionEditorProps } from '../../types';
import { State } from '../types';
import { FormatFactory } from '../../../common';
import { getSeriesColor } from '../state_helpers';
import { getAccessorColorConfig, getColorAssignments } from '../color_assignment';
import { getSortedAccessors } from '../to_expression';
import { updateLayer } from '.';
import { TooltipWrapper } from '../../shared_components';
const tooltipContent = {
auto: i18n.translate('xpack.lens.configPanel.color.tooltip.auto', {
defaultMessage: 'Lens automatically picks colors for you unless you specify a custom color.',
}),
custom: i18n.translate('xpack.lens.configPanel.color.tooltip.custom', {
defaultMessage: 'Clear the custom color to return to “Auto” mode.',
}),
disabled: i18n.translate('xpack.lens.configPanel.color.tooltip.disabled', {
defaultMessage:
'Individual series cannot be custom colored when the layer includes a “Break down by.“',
}),
};
export const ColorPicker = ({
state,
setState,
layerId,
accessor,
frame,
formatFactory,
paletteService,
label,
disableHelpTooltip,
}: VisualizationDimensionEditorProps<State> & {
formatFactory: FormatFactory;
paletteService: PaletteRegistry;
label?: string;
disableHelpTooltip?: boolean;
}) => {
const index = state.layers.findIndex((l) => l.layerId === layerId);
const layer = state.layers[index];
const disabled = Boolean(layer.splitAccessor);
const overwriteColor = getSeriesColor(layer, accessor);
const currentColor = useMemo(() => {
if (overwriteColor || !frame.activeData) return overwriteColor;
const datasource = frame.datasourceLayers[layer.layerId];
const sortedAccessors: string[] = getSortedAccessors(datasource, layer);
const colorAssignments = getColorAssignments(
state.layers,
{ tables: frame.activeData },
formatFactory
);
const mappedAccessors = getAccessorColorConfig(
colorAssignments,
frame,
{
...layer,
accessors: sortedAccessors.filter((sorted) => layer.accessors.includes(sorted)),
},
paletteService
);
return mappedAccessors.find((a) => a.columnId === accessor)?.color || null;
}, [overwriteColor, frame, paletteService, state.layers, accessor, formatFactory, layer]);
const [color, setColor] = useState(currentColor);
const handleColor: EuiColorPickerProps['onChange'] = (text, output) => {
setColor(text);
if (output.isValid || text === '') {
updateColorInState(text, output);
}
};
const updateColorInState: EuiColorPickerProps['onChange'] = useMemo(
() =>
debounce((text, output) => {
const newYConfigs = [...(layer.yConfig || [])];
const existingIndex = newYConfigs.findIndex((yConfig) => yConfig.forAccessor === accessor);
if (existingIndex !== -1) {
if (text === '') {
newYConfigs[existingIndex] = { ...newYConfigs[existingIndex], color: undefined };
} else {
newYConfigs[existingIndex] = { ...newYConfigs[existingIndex], color: output.hex };
}
} else {
newYConfigs.push({
forAccessor: accessor,
color: output.hex,
});
}
setState(updateLayer(state, { ...layer, yConfig: newYConfigs }, index));
}, 256),
[state, setState, layer, accessor, index]
);
const inputLabel =
label ??
i18n.translate('xpack.lens.xyChart.seriesColor.label', {
defaultMessage: 'Series color',
});
const colorPicker = (
<EuiColorPicker
data-test-subj="indexPattern-dimension-colorPicker"
compressed
isClearable={Boolean(overwriteColor)}
onChange={handleColor}
color={disabled ? '' : color || currentColor}
disabled={disabled}
placeholder={i18n.translate('xpack.lens.xyChart.seriesColor.auto', {
defaultMessage: 'Auto',
})}
aria-label={inputLabel}
/>
);
return (
<EuiFormRow
display="columnCompressed"
fullWidth
label={
<TooltipWrapper
delay="long"
position="top"
tooltipContent={color && !disabled ? tooltipContent.custom : tooltipContent.auto}
condition={!disableHelpTooltip}
>
<span>
{inputLabel}
{!disableHelpTooltip && (
<>
{''}
<EuiIcon
type="questionInCircle"
color="subdued"
size="s"
className="eui-alignTop"
/>
</>
)}
</span>
</TooltipWrapper>
}
>
{disabled ? (
<EuiToolTip
position="top"
content={tooltipContent.disabled}
delay="long"
anchorClassName="eui-displayBlock"
>
{colorPicker}
</EuiToolTip>
) : (
colorPicker
)}
</EuiFormRow>
);
};

View file

@ -6,24 +6,15 @@
*/
import './xy_config_panel.scss';
import React, { useMemo, useState, memo, useCallback } from 'react';
import React, { memo, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { Position, ScaleType, VerticalAlignment, HorizontalAlignment } from '@elastic/charts';
import { debounce } from 'lodash';
import {
EuiButtonGroup,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
htmlIdGenerator,
EuiColorPicker,
EuiColorPickerProps,
EuiToolTip,
EuiIcon,
EuiPopover,
EuiSelectable,
EuiText,
EuiPopoverTitle,
} from '@elastic/eui';
import type { PaletteRegistry } from 'src/plugins/charts/public';
import type {
@ -31,30 +22,34 @@ import type {
VisualizationToolbarProps,
VisualizationDimensionEditorProps,
FramePublicAPI,
} from '../types';
import { State, visualizationTypes, XYState } from './types';
import type { FormatFactory } from '../../common';
} from '../../types';
import { State, visualizationTypes, XYState } from '../types';
import type { FormatFactory } from '../../../common';
import {
SeriesType,
YAxisMode,
AxesSettingsConfig,
AxisExtentConfig,
} from '../../common/expressions';
import { isHorizontalChart, isHorizontalSeries, getSeriesColor } from './state_helpers';
import { trackUiEvent } from '../lens_ui_telemetry';
import { LegendSettingsPopover } from '../shared_components';
} from '../../../common/expressions';
import { isHorizontalChart, isHorizontalSeries } from '../state_helpers';
import { trackUiEvent } from '../../lens_ui_telemetry';
import { LegendSettingsPopover } from '../../shared_components';
import { AxisSettingsPopover } from './axis_settings_popover';
import { getAxesConfiguration, GroupsConfiguration } from './axes_configuration';
import { PalettePicker, TooltipWrapper } from '../shared_components';
import { getAccessorColorConfig, getColorAssignments } from './color_assignment';
import { getScaleType, getSortedAccessors } from './to_expression';
import { VisualOptionsPopover } from './visual_options_popover/visual_options_popover';
import { ToolbarButton } from '../../../../../src/plugins/kibana_react/public';
import { getAxesConfiguration, GroupsConfiguration } from '../axes_configuration';
import { VisualOptionsPopover } from './visual_options_popover';
import { getScaleType } from '../to_expression';
import { ColorPicker } from './color_picker';
import { ThresholdPanel } from './threshold_panel';
import { PalettePicker, TooltipWrapper } from '../../shared_components';
type UnwrapArray<T> = T extends Array<infer P> ? P : T;
type AxesSettingsConfigKeys = keyof AxesSettingsConfig;
function updateLayer(state: State, layer: UnwrapArray<State['layers']>, index: number): State {
export function updateLayer(
state: State,
layer: UnwrapArray<State['layers']>,
index: number
): State {
const newLayers = [...state.layers];
newLayers[index] = layer;
@ -92,90 +87,6 @@ const legendOptions: Array<{
},
];
export function LayerHeader(props: VisualizationLayerWidgetProps<State>) {
const [isPopoverOpen, setPopoverIsOpen] = useState(false);
const { state, layerId } = props;
const horizontalOnly = isHorizontalChart(state.layers);
const index = state.layers.findIndex((l) => l.layerId === layerId);
const layer = state.layers[index];
if (!layer) {
return null;
}
const currentVisType = visualizationTypes.find(({ id }) => id === layer.seriesType)!;
const createTrigger = function () {
return (
<ToolbarButton
data-test-subj="lns_layer_settings"
title={currentVisType.fullLabel || currentVisType.label}
onClick={() => setPopoverIsOpen(!isPopoverOpen)}
fullWidth
size="s"
>
<>
<EuiIcon type={currentVisType.icon} />
<EuiText size="s" className="lnsLayerPanelChartSwitch_title">
{currentVisType.fullLabel || currentVisType.label}
</EuiText>
</>
</ToolbarButton>
);
};
return (
<>
<EuiPopover
panelClassName="lnsChangeIndexPatternPopover"
button={createTrigger()}
isOpen={isPopoverOpen}
closePopover={() => setPopoverIsOpen(false)}
display="block"
panelPaddingSize="s"
ownFocus
>
<EuiPopoverTitle>
{i18n.translate('xpack.lens.layerPanel.layerVisualizationType', {
defaultMessage: 'Layer visualization type',
})}
</EuiPopoverTitle>
<div>
<EuiSelectable<{
key?: string;
label: string;
value?: string;
checked?: 'on' | 'off';
}>
singleSelection="always"
options={visualizationTypes
.filter((t) => isHorizontalSeries(t.id as SeriesType) === horizontalOnly)
.map((t) => ({
value: t.id,
key: t.id,
checked: t.id === currentVisType.id ? 'on' : undefined,
prepend: <EuiIcon type={t.icon} />,
label: t.fullLabel || t.label,
'data-test-subj': `lnsXY_seriesType-${t.id}`,
}))}
onChange={(newOptions) => {
const chosenType = newOptions.find(({ checked }) => checked === 'on');
if (!chosenType) {
return;
}
const id = chosenType.value!;
trackUiEvent('xy_change_layer_display');
props.setState(updateLayer(state, { ...layer, seriesType: id as SeriesType }, index));
setPopoverIsOpen(false);
}}
>
{(list) => <>{list}</>}
</EuiSelectable>
</div>
</EuiPopover>
</>
);
}
export function LayerContextMenu(props: VisualizationLayerWidgetProps<State>) {
const { state, layerId } = props;
const horizontalOnly = isHorizontalChart(state.layers);
@ -622,7 +533,7 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp
);
});
const idPrefix = htmlIdGenerator()();
export const idPrefix = htmlIdGenerator()();
export function DimensionEditor(
props: VisualizationDimensionEditorProps<State> & {
@ -653,6 +564,10 @@ export function DimensionEditor(
);
}
if (layer.layerType === 'threshold') {
return <ThresholdPanel {...props} />;
}
return (
<>
<ColorPicker {...props} />
@ -728,140 +643,3 @@ export function DimensionEditor(
</>
);
}
const tooltipContent = {
auto: i18n.translate('xpack.lens.configPanel.color.tooltip.auto', {
defaultMessage: 'Lens automatically picks colors for you unless you specify a custom color.',
}),
custom: i18n.translate('xpack.lens.configPanel.color.tooltip.custom', {
defaultMessage: 'Clear the custom color to return to “Auto” mode.',
}),
disabled: i18n.translate('xpack.lens.configPanel.color.tooltip.disabled', {
defaultMessage:
'Individual series cannot be custom colored when the layer includes a “Break down by.“',
}),
};
const ColorPicker = ({
state,
setState,
layerId,
accessor,
frame,
formatFactory,
paletteService,
}: VisualizationDimensionEditorProps<State> & {
formatFactory: FormatFactory;
paletteService: PaletteRegistry;
}) => {
const index = state.layers.findIndex((l) => l.layerId === layerId);
const layer = state.layers[index];
const disabled = !!layer.splitAccessor;
const overwriteColor = getSeriesColor(layer, accessor);
const currentColor = useMemo(() => {
if (overwriteColor || !frame.activeData) return overwriteColor;
const datasource = frame.datasourceLayers[layer.layerId];
const sortedAccessors: string[] = getSortedAccessors(datasource, layer);
const colorAssignments = getColorAssignments(
state.layers,
{ tables: frame.activeData },
formatFactory
);
const mappedAccessors = getAccessorColorConfig(
colorAssignments,
frame,
{
...layer,
accessors: sortedAccessors.filter((sorted) => layer.accessors.includes(sorted)),
},
paletteService
);
return mappedAccessors.find((a) => a.columnId === accessor)?.color || null;
}, [overwriteColor, frame, paletteService, state.layers, accessor, formatFactory, layer]);
const [color, setColor] = useState(currentColor);
const handleColor: EuiColorPickerProps['onChange'] = (text, output) => {
setColor(text);
if (output.isValid || text === '') {
updateColorInState(text, output);
}
};
const updateColorInState: EuiColorPickerProps['onChange'] = useMemo(
() =>
debounce((text, output) => {
const newYConfigs = [...(layer.yConfig || [])];
const existingIndex = newYConfigs.findIndex((yConfig) => yConfig.forAccessor === accessor);
if (existingIndex !== -1) {
if (text === '') {
newYConfigs[existingIndex] = { ...newYConfigs[existingIndex], color: undefined };
} else {
newYConfigs[existingIndex] = { ...newYConfigs[existingIndex], color: output.hex };
}
} else {
newYConfigs.push({
forAccessor: accessor,
color: output.hex,
});
}
setState(updateLayer(state, { ...layer, yConfig: newYConfigs }, index));
}, 256),
[state, setState, layer, accessor, index]
);
const colorPicker = (
<EuiColorPicker
data-test-subj="indexPattern-dimension-colorPicker"
compressed
isClearable={Boolean(overwriteColor)}
onChange={handleColor}
color={disabled ? '' : color || currentColor}
disabled={disabled}
placeholder={i18n.translate('xpack.lens.xyChart.seriesColor.auto', {
defaultMessage: 'Auto',
})}
aria-label={i18n.translate('xpack.lens.xyChart.seriesColor.label', {
defaultMessage: 'Series color',
})}
/>
);
return (
<EuiFormRow
display="columnCompressed"
fullWidth
label={
<EuiToolTip
delay="long"
position="top"
content={color && !disabled ? tooltipContent.custom : tooltipContent.auto}
>
<span>
{i18n.translate('xpack.lens.xyChart.seriesColor.label', {
defaultMessage: 'Series color',
})}{' '}
<EuiIcon type="questionInCircle" color="subdued" size="s" className="eui-alignTop" />
</span>
</EuiToolTip>
}
>
{disabled ? (
<EuiToolTip
position="top"
content={tooltipContent.disabled}
delay="long"
anchorClassName="eui-displayBlock"
>
{colorPicker}
</EuiToolTip>
) : (
colorPicker
)}
</EuiFormRow>
);
};

View file

@ -0,0 +1,115 @@
/*
* 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 './xy_config_panel.scss';
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiIcon, EuiPopover, EuiSelectable, EuiText, EuiPopoverTitle } from '@elastic/eui';
import type { VisualizationLayerWidgetProps } from '../../types';
import { State, visualizationTypes } from '../types';
import { layerTypes } from '../../../common';
import { SeriesType } from '../../../common/expressions';
import { isHorizontalChart, isHorizontalSeries } from '../state_helpers';
import { trackUiEvent } from '../../lens_ui_telemetry';
import { StaticHeader } from '../../shared_components';
import { ToolbarButton } from '../../../../../../src/plugins/kibana_react/public';
import { LensIconChartBarThreshold } from '../../assets/chart_bar_threshold';
import { updateLayer } from '.';
export function LayerHeader(props: VisualizationLayerWidgetProps<State>) {
const [isPopoverOpen, setPopoverIsOpen] = useState(false);
const { state, layerId } = props;
const horizontalOnly = isHorizontalChart(state.layers);
const index = state.layers.findIndex((l) => l.layerId === layerId);
const layer = state.layers[index];
if (!layer) {
return null;
}
// if it's a threshold just draw a static text
if (layer.layerType === layerTypes.THRESHOLD) {
return (
<StaticHeader
icon={LensIconChartBarThreshold}
label={i18n.translate('xpack.lens.xyChart.layerThresholdLabel', {
defaultMessage: 'Thresholds',
})}
/>
);
}
const currentVisType = visualizationTypes.find(({ id }) => id === layer.seriesType)!;
const createTrigger = function () {
return (
<ToolbarButton
data-test-subj="lns_layer_settings"
title={currentVisType.fullLabel || currentVisType.label}
onClick={() => setPopoverIsOpen(!isPopoverOpen)}
fullWidth
size="s"
>
<>
<EuiIcon type={currentVisType.icon} />
<EuiText size="s" className="lnsLayerPanelChartSwitch_title">
{currentVisType.fullLabel || currentVisType.label}
</EuiText>
</>
</ToolbarButton>
);
};
return (
<>
<EuiPopover
panelClassName="lnsChangeIndexPatternPopover"
button={createTrigger()}
isOpen={isPopoverOpen}
closePopover={() => setPopoverIsOpen(false)}
display="block"
panelPaddingSize="s"
ownFocus
>
<EuiPopoverTitle>
{i18n.translate('xpack.lens.layerPanel.layerVisualizationType', {
defaultMessage: 'Layer visualization type',
})}
</EuiPopoverTitle>
<div>
<EuiSelectable<{
key?: string;
label: string;
value?: string;
checked?: 'on' | 'off';
}>
singleSelection="always"
options={visualizationTypes
.filter((t) => isHorizontalSeries(t.id as SeriesType) === horizontalOnly)
.map((t) => ({
value: t.id,
key: t.id,
checked: t.id === currentVisType.id ? 'on' : undefined,
prepend: <EuiIcon type={t.icon} />,
label: t.fullLabel || t.label,
'data-test-subj': `lnsXY_seriesType-${t.id}`,
}))}
onChange={(newOptions) => {
const chosenType = newOptions.find(({ checked }) => checked === 'on');
if (!chosenType) {
return;
}
const id = chosenType.value!;
trackUiEvent('xy_change_layer_display');
props.setState(updateLayer(state, { ...layer, seriesType: id as SeriesType }, index));
setPopoverIsOpen(false);
}}
>
{(list) => <>{list}</>}
</EuiSelectable>
</div>
</EuiPopover>
</>
);
}

View file

@ -0,0 +1,326 @@
/*
* 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 './xy_config_panel.scss';
import React, { useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButtonGroup, EuiComboBox, EuiFormRow, EuiIcon, EuiRange } from '@elastic/eui';
import type { PaletteRegistry } from 'src/plugins/charts/public';
import type { VisualizationDimensionEditorProps } from '../../types';
import { State } from '../types';
import { FormatFactory } from '../../../common';
import { YConfig } from '../../../common/expressions';
import { LineStyle, FillStyle } from '../../../common/expressions/xy_chart';
import { ColorPicker } from './color_picker';
import { updateLayer, idPrefix } from '.';
import { useDebouncedValue } from '../../shared_components';
const icons = [
{
value: 'none',
label: i18n.translate('xpack.lens.xyChart.thresholds.noIconLabel', { defaultMessage: 'None' }),
},
{
value: 'asterisk',
label: i18n.translate('xpack.lens.xyChart.thresholds.asteriskIconLabel', {
defaultMessage: 'Asterisk',
}),
},
{
value: 'bell',
label: i18n.translate('xpack.lens.xyChart.thresholds.bellIconLabel', {
defaultMessage: 'Bell',
}),
},
{
value: 'bolt',
label: i18n.translate('xpack.lens.xyChart.thresholds.boltIconLabel', {
defaultMessage: 'Bolt',
}),
},
{
value: 'bug',
label: i18n.translate('xpack.lens.xyChart.thresholds.bugIconLabel', {
defaultMessage: 'Bug',
}),
},
{
value: 'editorComment',
label: i18n.translate('xpack.lens.xyChart.thresholds.commentIconLabel', {
defaultMessage: 'Comment',
}),
},
{
value: 'alert',
label: i18n.translate('xpack.lens.xyChart.thresholds.alertIconLabel', {
defaultMessage: 'Alert',
}),
},
{
value: 'flag',
label: i18n.translate('xpack.lens.xyChart.thresholds.flagIconLabel', {
defaultMessage: 'Flag',
}),
},
{
value: 'tag',
label: i18n.translate('xpack.lens.xyChart.thresholds.tagIconLabel', {
defaultMessage: 'Tag',
}),
},
];
const IconView = (props: { value?: string; label: string }) => {
if (!props.value) return null;
return (
<span>
<EuiIcon type={props.value} />
{` ${props.label}`}
</span>
);
};
const IconSelect = ({
value,
onChange,
}: {
value?: string;
onChange: (newIcon: string) => void;
}) => {
const selectedIcon = icons.find((option) => value === option.value) || icons[0];
return (
<EuiComboBox
isClearable={false}
options={icons}
selectedOptions={[selectedIcon]}
onChange={(selection) => {
onChange(selection[0].value!);
}}
singleSelection={{ asPlainText: true }}
renderOption={IconView}
compressed
/>
);
};
export const ThresholdPanel = (
props: VisualizationDimensionEditorProps<State> & {
formatFactory: FormatFactory;
paletteService: PaletteRegistry;
}
) => {
const { state, setState, layerId, accessor } = props;
const index = state.layers.findIndex((l) => l.layerId === layerId);
const layer = state.layers[index];
const setYConfig = useCallback(
(yConfig: Partial<YConfig> | undefined) => {
if (yConfig == null) {
return;
}
setState((currState) => {
const currLayer = currState.layers[index];
const newYConfigs = [...(currLayer.yConfig || [])];
const existingIndex = newYConfigs.findIndex(
(yAxisConfig) => yAxisConfig.forAccessor === accessor
);
if (existingIndex !== -1) {
newYConfigs[existingIndex] = { ...newYConfigs[existingIndex], ...yConfig };
} else {
newYConfigs.push({ forAccessor: accessor, ...yConfig });
}
return updateLayer(currState, { ...currLayer, yConfig: newYConfigs }, index);
});
},
[accessor, index, setState]
);
const currentYConfig = layer.yConfig?.find((yConfig) => yConfig.forAccessor === accessor);
return (
<>
<ColorPicker
{...props}
disableHelpTooltip
label={i18n.translate('xpack.lens.xyChart.thresholdColor.label', {
defaultMessage: 'Color',
})}
/>
<EuiFormRow
display="columnCompressed"
fullWidth
label={i18n.translate('xpack.lens.xyChart.lineStyle.label', {
defaultMessage: 'Line style',
})}
>
<EuiButtonGroup
isFullWidth
legend={i18n.translate('xpack.lens.xyChart.lineStyle.label', {
defaultMessage: 'Line style',
})}
data-test-subj="lnsXY_line_style"
name="lineStyle"
buttonSize="compressed"
options={[
{
id: `${idPrefix}solid`,
label: i18n.translate('xpack.lens.xyChart.lineStyle.solid', {
defaultMessage: 'Solid',
}),
'data-test-subj': 'lnsXY_line_style_solid',
},
{
id: `${idPrefix}dashed`,
label: i18n.translate('xpack.lens.xyChart.lineStyle.dashed', {
defaultMessage: 'Dashed',
}),
'data-test-subj': 'lnsXY_line_style_dashed',
},
{
id: `${idPrefix}dotted`,
label: i18n.translate('xpack.lens.xyChart.lineStyle.dotted', {
defaultMessage: 'Dotted',
}),
'data-test-subj': 'lnsXY_line_style_dotted',
},
]}
idSelected={`${idPrefix}${currentYConfig?.lineStyle || 'solid'}`}
onChange={(id) => {
const newMode = id.replace(idPrefix, '') as LineStyle;
setYConfig({ forAccessor: accessor, lineStyle: newMode });
}}
/>
</EuiFormRow>
<EuiFormRow
display="columnCompressed"
fullWidth
label={i18n.translate('xpack.lens.xyChart.lineThickness.label', {
defaultMessage: 'Line thickness',
})}
>
<LineThicknessSlider
value={currentYConfig?.lineWidth || 1}
onChange={(value) => {
setYConfig({ forAccessor: accessor, lineWidth: value });
}}
/>
</EuiFormRow>
<EuiFormRow
display="columnCompressed"
fullWidth
label={i18n.translate('xpack.lens.xyChart.fillThreshold.label', {
defaultMessage: 'Fill',
})}
>
<EuiButtonGroup
isFullWidth
legend={i18n.translate('xpack.lens.xyChart.fillThreshold.label', {
defaultMessage: 'Fill',
})}
data-test-subj="lnsXY_fill_threshold"
name="fill"
buttonSize="compressed"
options={[
{
id: `${idPrefix}none`,
label: i18n.translate('xpack.lens.xyChart.fillThreshold.none', {
defaultMessage: 'None',
}),
'data-test-subj': 'lnsXY_fill_none',
},
{
id: `${idPrefix}above`,
label: i18n.translate('xpack.lens.xyChart.fillThreshold.above', {
defaultMessage: 'Above',
}),
'data-test-subj': 'lnsXY_fill_above',
},
{
id: `${idPrefix}below`,
label: i18n.translate('xpack.lens.xyChart.fillThreshold.below', {
defaultMessage: 'Below',
}),
'data-test-subj': 'lnsXY_fill_below',
},
]}
idSelected={`${idPrefix}${currentYConfig?.fill || 'none'}`}
onChange={(id) => {
const newMode = id.replace(idPrefix, '') as FillStyle;
setYConfig({ forAccessor: accessor, fill: newMode });
}}
/>
</EuiFormRow>
<EuiFormRow
display="columnCompressed"
fullWidth
label={i18n.translate('xpack.lens.xyChart.axisSide.icon', {
defaultMessage: 'Icon',
})}
>
<IconSelect
value={currentYConfig?.icon}
onChange={(newIcon) => {
setYConfig({ forAccessor: accessor, icon: newIcon });
}}
/>
</EuiFormRow>
</>
);
};
const minRange = 1;
const maxRange = 10;
function getSafeValue(value: number | '', prevValue: number, min: number, max: number) {
if (value === '') {
return prevValue;
}
return Math.max(minRange, Math.min(value, maxRange));
}
const LineThicknessSlider = ({
value,
onChange,
}: {
value: number;
onChange: (value: number) => void;
}) => {
const onChangeWrapped = useCallback(
(newValue) => {
if (Number.isInteger(newValue)) {
onChange(getSafeValue(newValue, newValue, minRange, maxRange));
}
},
[onChange]
);
const { inputValue, handleInputChange } = useDebouncedValue<number | ''>(
{ value, onChange: onChangeWrapped },
{ allowFalsyValue: true }
);
return (
<EuiRange
fullWidth
data-test-subj="lnsXY_lineThickness"
value={inputValue}
showInput
min={minRange}
max={maxRange}
step={1}
compressed
onChange={(e) => {
const newValue = e.currentTarget.value;
handleInputChange(newValue === '' ? '' : Number(newValue));
}}
onBlur={() => {
handleInputChange(getSafeValue(inputValue, value, minRange, maxRange));
}}
/>
);
};

View file

@ -8,7 +8,7 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFormRow, EuiRange } from '@elastic/eui';
import { useDebouncedValue } from '../../shared_components';
import { useDebouncedValue } from '../../../shared_components';
export interface FillOpacityOptionProps {
/**

View file

@ -7,14 +7,14 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { ToolbarPopover, TooltipWrapper } from '../../shared_components';
import { ToolbarPopover, TooltipWrapper } from '../../../shared_components';
import { MissingValuesOptions } from './missing_values_option';
import { LineCurveOption } from './line_curve_option';
import { FillOpacityOption } from './fill_opacity_option';
import { XYState } from '../types';
import { hasHistogramSeries } from '../state_helpers';
import { ValidLayer } from '../../../common/expressions';
import type { FramePublicAPI } from '../../types';
import { XYState } from '../../types';
import { hasHistogramSeries } from '../../state_helpers';
import { ValidLayer } from '../../../../common/expressions';
import type { FramePublicAPI } from '../../../types';
function getValueLabelDisableReason({
isAreaPercentage,

View file

@ -8,7 +8,7 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFormRow, EuiSwitch } from '@elastic/eui';
import type { XYCurveType } from '../../../common/expressions';
import type { XYCurveType } from '../../../../common/expressions';
export interface LineCurveOptionProps {
/**

View file

@ -8,8 +8,8 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButtonGroup, EuiFormRow, EuiIconTip, EuiSuperSelect, EuiText } from '@elastic/eui';
import { fittingFunctionDefinitions } from '../../../common/expressions';
import type { FittingFunction, ValueLabelConfig } from '../../../common/expressions';
import { fittingFunctionDefinitions } from '../../../../common/expressions';
import type { FittingFunction, ValueLabelConfig } from '../../../../common/expressions';
export interface MissingValuesOptionProps {
valueLabels?: ValueLabelConfig;

View file

@ -8,14 +8,14 @@
import React from 'react';
import { shallowWithIntl as shallow } from '@kbn/test/jest';
import { Position } from '@elastic/charts';
import type { FramePublicAPI } from '../../types';
import { createMockDatasource, createMockFramePublicAPI } from '../../mocks';
import { State } from '../types';
import { VisualOptionsPopover } from './visual_options_popover';
import { ToolbarPopover } from '../../shared_components';
import type { FramePublicAPI } from '../../../types';
import { createMockDatasource, createMockFramePublicAPI } from '../../../mocks';
import { State } from '../../types';
import { VisualOptionsPopover } from '.';
import { ToolbarPopover } from '../../../shared_components';
import { MissingValuesOptions } from './missing_values_option';
import { FillOpacityOption } from './fill_opacity_option';
import { layerTypes } from '../../../common';
import { layerTypes } from '../../../../common';
describe('Visual options popover', () => {
let frame: FramePublicAPI;

View file

@ -8,15 +8,15 @@
import React from 'react';
import { mountWithIntl as mount, shallowWithIntl as shallow } from '@kbn/test/jest';
import { EuiButtonGroupProps, EuiButtonGroup } from '@elastic/eui';
import { LayerContextMenu, XyToolbar, DimensionEditor } from './xy_config_panel';
import { LayerContextMenu, XyToolbar, DimensionEditor } from '.';
import { AxisSettingsPopover } from './axis_settings_popover';
import { FramePublicAPI } from '../types';
import { State } from './types';
import { FramePublicAPI } from '../../types';
import { State } from '../types';
import { Position } from '@elastic/charts';
import { createMockFramePublicAPI, createMockDatasource } from '../mocks';
import { createMockFramePublicAPI, createMockDatasource } from '../../mocks';
import { chartPluginMock } from 'src/plugins/charts/public/mocks';
import { EuiColorPicker } from '@elastic/eui';
import { layerTypes } from '../../common';
import { layerTypes } from '../../../common';
describe('XY Config panels', () => {
let frame: FramePublicAPI;

View file

@ -236,6 +236,38 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
);
});
it('should keep the formula if the user does not fully transition to a static value', async () => {
await PageObjects.visualize.navigateToNewVisualization();
await PageObjects.visualize.clickVisType('lens');
await PageObjects.lens.goToTimeRange();
await PageObjects.lens.configureDimension({
dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension',
operation: 'average',
field: 'bytes',
});
await PageObjects.lens.createLayer('threshold');
await PageObjects.lens.configureDimension(
{
dimension: 'lnsXY_yThresholdLeftPanel > lns-dimensionTrigger',
operation: 'formula',
formula: `count()`,
keepOpen: true,
},
1
);
await PageObjects.lens.switchToStaticValue();
await PageObjects.lens.closeDimensionEditor();
await PageObjects.common.sleep(1000);
expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_yThresholdLeftPanel', 0)).to.eql(
'count()'
);
});
it('should allow numeric only formulas', async () => {
await PageObjects.visualize.navigateToNewVisualization();
await PageObjects.visualize.clickVisType('lens');

View file

@ -47,6 +47,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./lens_tagging'));
loadTestFile(require.resolve('./formula'));
loadTestFile(require.resolve('./heatmap'));
loadTestFile(require.resolve('./thresholds'));
loadTestFile(require.resolve('./inspector'));
// has to be last one in the suite because it overrides saved objects

View file

@ -180,6 +180,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
);
});
it('should not show static value tab for data layers', async () => {
await PageObjects.lens.openDimensionEditor('lnsXY_yDimensionPanel > lns-dimensionTrigger');
// Quick functions and Formula tabs should be visible
expect(await testSubjects.exists('lens-dimensionTabs-quickFunctions')).to.eql(true);
expect(await testSubjects.exists('lens-dimensionTabs-formula')).to.eql(true);
// Static value tab should not be visible
expect(await testSubjects.exists('lens-dimensionTabs-static_value')).to.eql(false);
await PageObjects.lens.closeDimensionEditor();
});
it('should be able to add very long labels and still be able to remove a dimension', async () => {
await PageObjects.lens.openDimensionEditor('lnsXY_yDimensionPanel > lns-dimensionTrigger');
const longLabel =

View file

@ -0,0 +1,68 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const PageObjects = getPageObjects(['visualize', 'lens', 'common', 'header']);
const find = getService('find');
const retry = getService('retry');
const testSubjects = getService('testSubjects');
describe('lens thresholds tests', () => {
it('should show a disabled threshold layer button if no data dimension is defined', async () => {
await PageObjects.visualize.navigateToNewVisualization();
await PageObjects.visualize.clickVisType('lens');
await testSubjects.click('lnsLayerAddButton');
await retry.waitFor('wait for layer popup to appear', async () =>
testSubjects.exists(`lnsLayerAddButton-threshold`)
);
expect(
await (await testSubjects.find(`lnsLayerAddButton-threshold`)).getAttribute('disabled')
).to.be('true');
});
it('should add a threshold layer with a static value in it', async () => {
await PageObjects.lens.goToTimeRange();
await PageObjects.lens.configureDimension({
dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension',
operation: 'date_histogram',
field: '@timestamp',
});
await PageObjects.lens.configureDimension({
dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension',
operation: 'average',
field: 'bytes',
});
await PageObjects.lens.createLayer('threshold');
expect((await find.allByCssSelector(`[data-test-subj^="lns-layerPanel-"]`)).length).to.eql(2);
expect(
await (
await testSubjects.find('lnsXY_yThresholdLeftPanel > lns-dimensionTrigger')
).getVisibleText()
).to.eql('Static value: 4992.44');
});
it('should create a dynamic threshold when dragging a field to a threshold dimension group', async () => {
await PageObjects.lens.dragFieldToDimensionTrigger(
'bytes',
'lnsXY_yThresholdLeftPanel > lns-empty-dimension'
);
expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_yThresholdLeftPanel')).to.eql([
'Static value: 4992.44',
'Median of bytes',
]);
});
});
}

View file

@ -645,8 +645,21 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
/**
* Adds a new layer to the chart, fails if the chart does not support new layers
*/
async createLayer() {
async createLayer(layerType: string = 'data') {
await testSubjects.click('lnsLayerAddButton');
const layerCount = (await find.allByCssSelector(`[data-test-subj^="lns-layerPanel-"]`))
.length;
await retry.waitFor('check for layer type support', async () => {
const fasterChecks = await Promise.all([
(await find.allByCssSelector(`[data-test-subj^="lns-layerPanel-"]`)).length > layerCount,
testSubjects.exists(`lnsLayerAddButton-${layerType}`),
]);
return fasterChecks.filter(Boolean).length > 0;
});
if (await testSubjects.exists(`lnsLayerAddButton-${layerType}`)) {
await testSubjects.click(`lnsLayerAddButton-${layerType}`);
}
},
/**
@ -1075,6 +1088,10 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
await testSubjects.click('lens-dimensionTabs-formula');
},
async switchToStaticValue() {
await testSubjects.click('lens-dimensionTabs-static_value');
},
async toggleFullscreen() {
await testSubjects.click('lnsFormula-fullscreen');
},