mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
9b410ce544
commit
0cbdf3f259
67 changed files with 4007 additions and 761 deletions
|
@ -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 {
|
||||
|
|
40
x-pack/plugins/lens/public/assets/chart_bar_threshold.tsx
Normal file
40
x-pack/plugins/lens/public/assets/chart_bar_threshold.tsx
Normal 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>
|
||||
);
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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),
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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 (
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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.',
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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()', () => {
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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 &&
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
};
|
|
@ -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({
|
||||
|
|
|
@ -378,6 +378,10 @@ describe('getOperationTypesForField', () => {
|
|||
"operationType": "formula",
|
||||
"type": "managedReference",
|
||||
},
|
||||
Object {
|
||||
"operationType": "static_value",
|
||||
"type": "managedReference",
|
||||
},
|
||||
],
|
||||
},
|
||||
Object {
|
||||
|
|
|
@ -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) => ({
|
||||
|
|
|
@ -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>(
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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 = [];
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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] : [],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
);
|
||||
};
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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 {
|
||||
/**
|
|
@ -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,
|
|
@ -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 {
|
||||
/**
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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');
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 =
|
||||
|
|
68
x-pack/test/functional/apps/lens/thresholds.ts
Normal file
68
x-pack/test/functional/apps/lens/thresholds.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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');
|
||||
},
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue